Stroke: custom arrowheads from an arrowheads file join the arrowhead menus

The Stroke panel menu's Edit Arrowheads… (stroke.arrowheads.edit) opens
Arrowheads.vectorcraft next to the preferences, creating it with one
example head when it is missing. Each symbol in the file is one head,
listed by name after the built-in heads in both arrowhead menus: it
points right, its registration point is the tip, and its height is drawn
as wide as the built-in heads. The file is read at startup and again
each time VectorCraft saves it; stroke.arrowheads.reload and
stroke.arrowheads.list read and list it.

Arrowhead gets a Custom variant, appended after the built-in names, that
carries the head's name and outline, so a document that uses one draws,
saves, reopens and exports it without the arrowheads file. stroke.set
takes {"custom": name} or the {"Custom": {name, path}} form that
document.inspect reports.

Part of #5421.
This commit is contained in:
dotCooCoo
2026-11-11 21:52:46 -08:00
parent 7f3a046b18
commit c1f7e2eeaf
59 changed files with 1143 additions and 59 deletions
+19 -9
View File
@@ -4,7 +4,8 @@
//! 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. Heads added after the first ten are drawn in local units ([`local`]).
//! past the end point. Heads added after the first ten are drawn in local units ([`local`]), as are
//! custom heads, whose stroke ends halfway along their length.
use std::borrow::Cow;
@@ -37,8 +38,14 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a>
let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone());
let cap = cap_extent(st.cap, st.width);
let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true));
let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap)));
let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap)));
let start = st.start_arrow.clone().zip(first).map(|(k, r)| {
let i = inset(&k, hw0, cap);
(k, r, i)
});
let end = st.end_arrow.clone().zip(last).map(|(k, r)| {
let i = inset(&k, hw1, cap);
(k, r, i)
});
if start.is_none() && end.is_none() {
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
}
@@ -56,7 +63,8 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a>
} else {
(end_pt + outward * inset, outward)
};
heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset });
let outline = shape(&kind, tip, dir, hw);
heads.push(Arrow { kind, outline, tip, dir, inset });
};
if let Some((k, r, i)) = start {
place(k, r, true, i, hw0);
@@ -174,7 +182,7 @@ fn bar(hw: f64) -> f64 {
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 {
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);
@@ -187,6 +195,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l),
// The inner corner of the chevron: arm / sin(half angle).
Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap),
Arrowhead::Custom(c) => (c.stop() * hw).max(cap),
_ => match local_stop(kind) {
Stop::At(u) => (u * hw).max(cap),
Stop::Wall(back) => hollow(back * hw),
@@ -195,7 +204,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
}
/// 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 {
fn shape(kind: &Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
let n = Vec2::new(-dir.y, dir.x);
let l = len(hw);
let half = l / 2.0;
@@ -253,7 +262,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
const LOCAL_TOL: f64 = 1e-3;
/// Where the stroke ends under a head drawn in local units ([`local`]), in those units.
fn local_stop(kind: Arrowhead) -> Stop {
fn local_stop(kind: &Arrowhead) -> Stop {
match kind {
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
Arrowhead::HexagonOpen => Stop::Wall(HEX),
@@ -276,8 +285,8 @@ fn local_stop(kind: Arrowhead) -> Stop {
/// The outline of a head added after the first ten, in local units: x from the tip (0) back
/// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2,
/// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape
/// is generated here.
fn local(kind: Arrowhead) -> BezPath {
/// is generated here, and a custom head's outline is its own.
fn local(kind: &Arrowhead) -> BezPath {
let mut p = BezPath::new();
let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>());
let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>(), ring(1.0));
@@ -373,6 +382,7 @@ fn local(kind: Arrowhead) -> BezPath {
let t = FRAC_1_SQRT_2;
poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]);
}
Arrowhead::Custom(c) => p = c.path.to_bezpath(),
// The first ten are drawn by `shape`.
Arrowhead::Triangle
| Arrowhead::TriangleOpen
+5 -5
View File
@@ -282,7 +282,7 @@ fn no_heads_borrows_the_path_and_closed_paths_get_none() {
#[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 = arrows(kind, ArrowAlign::Tip, LineCap::Butt);
let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let [h] = &p.heads[..] else { panic!("one head") };
@@ -337,7 +337,7 @@ fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() {
#[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 st = arrows(kind.clone(), ArrowAlign::Tip, cap);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let h = &p.heads[0];
@@ -363,7 +363,7 @@ fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() {
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);
let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round);
st.arrow_scale = (scale, scale);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
@@ -405,8 +405,8 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
for pat in patterns {
for off in [0.0, -7.0, 1e12, f64::INFINITY] {
let st = stroke(2.0, |s| {
s.start_arrow = Some(kind);
s.end_arrow = Some(kind);
s.start_arrow = Some(kind.clone());
s.end_arrow = Some(kind.clone());
s.arrow_align = align;
s.cap = cap;
s.dash = Some(dashed(pat, off));
+29 -2
View File
@@ -50,7 +50,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0));
assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself");
for kind in Arrowhead::ALL {
let h = head(kind);
let h = head(kind.clone());
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
let b = h.outline.bounding_box();
let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width());
@@ -73,7 +73,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
#[test]
fn the_stroke_ends_inside_every_head() {
for kind in Arrowhead::ALL {
let h = head(kind);
let h = head(kind.clone());
// Just in front of where the line stops, on its centre line.
let end = h.tip - h.dir * (h.inset - 0.1);
assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset);
@@ -112,3 +112,30 @@ fn half_arrows_barb_on_their_own_side() {
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5);
assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0);
}
#[test]
fn custom_heads_draw_their_own_art_at_the_tip() {
use vectorcraft_doc::CustomArrowhead;
use vectorcraft_geom::{PathData, SubPath};
// From the tip back 3 units, 2 to the right of the line and 1 to the left.
let wedge = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true));
let kind = Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), wedge)));
let h = head(kind.clone());
// Weight 2: drawn back to x 94, right of +x is +y on a y-down page.
let b = h.outline.bounding_box();
assert!((b.x0 - 94.0).abs() < 1e-9 && (b.x1 - 100.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.y1 - 4.0).abs() < 1e-9, "{b:?}");
assert_eq!(h.outline.winding(Point::new(95.0, 1.0)), h.outline.winding(Point::new(95.0, 0.5)));
assert_ne!(h.outline.winding(Point::new(95.0, 1.0)), 0);
// The line stops halfway along the head.
assert!((h.inset - 3.0).abs() < 1e-9, "inset {}", h.inset);
// Extended, the tip sits the inset past the end point, and scaling scales the art.
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
(st.start_arrow, st.arrow_scale) = (Some(kind), (200.0, 100.0));
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let p = stroke_pieces(&l, &st);
let [s] = &p.heads[..] else { panic!("one head") };
assert!((s.tip.x + 6.0).abs() < 1e-9 && s.dir.x < 0.0, "tip {:?}", s.tip);
assert!((s.outline.bounding_box().width() - 12.0).abs() < 1e-9);
}
+15 -2
View File
@@ -99,14 +99,27 @@ fn bounds_and_clicks_cover_what_every_stroke_paints() {
}
}
/// Custom heads: one wholly behind its tip, and a long one reaching as far in front of its tip as
/// behind it (its tip sits halfway along it past the end point with Extend).
fn custom_heads() -> Vec<Arrowhead> {
use vectorcraft_geom::{Point as P, SubPath};
let head = |name: &str, pts: &[(f64, f64)]| {
let pts: Vec<P> = pts.iter().map(|&(x, y)| P::new(x, y)).collect();
let path = PathData::single(SubPath::polyline(&pts, true));
Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(name.into(), path)))
};
vec![head("Wedge", &[(0.0, 0.0), (3.0, 2.0), (3.0, -1.0)]), head("Spindle", &[(-10.0, 0.0), (0.0, 2.0), (10.0, 0.0), (0.0, -2.0)])]
}
#[test]
fn bounds_and_clicks_cover_every_arrowhead() {
let bp = &paths()[0];
for kind in Arrowhead::ALL {
for kind in Arrowhead::ALL.into_iter().chain(custom_heads()) {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
(st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap);
(st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) =
(Some(kind.clone()), Some(kind.clone()), align, (scale, scale), cap);
check(bp, &st, &format!("{kind:?} {align:?} {scale}%"));
}
}