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