diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 7ae42d58..775fa1b1 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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)] pub enum Arrowhead { Triangle, @@ -60,21 +61,130 @@ pub enum Arrowhead { Diamond, Arrow, 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 { - 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::ArrowOpen, + Arrowhead::Barbed, + Arrowhead::Concave, + Arrowhead::DoubleArrow, + Arrowhead::HalfArrowLeft, + Arrowhead::HalfArrowRight, + Arrowhead::Chevron, + Arrowhead::Feather, + Arrowhead::Swallowtail, Arrowhead::Triangle, Arrowhead::TriangleOpen, + Arrowhead::TriangleReverse, + Arrowhead::TriangleBar, + Arrowhead::Kite, + Arrowhead::Leaf, + Arrowhead::Drop, Arrowhead::Circle, Arrowhead::CircleOpen, + Arrowhead::HalfCircle, + Arrowhead::Oval, + Arrowhead::Target, Arrowhead::Square, Arrowhead::SquareOpen, + Arrowhead::Tag, + Arrowhead::TagOpen, Arrowhead::Diamond, + Arrowhead::DiamondOpen, + Arrowhead::Hexagon, + Arrowhead::HexagonOpen, + Arrowhead::Star, + Arrowhead::Cross, + Arrowhead::Plus, 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 @@ -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)]); } + #[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] fn arrow_align_defaults_to_extend_and_round_trips() { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 9a4ca346..7dc1362b 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -196,4 +196,6 @@ pub(crate) fn segments(els: &[PathEl]) -> Vec { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_arrows; +#[cfg(test)] mod tests_fit; diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index 080ddeb3..1a5c9009 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -1,14 +1,16 @@ -//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide. -//! Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. The -//! stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, in -//! the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner +//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw` +//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. +//! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, +//! 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 //! 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 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 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), // 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), + _ => 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); 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::>()); + let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>(), 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 } diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs new file mode 100644 index 00000000..14e2fa23 --- /dev/null +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -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> { + let mut out: Vec> = 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); +} diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 5f734181..03e805da 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], 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, stroke_set ), diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 1b159cb5..66bc7da4 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -1,7 +1,6 @@ //! 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). -use std::cell::RefCell; use std::collections::HashMap; use egui::{Sense, Ui, vec2}; @@ -26,43 +25,25 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option Option { - thread_local! { - static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); - static CACHE: RefCell> = 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}:{def}"); - if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { - return Some(t); - } - let mut doc = Document::new(w, h); - doc.unknown.insert("brushes".into(), json!([def])); - let name = def["name"].as_str()?.to_string(); - let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); - ap.stroke_mut()?.brush = Some(name); - // 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) + widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| { + let mut doc = Document::new(w, h); + doc.unknown.insert("brushes".into(), json!([def])); + let name = def["name"].as_str()?.to_string(); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut()?.brush = Some(name); + // 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()?; + Some(doc) + }) } fn selected_brush(app: &VectorcraftApp) -> Option { diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 361cbea4..fcfa29e3 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -1,9 +1,10 @@ //! 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 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 super::{character, current_stroke, pstate, set_pstate, stroke_mixed}; @@ -64,14 +65,8 @@ pub fn dash_pattern(fields: &[Option; 6]) -> Vec { out } -fn arrow_label(a: Option) -> String { - match a { - None => "None".into(), - Some(a) => { - let s = format!("{a:?}"); - s.replace("Open", " (open)") - } - } +fn arrow_label(a: Option) -> &'static str { + a.map_or("None", Arrowhead::label) } /// 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, start: bool, w: f64, h: f64) -> Option { + 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, start: bool, color: Color32) { - let y = r.center().y; - let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) }; - 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| egui::Shape::convex_polygon(pts, color, Stroke::NONE); - let open = |pts: Vec| 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); + if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) { + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color); + } } /// 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, start: bool) -> let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" }); let mut out = None; egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| { - ui.set_min_width(140.0); - let opts: Vec> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect(); - for a in opts { - let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click()); - if a == cur { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if rr.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); + ui.set_min_width(170.0); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) { + let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click()); + if a == cur { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if rr.hovered() { + 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 } diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index 8792c184..e37b6e75 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -87,3 +87,34 @@ fn dropdown_lists_saved_profiles_and_applies_them() { click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show); 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, 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)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d2711516..c50f25c7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1027,3 +1027,33 @@ pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) { 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) -> Option { + use std::cell::RefCell; + use std::collections::HashMap; + thread_local! { + static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); + static CACHE: RefCell> = 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) +} diff --git a/docs/mcp.md b/docs/mcp.md index 07d51b99..6df362c0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.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.