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"); } }