From 5ae7bfb8cabbcbeb5889e11baa78d1eef68778b5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:20:40 +0700 Subject: [PATCH] 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"); } }