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
+1 -1
View File
@@ -10,7 +10,7 @@ mod stroke_style;
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
pub use stroke_style::{ArrowAlign, Arrowhead, CustomArrowhead, Dash, LineCap, LineJoin, StrokeAlign};
/// One entry of the appearance stack.
// A stroke takes 328 bytes and a fill 104; an appearance holds a few of each.
+4 -2
View File
@@ -193,8 +193,10 @@ impl StrokeLayer {
/// How far the start (`end == false`) or end arrowhead can reach from its end point (0
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
pub fn head_reach(&self, end: bool) -> f64 {
let head = if end { self.end_arrow } else { self.start_arrow };
head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0)
let head = if end { &self.end_arrow } else { &self.start_arrow };
// A custom head can paint in front of its tip, which Extend puts past the end point.
let reach = |k: &Arrowhead| if self.arrow_align == ArrowAlign::Extend { k.reach().max(k.length()) } else { k.reach() };
head.as_ref().map_or(0.0, |k| reach(k) * self.arrow_weight(end) + self.width / 2.0)
}
/// How far the arrowheads can reach from the path's end points (0 without heads).
pub fn arrow_reach(&self) -> f64 {
+96 -5
View File
@@ -1,3 +1,7 @@
use std::sync::Arc;
use vectorcraft_geom::{PathData, Rect};
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -53,8 +57,72 @@ impl Dash {
/// (the open arrow's arms are cut square a little behind the box).
const HEAD_REACH: f64 = 4.6;
/// An arrowhead the user drew (Stroke panel menu › Edit Arrowheads…), carried with its art so a
/// document that uses it draws it wherever it opens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "CustomArrowheadData")]
pub struct CustomArrowhead {
/// The name the arrowhead menus show (the symbol's name in the arrowheads file).
pub name: String,
/// The filled outline (non-zero rule) in head units: x from the tip (0) back along the line, y
/// across it, positive to the right of the direction the head points. A head is 4 units wide,
/// as the built-in heads are, and a unit is the head's weight.
pub path: PathData,
}
#[derive(Deserialize)]
struct CustomArrowheadData {
name: String,
#[serde(default)]
path: PathData,
}
impl From<CustomArrowheadData> for CustomArrowhead {
fn from(d: CustomArrowheadData) -> Self {
CustomArrowhead::new(d.name, d.path)
}
}
impl CustomArrowhead {
/// The most anchors a custom head's outline may have.
pub const MAX_ANCHORS: usize = 4096;
/// How far from the tip a custom head's outline may reach, in head units.
pub const MAX_UNITS: f64 = 64.0;
/// Head `name` with outline `path` (in head units). An outline with more than
/// [`Self::MAX_ANCHORS`] anchors, a point that is not finite, or a point farther than
/// [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing.
pub fn new(name: String, path: PathData) -> Self {
let path = if Self::fits(&path) { path } else { PathData::default() };
CustomArrowhead { name, path }
}
/// Whether outline `path` is within [`Self::MAX_ANCHORS`] and [`Self::MAX_UNITS`], with finite
/// points only.
pub fn fits(path: &PathData) -> bool {
let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS;
anchors <= Self::MAX_ANCHORS && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out))
}
fn bounds(&self) -> Option<Rect> {
self.path.control_bounds()
}
/// How far any part of the head can be from its tip, in head units.
pub fn reach(&self) -> f64 {
self.bounds().map_or(0.0, |b| b.x0.hypot(b.y0).max(b.x1.hypot(b.y0)).max(b.x0.hypot(b.y1)).max(b.x1.hypot(b.y1)))
}
/// How far the head spreads either side of its line, in head units.
pub fn half_width(&self) -> f64 {
self.bounds().map_or(0.0, |b| b.y0.abs().max(b.y1.abs()))
}
/// Where the stroke ends under the head, in head units back from the tip: halfway along the
/// head's length.
pub fn stop(&self) -> f64 {
self.bounds().map_or(0.0, |b| b.x1.max(0.0) / 2.0)
}
}
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Arrowhead {
Triangle,
TriangleOpen,
@@ -102,6 +170,8 @@ pub enum Arrowhead {
/// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as
/// the other heads.
LineArrow,
/// A head the user drew, with its art.
Custom(Arc<CustomArrowhead>),
}
impl Arrowhead {
@@ -151,23 +221,43 @@ impl Arrowhead {
];
/// How far any part of the head can be from its tip, in units of its weight.
pub fn reach(self) -> f64 {
pub fn reach(&self) -> f64 {
match self {
// Its farthest corners are 4 + ½√2 back and 4 − ½√2 out.
Arrowhead::LineArrow => 5.75,
Arrowhead::Custom(c) => c.reach(),
_ => HEAD_REACH,
}
}
/// How far the head spreads either side of its line, in units of its weight.
pub fn half_width(self) -> f64 {
pub fn half_width(&self) -> f64 {
match self {
Arrowhead::LineArrow => 4.0,
Arrowhead::Custom(c) => c.half_width(),
_ => 2.0,
}
}
/// How far past its end point a head placed with [`ArrowAlign::Extend`] can paint, in units of
/// its weight: the built-in heads' length, or a custom head's reach past its tip, which sits
/// [`CustomArrowhead::stop`] past the end point.
pub fn length(&self) -> f64 {
match self {
Arrowhead::Custom(c) => c.stop() + c.reach(),
_ => 4.0,
}
}
/// The name the arrowhead menus show.
pub fn label(self) -> &'static str {
/// The name the arrowhead menus show: [`Self::label`], or a custom head's own name.
pub fn name(&self) -> &str {
match self {
Arrowhead::Custom(c) => &c.name,
_ => self.label(),
}
}
/// The name the arrowhead menus show for a built-in head ("Custom" for a custom one, whose
/// own name is [`Self::name`]).
pub fn label(&self) -> &'static str {
match self {
Arrowhead::Triangle => "Triangle",
Arrowhead::TriangleOpen => "Triangle (open)",
@@ -210,6 +300,7 @@ impl Arrowhead {
Arrowhead::TriangleBar => "Triangle to Bar",
Arrowhead::Oval => "Oval",
Arrowhead::LineArrow => "Line Arrow",
Arrowhead::Custom(_) => "Custom",
}
}
}
+46 -1
View File
@@ -264,13 +264,58 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
];
for name in SAVED {
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
assert_eq!(serde_json::to_value(a).unwrap(), name);
assert_eq!(serde_json::to_value(&a).unwrap(), name);
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
}
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
}
/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line.
fn custom_head() -> Arrowhead {
use vectorcraft_geom::{PathData, Point, SubPath};
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true));
Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path)))
}
#[test]
fn custom_arrowheads_carry_their_art_and_measure_it() {
let head = custom_head();
assert_eq!((head.name(), head.label()), ("Wedge", "Custom"));
assert_eq!(head.reach(), 3.0f64.hypot(2.0));
assert_eq!(head.half_width(), 2.0);
let Arrowhead::Custom(c) = &head else { panic!("custom") };
assert_eq!(c.stop(), 1.5, "the line stops halfway along the head");
// Saved with the stroke, art and all, beside the built-in heads' names.
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
(st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone()));
let json = serde_json::to_value(&st).unwrap();
assert_eq!(json["start_arrow"], "Triangle");
assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge");
let back: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(back, st);
// Extended, the tip sits the stop past the end point; on it, only the reach counts.
assert_eq!(head.length(), 1.5 + head.reach());
assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0);
st.arrow_align = ArrowAlign::Tip;
assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0);
}
#[test]
fn custom_arrowheads_out_of_bounds_draw_nothing() {
let Arrowhead::Custom(c) = custom_head() else { panic!("custom") };
let mut path = c.path.clone();
path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0));
// A file can hold what `new` drops: reading it drops it too.
let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap();
let far: CustomArrowhead = serde_json::from_value(json).unwrap();
assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0));
assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0));
let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1];
let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true)));
assert!(many.path.subpaths.is_empty(), "too many anchors");
}
#[test]
fn arrow_align_defaults_to_extend_and_round_trips() {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
+2 -2
View File
@@ -82,8 +82,8 @@ pub(crate) mod skip;
pub use appearance::StrokeGradientMode;
pub use appearance::{
AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset,
SavedProfile, StrokeAlign, StrokeLayer, WidthProfile,
AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, CustomArrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile,
ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use compound_shape::ShapeMode;
+4 -2
View File
@@ -391,13 +391,15 @@ impl StrokeLayer {
/// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the
/// chord the path bends through under it, so anywhere within its length of the end.
fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool {
let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false };
let Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false };
let (hw, v) = (self.arrow_weight(end), p - at);
match self.arrow_align {
ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol,
ArrowAlign::Extend => {
let along = v.dot(out);
v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol
v.cross(out).abs() <= kind.half_width() * hw + tol
&& along <= kind.length() * hw + self.width / 2.0 + tol
&& along >= -kind.reach() * hw - tol
}
}
}