M3.62: thirty more generated arrowheads with rendered previews
Arrowhead gains 30 original shapes generated in code, appended after the first ten so saved names stay stable: Barbed, Half Arrow (left/right), Concave, Double Arrow, Chevron, Feather, Swallowtail, Reverse Triangle, Triangle to Bar, Kite, Leaf, Drop, Half Circle, Oval, Target, Tag (+ open), Diamond (open), Hexagon (+ open), Star, Cross, Plus, Double Bar, Dot on Bar, Slash, Double Slash, Bracket and Fork. Each is drawn in local units of the head's weight inside the usual 4 x 4 box, with its own stop for the stroke under it. Arrowhead::label() names them in the menus. The Stroke panel's arrowhead dropdowns render every head as the canvas draws it (cached textures, a shared widgets::doc_preview that the Brushes panel now uses too) in a scrolling list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3e73ad8581
commit
fdad11a99c
|
|
@ -48,6 +48,7 @@ impl Dash {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum Arrowhead {
|
pub enum Arrowhead {
|
||||||
Triangle,
|
Triangle,
|
||||||
|
|
@ -60,21 +61,130 @@ pub enum Arrowhead {
|
||||||
Diamond,
|
Diamond,
|
||||||
Arrow,
|
Arrow,
|
||||||
ArrowOpen,
|
ArrowOpen,
|
||||||
|
Barbed,
|
||||||
|
/// One barb, on the left of the direction the head points.
|
||||||
|
HalfArrowLeft,
|
||||||
|
/// One barb, on the right of the direction the head points.
|
||||||
|
HalfArrowRight,
|
||||||
|
Concave,
|
||||||
|
DoubleBar,
|
||||||
|
Feather,
|
||||||
|
DotOnBar,
|
||||||
|
Chevron,
|
||||||
|
DoubleArrow,
|
||||||
|
Target,
|
||||||
|
Star,
|
||||||
|
Cross,
|
||||||
|
Plus,
|
||||||
|
Hexagon,
|
||||||
|
HexagonOpen,
|
||||||
|
Tag,
|
||||||
|
TagOpen,
|
||||||
|
HalfCircle,
|
||||||
|
Drop,
|
||||||
|
Slash,
|
||||||
|
DoubleSlash,
|
||||||
|
DiamondOpen,
|
||||||
|
TriangleReverse,
|
||||||
|
Swallowtail,
|
||||||
|
Bracket,
|
||||||
|
Fork,
|
||||||
|
Leaf,
|
||||||
|
Kite,
|
||||||
|
TriangleBar,
|
||||||
|
Oval,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Arrowhead {
|
impl Arrowhead {
|
||||||
pub const ALL: [Arrowhead; 10] = [
|
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
|
||||||
|
pub const ALL: [Arrowhead; 40] = [
|
||||||
Arrowhead::Arrow,
|
Arrowhead::Arrow,
|
||||||
Arrowhead::ArrowOpen,
|
Arrowhead::ArrowOpen,
|
||||||
|
Arrowhead::Barbed,
|
||||||
|
Arrowhead::Concave,
|
||||||
|
Arrowhead::DoubleArrow,
|
||||||
|
Arrowhead::HalfArrowLeft,
|
||||||
|
Arrowhead::HalfArrowRight,
|
||||||
|
Arrowhead::Chevron,
|
||||||
|
Arrowhead::Feather,
|
||||||
|
Arrowhead::Swallowtail,
|
||||||
Arrowhead::Triangle,
|
Arrowhead::Triangle,
|
||||||
Arrowhead::TriangleOpen,
|
Arrowhead::TriangleOpen,
|
||||||
|
Arrowhead::TriangleReverse,
|
||||||
|
Arrowhead::TriangleBar,
|
||||||
|
Arrowhead::Kite,
|
||||||
|
Arrowhead::Leaf,
|
||||||
|
Arrowhead::Drop,
|
||||||
Arrowhead::Circle,
|
Arrowhead::Circle,
|
||||||
Arrowhead::CircleOpen,
|
Arrowhead::CircleOpen,
|
||||||
|
Arrowhead::HalfCircle,
|
||||||
|
Arrowhead::Oval,
|
||||||
|
Arrowhead::Target,
|
||||||
Arrowhead::Square,
|
Arrowhead::Square,
|
||||||
Arrowhead::SquareOpen,
|
Arrowhead::SquareOpen,
|
||||||
|
Arrowhead::Tag,
|
||||||
|
Arrowhead::TagOpen,
|
||||||
Arrowhead::Diamond,
|
Arrowhead::Diamond,
|
||||||
|
Arrowhead::DiamondOpen,
|
||||||
|
Arrowhead::Hexagon,
|
||||||
|
Arrowhead::HexagonOpen,
|
||||||
|
Arrowhead::Star,
|
||||||
|
Arrowhead::Cross,
|
||||||
|
Arrowhead::Plus,
|
||||||
Arrowhead::Bar,
|
Arrowhead::Bar,
|
||||||
|
Arrowhead::DoubleBar,
|
||||||
|
Arrowhead::DotOnBar,
|
||||||
|
Arrowhead::Slash,
|
||||||
|
Arrowhead::DoubleSlash,
|
||||||
|
Arrowhead::Bracket,
|
||||||
|
Arrowhead::Fork,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// The name the arrowhead menus show.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Arrowhead::Triangle => "Triangle",
|
||||||
|
Arrowhead::TriangleOpen => "Triangle (open)",
|
||||||
|
Arrowhead::Circle => "Circle",
|
||||||
|
Arrowhead::CircleOpen => "Circle (open)",
|
||||||
|
Arrowhead::Square => "Square",
|
||||||
|
Arrowhead::SquareOpen => "Square (open)",
|
||||||
|
Arrowhead::Bar => "Bar",
|
||||||
|
Arrowhead::Diamond => "Diamond",
|
||||||
|
Arrowhead::Arrow => "Arrow",
|
||||||
|
Arrowhead::ArrowOpen => "Arrow (open)",
|
||||||
|
Arrowhead::Barbed => "Barbed",
|
||||||
|
Arrowhead::HalfArrowLeft => "Half Arrow (left)",
|
||||||
|
Arrowhead::HalfArrowRight => "Half Arrow (right)",
|
||||||
|
Arrowhead::Concave => "Concave",
|
||||||
|
Arrowhead::DoubleBar => "Double Bar",
|
||||||
|
Arrowhead::Feather => "Feather",
|
||||||
|
Arrowhead::DotOnBar => "Dot on Bar",
|
||||||
|
Arrowhead::Chevron => "Chevron",
|
||||||
|
Arrowhead::DoubleArrow => "Double Arrow",
|
||||||
|
Arrowhead::Target => "Target",
|
||||||
|
Arrowhead::Star => "Star",
|
||||||
|
Arrowhead::Cross => "Cross",
|
||||||
|
Arrowhead::Plus => "Plus",
|
||||||
|
Arrowhead::Hexagon => "Hexagon",
|
||||||
|
Arrowhead::HexagonOpen => "Hexagon (open)",
|
||||||
|
Arrowhead::Tag => "Tag",
|
||||||
|
Arrowhead::TagOpen => "Tag (open)",
|
||||||
|
Arrowhead::HalfCircle => "Half Circle",
|
||||||
|
Arrowhead::Drop => "Drop",
|
||||||
|
Arrowhead::Slash => "Slash",
|
||||||
|
Arrowhead::DoubleSlash => "Double Slash",
|
||||||
|
Arrowhead::DiamondOpen => "Diamond (open)",
|
||||||
|
Arrowhead::TriangleReverse => "Reverse Triangle",
|
||||||
|
Arrowhead::Swallowtail => "Swallowtail",
|
||||||
|
Arrowhead::Bracket => "Bracket",
|
||||||
|
Arrowhead::Fork => "Fork",
|
||||||
|
Arrowhead::Leaf => "Leaf",
|
||||||
|
Arrowhead::Kite => "Kite",
|
||||||
|
Arrowhead::TriangleBar => "Triangle to Bar",
|
||||||
|
Arrowhead::Oval => "Oval",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
|
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
|
||||||
|
|
@ -880,6 +990,60 @@ mod tests {
|
||||||
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
|
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
|
||||||
|
// Saved files store these names: they may only ever be appended to.
|
||||||
|
const SAVED: [&str; 40] = [
|
||||||
|
"Triangle",
|
||||||
|
"TriangleOpen",
|
||||||
|
"Circle",
|
||||||
|
"CircleOpen",
|
||||||
|
"Square",
|
||||||
|
"SquareOpen",
|
||||||
|
"Bar",
|
||||||
|
"Diamond",
|
||||||
|
"Arrow",
|
||||||
|
"ArrowOpen",
|
||||||
|
"Barbed",
|
||||||
|
"HalfArrowLeft",
|
||||||
|
"HalfArrowRight",
|
||||||
|
"Concave",
|
||||||
|
"DoubleBar",
|
||||||
|
"Feather",
|
||||||
|
"DotOnBar",
|
||||||
|
"Chevron",
|
||||||
|
"DoubleArrow",
|
||||||
|
"Target",
|
||||||
|
"Star",
|
||||||
|
"Cross",
|
||||||
|
"Plus",
|
||||||
|
"Hexagon",
|
||||||
|
"HexagonOpen",
|
||||||
|
"Tag",
|
||||||
|
"TagOpen",
|
||||||
|
"HalfCircle",
|
||||||
|
"Drop",
|
||||||
|
"Slash",
|
||||||
|
"DoubleSlash",
|
||||||
|
"DiamondOpen",
|
||||||
|
"TriangleReverse",
|
||||||
|
"Swallowtail",
|
||||||
|
"Bracket",
|
||||||
|
"Fork",
|
||||||
|
"Leaf",
|
||||||
|
"Kite",
|
||||||
|
"TriangleBar",
|
||||||
|
"Oval",
|
||||||
|
];
|
||||||
|
for name in SAVED {
|
||||||
|
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(a).unwrap(), name);
|
||||||
|
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
|
||||||
|
}
|
||||||
|
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
|
||||||
|
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arrow_align_defaults_to_extend_and_round_trips() {
|
fn arrow_align_defaults_to_extend_and_round_trips() {
|
||||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||||||
|
|
|
||||||
|
|
@ -196,4 +196,6 @@ pub(crate) fn segments(els: &[PathEl]) -> Vec<PathSeg> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_arrows;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_fit;
|
mod tests_fit;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide.
|
//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw`
|
||||||
//! Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. The
|
//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms.
|
||||||
//! stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, in
|
//! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads,
|
||||||
//! the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner
|
//! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner
|
||||||
//! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by
|
//! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by
|
||||||
//! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset
|
//! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset
|
||||||
//! past the end point.
|
//! past the end point. Heads added after the first ten are drawn in local units ([`local`]).
|
||||||
|
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
use std::f64::consts::{FRAC_PI_2, PI};
|
||||||
|
|
||||||
|
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
||||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
||||||
|
|
||||||
use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit};
|
use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit};
|
||||||
|
|
@ -185,6 +187,10 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
|
||||||
Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l),
|
Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l),
|
||||||
// The inner corner of the chevron: arm / sin(half angle).
|
// The inner corner of the chevron: arm / sin(half angle).
|
||||||
Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap),
|
Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap),
|
||||||
|
_ => match local_stop(kind) {
|
||||||
|
Stop::At(u) => (u * hw).max(cap),
|
||||||
|
Stop::Wall(back) => hollow(back * hw),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,6 +230,150 @@ fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
|
||||||
let t = bar(hw);
|
let t = bar(hw);
|
||||||
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
|
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
|
||||||
}
|
}
|
||||||
|
_ => {
|
||||||
|
// Local x runs back from the tip, y across to the right of `dir`; a unit is `hw`.
|
||||||
|
p = local(kind);
|
||||||
|
p.apply_affine(Affine::new([-dir.x * hw, -dir.y * hw, n.x * hw, n.y * hw, tip.x, tip.y]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the stroke ends under a head drawn in local units.
|
||||||
|
enum Stop {
|
||||||
|
/// This many units back from the tip (or as far as the cap needs).
|
||||||
|
At(f64),
|
||||||
|
/// In the back wall of a ring whose back is this many units from the tip.
|
||||||
|
Wall(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat-to-flat length of the hexagons (their corners touch the sides of the 4-unit box).
|
||||||
|
const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
|
||||||
|
/// Flattening tolerance of the curved local heads, in units.
|
||||||
|
const LOCAL_TOL: f64 = 1e-3;
|
||||||
|
|
||||||
|
fn local_stop(kind: Arrowhead) -> Stop {
|
||||||
|
match kind {
|
||||||
|
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
|
||||||
|
Arrowhead::HexagonOpen => Stop::Wall(HEX),
|
||||||
|
Arrowhead::DoubleBar | Arrowhead::DotOnBar => Stop::At(0.375),
|
||||||
|
Arrowhead::Bracket => Stop::At(0.3),
|
||||||
|
Arrowhead::Fork => Stop::At(2.7),
|
||||||
|
Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3),
|
||||||
|
Arrowhead::Swallowtail => Stop::At(1.4),
|
||||||
|
Arrowhead::Chevron => Stop::At(1.0),
|
||||||
|
Arrowhead::Slash => Stop::At(1.2),
|
||||||
|
Arrowhead::DoubleSlash => Stop::At(1.05),
|
||||||
|
Arrowhead::DoubleArrow => Stop::At(1.7),
|
||||||
|
Arrowhead::HalfCircle => Stop::At(1.0),
|
||||||
|
Arrowhead::Concave => Stop::At(2.6),
|
||||||
|
Arrowhead::Hexagon => Stop::At(HEX / 2.0),
|
||||||
|
_ => Stop::At(2.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outline of a head added after the first ten, in local units: x from the tip (0) back
|
||||||
|
/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction
|
||||||
|
/// the head points). Every shape is generated here.
|
||||||
|
fn local(kind: Arrowhead) -> BezPath {
|
||||||
|
let mut p = BezPath::new();
|
||||||
|
let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>());
|
||||||
|
let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>(), ring(1.0));
|
||||||
|
let circle = |p: &mut BezPath, c: (f64, f64), r: f64| p.extend(kurbo::Circle::new(c, r).path_elements(LOCAL_TOL));
|
||||||
|
// A regular polygon of `n` corners around `c`, the first at angle `a0`, alternating radii.
|
||||||
|
let star = |c: (f64, f64), radii: &[f64], n: usize, a0: f64| -> Vec<(f64, f64)> {
|
||||||
|
(0..n).map(|i| (a0 + i as f64 * 2.0 * PI / n as f64, radii[i % radii.len()])).map(|(a, r)| (c.0 + r * a.cos(), c.1 + r * a.sin())).collect()
|
||||||
|
};
|
||||||
|
// A plus of arms `h` long and `k` thick round (2, 0), turned by `turn`.
|
||||||
|
let plus = |h: f64, k: f64, turn: f64| -> Vec<(f64, f64)> {
|
||||||
|
let arm = [(k, h), (k, k), (h, k)];
|
||||||
|
let (sin, cos) = turn.sin_cos();
|
||||||
|
(0..4)
|
||||||
|
.flat_map(|q| arm.iter().map(move |&(x, y)| [(x, y), (y, -x), (-x, -y), (-y, x)][q]))
|
||||||
|
.map(|(x, y)| (2.0 + x * cos - y * sin, x * sin + y * cos))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let hexagon = star((HEX / 2.0, 0.0), &[2.0], 6, PI / 6.0);
|
||||||
|
match kind {
|
||||||
|
Arrowhead::Barbed => poly(&mut p, &[(0.0, 0.0), (3.6, 2.0), (2.6, 0.55), (4.0, 0.55), (4.0, -0.55), (2.6, -0.55), (3.6, -2.0)]),
|
||||||
|
Arrowhead::HalfArrowLeft => poly(&mut p, &[(0.0, 0.0), (4.0, -2.0), (3.0, -0.5), (3.0, 0.5), (1.0, 0.5)]),
|
||||||
|
Arrowhead::HalfArrowRight => poly(&mut p, &[(0.0, 0.0), (4.0, 2.0), (3.0, 0.5), (3.0, -0.5), (1.0, -0.5)]),
|
||||||
|
Arrowhead::Concave => {
|
||||||
|
p.move_to((0.0, 0.0));
|
||||||
|
p.quad_to((2.4, 0.4), (4.0, 2.0));
|
||||||
|
p.line_to((4.0, -2.0));
|
||||||
|
p.quad_to((2.4, -0.4), (0.0, 0.0));
|
||||||
|
p.close_path();
|
||||||
|
}
|
||||||
|
Arrowhead::DoubleBar => {
|
||||||
|
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
|
||||||
|
poly(&mut p, &[(1.5, -2.0), (2.25, -2.0), (2.25, 2.0), (1.5, 2.0)]);
|
||||||
|
}
|
||||||
|
Arrowhead::Feather => poly(&mut p, &[(0.0, 0.5), (1.4, 2.0), (4.0, 2.0), (2.6, 0.5), (2.6, -0.5), (4.0, -2.0), (1.4, -2.0), (0.0, -0.5)]),
|
||||||
|
Arrowhead::DotOnBar => {
|
||||||
|
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
|
||||||
|
circle(&mut p, (2.5, 0.0), 1.25);
|
||||||
|
}
|
||||||
|
Arrowhead::Chevron => poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 2.0), (2.0, 0.0), (4.0, -2.0), (2.0, -2.0)]),
|
||||||
|
Arrowhead::DoubleArrow => poly(&mut p, &[(0.0, 0.0), (2.2, 1.8), (1.9, 0.5), (4.0, 2.0), (3.4, 0.0), (4.0, -2.0), (1.9, -0.5), (2.2, -1.8)]),
|
||||||
|
Arrowhead::Target => {
|
||||||
|
circle(&mut p, (2.0, 0.0), 2.0);
|
||||||
|
p.extend(kurbo::Circle::new((2.0, 0.0), 2.0 - ring(1.0)).to_path(LOCAL_TOL).reverse_subpaths());
|
||||||
|
circle(&mut p, (2.0, 0.0), 0.75);
|
||||||
|
}
|
||||||
|
Arrowhead::Star => poly(&mut p, &star((2.0, 0.0), &[2.0, 0.85], 10, PI)),
|
||||||
|
Arrowhead::Cross => poly(&mut p, &plus(2.2, 0.4, PI / 4.0)),
|
||||||
|
Arrowhead::Plus => poly(&mut p, &plus(1.9, 0.4, 0.0)),
|
||||||
|
Arrowhead::Hexagon => poly(&mut p, &hexagon),
|
||||||
|
Arrowhead::HexagonOpen => ring_poly(&mut p, &hexagon),
|
||||||
|
Arrowhead::Tag => poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
|
||||||
|
Arrowhead::TagOpen => ring_poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
|
||||||
|
Arrowhead::HalfCircle => {
|
||||||
|
p.move_to((2.0, 2.0));
|
||||||
|
p.extend(kurbo::Arc::new((2.0, 0.0), (2.0, 2.0), FRAC_PI_2, PI, 0.0).append_iter(LOCAL_TOL));
|
||||||
|
p.close_path();
|
||||||
|
}
|
||||||
|
Arrowhead::Drop => {
|
||||||
|
// Straight sides from the tip, tangent to a round back.
|
||||||
|
let (c, r) = (Point::new(2.6, 0.0), 1.4);
|
||||||
|
let a = PI - (r / c.x).acos();
|
||||||
|
p.move_to((0.0, 0.0));
|
||||||
|
p.line_to(c + Vec2::from_angle(a) * r);
|
||||||
|
p.extend(kurbo::Arc::new(c, (r, r), a, -2.0 * a, 0.0).append_iter(LOCAL_TOL));
|
||||||
|
p.close_path();
|
||||||
|
}
|
||||||
|
Arrowhead::Slash => poly(&mut p, &[(0.0, 2.0), (0.8, 2.0), (2.4, -2.0), (1.6, -2.0)]),
|
||||||
|
Arrowhead::DoubleSlash => {
|
||||||
|
poly(&mut p, &[(0.0, 2.0), (0.7, 2.0), (2.1, -2.0), (1.4, -2.0)]);
|
||||||
|
poly(&mut p, &[(1.6, 2.0), (2.3, 2.0), (3.7, -2.0), (3.0, -2.0)]);
|
||||||
|
}
|
||||||
|
Arrowhead::DiamondOpen => ring_poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 0.0), (2.0, -2.0)]),
|
||||||
|
Arrowhead::TriangleReverse => poly(&mut p, &[(0.0, 2.0), (4.0, 0.0), (0.0, -2.0)]),
|
||||||
|
Arrowhead::Swallowtail => poly(&mut p, &[(0.0, 2.0), (4.0, 2.0), (2.8, 0.0), (4.0, -2.0), (0.0, -2.0)]),
|
||||||
|
Arrowhead::Bracket => poly(&mut p, &[(0.0, 2.0), (2.0, 2.0), (2.0, 1.4), (0.6, 1.4), (0.6, -1.4), (2.0, -1.4), (2.0, -2.0), (0.0, -2.0)]),
|
||||||
|
Arrowhead::Fork => poly(&mut p, &[(0.0, 2.0), (3.0, 2.0), (3.0, -2.0), (0.0, -2.0), (0.0, -1.4), (2.4, -1.4), (2.4, 1.4), (0.0, 1.4)]),
|
||||||
|
Arrowhead::Leaf => {
|
||||||
|
p.move_to((0.0, 0.0));
|
||||||
|
p.quad_to((2.0, 3.2), (4.0, 0.0));
|
||||||
|
p.quad_to((2.0, -3.2), (0.0, 0.0));
|
||||||
|
p.close_path();
|
||||||
|
}
|
||||||
|
Arrowhead::Kite => poly(&mut p, &[(0.0, 0.0), (1.3, 1.7), (4.0, 0.0), (1.3, -1.7)]),
|
||||||
|
Arrowhead::TriangleBar => {
|
||||||
|
poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)])
|
||||||
|
}
|
||||||
|
Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)),
|
||||||
|
// The first ten are drawn by `shape`.
|
||||||
|
Arrowhead::Triangle
|
||||||
|
| Arrowhead::TriangleOpen
|
||||||
|
| Arrowhead::Circle
|
||||||
|
| Arrowhead::CircleOpen
|
||||||
|
| Arrowhead::Square
|
||||||
|
| Arrowhead::SquareOpen
|
||||||
|
| Arrowhead::Bar
|
||||||
|
| Arrowhead::Diamond
|
||||||
|
| Arrowhead::Arrow
|
||||||
|
| Arrowhead::ArrowOpen => {}
|
||||||
}
|
}
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut
|
||||||
|
//! arms reach a little further back), without self-intersections, and covering the end of the
|
||||||
|
//! stroke under it.
|
||||||
|
|
||||||
|
use kurbo::{BezPath, PathEl, Point, Shape};
|
||||||
|
use vectorcraft_color::{Color, Paint};
|
||||||
|
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
||||||
|
|
||||||
|
use super::stroke_pieces;
|
||||||
|
|
||||||
|
/// A 2 pt line along +x ending at (100, 0) with `kind` at its end, tip on the end point: heads
|
||||||
|
/// of weight 2, so 8 long and 8 wide.
|
||||||
|
fn head(kind: Arrowhead) -> super::Arrow {
|
||||||
|
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||||||
|
st.end_arrow = Some(kind);
|
||||||
|
st.arrow_align = ArrowAlign::Tip;
|
||||||
|
let mut l = BezPath::new();
|
||||||
|
l.move_to((0.0, 0.0));
|
||||||
|
l.line_to((100.0, 0.0));
|
||||||
|
stroke_pieces(&l, &st).heads.remove(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outline's closed contours as polygons (curves flattened).
|
||||||
|
fn contours(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||||
|
let mut out: Vec<Vec<Point>> = vec![];
|
||||||
|
kurbo::flatten(bp.iter(), 1e-3, |el| match el {
|
||||||
|
PathEl::MoveTo(p) => out.push(vec![p]),
|
||||||
|
PathEl::LineTo(p) => out.last_mut().unwrap().push(p),
|
||||||
|
PathEl::ClosePath => {
|
||||||
|
let c = out.last_mut().unwrap();
|
||||||
|
if c.len() > 1 && c[0].distance(*c.last().unwrap()) < 1e-9 {
|
||||||
|
c.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!("flattened"),
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Do segments `a` and `b` cross at a point inside both (touching ends don't count)?
|
||||||
|
fn cross(a: (Point, Point), b: (Point, Point)) -> bool {
|
||||||
|
let orient = |p: Point, q: Point, r: Point| (q - p).cross(r - p);
|
||||||
|
let (d1, d2) = (orient(b.0, b.1, a.0), orient(b.0, b.1, a.1));
|
||||||
|
let (d3, d4) = (orient(a.0, a.1, b.0), orient(a.0, a.1, b.1));
|
||||||
|
d1 * d2 < -1e-12 && d3 * d4 < -1e-12
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
|
||||||
|
let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0));
|
||||||
|
assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself");
|
||||||
|
for kind in Arrowhead::ALL {
|
||||||
|
let h = head(kind);
|
||||||
|
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
|
||||||
|
let b = h.outline.bounding_box();
|
||||||
|
assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}");
|
||||||
|
let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect();
|
||||||
|
assert!(edges.len() >= 3, "{kind:?}");
|
||||||
|
for (i, a) in edges.iter().enumerate() {
|
||||||
|
for b in &edges[i + 1..] {
|
||||||
|
assert!(!cross(*a, *b), "{kind:?}: {a:?} crosses {b:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stroke_ends_inside_every_head() {
|
||||||
|
for kind in Arrowhead::ALL {
|
||||||
|
let h = head(kind);
|
||||||
|
// Just in front of where the line stops, on its centre line.
|
||||||
|
let end = h.tip - h.dir * (h.inset - 0.1);
|
||||||
|
assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn half_arrows_barb_on_their_own_side() {
|
||||||
|
// Pointing along +x on a y-down page, left is −y.
|
||||||
|
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y0 < -3.0);
|
||||||
|
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5);
|
||||||
|
assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0);
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Stroke Options",
|
"Stroke Options",
|
||||||
["Window", "Stroke"],
|
["Window", "Stroke"],
|
||||||
None,
|
None,
|
||||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
|
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
|
||||||
has_doc,
|
has_doc,
|
||||||
stroke_set
|
stroke_set
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
|
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
|
||||||
//! applies it to the selected paths (and makes it the Paintbrush's current brush).
|
//! applies it to the selected paths (and makes it the Paintbrush's current brush).
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use egui::{Sense, Ui, vec2};
|
use egui::{Sense, Ui, vec2};
|
||||||
|
|
@ -26,43 +25,25 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<Strin
|
||||||
(list, v["current"].as_str().map(str::to_string))
|
(list, v["current"].as_str().map(str::to_string))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stroke preview for brush definition `def`, rendered at `size` pixels and cached by the
|
/// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's
|
||||||
/// definition's JSON.
|
/// JSON.
|
||||||
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
|
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
|
||||||
thread_local! {
|
widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| {
|
||||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
let mut doc = Document::new(w, h);
|
||||||
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
|
doc.unknown.insert("brushes".into(), json!([def]));
|
||||||
}
|
let name = def["name"].as_str()?.to_string();
|
||||||
let ppp = ui.ctx().pixels_per_point() as f64;
|
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||||
let (w, h) = (size.x as f64, size.y as f64);
|
ap.stroke_mut()?.brush = Some(name);
|
||||||
let key = format!("{w}x{h}@{ppp}:{def}");
|
// A gentle S-curve across the swatch.
|
||||||
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
let mut bp = vectorcraft_geom::BezPath::new();
|
||||||
return Some(t);
|
let (x0, x1) = (h * 0.5, w - h * 0.5);
|
||||||
}
|
bp.move_to((x0, h * 0.5));
|
||||||
let mut doc = Document::new(w, h);
|
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5));
|
||||||
doc.unknown.insert("brushes".into(), json!([def]));
|
let id = doc.alloc_id();
|
||||||
let name = def["name"].as_str()?.to_string();
|
let l = doc.layers[0].id;
|
||||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
|
||||||
ap.stroke_mut()?.brush = Some(name);
|
Some(doc)
|
||||||
// A gentle S-curve across the swatch.
|
})
|
||||||
let mut bp = vectorcraft_geom::BezPath::new();
|
|
||||||
let (x0, x1) = (h * 0.5, w - h * 0.5);
|
|
||||||
bp.move_to((x0, h * 0.5));
|
|
||||||
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5));
|
|
||||||
let id = doc.alloc_id();
|
|
||||||
let l = doc.layers[0].id;
|
|
||||||
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
|
|
||||||
let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false));
|
|
||||||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
|
||||||
let tex = ui.ctx().load_texture(format!("brush-{key}"), color, egui::TextureOptions::LINEAR);
|
|
||||||
CACHE.with(|c| {
|
|
||||||
let mut c = c.borrow_mut();
|
|
||||||
if c.len() > 256 {
|
|
||||||
c.clear();
|
|
||||||
}
|
|
||||||
c.insert(key, tex.clone());
|
|
||||||
});
|
|
||||||
Some(tex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_brush(app: &VectorcraftApp) -> Option<String> {
|
fn selected_brush(app: &VectorcraftApp) -> Option<String> {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
//! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line
|
//! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line
|
||||||
//! with three dash/gap pairs, arrowheads with drawn previews, arrow scale, width profiles.
|
//! with three dash/gap pairs, arrowheads with rendered previews, arrow scale, width profiles.
|
||||||
|
|
||||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
use vectorcraft_color::{Color, Paint};
|
||||||
|
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
|
||||||
use vectorcraft_engine::inspect::StrokeMixed;
|
use vectorcraft_engine::inspect::StrokeMixed;
|
||||||
|
|
||||||
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
|
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
|
||||||
|
|
@ -64,14 +65,8 @@ pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn arrow_label(a: Option<Arrowhead>) -> String {
|
fn arrow_label(a: Option<Arrowhead>) -> &'static str {
|
||||||
match a {
|
a.map_or("None", Arrowhead::label)
|
||||||
None => "None".into(),
|
|
||||||
Some(a) => {
|
|
||||||
let s = format!("{a:?}");
|
|
||||||
s.replace("Open", " (open)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
|
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
|
||||||
|
|
@ -284,31 +279,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw an arrowhead preview: a line with the head at the left (`start`) or right end.
|
/// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left
|
||||||
|
/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted).
|
||||||
|
pub(super) fn arrow_doc(a: Option<Arrowhead>, start: bool, w: f64, h: f64) -> Option<Document> {
|
||||||
|
let mut doc = Document::new(w, h);
|
||||||
|
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5);
|
||||||
|
let st = ap.stroke_mut()?;
|
||||||
|
// Heads of weight 2.5 pt: 10 pt long and wide.
|
||||||
|
st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0);
|
||||||
|
st.arrow_align = ArrowAlign::Tip;
|
||||||
|
if start {
|
||||||
|
st.start_arrow = a
|
||||||
|
} else {
|
||||||
|
st.end_arrow = a
|
||||||
|
}
|
||||||
|
let mut bp = vectorcraft_geom::BezPath::new();
|
||||||
|
bp.move_to((3.0, h / 2.0));
|
||||||
|
bp.line_to((w - 3.0, h / 2.0));
|
||||||
|
let (id, l) = (doc.alloc_id(), doc.layers[0].id);
|
||||||
|
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
|
||||||
|
Some(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the
|
||||||
|
/// left (`start`) or right end.
|
||||||
fn paint_arrow(ui: &Ui, r: Rect, a: Option<Arrowhead>, start: bool, color: Color32) {
|
fn paint_arrow(ui: &Ui, r: Rect, a: Option<Arrowhead>, start: bool, color: Color32) {
|
||||||
let y = r.center().y;
|
if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) {
|
||||||
let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) };
|
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color);
|
||||||
let s = Stroke::new(1.5, color);
|
}
|
||||||
let line_from = if a.is_some() { tip_x + back * 8.0 } else { tip_x };
|
|
||||||
let other = if start { r.right() - 4.0 } else { r.left() + 4.0 };
|
|
||||||
ui.painter().line_segment([pos2(line_from, y), pos2(other, y)], s);
|
|
||||||
let Some(a) = a else { return };
|
|
||||||
let p = |dx: f32, dy: f32| -> Pos2 { pos2(tip_x + back * dx, y + dy) };
|
|
||||||
let solid = |pts: Vec<Pos2>| egui::Shape::convex_polygon(pts, color, Stroke::NONE);
|
|
||||||
let open = |pts: Vec<Pos2>| egui::Shape::closed_line(pts, s);
|
|
||||||
let shape = match a {
|
|
||||||
Arrowhead::Triangle => solid(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
|
|
||||||
Arrowhead::TriangleOpen => open(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
|
|
||||||
Arrowhead::Arrow => solid(vec![p(0.0, 0.0), p(10.0, -5.0), p(7.0, 0.0), p(10.0, 5.0)]),
|
|
||||||
Arrowhead::ArrowOpen => egui::Shape::line(vec![p(9.0, -5.0), p(0.0, 0.0), p(9.0, 5.0)], s),
|
|
||||||
Arrowhead::Circle => egui::Shape::circle_filled(p(4.5, 0.0), 4.5, color),
|
|
||||||
Arrowhead::CircleOpen => egui::Shape::circle_stroke(p(4.5, 0.0), 4.0, s),
|
|
||||||
Arrowhead::Square => egui::Shape::rect_filled(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, color),
|
|
||||||
Arrowhead::SquareOpen => egui::Shape::rect_stroke(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, s, StrokeKind::Middle),
|
|
||||||
Arrowhead::Diamond => solid(vec![p(0.0, 0.0), p(5.0, -5.0), p(10.0, 0.0), p(5.0, 5.0)]),
|
|
||||||
Arrowhead::Bar => egui::Shape::line_segment([p(0.0, -6.0), p(0.0, 6.0)], Stroke::new(2.0, color)),
|
|
||||||
};
|
|
||||||
ui.painter().add(shape);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice).
|
/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice).
|
||||||
|
|
@ -323,28 +321,29 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<Arrowhead>, start: bool) ->
|
||||||
let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" });
|
let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" });
|
||||||
let mut out = None;
|
let mut out = None;
|
||||||
egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| {
|
egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| {
|
||||||
ui.set_min_width(140.0);
|
ui.set_min_width(170.0);
|
||||||
let opts: Vec<Option<Arrowhead>> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect();
|
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||||
for a in opts {
|
for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) {
|
||||||
let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click());
|
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click());
|
||||||
if a == cur {
|
if a == cur {
|
||||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||||
} else if rr.hovered() {
|
} else if rr.hovered() {
|
||||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||||
|
}
|
||||||
|
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
|
||||||
|
ui.painter().text(
|
||||||
|
row.left_center() + vec2(66.0, 0.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
arrow_label(a),
|
||||||
|
egui::FontId::proportional(11.5),
|
||||||
|
t.text,
|
||||||
|
);
|
||||||
|
if rr.clicked() {
|
||||||
|
out = Some(a);
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
|
});
|
||||||
ui.painter().text(
|
|
||||||
row.left_center() + vec2(66.0, 0.0),
|
|
||||||
egui::Align2::LEFT_CENTER,
|
|
||||||
arrow_label(a),
|
|
||||||
egui::FontId::proportional(11.5),
|
|
||||||
t.text,
|
|
||||||
);
|
|
||||||
if rr.clicked() {
|
|
||||||
out = Some(a);
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,34 @@ fn dropdown_lists_saved_profiles_and_applies_them() {
|
||||||
click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show);
|
click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show);
|
||||||
assert_eq!(profile_points(&app), Some(custom().points));
|
assert_eq!(profile_points(&app), Some(custom().points));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_arrowhead_preview_draws_its_head() {
|
||||||
|
use vectorcraft_doc::Arrowhead;
|
||||||
|
// Rendered on transparent: the head adds ink to its half of the preview.
|
||||||
|
let ink = |a: Option<Arrowhead>, start: bool| {
|
||||||
|
let d = stroke::arrow_doc(a, start, 56.0, 22.0).unwrap();
|
||||||
|
let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(0.0, 0.0, 56.0, 22.0), 2.0, false);
|
||||||
|
let xs = if start { 0..56 } else { 56..112 };
|
||||||
|
xs.flat_map(|x| (0..44).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).count()
|
||||||
|
};
|
||||||
|
let plain = (ink(None, false), ink(None, true));
|
||||||
|
for a in Arrowhead::ALL {
|
||||||
|
assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end");
|
||||||
|
assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrowhead_dropdown_offers_the_new_heads() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut app = app_with_line();
|
||||||
|
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
|
||||||
|
// The end-arrowhead dropdown is the second 64 pt button right of the "Arrowheads:" label.
|
||||||
|
let label = text_rect(&t, "Arrowheads:");
|
||||||
|
let sp = ctx.global_style().spacing.item_spacing.x;
|
||||||
|
click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show);
|
||||||
|
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
|
||||||
|
click(&ctx, &mut app, text_rect(&t, "Barbed").center(), stroke::show);
|
||||||
|
assert_eq!(current_stroke(&app).and_then(|s| s.end_arrow), Some(vectorcraft_doc::Arrowhead::Barbed));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1027,3 +1027,33 @@ pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
|
||||||
egui::DragAndDrop::set_payload(ui.ctx(), drag());
|
egui::DragAndDrop::set_payload(ui.ctx(), drag());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes,
|
||||||
|
/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when
|
||||||
|
/// the texture for `key` at this size isn't cached yet.
|
||||||
|
pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option<vectorcraft_doc::Document>) -> Option<egui::TextureHandle> {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
thread_local! {
|
||||||
|
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||||
|
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||||
|
}
|
||||||
|
let ppp = ui.ctx().pixels_per_point() as f64;
|
||||||
|
let (w, h) = (size.x as f64, size.y as f64);
|
||||||
|
let key = format!("{w}x{h}@{ppp}:{key}");
|
||||||
|
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||||
|
return Some(t);
|
||||||
|
}
|
||||||
|
let doc = build(w, h)?;
|
||||||
|
let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false));
|
||||||
|
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||||
|
let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR);
|
||||||
|
CACHE.with(|c| {
|
||||||
|
let mut c = c.borrow_mut();
|
||||||
|
if c.len() > 256 {
|
||||||
|
c.clear();
|
||||||
|
}
|
||||||
|
c.insert(key, tex.clone());
|
||||||
|
});
|
||||||
|
Some(tex)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -499,3 +499,11 @@ and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}`
|
||||||
{"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}}
|
{"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}}
|
||||||
{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"Ribbon"}}}
|
{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"Ribbon"}}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Arrowheads
|
||||||
|
|
||||||
|
`stroke.set {startArrow, endArrow}` takes any of 40 generated heads by name (the params doc lists them; `null` for
|
||||||
|
none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`,
|
||||||
|
`Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`,
|
||||||
|
the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`,
|
||||||
|
`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue