From 09d7aaa7f4507b93b2b76bc4fe43513bc48f890e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:16:17 +0700 Subject: [PATCH 1/5] M3.18: shared stroke-geometry module for arrowheads, width profiles and the profile preset catalogue Arrowheads, width-profile outlines and dashed line outlines now come from vectorcraft_effects::stroke (stroke_pieces, line_outline, width_outline), used by the canvas and PDF export alike; the renderer caches profile outlines too. Width profiles are a doc-level catalogue (WidthProfile::PRESETS, preset, preset_id, id_of) shared by stroke.set and the Stroke panel; stroke.set rejects unknown profile ids and its params doc lists the arrowhead names. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 51 ++++++- crates/doc/src/lib.rs | 4 +- crates/effects/src/lib.rs | 3 + crates/effects/src/stroke.rs | 126 ++++++++++++++++++ crates/effects/src/stroke/arrow.rs | 69 ++++++++++ crates/effects/src/stroke/tests.rs | 82 ++++++++++++ .../src => effects/src/stroke}/width.rs | 27 +--- crates/engine/src/cmd/stroke.rs | 21 +-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_strokegeom.rs | 47 +++++++ crates/pdf/src/export.rs | 88 +----------- crates/pdf/src/lib.rs | 2 + crates/pdf/src/tests_stroke.rs | 62 +++++++++ crates/render/src/lib.rs | 77 +++-------- crates/render/src/paint.rs | 87 +----------- crates/ui-egui/src/panels/stroke.rs | 65 +++++---- crates/ui-egui/src/tests_labels.rs | 2 +- 17 files changed, 526 insertions(+), 289 deletions(-) create mode 100644 crates/effects/src/stroke.rs create mode 100644 crates/effects/src/stroke/arrow.rs create mode 100644 crates/effects/src/stroke/tests.rs rename crates/{render/src => effects/src/stroke}/width.rs (88%) create mode 100644 crates/engine/src/tests_strokegeom.rs create mode 100644 crates/pdf/src/tests_stroke.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 91ef1b84..c57ac443 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -74,6 +74,17 @@ pub struct WidthProfile { pub points: Vec<(f64, f64, f64)>, } +/// A built-in width profile (the Stroke panel's Profile list). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProfilePreset { + /// Stable id used by `stroke.set {profile}`. + pub id: &'static str, + /// Menu label. + pub label: &'static str, + /// (t, left, right) width points. + pub points: &'static [(f64, f64, f64)], +} + impl WidthProfile { /// Width factor at `t` (average of both sides), linear between points. pub fn at(&self, t: f64) -> (f64, f64) { @@ -93,15 +104,34 @@ impl WidthProfile { let l = p.last().unwrap(); (l.1, l.2) } - /// Illustrator's "Width Profile 1" (lens shape) analogue. + /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). + pub const PRESETS: [ProfilePreset; 4] = [ + ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }, + ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }, + ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + ]; + /// The built-in profile with this id. + pub fn preset(id: &str) -> Option { + Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() }) + } + /// The id of the built-in profile these points match, if any. + pub fn preset_id(&self) -> Option<&'static str> { + Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id) + } + /// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset. + pub fn id_of(p: Option<&Self>) -> &'static str { + p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom") + } + /// The lens profile (thin ends, full width in the middle). pub fn lens() -> Self { - Self { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] } + Self::preset("lens").expect("built-in") } pub fn taper_end() -> Self { - Self { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] } + Self::preset("taperEnd").expect("built-in") } pub fn taper_start() -> Self { - Self { points: vec![(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] } + Self::preset("taperStart").expect("built-in") } } @@ -347,6 +377,19 @@ mod tests { assert_eq!(p.at(2.0), (0.0, 0.0)); } + #[test] + fn profile_presets_round_trip_their_ids() { + for p in WidthProfile::PRESETS { + let prof = WidthProfile::preset(p.id).unwrap(); + assert_eq!(prof.preset_id(), Some(p.id)); + assert_eq!(WidthProfile::id_of(Some(&prof)), p.id); + } + assert_eq!(WidthProfile::id_of(None), "uniform"); + assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom"); + assert!(WidthProfile::preset("nope").is_none()); + 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 outset_depends_on_align() { let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d4b9cc0..34e4c7c5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -30,7 +30,9 @@ pub(crate) mod skip { } } -pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; +pub use appearance::{ + Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile, +}; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 17c4911f..3268d948 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -8,6 +8,8 @@ //! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by //! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond //! the geometry. +//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the +//! renderer, the exporters and Outline Stroke. //! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the //! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. //! @@ -19,6 +21,7 @@ mod bake; mod distort; mod group; mod raster; +pub mod stroke; mod stylize; mod util; mod warp; diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs new file mode 100644 index 00000000..91d6523b --- /dev/null +++ b/crates/effects/src/stroke.rs @@ -0,0 +1,126 @@ +//! Stroke geometry shared by the canvas, the PDF/SVG exporters and Outline Stroke, so every +//! consumer paints the same shapes: +//! +//! - [`stroke_pieces`]: the centre line and the arrowheads as filled outlines; +//! - [`width_outline`]: variable-width (profile) strokes; +//! - [`line_outline`]: the line part of a stroke as one filled outline (profile or dashes, caps +//! and joins). + +mod arrow; +mod width; + +use std::borrow::Cow; + +use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2}; +use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; + +pub use arrow::Arrow; +pub use width::width_outline; + +/// The geometry one stroke paints, in the coordinate space of its path. +#[derive(Clone, Debug)] +pub struct StrokePieces<'a> { + /// The centre line to stroke. + pub line: Cow<'a, BezPath>, + /// The arrowheads, start first. + pub heads: Vec, +} + +/// The centre line and arrowheads of stroke `st` along `bp`. +pub fn stroke_pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> { + if st.start_arrow.is_none() && st.end_arrow.is_none() { + return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; + } + arrow::pieces(bp, st) +} + +/// Does `bp` end with a `ClosePath`? +pub fn is_closed(bp: &BezPath) -> bool { + bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath)) +} + +/// The width actually stroked: inside/outside strokes of closed paths are drawn twice as wide +/// and clipped to one side; open paths always stroke centred. +pub fn aligned_width(st: &StrokeLayer, closed: bool) -> f64 { + match st.align { + StrokeAlign::Inside | StrokeAlign::Outside if closed => st.width * 2.0, + _ => st.width, + } +} + +/// The stroke style (cap, join, miter limit) of `st` at `width`, without dashes. +pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke { + kurbo::Stroke::new(width) + .with_join(match st.join { + LineJoin::Miter => kurbo::Join::Miter, + LineJoin::Round => kurbo::Join::Round, + LineJoin::Bevel => kurbo::Join::Bevel, + }) + .with_caps(match st.cap { + LineCap::Butt => kurbo::Cap::Butt, + LineCap::Round => kurbo::Cap::Round, + LineCap::Square => kurbo::Cap::Square, + }) + .with_miter_limit(st.miter_limit) +} + +/// Does the dash pattern of `st` draw anything other than a solid line? +pub fn is_dashed(st: &StrokeLayer) -> bool { + st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0)) +} + +/// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at +/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the width profile, or the dash +/// pattern, with the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not +/// included. +pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath { + if line.elements().is_empty() || width <= 0.0 || !width.is_finite() { + return BezPath::new(); + } + let tol = tol.max(1e-4); + if let Some(profile) = st.profile.as_ref().filter(|_| !is_dashed(st)) { + return width_outline(line, width, profile, st.cap, tol); + } + let mut style = style(st, width); + if let Some(d) = st.dash.as_ref().filter(|_| is_dashed(st)) { + // An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF). + let mut pat = d.pattern.clone(); + if pat.len() % 2 == 1 { + pat.extend_from_within(..); + } + style = style.with_dashes(d.offset, pat); + } + kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol) +} + +/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point. +pub(crate) fn tangent(s: &PathSeg, t: f64) -> Vec2 { + let d = match s { + PathSeg::Line(l) => l.p1 - l.p0, + PathSeg::Quad(q) => { + let v = q.deriv().eval(t).to_vec2(); + if v.hypot() > 1e-9 { v } else { q.p2 - q.p0 } + } + PathSeg::Cubic(c) => { + let v = c.deriv().eval(t).to_vec2(); + if v.hypot() > 1e-9 { + v + } else if t < 0.5 { + // The first control point that differs from the start gives the direction. + [c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|v| v.hypot() > 1e-9).unwrap_or_default() + } else { + [c.p1, c.p0].into_iter().map(|p| c.p3 - p).find(|v| v.hypot() > 1e-9).unwrap_or_default() + } + } + }; + unit(d) +} + +/// `v` normalised, or +x when it has no length. +pub(crate) fn unit(v: Vec2) -> Vec2 { + let h = v.hypot(); + if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) } +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs new file mode 100644 index 00000000..6b5f1bcf --- /dev/null +++ b/crates/effects/src/stroke/arrow.rs @@ -0,0 +1,69 @@ +//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide, +//! with its tip on the end point of the path. + +use std::borrow::Cow; + +use kurbo::{BezPath, ParamCurve, PathSeg, Point, Shape, Vec2}; +use vectorcraft_doc::{Arrowhead, StrokeLayer}; + +use super::{StrokePieces, tangent}; + +/// One arrowhead. +#[derive(Clone, Debug, PartialEq)] +pub struct Arrow { + pub kind: Arrowhead, + /// The filled outline (non-zero rule). + pub outline: BezPath, + pub tip: Point, + /// Unit direction the head points in. + pub dir: Vec2, +} + +/// [`super::stroke_pieces`] for a stroke with at least one arrowhead. +pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> { + let segs: Vec = bp.segments().collect(); + let mut heads = vec![]; + if let (Some(first), Some(last)) = (segs.first(), segs.last()) { + let weight = |pct: f64| (st.width * pct / 100.0).max(0.25); + if let Some(kind) = st.start_arrow { + let (tip, dir) = (first.start(), -tangent(first, 0.0)); + heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(st.arrow_scale.0)), tip, dir }); + } + if let Some(kind) = st.end_arrow { + let (tip, dir) = (last.end(), tangent(last, 1.0)); + heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(st.arrow_scale.1)), tip, dir }); + } + } + StrokePieces { line: Cow::Borrowed(bp), heads } +} + +/// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`. +fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { + let n = Vec2::new(-dir.y, dir.x); + let len = 4.0 * hw; + let half = len / 2.0; + let base = tip - dir * len; + let centre = tip - dir * half; + let mut p = BezPath::new(); + match kind { + Arrowhead::Triangle | Arrowhead::TriangleOpen => polygon(&mut p, &[tip, base + n * half, base - n * half]), + Arrowhead::Arrow | Arrowhead::ArrowOpen => polygon(&mut p, &[tip, base + n * half, tip - dir * (len * 0.7), base - n * half]), + Arrowhead::Circle | Arrowhead::CircleOpen => p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)), + Arrowhead::Square | Arrowhead::SquareOpen => polygon(&mut p, &[tip + n * half, base + n * half, base - n * half, tip - n * half]), + Arrowhead::Diamond => polygon(&mut p, &[tip, centre + n * half, base, centre - n * half]), + Arrowhead::Bar => { + let t = 0.75 * hw; + polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]); + } + } + p +} + +fn polygon(p: &mut BezPath, pts: &[Point]) { + let Some((first, rest)) = pts.split_first() else { return }; + p.move_to(*first); + for q in rest { + p.line_to(*q); + } + p.close_path(); +} diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs new file mode 100644 index 00000000..49e03c3b --- /dev/null +++ b/crates/effects/src/stroke/tests.rs @@ -0,0 +1,82 @@ +use kurbo::{BezPath, Point, Shape}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Arrowhead, Dash, StrokeLayer, WidthProfile}; + +use super::*; + +fn line(x0: f64, x1: f64) -> BezPath { + let mut b = BezPath::new(); + b.move_to((x0, 0.0)); + b.line_to((x1, 0.0)); + b +} + +fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); + f(&mut st); + st +} + +fn dashed(pattern: &[f64], offset: f64) -> Dash { + Dash { pattern: pattern.to_vec(), offset, align_corners: false } +} + +fn near(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { + let l = line(0.0, 100.0); + let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3); + assert!(near(plain.area().abs(), 1000.0, 1e-6)); + let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3); + assert!(near(lens.area().abs(), 500.0, 1e-6)); + // Dashes take precedence over a profile (as before). + let st = stroke(10.0, |s| { + s.profile = Some(WidthProfile::lens()); + s.dash = Some(dashed(&[25.0, 25.0], 0.0)); + }); + assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0, 1e-6)); + assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); +} + +#[test] +fn aligned_width_doubles_only_closed_inside_and_outside_strokes() { + let mut st = stroke(3.0, |s| s.align = vectorcraft_doc::StrokeAlign::Inside); + assert_eq!(aligned_width(&st, true), 6.0); + assert_eq!(aligned_width(&st, false), 3.0); + st.align = vectorcraft_doc::StrokeAlign::Center; + assert_eq!(aligned_width(&st, true), 3.0); +} + +#[test] +fn no_heads_borrows_the_path() { + let l = line(0.0, 50.0); + let p = stroke_pieces(&l, &stroke(2.0, |_| {})); + assert!(matches!(p.line, Cow::Borrowed(_)) && p.heads.is_empty()); +} + +#[test] +fn heads_sit_on_the_end_points_and_point_out_of_the_path() { + for kind in Arrowhead::ALL { + let st = stroke(2.0, |s| { + s.start_arrow = Some(kind); + s.end_arrow = Some(kind); + }); + let l = line(0.0, 100.0); + let p = stroke_pieces(&l, &st); + let [a, b] = &p.heads[..] else { panic!("two heads") }; + assert_eq!((a.tip, b.tip), (Point::new(0.0, 0.0), Point::new(100.0, 0.0)), "{kind:?}"); + assert!(near(a.dir.x, -1.0, 1e-9) && near(b.dir.x, 1.0, 1e-9), "{kind:?}"); + let (ba, bb) = (a.outline.bounding_box(), b.outline.bounding_box()); + assert!(ba.x0 >= -0.01 && bb.x1 <= 100.01, "{kind:?}: nothing past the tips"); + assert!(a.outline.area().abs() > 0.5, "{kind:?}"); + } + // The start head of a curve points back along its first tangent. + let mut c = BezPath::new(); + c.move_to((0.0, 0.0)); + c.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0)); + let p = stroke_pieces(&c, &stroke(2.0, |s| s.start_arrow = Some(Arrowhead::Triangle))); + assert!(p.heads[0].dir.x < 0.0 && p.heads[0].dir.y > 0.0, "{:?}", p.heads[0].dir); +} diff --git a/crates/render/src/width.rs b/crates/effects/src/stroke/width.rs similarity index 88% rename from crates/render/src/width.rs rename to crates/effects/src/stroke/width.rs index b7901343..aef94bf6 100644 --- a/crates/render/src/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -6,24 +6,10 @@ //! projecting caps (the round cap blends the two side widths); closed subpaths become two loops //! of opposite orientation filled with the non-zero rule. -use vectorcraft_doc::{LineCap, StrokeAlign, StrokeLayer, WidthProfile}; -use vectorcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2}; +use kurbo::{BezPath, PathEl, Point, Vec2}; +use vectorcraft_doc::{LineCap, WidthProfile}; -/// The outline to fill instead of stroking, when `st` has a width profile (and no dashes). -pub(crate) fn outline_for(bp: &BezPath, st: &StrokeLayer, tol: f64) -> Option { - let profile = st.profile.as_ref()?; - if st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0)) { - return None; - } - let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath)); - let width = match st.align { - StrokeAlign::Center => st.width, - _ if closed => st.width * 2.0, - _ => st.width, - }; - let o = width_outline(bp, width, profile, st.cap, tol); - (!o.elements().is_empty()).then_some(o) -} +use super::unit; /// Filled outline of `bp` stroked with `width` scaled by `profile`, flattened to `tol`. pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, cap: LineCap, tol: f64) -> BezPath { @@ -72,11 +58,6 @@ fn subpaths(bp: &BezPath, tol: f64) -> Vec<(Vec, bool)> { out } -fn unit(v: Vec2) -> Vec2 { - let h = v.hypot(); - if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) } -} - /// Left normal in y-down space (left of the direction of travel). fn left(t: Vec2) -> Vec2 { Vec2::new(t.y, -t.x) @@ -255,7 +236,5 @@ mod tests { let a = area(&width_outline(&c, 10.0, &p, LineCap::Butt, 0.001)); let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); - let st = StrokeLayer { profile: None, ..StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 4.0) }; - assert!(outline_for(&c, &st, 0.1).is_none()); } } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c97ace47..0ddf153e 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -2,7 +2,7 @@ use serde_json::Value; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; use super::*; @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", has_doc, stroke_set ), @@ -48,6 +48,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } }; let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); + let profile = match str_param(p, "profile") { + None => None, + Some("uniform") => Some(None), + Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad("stroke.set", format!("unknown profile {id}")))?)), + }; s.edit("Stroke", |d, _| { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; @@ -97,12 +102,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(a) = ea { st.end_arrow = a; } - match str_param(p, "profile") { - Some("uniform") => st.profile = None, - Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()), - Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()), - Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()), - _ => {} + if let Some(pr) = &profile { + st.profile = pr.clone(); } } Ok(()) @@ -111,11 +112,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } /// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths). -pub(crate) fn flip_profile(p: &vectorcraft_doc::WidthProfile, along: bool) -> vectorcraft_doc::WidthProfile { +pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile { let mut pts: Vec<(f64, f64, f64)> = if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() }; pts.sort_by(|a, b| a.0.total_cmp(&b.0)); - vectorcraft_doc::WidthProfile { points: pts } + WidthProfile { points: pts } } fn stroke_advanced(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..d5920467 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -733,6 +733,8 @@ mod tests_pattern; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_strokegeom; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_strokegeom.rs b/crates/engine/src/tests_strokegeom.rs new file mode 100644 index 00000000..431605e8 --- /dev/null +++ b/crates/engine/src/tests_strokegeom.rs @@ -0,0 +1,47 @@ +//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): the width-profile presets. + +use serde_json::json; +use vectorcraft_doc::{StrokeLayer, WidthProfile}; + +use super::*; + +fn session_with_line() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn stroke(s: &Session, id: NodeId) -> StrokeLayer { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone() +} + +#[test] +fn profiles_come_from_the_preset_catalogue() { + let (mut s, id) = session_with_line(); + for p in WidthProfile::PRESETS { + s.execute("stroke.set", &json!({"profile": p.id})).unwrap(); + let st = stroke(&s, id); + assert_eq!(WidthProfile::id_of(st.profile.as_ref()), p.id); + } + assert!(stroke(&s, id).profile.is_some()); + s.execute("stroke.set", &json!({"profile": "uniform"})).unwrap(); + assert!(stroke(&s, id).profile.is_none(), "uniform is the plain stroke"); + assert!(s.execute("stroke.set", &json!({"profile": "zigzag"})).is_err()); + // The params doc (what agents read) lists every preset. + let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap(); + for p in WidthProfile::PRESETS { + assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id); + } +} + +#[test] +fn profiles_survive_the_native_format() { + let (mut s, id) = session_with_line(); + s.execute("stroke.set", &json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "profile": "taperEnd"})).unwrap(); + let doc = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); + let st = back.node(id).unwrap().appearance.stroke().unwrap().clone(); + assert_eq!(st, stroke(&s, id)); + assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "taperEnd"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 20cea8e3..9cbe96f7 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -11,9 +11,9 @@ use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; use krilla::surface::Surface; -use kurbo::{ParamCurve, ParamCurveDeriv, PathEl, Shape, Vec2}; +use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; -use vectorcraft_doc::{AppearanceItem, Arrowhead, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use crate::{Compatibility, ExportReport, PdfError, PdfOptions}; @@ -577,10 +577,11 @@ impl Exporter<'_> { s.pop(); pushes -= 1; } - if st.start_arrow.is_some() || st.end_arrow.is_some() { - for head in arrowheads(bp, st) { - if let Some(p) = to_path(&head) { - s.set_fill(Some(Fill { paint: paint.clone(), opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero })); + let heads = vectorcraft_effects::stroke::stroke_pieces(bp, st).heads; + if !heads.is_empty() { + s.set_fill(Some(Fill { paint, opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero })); + for head in &heads { + if let Some(p) = to_path(&head.outline) { s.draw_path(&p); } } @@ -679,78 +680,3 @@ impl Exporter<'_> { s.pop(); } } - -/// Arrowhead outlines for a stroke (same geometry as the renderer). -fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec { - let segs: Vec = bp.segments().collect(); - let mut out = Vec::new(); - let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out }; - if let Some(a) = st.start_arrow { - out.push(head(a, first.eval(0.0), tangent(first, 0.0) * -1.0, st.width * st.arrow_scale.0 / 100.0)); - } - if let Some(a) = st.end_arrow { - out.push(head(a, last.eval(1.0), tangent(last, 1.0), st.width * st.arrow_scale.1 / 100.0)); - } - out -} - -fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 { - let d = match s { - kurbo::PathSeg::Line(l) => l.p1 - l.p0, - kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), - kurbo::PathSeg::Cubic(c) => { - let v = c.deriv().eval(t).to_vec2(); - if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v } - } - }; - let l = d.hypot(); - if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l } -} - -fn head(kind: Arrowhead, tip: kurbo::Point, dir: Vec2, w: f64) -> BezPath { - let w = w.max(0.25); - let n = Vec2::new(-dir.y, dir.x); - let len = 4.0 * w; - let half = 2.0 * w; - let base = tip - dir * len; - let mut p = BezPath::new(); - match kind { - Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => { - p.move_to(tip); - p.line_to(base + n * half); - if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) { - p.line_to(tip - dir * (len * 0.7)); - } - p.line_to(base - n * half); - p.close_path(); - } - Arrowhead::Circle | Arrowhead::CircleOpen => { - p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01); - } - Arrowhead::Square | Arrowhead::SquareOpen => { - let c = tip - dir * half; - p.move_to(c + dir * half + n * half); - p.line_to(c - dir * half + n * half); - p.line_to(c - dir * half - n * half); - p.line_to(c + dir * half - n * half); - p.close_path(); - } - Arrowhead::Diamond => { - let c = tip - dir * half; - p.move_to(tip); - p.line_to(c + n * half); - p.line_to(c - dir * half); - p.line_to(c - n * half); - p.close_path(); - } - Arrowhead::Bar => { - let t = w * 0.75; - p.move_to(tip + n * half); - p.line_to(tip + n * half - dir * t); - p.line_to(tip - n * half - dir * t); - p.line_to(tip - n * half); - p.close_path(); - } - } - p -} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ed602a51..680c0076 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -105,3 +105,5 @@ pub enum PdfError { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_stroke; diff --git a/crates/pdf/src/tests_stroke.rs b/crates/pdf/src/tests_stroke.rs new file mode 100644 index 00000000..454ce90c --- /dev/null +++ b/crates/pdf/src/tests_stroke.rs @@ -0,0 +1,62 @@ +//! Stroke geometry in PDF export: arrowheads come from the shared `vectorcraft_effects::stroke` +//! geometry, the same the canvas draws. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_effects::stroke::stroke_pieces; +use vectorcraft_geom::{Point, Rect, Shape, shapes}; + +use crate::*; + +fn line_doc(st: StrokeLayer) -> (Document, Node) { + let mut d = Document::new(100.0, 100.0); + let mut n = Node::path( + NodeId(0), + shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)), + Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, + ); + n.id = d.alloc_id(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n.clone()).unwrap(); + (d, n) +} + +fn arrow_stroke(kind: Arrowhead) -> StrokeLayer { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + st.end_arrow = Some(kind); + st +} + +fn close(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.01) +} + +/// Bounding boxes of the filled and the stroked paths of a PDF read back. +fn painted_boxes(d: &Document) -> (Vec, Vec) { + let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap(); + let (mut fills, mut strokes) = (vec![], vec![]); + back.walk(|m| { + let Some(bb) = m.geometric_bounds().filter(|_| matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })) else { return }; + match m.appearance.items.first() { + Some(AppearanceItem::Fill(_)) => fills.push(bb), + Some(AppearanceItem::Stroke(_)) => strokes.push(bb), + None => {} + } + }); + (fills, strokes) +} + +#[test] +fn arrowheads_match_the_shared_geometry() { + for kind in Arrowhead::ALL { + let (d, n) = line_doc(arrow_stroke(kind)); + let bp = n.path_data().unwrap().to_bezpath(); + let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap()); + let (fills, strokes) = painted_boxes(&d); + let head = pieces.heads[0].outline.bounding_box(); + assert_eq!(fills.len(), 1, "{kind:?}: one head"); + assert!(close(fills[0], head), "{kind:?}: {:?} vs {head:?}", fills[0]); + assert_eq!(strokes.len(), 1, "{kind:?}: one line"); + assert!(close(strokes[0], bp.bounding_box()), "{kind:?}: {:?}", strokes[0]); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 10cfadc6..3cb957d0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,22 +12,21 @@ mod live; mod paint; mod pattern; pub mod proof; -mod width; use std::collections::HashMap; use std::sync::Arc; -use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; use vello_cpu::{Pixmap, RenderContext, Resources}; +pub use effects::stroke::width_outline; pub use live::expand_live; pub use pattern::render_pattern_swatch; pub use vectorcraft_effects as effects; pub use vello_cpu; -pub use width::width_outline; /// Rendering options. #[derive(Clone, Debug)] @@ -653,36 +652,10 @@ impl Renderer { ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None); } ctx.set_transform(f.view); - let closed = bp.elements().last().is_some_and(|e| matches!(e, kurbo::PathEl::ClosePath)); - let width = match st.align { - StrokeAlign::Center => st.width, - _ if closed => st.width * 2.0, - _ => st.width, - }; - // Keep hairlines visible when zoomed far out (Illustrator shows at least ~1 device pixel). - let width = width.max(f.px * 0.5); - let mut stroke = kurbo::Stroke::new(width) - .with_join(match st.join { - LineJoin::Miter => kurbo::Join::Miter, - LineJoin::Round => kurbo::Join::Round, - LineJoin::Bevel => kurbo::Join::Bevel, - }) - .with_caps(match st.cap { - LineCap::Butt => kurbo::Cap::Butt, - LineCap::Round => kurbo::Cap::Round, - LineCap::Square => kurbo::Cap::Square, - }) - .with_miter_limit(st.miter_limit); - if let Some(d) = &st.dash - && d.pattern.iter().any(|v| *v > 0.0) - { - let mut pat = d.pattern.clone(); - if pat.len() % 2 == 1 { - pat.extend(pat.clone()); - } - stroke = stroke.with_dashes(d.offset, pat); - } - ctx.set_stroke(stroke.clone()); + let pieces = effects::stroke::stroke_pieces(bp, st); + let closed = effects::stroke::is_closed(bp); + // Keep hairlines visible when zoomed far out (at least ~1 device pixel). + let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5); let inside = st.align == StrokeAlign::Inside && closed; if inside { ctx.set_fill_rule(fill_rule(rule)); @@ -690,14 +663,10 @@ impl Renderer { } if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) { self.fold_alpha(ctx, &st.paint); - if let Some(o) = width::outline_for(bp, st, f.px * 0.25) { - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.fill_path(&o); - } else if let Some(o) = self.cached_stroke(f, bp, st, &stroke) { - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.fill_path(&o); - } else { - ctx.stroke_path(bp); + ctx.set_fill_rule(peniko::Fill::NonZero); + match self.cached_stroke(f, &pieces.line, st, width) { + Some(o) => ctx.fill_path(&o), + None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)), } } if inside { @@ -711,14 +680,10 @@ impl Renderer { ctx.fill_path(bp); ctx.set_blend_mode(BlendMode::default()); } - // Arrowheads. - if st.start_arrow.is_some() || st.end_arrow.is_some() { - for (arrow, head) in paint::arrowheads(bp, st) { - let _ = arrow; - if paint::set_paint(ctx, &st.paint, bounds, f.doc) { - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.fill_path(&head); - } + if !pieces.heads.is_empty() && paint::set_paint(ctx, &st.paint, bounds, f.doc) { + ctx.set_fill_rule(peniko::Fill::NonZero); + for head in &pieces.heads { + ctx.fill_path(&head.outline); } } if layered { @@ -726,21 +691,21 @@ impl Renderer { } } - /// The stroke of the fast-path node `self.cur` expanded to a fill outline in document space, - /// cached across frames. The tolerance (vello's 0.25 device px) is bucketed to powers of two, - /// never coarser than needed, so zooming reuses outlines until the level changes. - fn cached_stroke(&mut self, f: &Frame, bp: &BezPath, st: &StrokeLayer, stroke: &kurbo::Stroke) -> Option> { + /// The line part of a stroke of the fast-path node `self.cur` (see + /// [`effects::stroke::line_outline`]), cached across frames. The tolerance (vello's 0.25 + /// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses + /// outlines until the level changes. + fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option> { let node = self.cur.clone()?; let level = (f.px * 0.25).log2().floor() as i32; - let key = (st as *const StrokeLayer as usize, stroke.width.to_bits(), level); + let key = (st as *const StrokeLayer as usize, width.to_bits(), level); if let Some(e) = self.strokes.get_mut(&key) && Arc::ptr_eq(&e.node, &node) { e.stamp = self.stamp; return Some(e.outline.clone()); } - let tolerance = 2f64.powi(level); - let outline = Arc::new(kurbo::stroke(bp.iter(), stroke, &kurbo::StrokeOpts::default(), tolerance)); + let outline = Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level))); self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp }); Some(outline) } diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 8c3d030a..c33e89c5 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -1,10 +1,9 @@ -//! Paint conversion (solid, gradients, patterns), arrowheads and image decoding. +//! Paint conversion (solid, gradients, patterns) and image decoding. use vectorcraft_color::{GradientKind, Paint}; -use vectorcraft_doc::{Arrowhead, Document, StrokeLayer}; -use vectorcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; +use vectorcraft_doc::Document; +use vectorcraft_geom::{Affine, Rect, Vec2}; use vello_cpu::RenderContext; -use vello_cpu::kurbo::{self, ParamCurve, ParamCurveDeriv}; use vello_cpu::peniko::{self, ColorStop}; fn color(c: &vectorcraft_color::Color, alpha: f32) -> peniko::Color { @@ -54,86 +53,6 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Documen } } -/// Arrowhead geometry for a stroke: (which, filled outline) in document coordinates. -pub fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<(Arrowhead, BezPath)> { - let segs: Vec = bp.segments().collect(); - let mut out = Vec::new(); - let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out }; - if let Some(a) = st.start_arrow { - let p = first.eval(0.0); - let d = tangent(first, 0.0) * -1.0; - out.push((a, head(a, p, d, st.width * st.arrow_scale.0 / 100.0))); - } - if let Some(a) = st.end_arrow { - let p = last.eval(1.0); - let d = tangent(last, 1.0); - out.push((a, head(a, p, d, st.width * st.arrow_scale.1 / 100.0))); - } - out -} - -fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 { - let d = match s { - kurbo::PathSeg::Line(l) => l.p1 - l.p0, - kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), - kurbo::PathSeg::Cubic(c) => { - let v = c.deriv().eval(t).to_vec2(); - if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v } - } - }; - let l = d.hypot(); - if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l } -} - -/// Arrowhead outline pointing along `dir` with its tip at `tip`, sized for stroke weight `w`. -fn head(kind: Arrowhead, tip: Point, dir: Vec2, w: f64) -> BezPath { - let w = w.max(0.25); - let n = Vec2::new(-dir.y, dir.x); - let len = 4.0 * w; - let half = 2.0 * w; - let base = tip - dir * len; - let mut p = BezPath::new(); - match kind { - Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => { - p.move_to(tip); - p.line_to(base + n * half); - if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) { - p.line_to(tip - dir * (len * 0.7)); - } - p.line_to(base - n * half); - p.close_path(); - } - Arrowhead::Circle | Arrowhead::CircleOpen => { - p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01); - } - Arrowhead::Square | Arrowhead::SquareOpen => { - let c = tip - dir * half; - p.move_to(c + dir * half + n * half); - p.line_to(c - dir * half + n * half); - p.line_to(c - dir * half - n * half); - p.line_to(c + dir * half - n * half); - p.close_path(); - } - Arrowhead::Diamond => { - let c = tip - dir * half; - p.move_to(tip); - p.line_to(c + n * half); - p.line_to(c - dir * half); - p.line_to(c - n * half); - p.close_path(); - } - Arrowhead::Bar => { - let t = w * 0.75; - p.move_to(tip + n * half); - p.line_to(tip + n * half - dir * t); - p.line_to(tip - n * half - dir * t); - p.line_to(tip - n * half); - p.close_path(); - } - } - p -} - /// Decode encoded image bytes into a premultiplied pixmap. pub fn decode_pixmap(bytes: &[u8]) -> Option { let img = image::load_from_memory(bytes).ok()?.to_rgba8(); diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 90b456c5..ff358fb8 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -3,7 +3,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, StrokeAlign, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; use super::{first_selected, pstate, set_pstate}; use crate::theme::Tokens; @@ -13,27 +13,9 @@ use crate::{VectorcraftApp, icons}; pub const WEIGHT_PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; -/// Width profile presets: (id for `stroke.set`, label). -pub const PROFILES: [(&str, &str); 4] = [("uniform", "Uniform"), ("lens", "Lens"), ("taperStart", "Taper Start"), ("taperEnd", "Taper End")]; - -/// Which preset a stroke's profile is (None profile = uniform). -pub fn profile_id(p: Option<&WidthProfile>) -> &'static str { - let Some(p) = p else { return "uniform" }; - for (id, prof) in [("lens", WidthProfile::lens()), ("taperStart", WidthProfile::taper_start()), ("taperEnd", WidthProfile::taper_end())] { - if prof.points == p.points { - return id; - } - } - "custom" -} - +/// The silhouette drawn for profile `id` (None draws the plain uniform bar). fn profile_of(id: &str) -> Option { - match id { - "lens" => Some(WidthProfile::lens()), - "taperStart" => Some(WidthProfile::taper_start()), - "taperEnd" => Some(WidthProfile::taper_end()), - _ => None, - } + WidthProfile::preset(id).filter(|_| id != "uniform") } /// Dash pattern → the panel's six dash/gap fields (None = empty field). @@ -237,7 +219,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Profile. - let pid = profile_id(st.as_ref().and_then(|s| s.profile.as_ref())); + let pid = WidthProfile::id_of(st.as_ref().and_then(|s| s.profile.as_ref())); ui.horizontal(|ui| { row_label(ui, "Profile:"); if let Some(id) = profile_dropdown(ui, pid) { @@ -355,7 +337,7 @@ fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { let resp = resp.on_hover_text("Variable Width Profile"); let mut out = None; egui::Popup::menu(&resp).show(|ui| { - for (id, label) in PROFILES { + for ProfilePreset { id, label, .. } in WidthProfile::PRESETS { let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click()); if id == cur { ui.painter().rect_filled(row, 0.0, t.row_selected); @@ -404,11 +386,38 @@ mod tests { } #[test] - fn profile_ids() { - assert_eq!(profile_id(None), "uniform"); - assert_eq!(profile_id(Some(&WidthProfile::lens())), "lens"); - assert_eq!(profile_id(Some(&WidthProfile::taper_end())), "taperEnd"); - assert_eq!(profile_id(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom"); + fn profile_silhouettes_and_arrow_labels() { + assert!(profile_of("uniform").is_none()); + assert_eq!(profile_of("lens"), Some(WidthProfile::lens())); + assert!(profile_of("custom").is_none()); assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); } + + /// Run the panel and its ≡ menu for one headless frame. + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(app, ui); + menu(app, ui); + }); + out.textures_delta.clear(); + } + + #[test] + fn panel_draws_for_lines_with_and_without_heads() { + use vectorcraft_engine::Session; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let r = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); + r(&mut app, "file.new", json!({"width": 100, "height": 100})); + frame(&mut app); + r(&mut app, "shape.line", json!({"x1": 10, "y1": 50, "x2": 90, "y2": 50})); + frame(&mut app); + for p in [json!({"endArrow": "ArrowOpen"}), json!({"profile": "lens"}), json!({"dash": [0, 6], "cap": "round"})] { + r(&mut app, "stroke.set", p); + frame(&mut app); + } + let st = stroke_now(&app).unwrap(); + assert_eq!(st.end_arrow, Some(Arrowhead::ArrowOpen)); + assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens"); + } } diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index f17a0d6a..7907abce 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -56,7 +56,7 @@ fn library_submenus_are_disabled_placeholders() { #[test] fn stroke_profiles_have_descriptive_names() { - let labels: Vec<&str> = crate::panels::stroke::PROFILES.iter().map(|p| p.1).collect(); + let labels: Vec<&str> = vectorcraft_doc::WidthProfile::PRESETS.iter().map(|p| p.label).collect(); assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]); } From 23f19a861b74887cb31e00ee098c014dea54b8a1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:18:13 +0700 Subject: [PATCH 2/5] M3.19: dotted lines: zero-length dashes draw discs or squares The shared stroke geometry now has its own arc-length dash generator: a dash of length 0 becomes a dot that a round cap paints as a disc and a projecting cap as a square turned along the path (a butt cap paints nothing). Dashes restart per subpath, join across the start of closed subpaths, and a pattern too fine for the path draws solid. The canvas and raster export use it through line_outline; the stroke.set params doc says how to get dots. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/stroke.rs | 70 +++++++-- crates/effects/src/stroke/dash.rs | 188 +++++++++++++++++++++++++ crates/effects/src/stroke/tests.rs | 91 +++++++++++- crates/engine/src/cmd/stroke.rs | 2 +- crates/render/tests/stroke_geometry.rs | 77 ++++++++++ 5 files changed, 413 insertions(+), 15 deletions(-) create mode 100644 crates/effects/src/stroke/dash.rs create mode 100644 crates/render/tests/stroke_geometry.rs diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 91d6523b..8c75ccde 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -2,11 +2,14 @@ //! consumer paints the same shapes: //! //! - [`stroke_pieces`]: the centre line and the arrowheads as filled outlines; +//! - [`dash`]: the dash pattern; zero-length dashes become [`Dot`]s that a round or projecting cap +//! turns into discs or squares ([`dot_outline`]); //! - [`width_outline`]: variable-width (profile) strokes; -//! - [`line_outline`]: the line part of a stroke as one filled outline (profile or dashes, caps -//! and joins). +//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots, +//! caps and joins). mod arrow; +mod dash; mod width; use std::borrow::Cow; @@ -15,8 +18,12 @@ use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; pub use arrow::Arrow; +pub use dash::{Dashed, Dot, dash, dot_outline}; pub use width::width_outline; +/// Arc-length accuracy for dashing and trimming (document points). +const ARCLEN_ACCURACY: f64 = 1e-6; + /// The geometry one stroke paints, in the coordinate space of its path. #[derive(Clone, Debug)] pub struct StrokePieces<'a> { @@ -71,8 +78,8 @@ pub fn is_dashed(st: &StrokeLayer) -> bool { /// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at /// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the width profile, or the dash -/// pattern, with the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not -/// included. +/// pattern with its dots, with the stroke's caps and joins. Fill it with the non-zero rule. +/// Arrowheads are not included. pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath { if line.elements().is_empty() || width <= 0.0 || !width.is_finite() { return BezPath::new(); @@ -81,16 +88,15 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B if let Some(profile) = st.profile.as_ref().filter(|_| !is_dashed(st)) { return width_outline(line, width, profile, st.cap, tol); } - let mut style = style(st, width); - if let Some(d) = st.dash.as_ref().filter(|_| is_dashed(st)) { - // An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF). - let mut pat = d.pattern.clone(); - if pat.len() % 2 == 1 { - pat.extend_from_within(..); + let style = style(st, width); + match st.dash.as_ref().and_then(|d| dash(line, d)) { + Some(d) => { + let mut out = kurbo::stroke(d.path.iter(), &style, &kurbo::StrokeOpts::default(), tol); + out.extend(dot_outline(&d.dots, width, st.cap, tol).iter()); + out } - style = style.with_dashes(d.offset, pat); + None => kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol), } - kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol) } /// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point. @@ -122,5 +128,45 @@ pub(crate) fn unit(v: Vec2) -> Vec2 { if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) } } +/// Append `seg` to `out` (which must already have a current point at its start). +pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) { + match seg { + PathSeg::Line(l) => out.line_to(l.p1), + PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), + PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), + } +} + +/// Element ranges of the subpaths of `bp` (each with whether it is closed). +pub(crate) fn subpaths(bp: &BezPath) -> Vec<(std::ops::Range, bool)> { + let els = bp.elements(); + let mut out = vec![]; + let mut start = 0; + for (i, el) in els.iter().enumerate() { + match el { + PathEl::MoveTo(_) if i > start => { + out.push((start..i, false)); + start = i; + } + PathEl::ClosePath => { + out.push((start..i + 1, true)); + start = i + 1; + } + _ => {} + } + } + if start < els.len() { + out.push((start..els.len(), false)); + } + // A lone MoveTo (or a MoveTo straight after a ClosePath) draws nothing. + out.retain(|(r, _)| r.len() > 1); + out +} + +/// The segments of one subpath (`els` starts with its MoveTo; a ClosePath adds the closing line). +pub(crate) fn segments(els: &[PathEl]) -> Vec { + kurbo::segments(els.iter().copied()).collect() +} + #[cfg(test)] mod tests; diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs new file mode 100644 index 00000000..b4eb7f2b --- /dev/null +++ b/crates/effects/src/stroke/dash.rs @@ -0,0 +1,188 @@ +//! Dash patterns. Dashes follow the path's arc length, restart on every subpath and join across +//! the start of a closed subpath. A dash of zero length is a [`Dot`]: with a round cap it paints +//! a disc, with a projecting cap a square turned along the path, with a butt cap nothing. + +use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; +use vectorcraft_doc::{Dash, LineCap}; + +use super::{ARCLEN_ACCURACY, push_seg, segments, subpaths, tangent}; + +/// More dashes than this (a tiny pattern on a long path) draws the line solid instead. +const MAX_DASHES: f64 = 200_000.0; + +/// A zero-length dash: where it sits and the path's direction there. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dot { + pub at: Point, + pub dir: Vec2, +} + +/// A dashed path: the dashes (open subpaths to stroke without a dash pattern) and the dots. +#[derive(Clone, Debug, Default)] +pub struct Dashed { + pub path: BezPath, + pub dots: Vec, +} + +/// Apply dash pattern `d` to `bp`. `None` when the pattern has no positive length (the line is +/// solid) or is too fine to draw. +pub fn dash(bp: &BezPath, d: &Dash) -> Option { + if d.pattern.iter().any(|v| !v.is_finite()) || !d.offset.is_finite() { + return None; + } + let mut pat: Vec = d.pattern.iter().map(|v| v.max(0.0)).collect(); + if !pat.iter().any(|v| *v > 0.0) { + return None; + } + // An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF). + if pat.len() % 2 == 1 { + pat.extend_from_within(..); + } + let period: f64 = pat.iter().sum(); + let subs: Vec<(Vec, bool)> = subpaths(bp).into_iter().map(|(r, closed)| (segments(&bp.elements()[r]), closed)).collect(); + let lens: Vec> = subs.iter().map(|(segs, _)| segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect()).collect(); + let total: f64 = lens.iter().flatten().sum(); + if total / period * pat.len() as f64 > MAX_DASHES { + return None; + } + // Where the pattern starts: `offset` into it. + let mut off = d.offset.rem_euclid(period); + let mut ix0 = 0; + while off > pat[ix0] { + off -= pat[ix0]; + ix0 = (ix0 + 1) % pat.len(); + } + let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 }; + let mut out = Dashed::default(); + for ((segs, closed), lens) in subs.iter().zip(&lens) { + dash_subpath(&mut out, segs, lens, *closed, &pat, phase); + } + Some(out) +} + +/// Position in the pattern: entry index, length left in it, and whether it is a dash. +#[derive(Clone, Copy)] +struct Phase { + ix: usize, + rem: f64, + on: bool, +} + +fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool, pat: &[f64], mut ph: Phase) { + const EPS: f64 = 1e-9; + let first_dot = out.dots.len(); + let starts_on = ph.on && ph.rem > 0.0; + let mut dashes: Vec> = vec![]; + let mut cur: Vec = vec![]; + let mut cuts = 0; + for (seg, &len) in segs.iter().zip(lens) { + let mut s = 0.0; + loop { + let left = len - s; + if ph.rem > left + EPS { + if ph.on && left > EPS { + cur.push(sub(seg, len, s, len)); + } + ph.rem -= left; + break; + } + let end = (s + ph.rem).min(len); + if ph.on { + if pat[ph.ix] > 0.0 { + if end > s { + cur.push(sub(seg, len, s, end)); + } + if !cur.is_empty() { + dashes.push(std::mem::take(&mut cur)); + } + } else { + let t = param(seg, len, end); + out.dots.push(Dot { at: seg.eval(t), dir: tangent(seg, t) }); + } + } + cuts += 1; + s = end; + ph.ix = (ph.ix + 1) % pat.len(); + ph.rem = pat[ph.ix]; + ph.on = !ph.on; + } + } + let ends_on = !cur.is_empty(); + if ends_on { + dashes.push(cur); + } + if closed { + if cuts == 0 && ends_on { + // One dash all the way round: keep it closed so the start gets a join, not caps. + let segs = dashes.pop().unwrap_or_default(); + write(&mut out.path, &segs); + out.path.close_path(); + return; + } + if starts_on && ends_on && dashes.len() > 1 { + // The dash running through the start point is one dash. + let mut last = dashes.pop().unwrap_or_default(); + last.append(&mut dashes[0]); + dashes[0] = last; + } + // A dot on the start point is reached again at the end. + if out.dots.len() > first_dot + 1 && out.dots[first_dot].at.distance(out.dots[out.dots.len() - 1].at) < 1e-6 { + out.dots.pop(); + } + } + for d in &dashes { + write(&mut out.path, d); + } +} + +fn write(out: &mut BezPath, segs: &[PathSeg]) { + let Some(first) = segs.first() else { return }; + out.move_to(first.start()); + for s in segs { + push_seg(out, s); + } +} + +/// Curve parameter at arc length `s` along `seg` (of total length `len`). +fn param(seg: &PathSeg, len: f64, s: f64) -> f64 { + if s <= 0.0 { + 0.0 + } else if s >= len { + 1.0 + } else if let PathSeg::Line(_) = seg { + s / len + } else { + seg.inv_arclen(s, ARCLEN_ACCURACY) + } +} + +/// The part of `seg` between arc lengths `s0` and `s1`. +fn sub(seg: &PathSeg, len: f64, s0: f64, s1: f64) -> PathSeg { + seg.subsegment(param(seg, len, s0)..param(seg, len, s1)) +} + +/// Outlines of `dots` for a stroke of `width` with `cap`: discs (round), squares turned along the +/// path (projecting) or nothing (butt). Each outline winds like kurbo's stroke outlines, so a dot +/// touching a dash doesn't cancel it under the non-zero rule. +pub fn dot_outline(dots: &[Dot], width: f64, cap: LineCap, tol: f64) -> BezPath { + let mut out = BezPath::new(); + let r = width / 2.0; + if r <= 0.0 { + return out; + } + for d in dots { + match cap { + LineCap::Butt => {} + LineCap::Round => out.extend(kurbo::Circle::new(d.at, r).path_elements(tol)), + LineCap::Square => { + let (u, n) = (d.dir * r, Vec2::new(-d.dir.y, d.dir.x) * r); + out.move_to(d.at - u - n); + out.line_to(d.at + u - n); + out.line_to(d.at + u + n); + out.line_to(d.at - u + n); + out.close_path(); + } + } + } + out +} diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 49e03c3b..d7f3b20a 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -1,6 +1,6 @@ -use kurbo::{BezPath, Point, Shape}; +use kurbo::{BezPath, PathEl, Point, Rect, Shape, Vec2}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Arrowhead, Dash, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile}; use super::*; @@ -11,6 +11,10 @@ fn line(x0: f64, x1: f64) -> BezPath { b } +fn square(size: f64) -> BezPath { + Rect::new(0.0, 0.0, size, size).to_path(0.1) +} + fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); f(&mut st); @@ -21,10 +25,91 @@ fn dashed(pattern: &[f64], offset: f64) -> Dash { Dash { pattern: pattern.to_vec(), offset, align_corners: false } } +fn length(bp: &BezPath) -> f64 { + bp.segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-9)).sum() +} + +fn moves(bp: &BezPath) -> usize { + bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() +} + fn near(a: f64, b: f64, tol: f64) -> bool { (a - b).abs() <= tol } +// ---------------------------------------------------------------- dashes and dots + +#[test] +fn zero_length_dashes_are_dots_along_the_tangent() { + let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap(); + assert!(d.path.elements().is_empty(), "no dash has length"); + let xs: Vec = d.dots.iter().map(|p| p.at.x).collect(); + assert_eq!(d.dots.len(), 11, "{xs:?}"); + for (i, dot) in d.dots.iter().enumerate() { + assert!(near(dot.at.x, 6.0 * i as f64, 1e-9) && near(dot.at.y, 0.0, 1e-12)); + assert!(near(dot.dir.x, 1.0, 1e-12)); + } + // The offset shifts the dots along the path. + let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 3.0)).unwrap(); + assert_eq!(d.dots.len(), 10); + assert!(near(d.dots[0].at.x, 3.0, 1e-9)); + // Dots on a curve follow its direction. + let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6); + let d = dash(&c, &dashed(&[0.0, 10.0], 0.0)).unwrap(); + for dot in &d.dots { + assert!(dot.dir.dot(dot.at.to_vec2()).abs() < 1e-3, "tangent ⟂ radius at {:?}", dot.at); + } +} + +#[test] +fn dots_on_a_closed_path_are_not_doubled_at_the_start() { + let d = dash(&square(60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap(); + assert_eq!(d.dots.len(), 40); +} + +#[test] +fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() { + let d = dash(&line(10.0, 90.0), &dashed(&[10.0, 10.0], 0.0)).unwrap(); + assert_eq!(moves(&d.path), 4); + assert!(near(length(&d.path), 40.0, 1e-6)); + // An odd pattern repeats with dashes and gaps swapped: 10 on, 10 off, 10 on… + let odd = dash(&line(0.0, 80.0), &dashed(&[10.0], 0.0)).unwrap(); + assert_eq!(moves(&odd.path), 4); + // Perimeter 400, dashes of 20 starting 10 into the pattern: the dash at 390..410 runs + // through the start point and is one dash. + let sq = dash(&square(100.0), &dashed(&[20.0, 20.0], 10.0)).unwrap(); + assert_eq!(moves(&sq.path), 10); + assert!(near(length(&sq.path), 200.0, 1e-6)); + // A pattern without length is a solid line; one dash round a closed path stays closed. + assert!(dash(&line(0.0, 10.0), &dashed(&[0.0, 0.0], 0.0)).is_none()); + let whole = dash(&square(10.0), &dashed(&[100.0, 1.0], 0.0)).unwrap(); + assert!(is_closed(&whole.path)); + // A pattern far too fine for the path is drawn solid rather than as millions of dashes. + assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none()); +} + +#[test] +fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() { + let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0) }]; + let round = dot_outline(&dots, 4.0, LineCap::Round, 1e-4); + assert!(near(round.area(), std::f64::consts::PI * 4.0, 1e-3)); + let sq = dot_outline(&dots, 4.0, LineCap::Square, 1e-4); + assert!(near(sq.area(), 16.0, 1e-9)); + assert!(sq.contains(Point::new(11.9, 1.9)) && !round.contains(Point::new(11.9, 1.9))); + assert!(dot_outline(&dots, 4.0, LineCap::Butt, 1e-4).elements().is_empty()); + // A dot overlapping a dash adds to it under the non-zero rule instead of cancelling it. + let dash_outline = kurbo::stroke(line(0.0, 10.0).iter(), &kurbo::Stroke::new(4.0), &Default::default(), 1e-4); + for o in [&round, &sq] { + assert_eq!(o.area().signum(), dash_outline.area().signum()); + } + let st = stroke(4.0, |s| { + s.cap = LineCap::Round; + s.dash = Some(dashed(&[10.0, 1.0, 0.0, 1.0], 0.0)); + }); + let o = line_outline(&line(0.0, 12.0), &st, 4.0, 1e-3); + assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted"); +} + #[test] fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let l = line(0.0, 100.0); @@ -50,6 +135,8 @@ fn aligned_width_doubles_only_closed_inside_and_outside_strokes() { assert_eq!(aligned_width(&st, true), 3.0); } +// ---------------------------------------------------------------- arrowheads + #[test] fn no_heads_borrows_the_path() { let l = line(0.0, 50.0); diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 0ddf153e..b32f440d 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + "{weight?, 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), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", has_doc, stroke_set ), diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/stroke_geometry.rs new file mode 100644 index 00000000..f2d5215f --- /dev/null +++ b/crates/render/tests/stroke_geometry.rs @@ -0,0 +1,77 @@ +//! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas: +//! dotted lines (zero-length dashes). + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, LineCap, StrokeLayer}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_testkit::raster::{Image, assert_similar, render_region}; +use vectorcraft_testkit::svg; + +/// Pixels per point in these tests. +const S: f64 = 4.0; + +fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); + f(&mut st); + Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] } +} + +/// A horizontal line from x0 to x1 at y = 50 on a 100 × 100 artboard. +fn line_doc(x0: f64, x1: f64, app: Appearance, opacity: f32) -> Document { + let mut b = DocBuilder::new(100.0, 100.0); + b.path(shapes::line(Point::new(x0, 50.0), Point::new(x1, 50.0)), app, |n| n.opacity = opacity); + b.build() +} + +fn render(d: &Document) -> Image { + render_region(d, Rect::new(0.0, 0.0, 100.0, 100.0), S) +} + +/// Is the pixel at document point (x, y) dark? +fn dark(img: &Image, x: f64, y: f64) -> bool { + img.over_white((x * S) as u32, (y * S) as u32)[0] < 128 +} + +fn dotted(cap: LineCap) -> Document { + line_doc( + 20.0, + 80.0, + stroked(4.0, |s| { + s.cap = cap; + s.dash = Some(Dash { pattern: vec![0.0, 6.0], offset: 0.0, align_corners: false }); + }), + 1.0, + ) +} + +#[test] +fn zero_length_dashes_draw_dots_with_a_round_cap_and_squares_with_a_projecting_cap() { + let round = render(&dotted(LineCap::Round)); + // 60 pt with a dot every 6 pt: 11 separate dots along the centre line. + let row: Vec = (0..round.width).map(|x| round.over_white(x, (50.0 * S) as u32)[0] < 128).collect(); + let runs = row.windows(2).filter(|w| !w[0] && w[1]).count(); + assert_eq!(runs, 11, "dots along the line"); + for k in 0..=10 { + let x = 20.0 + 6.0 * k as f64; + assert!(dark(&round, x, 50.0), "dot {k}"); + assert!(!dark(&round, x + 3.0, 50.0), "gap after dot {k}"); + } + // A disc's corner region is empty; a square fills it. + assert!(!dark(&round, 20.0 + 1.8, 50.0 + 1.8)); + let square = render(&dotted(LineCap::Square)); + assert!(dark(&square, 20.0 + 1.8, 50.0 + 1.8)); + assert!(dark(&square, 26.0 - 1.8, 50.0 - 1.8)); + // A butt cap gives a zero-length dash no area at all. + assert_eq!(render(&dotted(LineCap::Butt)).ink(), 0); +} + +#[test] +fn dotted_lines_survive_svg_export_and_render_the_same() { + for cap in [LineCap::Round, LineCap::Square] { + let d = dotted(cap); + let text = svg::export(&d, &Default::default()); + let back = svg::import(&text).unwrap(); + assert_similar(&render(&d), &render(&back), 0.05, 0.001); + } +} From 5ae7bfb8cabbcbeb5889e11baa78d1eef68778b5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:20:40 +0700 Subject: [PATCH 3/5] M3.20: arrowheads: hollow outlines, tip/extend alignment, one opacity for line and head Hollow heads draw as rings (the open arrow as a chevron) and closed paths get no heads. The stroke stops under each head, so it never shows through a hollow head or past the tip; stroke.set {arrowAlign: "extend"|"tip"} chooses whether the tip extends past the end point (default) or sits on it with the stroke shortened. Line and heads share one paint box (a gradient runs on into the head) and take the stroke and object opacity once, as one layer on the canvas and one group in PDF. StrokeLayer.arrow_align is a new defaulted field, so older files load unchanged. The Stroke panel's Align buttons get two generated icons and are disabled when the stroke has no head. Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 2 + assets/icons/dc-arrow-extend.svg | 1 + assets/icons/dc-arrow-tip.svg | 1 + crates/doc/src/appearance.rs | 28 +++ crates/doc/src/lib.rs | 3 +- crates/effects/src/stroke.rs | 16 +- crates/effects/src/stroke/arrow.rs | 240 ++++++++++++++++++++++--- crates/effects/src/stroke/tests.rs | 124 +++++++++++-- crates/engine/src/cmd/stroke.rs | 13 +- crates/engine/src/tests_strokegeom.rs | 38 +++- crates/pdf/src/export.rs | 26 ++- crates/pdf/src/tests_stroke.rs | 60 +++++-- crates/render/src/lib.rs | 26 +-- crates/render/src/tests.rs | 5 +- crates/render/tests/stroke_geometry.rs | 103 ++++++++++- crates/ui-egui/src/icon_data.rs | 2 + crates/ui-egui/src/panels/stroke.rs | 38 +++- 17 files changed, 625 insertions(+), 101 deletions(-) create mode 100644 assets/icons/dc-arrow-extend.svg create mode 100644 assets/icons/dc-arrow-tip.svg diff --git a/ASSETS.md b/ASSETS.md index 0428b83b..a60d4039 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -89,6 +89,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-artboards.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/assets/icons/dc-arrow-extend.svg b/assets/icons/dc-arrow-extend.svg new file mode 100644 index 00000000..fd1092ba --- /dev/null +++ b/assets/icons/dc-arrow-extend.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-arrow-tip.svg b/assets/icons/dc-arrow-tip.svg new file mode 100644 index 00000000..a83f67a6 --- /dev/null +++ b/assets/icons/dc-arrow-tip.svg @@ -0,0 +1 @@ + diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index c57ac443..1bff9875 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -68,6 +68,17 @@ impl Arrowhead { ]; } +/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under +/// the head, so the line never shows through a hollow head or past its tip. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ArrowAlign { + /// The tip extends past the end point (the path keeps its length). + #[default] + Extend, + /// The tip sits on the end point (the stroke is shortened by the head). + Tip, +} + /// Variable-width profile: (position 0..1 along the path, left width factor, right width factor). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct WidthProfile { @@ -207,6 +218,9 @@ pub struct StrokeLayer { pub visible: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effects: Vec, + /// Arrowhead placement at both ends. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub arrow_align: ArrowAlign, } fn ten() -> f64 { @@ -241,6 +255,7 @@ impl StrokeLayer { blend: BlendMode::Normal, visible: true, effects: vec![], + arrow_align: ArrowAlign::Extend, } } } @@ -390,6 +405,19 @@ 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 arrow_align_defaults_to_extend_and_round_trips() { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + assert_eq!(st.arrow_align, ArrowAlign::Extend); + // The default is not written, so older readers see the same JSON as before. + assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align")); + let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap(); + assert_eq!(old.arrow_align, ArrowAlign::Extend); + st.arrow_align = ArrowAlign::Tip; + let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert_eq!(back, st); + } + #[test] fn outset_depends_on_align() { let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 34e4c7c5..f389ab03 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -31,7 +31,8 @@ pub(crate) mod skip { } pub use appearance::{ - Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile, + Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, + WidthProfile, }; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 8c75ccde..f57f949f 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -1,7 +1,8 @@ //! Stroke geometry shared by the canvas, the PDF/SVG exporters and Outline Stroke, so every //! consumer paints the same shapes: //! -//! - [`stroke_pieces`]: the centre line and the arrowheads as filled outlines; +//! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as +//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign); //! - [`dash`]: the dash pattern; zero-length dashes become [`Dot`]s that a round or projecting cap //! turns into discs or squares ([`dot_outline`]); //! - [`width_outline`]: variable-width (profile) strokes; @@ -27,9 +28,10 @@ const ARCLEN_ACCURACY: f64 = 1e-6; /// The geometry one stroke paints, in the coordinate space of its path. #[derive(Clone, Debug)] pub struct StrokePieces<'a> { - /// The centre line to stroke. + /// The centre line to stroke: the path with its ends trimmed under the arrowheads (borrowed + /// when nothing is trimmed). Empty when the heads cover the whole path. pub line: Cow<'a, BezPath>, - /// The arrowheads, start first. + /// The arrowheads, start first. Closed ends get none. pub heads: Vec, } @@ -55,6 +57,14 @@ pub fn aligned_width(st: &StrokeLayer, closed: bool) -> f64 { } } +/// How far a cap reaches past the end of a stroke of `width`. +pub fn cap_extent(cap: LineCap, width: f64) -> f64 { + match cap { + LineCap::Butt => 0.0, + LineCap::Round | LineCap::Square => width / 2.0, + } +} + /// The stroke style (cap, join, miter limit) of `st` at `width`, without dashes. pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke { kurbo::Stroke::new(width) diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index 6b5f1bcf..59ccd7da 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -1,58 +1,227 @@ -//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide, -//! with its tip on the end point of the path. +//! 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 +//! 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. use std::borrow::Cow; -use kurbo::{BezPath, ParamCurve, PathSeg, Point, Shape, Vec2}; -use vectorcraft_doc::{Arrowhead, StrokeLayer}; +use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer}; -use super::{StrokePieces, tangent}; +use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit}; /// One arrowhead. #[derive(Clone, Debug, PartialEq)] pub struct Arrow { pub kind: Arrowhead, - /// The filled outline (non-zero rule). + /// The filled outline (non-zero rule; hollow kinds are rings). pub outline: BezPath, pub tip: Point, /// Unit direction the head points in. pub dir: Vec2, + /// Distance from the tip back to where the stroke ends under the head. + pub inset: f64, } /// [`super::stroke_pieces`] for a stroke with at least one arrowhead. pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> { - let segs: Vec = bp.segments().collect(); + let subs = subpaths(bp); + let els = bp.elements(); + // Heads go on the start of the first subpath and the end of the last one, when open. + let first = subs.first().filter(|s| !s.1).map(|s| s.0.clone()); + let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone()); + let cap = cap_extent(st.cap, st.width); + let weight = |pct: f64| (st.width * pct / 100.0).max(0.25); + let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.0), cap))); + let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.1), cap))); + if start.is_none() && end.is_none() { + return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; + } + let tip_mode = st.arrow_align == ArrowAlign::Tip; let mut heads = vec![]; - if let (Some(first), Some(last)) = (segs.first(), segs.last()) { - let weight = |pct: f64| (st.width * pct / 100.0).max(0.25); - if let Some(kind) = st.start_arrow { - let (tip, dir) = (first.start(), -tangent(first, 0.0)); - heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(st.arrow_scale.0)), tip, dir }); - } - if let Some(kind) = st.end_arrow { - let (tip, dir) = (last.end(), tangent(last, 1.0)); - heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(st.arrow_scale.1)), tip, dir }); + // Trims per subpath index (start, end), applied in tip mode only. + let mut trims: Vec<(f64, f64)> = vec![(0.0, 0.0); subs.len()]; + let mut place = |kind: Arrowhead, range: std::ops::Range, at_start: bool, inset: f64, pct: f64| { + let segs = segments(&els[range]); + let (end_pt, outward) = end_of(&segs, at_start); + let (tip, dir) = if tip_mode { + // Point along the chord from where the stroke now ends to the end point. + let dir = cut_point(&segs, at_start, inset).map(|p| end_pt - p).filter(|v| v.hypot() > 1e-9).map(unit).unwrap_or(outward); + (end_pt, dir) + } else { + (end_pt + outward * inset, outward) + }; + heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(pct)), tip, dir, inset }); + }; + if let Some((k, r, i)) = start { + place(k, r, true, i, st.arrow_scale.0); + if tip_mode { + trims[0].0 = i; } } - StrokePieces { line: Cow::Borrowed(bp), heads } + if let Some((k, r, i)) = end { + place(k, r, false, i, st.arrow_scale.1); + if tip_mode { + trims[subs.len() - 1].1 = i; + } + } + if !tip_mode { + return StrokePieces { line: Cow::Borrowed(bp), heads }; + } + let mut line = BezPath::new(); + for ((range, _), &(s0, s1)) in subs.iter().zip(&trims) { + if s0 == 0.0 && s1 == 0.0 { + line.extend(els[range.clone()].iter().copied()); + continue; + } + // Only open subpaths get heads, so a trimmed subpath stays open. + let segs = trim(&segments(&els[range.clone()]), s0, s1); + if let Some(first) = segs.first() { + line.move_to(first.start()); + for s in &segs { + push_seg(&mut line, s); + } + } + } + StrokePieces { line: Cow::Owned(line), heads } +} + +/// The end point of a subpath and the unit direction pointing out of it there. +fn end_of(segs: &[PathSeg], at_start: bool) -> (Point, Vec2) { + let long = |s: &&PathSeg| s.arclen(ARCLEN_ACCURACY) > 1e-9; + if at_start { + let p = segs.first().map_or(Point::ORIGIN, |s| s.start()); + let d = segs.iter().find(long).map_or(Vec2::new(-1.0, 0.0), |s| -tangent(s, 0.0)); + (p, d) + } else { + let p = segs.last().map_or(Point::ORIGIN, |s| s.end()); + let d = segs.iter().rev().find(long).map_or(Vec2::new(1.0, 0.0), |s| tangent(s, 1.0)); + (p, d) + } +} + +/// The point `dist` along the subpath from its start (or back from its end), if it is that long. +fn cut_point(segs: &[PathSeg], from_start: bool, dist: f64) -> Option { + let (i, t) = if from_start { cut_from_start(segs, dist)? } else { cut_from_end(segs, dist)? }; + Some(segs[i].eval(t)) +} + +/// (segment, parameter) at arc length `dist` from the start. +fn cut_from_start(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> { + let mut acc = 0.0; + for (i, s) in segs.iter().enumerate() { + let len = s.arclen(ARCLEN_ACCURACY); + if acc + len >= dist { + return Some((i, if len > 0.0 { s.inv_arclen(dist - acc, ARCLEN_ACCURACY) } else { 0.0 })); + } + acc += len; + } + None +} + +/// (segment, parameter) at arc length `dist` back from the end. +fn cut_from_end(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> { + let mut acc = 0.0; + for (i, s) in segs.iter().enumerate().rev() { + let len = s.arclen(ARCLEN_ACCURACY); + if acc + len >= dist { + return Some((i, if len > 0.0 { s.inv_arclen(len - (dist - acc), ARCLEN_ACCURACY) } else { 1.0 })); + } + acc += len; + } + None +} + +/// The segments with `s0` of arc length removed from the start and `s1` from the end (empty when +/// nothing is left). +fn trim(segs: &[PathSeg], s0: f64, s1: f64) -> Vec { + let (Some((i0, t0)), Some((i1, t1))) = (cut_from_start(segs, s0), cut_from_end(segs, s1)) else { return vec![] }; + if (i0, t0) >= (i1, t1) { + return vec![]; + } + if i0 == i1 { + return vec![segs[i0].subsegment(t0..t1)]; + } + let mut out = Vec::with_capacity(i1 - i0 + 1); + out.push(segs[i0].subsegment(t0..1.0)); + out.extend_from_slice(&segs[i0 + 1..i1]); + out.push(segs[i1].subsegment(0.0..t1)); + out +} + +/// Head length (and width) for weight `hw`. +fn len(hw: f64) -> f64 { + 4.0 * hw +} +/// Wall of the hollow ring heads. +fn ring(hw: f64) -> f64 { + 0.5 * hw +} +/// Arm thickness of the open arrow. +fn arm(hw: f64) -> f64 { + hw +} +/// Thickness of the bar. +fn bar(hw: f64) -> f64 { + 0.75 * hw +} +/// Where the notch of the (open) arrow sits, as a fraction of the length. +const NOTCH: f64 = 0.7; + +/// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. +fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { + let l = len(hw); + // Solid heads: halfway in, far enough that the cap stays behind the tip. + let solid = |back: f64| (back / 2.0).max(cap); + // Hollow heads: in the back wall, with the cap kept out of the hole. + let hollow = |back: f64| (back - ring(hw) / 2.0).max(back - ring(hw) + cap); + match kind { + Arrowhead::Triangle | Arrowhead::Circle | Arrowhead::Square | Arrowhead::Diamond => solid(l), + Arrowhead::Arrow => solid(NOTCH * l), + Arrowhead::Bar => solid(bar(hw)), + 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), + } } /// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`. fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { let n = Vec2::new(-dir.y, dir.x); - let len = 4.0 * hw; - let half = len / 2.0; - let base = tip - dir * len; - let centre = tip - dir * half; + let l = len(hw); + let half = l / 2.0; + let base = tip - dir * l; let mut p = BezPath::new(); + let triangle = [tip, base + n * half, base - n * half]; + let square = [tip + n * half, base + n * half, base - n * half, tip - n * half]; + let centre = tip - dir * half; match kind { - Arrowhead::Triangle | Arrowhead::TriangleOpen => polygon(&mut p, &[tip, base + n * half, base - n * half]), - Arrowhead::Arrow | Arrowhead::ArrowOpen => polygon(&mut p, &[tip, base + n * half, tip - dir * (len * 0.7), base - n * half]), - Arrowhead::Circle | Arrowhead::CircleOpen => p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)), - Arrowhead::Square | Arrowhead::SquareOpen => polygon(&mut p, &[tip + n * half, base + n * half, base - n * half, tip - n * half]), + Arrowhead::Triangle => polygon(&mut p, &triangle), + Arrowhead::TriangleOpen => ring_polygon(&mut p, &triangle, ring(hw)), + Arrowhead::Arrow => polygon(&mut p, &[tip, base + n * half, tip - dir * (NOTCH * l), base - n * half]), + Arrowhead::ArrowOpen => { + // Arms of thickness `arm` from the tip to the base corners, cut square at their ends. + let t = arm(hw); + let (a, b) = (base + n * half, base - n * half); + let (ua, ub) = (unit(a - tip), unit(b - tip)); + // Inward (towards the axis) normals of the two arms. + let (na, nb) = (Vec2::new(-ua.y, ua.x), Vec2::new(ub.y, -ub.x)); + let inner = tip - dir * (t * (a - tip).hypot() / half); + polygon(&mut p, &[tip, a, a + na * t, inner, b + nb * t, b]); + } + Arrowhead::Circle => p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)), + Arrowhead::CircleOpen => { + p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)); + p.extend(kurbo::Circle::new(centre, half - ring(hw)).to_path(0.01).reverse_subpaths()); + } + Arrowhead::Square => polygon(&mut p, &square), + Arrowhead::SquareOpen => ring_polygon(&mut p, &square, ring(hw)), Arrowhead::Diamond => polygon(&mut p, &[tip, centre + n * half, base, centre - n * half]), Arrowhead::Bar => { - let t = 0.75 * hw; + let t = bar(hw); polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]); } } @@ -67,3 +236,22 @@ fn polygon(p: &mut BezPath, pts: &[Point]) { } p.close_path(); } + +/// A convex polygon with a hole: the polygon inset by `wall`, wound the other way. +fn ring_polygon(p: &mut BezPath, pts: &[Point], wall: f64) { + polygon(p, pts); + let n = pts.len(); + // Signed area: which side of each edge is inside. + let area: f64 = (0..n).map(|i| pts[i].to_vec2().cross(pts[(i + 1) % n].to_vec2())).sum(); + let inward = |e: Vec2| if area > 0.0 { Vec2::new(-e.y, e.x) } else { Vec2::new(e.y, -e.x) }; + let inner: Vec = (0..n) + .map(|i| { + let (prev, cur, next) = (pts[(i + n - 1) % n], pts[i], pts[(i + 1) % n]); + let (n1, n2) = (inward(unit(cur - prev)), inward(unit(next - cur))); + // The corner where both edges, moved in by `wall`, meet. + cur + (n1 + n2) * (wall / (1.0 + n1.dot(n2))) + }) + .rev() + .collect(); + polygon(p, &inner); +} diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index d7f3b20a..578a36b0 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -1,6 +1,6 @@ use kurbo::{BezPath, PathEl, Point, Rect, Shape, Vec2}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile}; use super::*; @@ -137,33 +137,119 @@ fn aligned_width_doubles_only_closed_inside_and_outside_strokes() { // ---------------------------------------------------------------- arrowheads -#[test] -fn no_heads_borrows_the_path() { - let l = line(0.0, 50.0); - let p = stroke_pieces(&l, &stroke(2.0, |_| {})); - assert!(matches!(p.line, Cow::Borrowed(_)) && p.heads.is_empty()); +fn arrows(kind: Arrowhead, align: ArrowAlign, cap: LineCap) -> StrokeLayer { + stroke(2.0, |s| { + s.end_arrow = Some(kind); + s.arrow_align = align; + s.cap = cap; + }) } #[test] -fn heads_sit_on_the_end_points_and_point_out_of_the_path() { +fn no_heads_borrows_the_path_and_closed_paths_get_none() { + let l = line(0.0, 50.0); + assert!(matches!(stroke_pieces(&l, &stroke(2.0, |_| {})).line, Cow::Borrowed(_))); + let st = stroke(2.0, |s| { + s.start_arrow = Some(Arrowhead::Triangle); + s.end_arrow = Some(Arrowhead::Triangle); + s.arrow_align = ArrowAlign::Tip; + }); + let sq = square(50.0); + let p = stroke_pieces(&sq, &st); + assert!(p.heads.is_empty()); + assert_eq!(p.line.as_ref(), &sq); +} + +#[test] +fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() { for kind in Arrowhead::ALL { - let st = stroke(2.0, |s| { - s.start_arrow = Some(kind); - s.end_arrow = Some(kind); - }); + let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); - let [a, b] = &p.heads[..] else { panic!("two heads") }; - assert_eq!((a.tip, b.tip), (Point::new(0.0, 0.0), Point::new(100.0, 0.0)), "{kind:?}"); - assert!(near(a.dir.x, -1.0, 1e-9) && near(b.dir.x, 1.0, 1e-9), "{kind:?}"); - let (ba, bb) = (a.outline.bounding_box(), b.outline.bounding_box()); - assert!(ba.x0 >= -0.01 && bb.x1 <= 100.01, "{kind:?}: nothing past the tips"); - assert!(a.outline.area().abs() > 0.5, "{kind:?}"); + let [h] = &p.heads[..] else { panic!("one head") }; + assert_eq!(h.tip, Point::new(100.0, 0.0), "{kind:?}"); + assert!(near(h.dir.x, 1.0, 1e-9), "{kind:?}"); + assert!(h.inset > 0.0 && h.inset <= 8.0, "{kind:?} inset {}", h.inset); + assert!(near(length(&p.line), 100.0 - h.inset, 1e-6), "{kind:?}"); + // Nothing reaches past the tip. + assert!(h.outline.bounding_box().x1 <= 100.0 + 0.01, "{kind:?}"); + assert!(h.outline.area().abs() > 0.5, "{kind:?}"); } - // The start head of a curve points back along its first tangent. + // Both ends, on a curve: the start head points back along the path. let mut c = BezPath::new(); c.move_to((0.0, 0.0)); c.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0)); - let p = stroke_pieces(&c, &stroke(2.0, |s| s.start_arrow = Some(Arrowhead::Triangle))); + let st = stroke(2.0, |s| { + s.start_arrow = Some(Arrowhead::Triangle); + s.end_arrow = Some(Arrowhead::Arrow); + s.arrow_align = ArrowAlign::Tip; + }); + let p = stroke_pieces(&c, &st); + assert_eq!(p.heads.len(), 2); + assert_eq!(p.heads[0].tip, Point::new(0.0, 0.0)); assert!(p.heads[0].dir.x < 0.0 && p.heads[0].dir.y > 0.0, "{:?}", p.heads[0].dir); + let total = length(&c); + assert!(near(length(&p.line), total - p.heads[0].inset - p.heads[1].inset, 1e-5)); + // A path shorter than its heads leaves no line. + let short = line(0.0, 3.0); + let p = stroke_pieces(&short, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt)); + assert!(p.line.elements().is_empty()); + assert_eq!(p.heads.len(), 1); +} + +#[test] +fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() { + let l = line(0.0, 100.0); + let st = arrows(Arrowhead::Triangle, ArrowAlign::Extend, LineCap::Butt); + let p = stroke_pieces(&l, &st); + assert!(matches!(p.line, Cow::Borrowed(_))); + let h = &p.heads[0]; + assert!(near(h.tip.x, 100.0 + h.inset, 1e-9)); + // The same head as in tip mode, moved by the overhang. + let tip = stroke_pieces(&l, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt)); + let mut moved = tip.heads[0].outline.clone(); + moved.apply_affine(kurbo::Affine::translate((h.inset, 0.0))); + let pts = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).collect::>(); + let (a, b) = (pts(&moved), pts(&h.outline)); + assert_eq!(a.len(), b.len()); + assert!(a.iter().zip(&b).all(|(p, q)| p.distance(*q) < 1e-9)); +} + +#[test] +fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { + for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] { + let st = arrows(kind, ArrowAlign::Tip, cap); + let l = line(0.0, 100.0); + let p = stroke_pieces(&l, &st); + let h = &p.heads[0]; + let line_o = line_outline(&p.line, &st, st.width, 1e-3); + // A point inside the hole is neither head nor line. + let hole = Point::new(100.0 - 8.0 * 0.45, 0.0); + assert_eq!(h.outline.winding(hole), 0, "{kind:?} hole"); + assert_eq!(line_o.winding(hole), 0, "{kind:?} line in the hole"); + // The line reaches the back wall, so they overlap (no seam). + let back_wall = Point::new(100.0 - 8.0 + 0.25, 0.0); + assert_ne!(h.outline.winding(back_wall), 0, "{kind:?} wall"); + assert_ne!(line_o.winding(back_wall - Vec2::new(0.2, 0.0)), 0, "{kind:?} line reaches the wall"); + } + // The open arrow is a chevron: open between its arms. + let l = line(0.0, 100.0); + let p = stroke_pieces(&l, &arrows(Arrowhead::ArrowOpen, ArrowAlign::Tip, LineCap::Butt)); + let h = &p.heads[0]; + assert_eq!(h.outline.winding(Point::new(93.0, 0.0)), 0); + assert_ne!(h.outline.winding(Point::new(99.0, 0.0)), 0); +} + +#[test] +fn round_caps_never_poke_past_the_tip() { + for kind in Arrowhead::ALL { + for scale in [10.0, 100.0, 300.0] { + let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round); + st.arrow_scale = (scale, scale); + let l = line(0.0, 100.0); + let p = stroke_pieces(&l, &st); + let o = line_outline(&p.line, &st, st.width, 1e-3); + assert!(o.bounding_box().x1 <= 100.0 + 1e-3, "{kind:?} at {scale}%: {}", o.bounding_box().x1); + } + } } diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index b32f440d..2f89ef7c 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -2,7 +2,7 @@ use serde_json::Value; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; use super::*; @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?, 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), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + "{weight?, 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), dashOffset?, alignDashes?, 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\", ids?}", has_doc, stroke_set ), @@ -48,6 +48,12 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } }; let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); + let arrow_align = match str_param(p, "arrowAlign") { + None => None, + Some("extend") => Some(ArrowAlign::Extend), + Some("tip") => Some(ArrowAlign::Tip), + Some(o) => return Err(bad("stroke.set", format!("arrowAlign must be extend|tip, got {o}"))), + }; let profile = match str_param(p, "profile") { None => None, Some("uniform") => Some(None), @@ -102,6 +108,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(a) = ea { st.end_arrow = a; } + if let Some(a) = arrow_align { + st.arrow_align = a; + } if let Some(pr) = &profile { st.profile = pr.clone(); } diff --git a/crates/engine/src/tests_strokegeom.rs b/crates/engine/src/tests_strokegeom.rs index 431605e8..42867cc7 100644 --- a/crates/engine/src/tests_strokegeom.rs +++ b/crates/engine/src/tests_strokegeom.rs @@ -1,7 +1,8 @@ -//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): the width-profile presets. +//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): arrowhead alignment and +//! the width-profile presets. use serde_json::json; -use vectorcraft_doc::{StrokeLayer, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, StrokeLayer, WidthProfile}; use super::*; @@ -16,6 +17,20 @@ fn stroke(s: &Session, id: NodeId) -> StrokeLayer { s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone() } +#[test] +fn arrow_align_is_set_per_stroke_and_validated() { + let (mut s, id) = session_with_line(); + assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend); + s.execute("stroke.set", &json!({"endArrow": "TriangleOpen", "arrowAlign": "tip"})).unwrap(); + let st = stroke(&s, id); + assert_eq!((st.end_arrow, st.arrow_align), (Some(vectorcraft_doc::Arrowhead::TriangleOpen), ArrowAlign::Tip)); + s.execute("stroke.set", &json!({"arrowAlign": "extend", "ids": [id.0]})).unwrap(); + assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend); + assert!(s.execute("stroke.set", &json!({"arrowAlign": "middle"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Tip); +} + #[test] fn profiles_come_from_the_preset_catalogue() { let (mut s, id) = session_with_line(); @@ -28,20 +43,27 @@ fn profiles_come_from_the_preset_catalogue() { s.execute("stroke.set", &json!({"profile": "uniform"})).unwrap(); assert!(stroke(&s, id).profile.is_none(), "uniform is the plain stroke"); assert!(s.execute("stroke.set", &json!({"profile": "zigzag"})).is_err()); - // The params doc (what agents read) lists every preset. + // The params doc (what agents read) lists every preset and the arrow alignment. let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap(); for p in WidthProfile::PRESETS { assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id); } + assert!(spec.params.contains("arrowAlign")); } #[test] -fn profiles_survive_the_native_format() { +fn arrow_align_and_profiles_survive_the_native_format() { let (mut s, id) = session_with_line(); - s.execute("stroke.set", &json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "profile": "taperEnd"})).unwrap(); + s.execute( + "stroke.set", + &json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "arrowAlign": "tip", "profile": "lens", "dash": [0, 6], "cap": "round"}), + ) + .unwrap(); let doc = &s.doc().unwrap().doc; let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap(); - let st = back.node(id).unwrap().appearance.stroke().unwrap().clone(); - assert_eq!(st, stroke(&s, id)); - assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "taperEnd"); + assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap(), &stroke(&s, id)); + // A stroke with the default alignment writes nothing new, so older builds read it unchanged. + s.execute("stroke.set", &json!({"arrowAlign": "extend"})).unwrap(); + let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, true)).unwrap(); + assert!(!text.contains("arrow_align")); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 9cbe96f7..9be82c86 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -523,6 +523,18 @@ impl Exporter<'_> { s.push_blend_mode(blend(st.blend)); pushes += 1; } + // The line ends under its arrowheads; they overlap, so both take the opacity once, as a group. + let pieces = vectorcraft_effects::stroke::stroke_pieces(bp, st); + let grouped = !pieces.heads.is_empty() && st.opacity < 1.0; + if grouped { + s.push_opacity(norm(st.opacity)); + pushes += 1; + } + let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) }; + let trimmed = match &pieces.line { + std::borrow::Cow::Owned(line) => Some(to_path(line)), + std::borrow::Cow::Borrowed(_) => None, + }; let width = match st.align { StrokeAlign::Center => st.width, _ if closed => st.width * 2.0, @@ -567,20 +579,22 @@ impl Exporter<'_> { LineJoin::Round => krilla::paint::LineJoin::Round, LineJoin::Bevel => krilla::paint::LineJoin::Bevel, }, - opacity: norm(st.opacity), + opacity, dash, })); - s.draw_path(path); + // A line fully covered by its heads (Tip alignment) has nothing left to stroke. + if let Some(line) = trimmed.as_ref().map_or(Some(path), Option::as_ref) { + s.draw_path(line); + } s.set_stroke(None); // Pop the alignment clip before drawing arrowheads. if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 { s.pop(); pushes -= 1; } - let heads = vectorcraft_effects::stroke::stroke_pieces(bp, st).heads; - if !heads.is_empty() { - s.set_fill(Some(Fill { paint, opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero })); - for head in &heads { + if !pieces.heads.is_empty() { + s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero })); + for head in &pieces.heads { if let Some(p) = to_path(&head.outline) { s.draw_path(&p); } diff --git a/crates/pdf/src/tests_stroke.rs b/crates/pdf/src/tests_stroke.rs index 454ce90c..26515ccf 100644 --- a/crates/pdf/src/tests_stroke.rs +++ b/crates/pdf/src/tests_stroke.rs @@ -1,8 +1,8 @@ -//! Stroke geometry in PDF export: arrowheads come from the shared `vectorcraft_effects::stroke` -//! geometry, the same the canvas draws. +//! Stroke geometry in PDF export: arrowheads and the trimmed line come from the shared +//! `vectorcraft_effects::stroke` geometry, and they take the stroke opacity once. use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_effects::stroke::stroke_pieces; use vectorcraft_geom::{Point, Rect, Shape, shapes}; @@ -21,9 +21,11 @@ fn line_doc(st: StrokeLayer) -> (Document, Node) { (d, n) } -fn arrow_stroke(kind: Arrowhead) -> StrokeLayer { +fn arrow_stroke(kind: Arrowhead, align: ArrowAlign, opacity: f32) -> StrokeLayer { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); st.end_arrow = Some(kind); + st.arrow_align = align; + st.opacity = opacity; st } @@ -47,16 +49,44 @@ fn painted_boxes(d: &Document) -> (Vec, Vec) { } #[test] -fn arrowheads_match_the_shared_geometry() { - for kind in Arrowhead::ALL { - let (d, n) = line_doc(arrow_stroke(kind)); - let bp = n.path_data().unwrap().to_bezpath(); - let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap()); - let (fills, strokes) = painted_boxes(&d); - let head = pieces.heads[0].outline.bounding_box(); - assert_eq!(fills.len(), 1, "{kind:?}: one head"); - assert!(close(fills[0], head), "{kind:?}: {:?} vs {head:?}", fills[0]); - assert_eq!(strokes.len(), 1, "{kind:?}: one line"); - assert!(close(strokes[0], bp.bounding_box()), "{kind:?}: {:?}", strokes[0]); +fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() { + for align in [ArrowAlign::Extend, ArrowAlign::Tip] { + for kind in Arrowhead::ALL { + let (d, n) = line_doc(arrow_stroke(kind, align, 1.0)); + let bp = n.path_data().unwrap().to_bezpath(); + let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap()); + let (fills, strokes) = painted_boxes(&d); + let head = pieces.heads[0].outline.bounding_box(); + assert_eq!(fills.len(), 1, "{kind:?} {align:?}: one head"); + assert!(close(fills[0], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[0]); + assert_eq!(strokes.len(), 1, "{kind:?} {align:?}: one line"); + assert!(close(strokes[0], pieces.line.bounding_box()), "{kind:?} {align:?}: {:?}", strokes[0]); + if align == ArrowAlign::Tip { + assert!((strokes[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head"); + } + } } } + +#[test] +fn line_and_head_share_one_opacity_group() { + let (d, _) = line_doc(arrow_stroke(Arrowhead::Triangle, ArrowAlign::Tip, 0.5)); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut group_opacity = None; + let mut leaf_opacities = vec![]; + back.walk(|m| match &m.kind { + NodeKind::Group { .. } if m.opacity < 1.0 => group_opacity = Some(m.opacity), + NodeKind::Path { .. } => { + leaf_opacities.push(m.opacity); + for i in &m.appearance.items { + leaf_opacities.push(match i { + AppearanceItem::Fill(f) => f.opacity, + AppearanceItem::Stroke(s) => s.opacity, + }); + } + } + _ => {} + }); + assert!(group_opacity.is_some_and(|o| (o - 0.5).abs() < 0.01), "{group_opacity:?}"); + assert!(leaf_opacities.iter().all(|o| (o - 1.0).abs() < 1e-3), "{leaf_opacities:?}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3cb957d0..376f0568 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -646,13 +646,17 @@ impl Renderer { } fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { - let layered = st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside; + let pieces = effects::stroke::stroke_pieces(bp, st); + // The line and its heads overlap: they take the opacity once, in one layer, instead of + // each folding it into its paint. + let folded = if pieces.heads.is_empty() { 1.0 } else { std::mem::replace(&mut self.alpha, 1.0) }; + let opacity = st.opacity * folded; + let layered = opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside; if layered { ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None); + ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None); } ctx.set_transform(f.view); - let pieces = effects::stroke::stroke_pieces(bp, st); let closed = effects::stroke::is_closed(bp); // Keep hairlines visible when zoomed far out (at least ~1 device pixel). let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5); @@ -661,13 +665,18 @@ impl Renderer { ctx.set_fill_rule(fill_rule(rule)); ctx.push_clip_layer(bp); } - if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) { + // One paint box for the line and the heads, so a gradient runs on into the heads. + let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0); + if paint::set_paint(ctx, &st.paint, paint_bounds, f.doc) { self.fold_alpha(ctx, &st.paint); ctx.set_fill_rule(peniko::Fill::NonZero); match self.cached_stroke(f, &pieces.line, st, width) { Some(o) => ctx.fill_path(&o), None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)), } + for head in &pieces.heads { + ctx.fill_path(&head.outline); + } } if inside { ctx.pop_layer(); @@ -680,15 +689,12 @@ impl Renderer { ctx.fill_path(bp); ctx.set_blend_mode(BlendMode::default()); } - if !pieces.heads.is_empty() && paint::set_paint(ctx, &st.paint, bounds, f.doc) { - ctx.set_fill_rule(peniko::Fill::NonZero); - for head in &pieces.heads { - ctx.fill_path(&head.outline); - } - } if layered { ctx.pop_layer(); } + if !pieces.heads.is_empty() { + self.alpha = folded; + } } /// The line part of a stroke of the fast-path node `self.cur` (see diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 1397f2ba..63c219cb 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -189,8 +189,9 @@ fn arrowheads_render() { let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = render(&d); - // Arrowhead is wider than the 2 pt line. - assert!(r.pixel(73, 47)[0] < 128); + // The arrowhead (past the end point by default: tip at 84) is wider than the 2 pt line. + assert!(r.pixel(78, 48)[0] < 128); + assert!(r.pixel(85, 50)[0] > 200, "nothing past the tip"); } use vectorcraft_doc::Arrowhead; diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/stroke_geometry.rs index f2d5215f..886078e7 100644 --- a/crates/render/tests/stroke_geometry.rs +++ b/crates/render/tests/stroke_geometry.rs @@ -1,9 +1,10 @@ //! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas: -//! dotted lines (zero-length dashes). +//! dotted lines (zero-length dashes) and arrowheads (hollow kinds, alignment, compositing). -use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, LineCap, StrokeLayer}; -use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Document, LineCap, StrokeLayer}; +use vectorcraft_geom::{Point, Rect, Shape, shapes}; +use vectorcraft_render::{Renderer, effects}; use vectorcraft_testkit::fixtures::DocBuilder; use vectorcraft_testkit::raster::{Image, assert_similar, render_region}; use vectorcraft_testkit::svg; @@ -75,3 +76,97 @@ fn dotted_lines_survive_svg_export_and_render_the_same() { assert_similar(&render(&d), &render(&back), 0.05, 0.001); } } + +fn arrow_doc(kind: Arrowhead, align: ArrowAlign, opacity: f32, f: impl FnOnce(&mut StrokeLayer)) -> Document { + line_doc( + 20.0, + 80.0, + stroked(4.0, |s| { + s.end_arrow = Some(kind); + s.arrow_align = align; + f(s); + }), + opacity, + ) +} + +/// The arrowhead the shared geometry computes for the line of `d`. +fn head_of(d: &Document) -> effects::stroke::Arrow { + let n = d.layers[0].children().unwrap()[0].clone(); + let bp = n.path_data().unwrap().to_bezpath(); + effects::stroke::stroke_pieces(&bp, n.appearance.stroke().unwrap()).heads.remove(0) +} + +#[test] +fn a_hollow_circle_head_is_empty_inside() { + let d = arrow_doc(Arrowhead::CircleOpen, ArrowAlign::Extend, 1.0, |_| {}); + let h = head_of(&d); + let centre = h.tip - h.dir * 8.0; + let img = render(&d); + assert!(!dark(&img, centre.x, centre.y), "the centre of an open circle is empty"); + assert!(dark(&img, h.tip.x - 1.0, 50.0), "its wall is painted"); + // The filled circle is solid. + let solid = render(&arrow_doc(Arrowhead::Circle, ArrowAlign::Extend, 1.0, |_| {})); + assert!(dark(&solid, centre.x, centre.y)); +} + +#[test] +fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() { + for align in [ArrowAlign::Extend, ArrowAlign::Tip] { + for kind in Arrowhead::ALL { + let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round); + let h = head_of(&d); + if align == ArrowAlign::Tip { + assert_eq!(h.tip, Point::new(80.0, 50.0)); + } else { + assert!((h.tip.x - (80.0 + h.inset)).abs() < 1e-9); + } + let img = render(&d); + // The rightmost ink is the tip (allowing for antialiasing). + let right = (0..img.width).rev().find(|&x| (0..img.height).any(|y| img.over_white(x, y)[0] < 250)).unwrap(); + let bb = h.outline.bounding_box(); + assert!((right as f64 / S - bb.x1).abs() <= 0.5, "{kind:?} {align:?}: ink ends at {} vs head {}", right as f64 / S, bb.x1); + assert!(!dark(&img, h.tip.x + 0.5, 50.0), "{kind:?} {align:?}: nothing past the tip"); + } + } +} + +/// Straight-alpha pixel at document point (x, y), rendered on transparent. +fn alpha_at(d: &Document, x: f64, y: f64) -> u8 { + let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 100.0, 100.0), S, false); + r.pixel((x * S) as u32, (y * S) as u32)[3] +} + +#[test] +fn line_and_head_take_the_opacity_once() { + for (node, stroke) in [(0.5, 1.0), (1.0, 0.5)] { + let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, node, |s| s.opacity = stroke); + let h = head_of(&d); + let line = alpha_at(&d, 40.0, 50.0); + assert!((120..=135).contains(&line), "half-opaque line: {line}"); + // Where the line runs under the head, and in the head beside it. + let overlap = alpha_at(&d, h.tip.x - h.inset - 1.0, 50.0); + let beside = alpha_at(&d, h.tip.x - 12.0, 50.0 + 4.5); + assert_eq!(overlap, line, "no double darkening where line and head overlap"); + assert_eq!(beside, line, "the head has the line's opacity"); + } +} + +#[test] +fn a_gradient_runs_on_into_the_head() { + let stops = vec![ + GradientStop { offset: 0.0, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 }, + GradientStop { offset: 1.0, color: Color::rgb(0.0, 0.0, 1.0), opacity: 1.0, midpoint: 0.5 }, + ]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint); + let h = head_of(&d); + let img = render(&d); + // Just behind the head's back edge (line) and just inside it (head): nearly the same colour. + let back = h.tip.x - 16.0; + let (a, b) = (img.over_white(((back - 0.5) * S) as u32, (50.0 * S) as u32), img.over_white(((back + 0.5) * S) as u32, (50.0 * S) as u32)); + assert!(a[0].abs_diff(b[0]) < 12 && a[2].abs_diff(b[2]) < 12, "{a:?} vs {b:?}"); + // The head is part of the same gradient: bluer than the line's start. + let start = img.over_white((22.0 * S) as u32, (50.0 * S) as u32); + assert!(b[2] > start[2] + 100, "{b:?} vs {start:?}"); +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 64f2ceb7..c3f309dc 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -44,6 +44,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")), ("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")), ("dc-arrow-down", include_bytes!("../../../assets/icons/dc-arrow-down.svg")), + ("dc-arrow-extend", include_bytes!("../../../assets/icons/dc-arrow-extend.svg")), + ("dc-arrow-tip", include_bytes!("../../../assets/icons/dc-arrow-tip.svg")), ("dc-arrow-up", include_bytes!("../../../assets/icons/dc-arrow-up.svg")), ("dc-artboard-options", include_bytes!("../../../assets/icons/dc-artboard-options.svg")), ("dc-artboards", include_bytes!("../../../assets/icons/dc-artboards.svg")), diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index ff358fb8..4f6e0be2 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -3,7 +3,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; use super::{first_selected, pstate, set_pstate}; use crate::theme::Tokens; @@ -18,6 +18,17 @@ fn profile_of(id: &str) -> Option { WidthProfile::preset(id).filter(|_| id != "uniform") } +/// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip). +const ARROW_ALIGN: [(ArrowAlign, &str, &str, &str); 2] = [ + (ArrowAlign::Extend, "extend", "dc-arrow-extend", "Tip extends past the end point"), + (ArrowAlign::Tip, "tip", "dc-arrow-tip", "Tip on the end point"), +]; + +/// Which Align button is on, and whether they are usable (only a stroke with a head can be aligned). +fn arrow_align_state(st: Option<&StrokeLayer>) -> (ArrowAlign, bool) { + st.map_or((ArrowAlign::default(), false), |s| (s.arrow_align, s.start_arrow.is_some() || s.end_arrow.is_some())) +} + /// Dash pattern → the panel's six dash/gap fields (None = empty field). pub fn dash_fields(pattern: &[f64]) -> [Option; 6] { let mut out = [None; 6]; @@ -212,10 +223,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("stroke.setAdvanced", json!({"arrowScale": [a, b]})).ok(); } }); + let (arrow_align, can_align) = arrow_align_state(st.as_ref()); ui.horizontal(|ui| { row_label(ui, "Align:"); - widgets::icon_button_enabled(ui, "dc-cap-square", "Tip extends past the end (on the roadmap)", true, false, 22.0); - widgets::icon_button_enabled(ui, "dc-cap-butt", "Tip on the end point (on the roadmap)", false, false, 22.0); + for (v, name, icon, tip) in ARROW_ALIGN { + if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, can_align, 22.0).clicked() { + set(app, json!({"arrowAlign": name})); + } + } }); widgets::divider(ui); // Profile. @@ -393,6 +408,19 @@ mod tests { assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); } + #[test] + fn arrow_align_buttons_need_a_head() { + let mut st = StrokeLayer::new(vectorcraft_color::Paint::None, 1.0); + assert_eq!(arrow_align_state(None), (ArrowAlign::Extend, false)); + assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Extend, false)); + st.start_arrow = Some(Arrowhead::Bar); + st.arrow_align = ArrowAlign::Tip; + assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Tip, true)); + for (_, _, icon, _) in ARROW_ALIGN { + assert!(icons::exists(icon), "{icon}"); + } + } + /// Run the panel and its ≡ menu for one headless frame. fn frame(app: &mut VectorcraftApp) { let ctx = egui::Context::default(); @@ -412,12 +440,12 @@ mod tests { frame(&mut app); r(&mut app, "shape.line", json!({"x1": 10, "y1": 50, "x2": 90, "y2": 50})); frame(&mut app); - for p in [json!({"endArrow": "ArrowOpen"}), json!({"profile": "lens"}), json!({"dash": [0, 6], "cap": "round"})] { + for p in [json!({"endArrow": "ArrowOpen", "arrowAlign": "tip"}), json!({"profile": "lens"}), json!({"dash": [0, 6], "cap": "round"})] { r(&mut app, "stroke.set", p); frame(&mut app); } let st = stroke_now(&app).unwrap(); - assert_eq!(st.end_arrow, Some(Arrowhead::ArrowOpen)); + assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip)); assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens"); } } From 4283820a3094aeb4db4782b8cc8c1e9cbc17ec80 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:40:39 +0700 Subject: [PATCH 4/5] M3.19: dash patterns too fine to advance draw solid instead of hanging on zero-length paths Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/stroke/dash.rs | 7 +++-- crates/effects/src/stroke/tests.rs | 50 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index b4eb7f2b..a5d8ce00 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -9,6 +9,9 @@ use super::{ARCLEN_ACCURACY, push_seg, segments, subpaths, tangent}; /// More dashes than this (a tiny pattern on a long path) draws the line solid instead. const MAX_DASHES: f64 = 200_000.0; +/// A pattern whose longest entry is shorter than this can't advance along the path (each entry +/// ends within the generator's tolerance of where it starts), so it draws the line solid. +const MIN_ENTRY: f64 = 1e-6; /// A zero-length dash: where it sits and the path's direction there. #[derive(Clone, Copy, Debug, PartialEq)] @@ -25,13 +28,13 @@ pub struct Dashed { } /// Apply dash pattern `d` to `bp`. `None` when the pattern has no positive length (the line is -/// solid) or is too fine to draw. +/// solid) or is too fine to draw (then the line is drawn solid too). pub fn dash(bp: &BezPath, d: &Dash) -> Option { if d.pattern.iter().any(|v| !v.is_finite()) || !d.offset.is_finite() { return None; } let mut pat: Vec = d.pattern.iter().map(|v| v.max(0.0)).collect(); - if !pat.iter().any(|v| *v > 0.0) { + if !pat.iter().any(|v| *v > MIN_ENTRY) { return None; } // An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF). diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 578a36b0..6811e810 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -253,3 +253,53 @@ fn round_caps_never_poke_past_the_tip() { } } } + +// ---------------------------------------------------------------- robustness + +#[test] +fn degenerate_paths_and_patterns_neither_panic_nor_hang() { + let finite = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).all(|q| q.x.is_finite() && q.y.is_finite()); + let mut point = BezPath::new(); + point.move_to((0.0, 0.0)); + let lone = point.clone(); + point.line_to((0.0, 0.0)); + let mut still_curve = BezPath::new(); + still_curve.move_to((0.0, 0.0)); + still_curve.curve_to((0.0, 0.0), (0.0, 0.0), (0.0, 0.0)); + // Drawing on after a ClosePath, and curves whose handles sit on their end points. + let mut mixed = BezPath::new(); + mixed.move_to((0.0, 0.0)); + mixed.line_to((10.0, 0.0)); + mixed.close_path(); + mixed.line_to((5.0, 5.0)); + mixed.move_to((20.0, 0.0)); + mixed.curve_to((20.0, 0.0), (50.0, 50.0), (50.0, 50.0)); + mixed.quad_to((50.0, 50.0), (90.0, 0.0)); + let paths = [BezPath::new(), lone, point, still_curve, mixed, square(20.0)]; + // Negative, non-finite and sub-tolerance entries (the last used to loop forever on a + // zero-length path), and offsets far outside the pattern. + let patterns: [&[f64]; 6] = [&[0.0, 6.0], &[-1.0, 3.0], &[f64::NAN, 1.0], &[1e-12, 1e-12], &[0.0], &[3.0, 0.0]]; + for bp in &paths { + for kind in Arrowhead::ALL { + for align in [ArrowAlign::Extend, ArrowAlign::Tip] { + for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] { + for pat in patterns { + for off in [0.0, -7.0, 1e12, f64::INFINITY] { + let st = stroke(2.0, |s| { + s.start_arrow = Some(kind); + s.end_arrow = Some(kind); + s.arrow_align = align; + s.cap = cap; + s.dash = Some(dashed(pat, off)); + }); + let p = stroke_pieces(bp, &st); + assert!(p.heads.iter().all(|h| finite(&h.outline)), "{bp:?} {kind:?}"); + assert!(finite(&line_outline(&p.line, &st, 2.0, 0.01)), "{bp:?} {pat:?} {off}"); + } + } + } + } + } + } + assert!(dash(&line(0.0, 1e-9), &dashed(&[1e-12, 1e-12], 0.0)).is_none(), "too fine to advance: solid"); +} From 014ee0382f5eeed5877118a1ce04ea5c17b4ee9a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:40:41 +0700 Subject: [PATCH 5/5] M3.20: visual bounds cover arrowheads so overhanging heads are not culled; one arrowhead-weight helper Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 41 ++++++++++++++++++++++---- crates/effects/src/stroke/arrow.rs | 14 ++++----- crates/engine/src/tests_strokegeom.rs | 7 +++++ crates/render/tests/stroke_geometry.rs | 12 ++++++++ crates/ui-egui/src/panels/stroke.rs | 8 ++--- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 1bff9875..e59d8f29 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -258,6 +258,18 @@ impl StrokeLayer { arrow_align: ArrowAlign::Extend, } } + /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least + /// a quarter point. A head of weight `hw` is `4·hw` long and wide. + pub fn arrow_weight(&self, end: bool) -> f64 { + let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; + (self.width * pct / 100.0).max(0.25) + } + /// How far the arrowheads can reach from the path's end points (0 without heads): the + /// head's diagonal, or with [`ArrowAlign::Extend`] its length plus the cap past the end. + pub fn arrow_reach(&self) -> f64 { + let reach = |head: Option, end: bool| head.map_or(0.0, |_| 4.5 * self.arrow_weight(end) + self.width / 2.0); + reach(self.start_arrow, false).max(reach(self.end_arrow, true)) + } } /// One entry of the appearance stack. @@ -339,11 +351,14 @@ impl Appearance { self.items .iter() .filter_map(|i| match i { - AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(match s.align { - StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 }, - StrokeAlign::Outside => s.width, - StrokeAlign::Inside => 0.0, - }), + AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some( + match s.align { + StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 }, + StrokeAlign::Outside => s.width, + StrokeAlign::Inside => 0.0, + } + .max(s.arrow_reach()), + ), _ => None, }) .fold(0.0, f64::max) @@ -418,6 +433,22 @@ mod tests { assert_eq!(back, st); } + #[test] + fn outset_covers_arrowheads() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + a.stroke_mut().unwrap().join = LineJoin::Round; + assert_eq!(a.outset(), 2.0); + let st = a.stroke_mut().unwrap(); + st.end_arrow = Some(Arrowhead::Triangle); + st.arrow_scale = (100.0, 200.0); + assert_eq!(st.arrow_weight(false), 4.0); + assert_eq!(st.arrow_weight(true), 8.0); + // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. + assert_eq!(a.outset(), 4.5 * 8.0 + 2.0); + a.stroke_mut().unwrap().width = 0.01; + assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); + } + #[test] fn outset_depends_on_align() { let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index 59ccd7da..080ddeb3 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -34,9 +34,9 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> let first = subs.first().filter(|s| !s.1).map(|s| s.0.clone()); let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone()); let cap = cap_extent(st.cap, st.width); - let weight = |pct: f64| (st.width * pct / 100.0).max(0.25); - let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.0), cap))); - let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.1), cap))); + let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true)); + let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap))); + let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap))); if start.is_none() && end.is_none() { return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; } @@ -44,7 +44,7 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> let mut heads = vec![]; // Trims per subpath index (start, end), applied in tip mode only. let mut trims: Vec<(f64, f64)> = vec![(0.0, 0.0); subs.len()]; - let mut place = |kind: Arrowhead, range: std::ops::Range, at_start: bool, inset: f64, pct: f64| { + let mut place = |kind: Arrowhead, range: std::ops::Range, at_start: bool, inset: f64, hw: f64| { let segs = segments(&els[range]); let (end_pt, outward) = end_of(&segs, at_start); let (tip, dir) = if tip_mode { @@ -54,16 +54,16 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> } else { (end_pt + outward * inset, outward) }; - heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(pct)), tip, dir, inset }); + heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset }); }; if let Some((k, r, i)) = start { - place(k, r, true, i, st.arrow_scale.0); + place(k, r, true, i, hw0); if tip_mode { trims[0].0 = i; } } if let Some((k, r, i)) = end { - place(k, r, false, i, st.arrow_scale.1); + place(k, r, false, i, hw1); if tip_mode { trims[subs.len() - 1].1 = i; } diff --git a/crates/engine/src/tests_strokegeom.rs b/crates/engine/src/tests_strokegeom.rs index 42867cc7..ee43644c 100644 --- a/crates/engine/src/tests_strokegeom.rs +++ b/crates/engine/src/tests_strokegeom.rs @@ -49,6 +49,13 @@ fn profiles_come_from_the_preset_catalogue() { assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id); } assert!(spec.params.contains("arrowAlign")); + // …and every arrowhead name `startArrow`/`endArrow` accept. + for a in vectorcraft_doc::Arrowhead::ALL { + let name = serde_json::to_value(a).unwrap(); + assert!(spec.params.contains(name.as_str().unwrap()), "{name}"); + s.execute("stroke.set", &json!({"endArrow": name})).unwrap(); + assert_eq!(stroke(&s, id).end_arrow, Some(a)); + } } #[test] diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/stroke_geometry.rs index 886078e7..80f326e1 100644 --- a/crates/render/tests/stroke_geometry.rs +++ b/crates/render/tests/stroke_geometry.rs @@ -170,3 +170,15 @@ fn a_gradient_runs_on_into_the_head() { let start = img.over_white((22.0 * S) as u32, (50.0 * S) as u32); assert!(b[2] > start[2] + 100, "{b:?} vs {start:?}"); } + +#[test] +fn a_head_past_the_end_counts_in_the_visual_bounds_and_is_never_culled() { + let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, 1.0, |s| s.join = vectorcraft_doc::LineJoin::Round); + let h = head_of(&d); + let vb = d.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + let head = h.outline.bounding_box(); + assert_eq!(vb.union(head), vb, "{vb:?} covers the head {head:?}"); + // A view that shows only the overhanging part of the head (the line ends at 80). + let img = render_region(&d, Rect::new(83.0, 40.0, 100.0, 60.0), S); + assert!(img.ink() > 0, "the head is drawn"); +} diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 4f6e0be2..e9a466d0 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -204,11 +204,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let scale = st.as_ref().map(|s| s.arrow_scale).unwrap_or((100.0, 100.0)); let linked: bool = pstate(ui.ctx(), "arrow-scale-link"); + // Scale and Align only mean something for a stroke with a head. + let (arrow_align, has_head) = arrow_align_state(st.as_ref()); ui.horizontal(|ui| { row_label(ui, "Scale:"); - let on = st.is_some() && (sa.is_some() || ea.is_some()); let mut ns = None; - ui.add_enabled_ui(on, |ui| { + ui.add_enabled_ui(has_head, |ui| { if let Some(v) = widgets::plain_field(ui, "arrow-scale-s", scale.0, "%", 0, 56.0) { ns = Some((v, if linked { v } else { scale.1 })); } @@ -223,11 +224,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("stroke.setAdvanced", json!({"arrowScale": [a, b]})).ok(); } }); - let (arrow_align, can_align) = arrow_align_state(st.as_ref()); ui.horizontal(|ui| { row_label(ui, "Align:"); for (v, name, icon, tip) in ARROW_ALIGN { - if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, can_align, 22.0).clicked() { + if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, has_head, 22.0).clicked() { set(app, json!({"arrowAlign": name})); } }