From c1f7e2eeafd24890fbdb22d01ac0bbf52b2e1fd8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:44:32 -0800 Subject: [PATCH] Stroke: custom arrowheads from an arrowheads file join the arrowhead menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/vectorcraft-cli/src/main.rs | 1 + apps/vectorcraft/src/main.rs | 2 + apps/vectorcraft/src/prefs_dir.rs | 8 + crates/doc/src/appearance.rs | 2 +- crates/doc/src/appearance/layers.rs | 6 +- crates/doc/src/appearance/stroke_style.rs | 101 ++++- crates/doc/src/appearance/tests.rs | 47 +- crates/doc/src/lib.rs | 4 +- crates/doc/src/reach.rs | 6 +- crates/effects/src/stroke/arrow.rs | 28 +- crates/effects/src/stroke/tests.rs | 10 +- crates/effects/src/stroke/tests_arrows.rs | 31 +- crates/effects/src/stroke/tests_reach.rs | 17 +- crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/arrowheads.rs | 423 ++++++++++++++++++ crates/engine/src/cmd/dimension.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 5 + crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/cmd/stroke.rs | 31 +- crates/engine/src/lib.rs | 2 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/arrowheads.rs | 252 +++++++++++ crates/engine/src/tests/panelcmds.rs | 2 +- crates/engine/src/tests/strokegeom.rs | 2 +- crates/pdf/src/tests/stroke.rs | 2 +- crates/render/tests/it/golden.rs | 2 +- crates/render/tests/it/stroke_geometry.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/stroke.rs | 65 ++- .../ui-egui/src/panels/tests_strokedepth.rs | 10 +- docs/mcp.md | 25 ++ docs/ui-parity.md | 1 + 59 files changed, 1143 insertions(+), 59 deletions(-) create mode 100644 crates/engine/src/cmd/arrowheads.rs create mode 100644 crates/engine/src/tests/arrowheads.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 57ab103590..8c4c4fffa2 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -246,6 +246,7 @@ fn with_stores(mut h: Headless) -> Headless { prefs_dir::attach_plugin_store(&mut h.session); prefs_dir::attach_pdf_preset_store(&mut h.session); prefs_dir::attach_custom_dictionary(&mut h.session); + prefs_dir::attach_arrowheads(&mut h.session); h } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bb94bad1cc..b3b465b6c8 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -474,6 +474,8 @@ fn main() -> std::process::ExitCode { prefs_dir::attach_pdf_preset_store(&mut app.session); // The custom dictionary's words, kept in their own file next to the preferences. prefs_dir::attach_custom_dictionary(&mut app.session); + // The custom arrowheads, kept in their own file next to the preferences. + prefs_dir::attach_arrowheads(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). let winit_window = cc.winit_window().cloned(); if let Some(w) = &winit_window { diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 96e09f5e78..b4c0a41103 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -81,6 +81,14 @@ pub fn attach_custom_dictionary(session: &mut vectorcraft_engine::Session) { } } +/// Keep the custom arrowheads (Stroke panel menu › Edit Arrowheads…) in the `Arrowheads.vectorcraft` +/// file next to the settings, and list the heads it holds. +pub fn attach_arrowheads(session: &mut vectorcraft_engine::Session) { + if let Some(path) = store_dir(vectorcraft_engine::cmd::arrowheads::FILE_NAME) { + session.attach_arrowheads_file(path.to_string_lossy().to_string()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 2e19ad44a3..3a37e4ee57 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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. diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 923275999b..f8a58758d8 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -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 { diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 46a0c30c33..e029f8bc27 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -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 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::(); + 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 { + 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), } 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", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 0a1b3e1a5d..66f7fa2462 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -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); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..cdad1e7a93 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index f6b9a4d013..763eea56a3 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -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 } } } diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index c96dcb477c..075ab3f7c3 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -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::>()); let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>(), 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 diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 46969ff639..aa4ea8dc9f 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -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)); diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 4328048991..a32a70245b 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -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); +} diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..6e141fd5b5 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -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 { + use vectorcraft_geom::{Point as P, SubPath}; + let head = |name: &str, pts: &[(f64, f64)]| { + let pts: Vec

= 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}%")); } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b8993..12e39d1c3c 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -4,6 +4,7 @@ pub mod actionset; mod addons; mod apiref; pub(crate) mod appearance; +pub mod arrowheads; mod assets; mod attributes; pub mod brushlib; @@ -297,6 +298,7 @@ pub fn command_specs() -> &'static [CommandSpec] { // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command // palette, `engine.commands`) show them where they always were. v.extend(stroke::specs()); + v.extend(arrowheads::specs()); v.extend(appearance::specs()); v.extend(style::specs()); v.extend(swatch::specs()); diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs new file mode 100644 index 0000000000..8e2eee865f --- /dev/null +++ b/crates/engine/src/cmd/arrowheads.rs @@ -0,0 +1,423 @@ +//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native +//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols +//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's +//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the +//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times +//! the head's weight. A head covers what its art paints: the visible fills, and the visible +//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with +//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 % +//! opacity is left out, and other opacities, blend modes and opacity masks do not change the +//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art +//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath}; +use vectorcraft_pathops::BoolOp; + +use super::{CommandSpec, Result, Session, always, bad, cmd}; + +/// The arrowheads file's name, in the folder of the preferences. +pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; +/// The largest arrowheads file read. +pub const MAX_FILE_BYTES: u64 = 64 << 20; +/// The most heads read from the file; symbols past it are skipped. +pub const MAX_HEADS: usize = 256; +/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped. +pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "stroke.arrowheads.list", + "Arrowheads", + [], + None, + "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus", + always, + list + ), + cmd!( + "stroke.arrowheads.edit", + "Edit Arrowheads", + [], + None, + "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}", + has_file, + edit + ), + cmd!( + "stroke.arrowheads.reload", + "Reload Arrowheads", + [], + None, + "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}", + has_file, + reload + ), + ] +} + +/// The custom arrowheads read from the arrowheads file. +#[derive(Default)] +pub struct Arrowheads { + path: Option, + heads: Vec>, + skipped: Vec<(String, String)>, + error: Option, +} + +impl Arrowheads { + /// The arrowheads file's path; `None` without one (the web, runs without preferences). + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + /// The heads, in the order of the file's symbols. + pub fn heads(&self) -> &[Arc] { + &self.heads + } + /// Head `name`. + pub fn get(&self, name: &str) -> Option> { + self.heads.iter().find(|h| h.name == name).cloned() + } + fn set(&mut self, read: Read) { + (self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped); + self.error = None; + } +} + +/// The heads a document holds, and the symbols that are not heads with the reason. +#[derive(Debug, Default)] +pub struct Read { + pub heads: Vec, + pub skipped: Vec<(String, String)>, +} + +/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs). +pub fn heads_of(doc: &Document) -> Read { + let mut d = doc.clone(); + let brushes = vectorcraft_brush::library(&d); + let mut out = Read::default(); + for (i, sym) in doc.symbols.iter().enumerate() { + if i >= MAX_HEADS { + out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read"))); + continue; + } + if out.heads.iter().any(|h| h.name == sym.name) { + out.skipped.push((sym.name.clone(), "another symbol has this name".into())); + continue; + } + match head(&mut d, &brushes, sym) { + Ok(h) => out.heads.push(h), + Err(why) => out.skipped.push((sym.name.clone(), why.into())), + } + } + out +} + +/// Symbol `sym` of `d` as a head. +fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { + const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + if art_anchors(&sym.art) > MAX_ART_ANCHORS { + return Err(TOO_DETAILED); + } + let art = baked_art(d, sym); + let mut shapes = Shapes::default(); + painted(d, brushes, &art, &mut shapes); + if shapes.too_detailed { + return Err(TOO_DETAILED); + } + let area = shapes.united(); + let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; + let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); + // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. + let k = 4.0 / b.height(); + let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]); + let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local)); + if h.path.subpaths.is_empty() { + return Err(TOO_DETAILED); + } + Ok(h) +} + +/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its +/// members' included. +fn art_anchors(n: &Node) -> usize { + let own = match &n.kind { + NodeKind::Mesh(m) => mesh_anchors(m), + _ => n.path_data().map_or(0, PathData::anchor_count), + }; + n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c)))) +} + +/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills', +/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`. +fn baked_art(d: &Document, sym: &Symbol) -> Node { + // A plain new layer, so the file's own layers' effects stay out of the art. + let mut tmp = d.clone(); + tmp.layers = Document::new(1.0, 1.0).layers; + let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() }; + *children = vec![sym.art.clone()]; + let baked = vectorcraft_render::effects::bake_document(&tmp); + baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone()) +} + +/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all. +#[derive(Default)] +struct Shapes { + list: Vec<(PathData, FillRule)>, + anchors: usize, + /// More anchors were asked for than fit; the shapes past the limit were not made. + too_detailed: bool, +} + +impl Shapes { + /// Whether `anchors` more fit; when they do not, the shapes are too detailed. + fn room(&mut self, anchors: usize) -> bool { + self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS; + !self.too_detailed + } + fn push(&mut self, shape: (PathData, FillRule)) { + if self.room(shape.0.anchor_count()) { + self.anchors += shape.0.anchor_count(); + self.list.push(shape); + } + } + fn united(&self) -> PathData { + let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); + vectorcraft_pathops::unite_all(&refs) + } + /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. + fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { + if inside.too_detailed || !self.room(inside.anchors) { + self.too_detailed = true; + return; + } + let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect); + if !clipped.subpaths.is_empty() { + self.push((clipped, FillRule::NonZero)); + } + } +} + +/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and +/// their strokes' outlines, its groups' members included (baking has already made a group's own +/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip +/// group's clipped art is clipped to +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas. Hidden or fully +/// transparent art is left out. +fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + fills(n, out); + // The outline holds a copy of the path for each fill too, which adds nothing new. + if let Some(o) = super::pathops::outline_strokes(d, brushes, n) { + fills_in(&o, out); + } + } + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + painted(d, brushes, clip, out); + // Nothing to clip by hides the clipped art, as on the canvas. + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| painted(d, brushes, c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)), + NodeKind::Mesh(_) => mesh_area(n, out), + _ => {} + } +} + +/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its +/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or +/// across it is outlined as gradient meshes clipped to the stroke's outline. +fn fills_in(n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Mesh(_) => mesh_area(n, out), + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + fills_in(clip, out); + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| fills_in(c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + _ => match n.children() { + Some(children) => children.iter().for_each(|c| fills_in(c, out)), + None => fills(n, out), + }, + } +} + +/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side, +/// when no point of it is fully transparent, else four by four quads of four anchors a patch. +fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize { + let (rows, cols) = (m.rows as usize, m.cols as usize); + if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) } +} + +fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool { + m.points.iter().all(|p| p.opacity > 0.0) +} + +/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else +/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose +/// pieces do not fit in `out` makes the shapes too detailed. +fn mesh_area(n: &Node, out: &mut Shapes) { + let NodeKind::Mesh(m) = &n.kind else { return }; + if !out.room(mesh_anchors(m)) { + return; + } + if opaque(m) { + let outline = m.outline(); + if !outline.subpaths.is_empty() { + out.push((outline, FillRule::NonZero)); + } + return; + } + for q in m.quads(4) { + if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) { + out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero)); + } + } +} + +/// The area path `n` fills, when a shown fill paints it. +fn fills(n: &Node, out: &mut Shapes) { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none()); + if n.appearance.items.iter().any(paints) + && let Some(shape) = super::pathops::node_path(n) + { + out.push(shape); + } +} + +/// The document a missing arrowheads file starts as: one example head, a solid triangle. +pub fn starter() -> Document { + let mut doc = Document::new(612.0, 792.0); + doc.title = "Arrowheads".into(); + let name = "Example Arrowhead"; + let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true); + let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)); + let art = doc.reid(&art); + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); + // The tip is the right edge's middle. + doc.set_symbol_registration(name, SymbolRegistration(5)); + let id = doc.alloc_id(); + let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) }); + let layer = doc.layers.first().map(|l| l.id); + let _ = doc.insert(layer, 0, instance); + doc +} + +impl Session { + /// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it + /// is missing; an unreadable file is reported by `stroke.arrowheads.list`). + pub fn attach_arrowheads_file(&mut self, path: String) { + self.arrowheads.path = Some(path); + self.reload_arrowheads(); + } + + /// Read the arrowheads file again. + fn reload_arrowheads(&mut self) { + let Some(path) = self.arrowheads.path.clone() else { return }; + match read(&path) { + Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)), + Ok(None) => self.arrowheads.set(Read::default()), + Err(e) => { + self.arrowheads.set(Read::default()); + self.arrowheads.error = Some(e); + } + } + } + + /// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the + /// ones listed now. + pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) { + if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { + self.arrowheads.set(heads_of(doc)); + } + } +} + +fn same_file(a: &str, b: &str) -> bool { + let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p)); + std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b) +} + +/// The document in the arrowheads file at `path`; `None` when there is no file. +fn read(path: &str) -> std::result::Result, String> { + let size = match std::fs::metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{path}: {e}")), + Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")), + Ok(m) => m.len(), + }; + if size > MAX_FILE_BYTES { + return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); + } + let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; + let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string()); + crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) +} + +fn has_file(s: &Session) -> std::result::Result<(), String> { + if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) } +} + +fn skipped(s: &Session) -> Vec { + s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect() +} + +fn list(s: &mut Session, _: &Value) -> Result { + let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect(); + Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error})) +} + +fn reload(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.reload"; + s.reload_arrowheads(); + if let Some(e) = &s.arrowheads.error { + return Err(bad(C, e.clone())); + } + Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)})) +} + +fn edit(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.edit"; + let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; + let created = !std::path::Path::new(&path).exists(); + if created { + if let Some(dir) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } + let bytes = vectorcraft_format::save_file(&starter()); + crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?; + s.reload_arrowheads(); + } + // Already open: that document comes to the front. + let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path))); + match open { + Some(i) => { + s.set_active(i); + } + None => { + crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?; + } + } + Ok(json!({"path": path, "created": created})) +} diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index e1b88d5d9f..44ac08d7ad 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> ArrowStyle::Tick => Some(Arrowhead::Slash), ArrowStyle::None => None, }; - st.start_arrow = head; + st.start_arrow = head.clone(); st.end_arrow = head; st.arrow_scale = (o.arrow_scale, o.arrow_scale); st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..6e814569a0 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1005,6 +1005,8 @@ impl SaveJob { } // The other open documents that place this one show the new version. let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template")); + // A save of the arrowheads file lists its heads in the arrowhead menus. + let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone()); if let Some(st) = s.document_mut(uid) { self.finish(st); } @@ -1012,6 +1014,9 @@ impl SaveJob { crate::cmd::recovery::forget(s, uid); } if let Some(path) = written { + if let Some(doc) = native { + s.arrowheads_saved(&path, &doc); + } crate::cmd::links::refresh_placed(s, uid, &path); } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 541389bb89..ce48ccd933 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -675,5 +675,6 @@ pub(super) const DENIED: &[&str] = &[ "object.blend.options", "object.liquify", "object.pathfinder.options", + "stroke.arrowheads.edit", "type.compositeFonts.set", ]; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index e97659bfc9..49b3128f61 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -4,8 +4,10 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile, + ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, + Unit, WidthProfile, }; +use vectorcraft_geom::PathData; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -20,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, 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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, 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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -222,6 +224,23 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(Value::String(n)) if n == "none" => Ok(Some(None)), + // A custom head by name, from the arrowheads file (stroke.arrowheads.list). + Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => { + let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) }; + let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?; + Ok(Some(Some(Arrowhead::Custom(head)))) + } + // A custom head with its art: an outline the head would drop is refused, not drawn empty. + Some(Value::Object(o)) if o.contains_key("Custom") => { + let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null); + let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?; + if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) { + let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS); + return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip"))); + } + let head = p.get(k).cloned().unwrap_or(Value::Null); + serde_json::from_value::(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}"))) + } Some(v) => serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))), } }; @@ -238,11 +257,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let set = |st: &mut StrokeLayer| { change.apply(st); - if let Some(a) = sa { - st.start_arrow = a; + if let Some(a) = &sa { + st.start_arrow = a.clone(); } - if let Some(a) = ea { - st.end_arrow = a; + if let Some(a) = &ea { + st.end_arrow = a.clone(); } if let Some(a) = arrow_align { st.arrow_align = a; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..9a1a536d84 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,8 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one. + pub arrowheads: cmd::arrowheads::Arrowheads, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 63fbcd5a78..a0ca0e3084 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,7 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + arrowheads: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..db0a8521a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9,6 +9,7 @@ pub(super) mod adjust; mod ai_open_path; mod appearance; mod areafit; +mod arrowheads; mod assets; mod assets_3d; mod attributes; diff --git a/crates/engine/src/tests/arrowheads.rs b/crates/engine/src/tests/arrowheads.rs new file mode 100644 index 0000000000..1e5c1fe5a8 --- /dev/null +++ b/crates/engine/src/tests/arrowheads.rs @@ -0,0 +1,252 @@ +//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed, +//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use +//! them. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration}; +use vectorcraft_geom::{PathData, Point, SubPath}; + +use crate::cmd::arrowheads::{self, heads_of}; +use crate::*; + +fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node { + let pts: Vec = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + let art = d.reid(&art); + d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); +} + +fn filled() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0) +} + +/// A folder of its own for test `name`, emptied. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session_with_line() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn stroke(s: &Session, id: NodeId) -> StrokeLayer { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone() +} + +#[test] +fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() { + let mut d = Document::new(100.0, 100.0); + // 20 high, so a unit is 5 pt: tip on the right edge's middle. + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + d.set_symbol_registration("Wedge", SymbolRegistration(5)); + // A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default). + let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0); + line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt; + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line); + add_symbol(&mut d, "Band", band); + // Neither filled nor stroked: no head. + add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default())); + // Far longer than wide. + add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled())); + // A fill at 0 % opacity paints nothing. + let mut clear = filled(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() { + f.opacity = 0.0; + } + add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear)); + // More anchors than a symbol's art may have: skipped before its shapes are united. + let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect(); + add_symbol(&mut d, "Dense", polygon(&dense, filled())); + // A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head. + let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true })); + // An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long. + let mut grown = filled(); + grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown)); + // The same effect on the fill alone. + let mut fill_grown = filled(); + fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown)); + // And on a group holding the bar. + let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]); + group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true }); + add_symbol(&mut d, "Group Grown", group); + // A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip. + let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled()); + add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true })); + // A group's own fill paints its unpainted 8 × 4 member. + let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]); + painted_group.appearance = filled().into(); + add_symbol(&mut d, "Group Fill", painted_group); + // A 10 × 4 band stroked with a gradient along it. + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut along = Appearance::basic(Paint::None, gradient, 4.0); + if let Some(st) = along.stroke_mut() { + (st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along); + } + let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along); + add_symbol(&mut d, "Gradient Band", band); + // An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head. + let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2); + let points = corners + .iter() + .map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() }) + .collect(); + let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points }; + add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh))); + // An effect on the file's layer is not part of any symbol. + Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect { + id: "path.offsetPath".into(), + params: json!({"offset": 5}), + visible: true, + }); + let read = heads_of(&d); + let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect(); + assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]); + let b = read.heads[9].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}"); + let b = read.heads[7].path.bounds().unwrap(); + assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}"); + let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap()); + let near = |a: f64, b: f64| (a - b).abs() < 1e-3; + assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}"); + let framed = read.heads[6].path.to_bezpath(); + let b = vectorcraft_geom::Shape::bounding_box(&framed); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}"); + assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle"); + for h in &read.heads[3..6] { + let b = h.path.bounds().unwrap(); + assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name); + } + let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped); + let b = read.heads[2].path.bounds().unwrap(); + assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}"); + let b = read.heads[0].path.bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}"); + assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6); + let b = read.heads[1].path.bounds().unwrap(); + assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() { + let dir = folder("edit"); + let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string(); + let (mut s, id) = session_with_line(); + s.attach_arrowheads_file(file.clone()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str()))); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet"); + let line_doc = s.doc().unwrap().uid; + let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap(); + assert_eq!(r["created"], true); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document"); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead"); + // A second head drawn in the file is listed once the file is saved. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled())); + assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1); + s.execute("document.save", &json!({})).unwrap(); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect(); + assert_eq!(names, ["Example Arrowhead", "Square"]); + // Editing again from the line's document brings the open file to the front, without making + // it anew or opening it twice. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().uid, line_doc); + assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false); + assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str()))); + // The line takes the head with its art. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap(); + let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") }; + assert_eq!(c.name, "Square"); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err()); + assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err()); + // A head given with its art is refused when the art could not be drawn. + let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap(); + far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap(); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err()); + let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap(); + empty["Custom"]["path"] = json!({"subpaths": []}); + assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err()); + assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() { + let (mut s, id) = session_with_line(); + let mut d = Document::new(100.0, 100.0); + add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled())); + let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0))); + // As document.inspect reports it, so it reads back unchanged. + s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap(); + assert_eq!(stroke(&s, id).start_arrow, Some(head.clone())); + let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string(); + assert!(inspect.contains("\"Wedge\""), "inspect names the head"); + let ink = |s: &Session| { + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default()) + }; + let with_head = ink(&s); + // Saved and opened in a session that has no arrowheads file: the head is drawn the same. + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + assert_eq!(stroke(&o, id).start_arrow, Some(head)); + assert_eq!(ink(&o).pixels, with_head.pixels); + // SVG and PDF write it as they write the built-in heads. + let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len(); + let headed = svg(&mut o); + o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap(); + assert!(headed > svg(&mut o), "the head is in the SVG"); + // Undo puts it back. + o.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_)))); +} + +#[test] +fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() { + let dir = folder("bad"); + let file = dir.join(arrowheads::FILE_NAME); + std::fs::write(&file, b"not a drawing").unwrap(); + let mut s = Session::new(); + s.attach_arrowheads_file(file.to_string_lossy().to_string()); + let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap(); + assert!(list["arrowheads"].as_array().unwrap().is_empty()); + assert!(list["error"].is_string(), "{list}"); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + // A folder in its place is not read either. + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + // Without a file (the web, runs without preferences) there is nothing to edit. + let mut bare = Session::new(); + assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err()); + assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null); +} diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 7e7204f6e1..79b5f724a4 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() { st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize]; st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize]; st.miter_limit = 1.0 + next(20) as f64; - let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]); + let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone()); (st.start_arrow, st.end_arrow) = (head(), head()); st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64); if next(3) == 0 { diff --git a/crates/engine/src/tests/strokegeom.rs b/crates/engine/src/tests/strokegeom.rs index f7500af358..524dabef3e 100644 --- a/crates/engine/src/tests/strokegeom.rs +++ b/crates/engine/src/tests/strokegeom.rs @@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() { assert!(spec.params.contains("arrowAlign")); // …and every arrowhead name `startArrow`/`endArrow` accept. for a in vectorcraft_doc::Arrowhead::ALL { - let name = serde_json::to_value(a).unwrap(); + let name = serde_json::to_value(&a).unwrap(); assert!(spec.params.contains(name.as_str().unwrap()), "{name}"); s.execute("stroke.set", &json!({"endArrow": name})).unwrap(); assert_eq!(stroke(&s, id).end_arrow, Some(a)); diff --git a/crates/pdf/src/tests/stroke.rs b/crates/pdf/src/tests/stroke.rs index 91436a093c..d57cbc42d0 100644 --- a/crates/pdf/src/tests/stroke.rs +++ b/crates/pdf/src/tests/stroke.rs @@ -52,7 +52,7 @@ pub(crate) fn painted_boxes(d: &Document) -> (Vec, Vec) { 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 (d, n) = line_doc(arrow_stroke(kind.clone(), align, 1.0)); let bp = n.path_data().unwrap().to_bezpath(); let st = n.appearance.stroke().unwrap(); let pieces = stroke_pieces(&bp, st); diff --git a/crates/render/tests/it/golden.rs b/crates/render/tests/it/golden.rs index 4820cf8b60..d6962e0e67 100644 --- a/crates/render/tests/it/golden.rs +++ b/crates/render/tests/it/golden.rs @@ -300,7 +300,7 @@ fn arrowheads_add_ink_at_the_ends() { // Every arrowhead kind renders. for a in Arrowhead::ALL { let mut b = DocBuilder::new(100.0, 100.0); - b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {}); + b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a.clone())), |_| {}); assert!(render(&b.build()).ink() > plain.ink(), "{a:?}"); } } diff --git a/crates/render/tests/it/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs index a8813da23a..2580fc79c8 100644 --- a/crates/render/tests/it/stroke_geometry.rs +++ b/crates/render/tests/it/stroke_geometry.rs @@ -114,7 +114,7 @@ fn a_hollow_circle_head_is_empty_inside() { 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 d = arrow_doc(kind.clone(), 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)); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..73d8e836c9 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2759,6 +2759,10 @@ digits Malayalam Малаялам Search Preferences Търсене в настройките No settings found Няма намерени настройки Reset Profiles Нулиране на профилите + Arrowheads Върхове на стрелки + Edit Arrowheads Редактиране на върхове на стрелки + Edit Arrowheads… Редактиране на върхове на стрелки… + Reload Arrowheads Презареждане на върхове на стрелки Reset Rotate View Нулиране на изгледа на завъртане Rotate View to Selection Завъртане на изгледа спрямо селекцията Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..22faf858cd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3819,6 +3819,10 @@ theme Appearance Izgled Add to Profiles Dodaj u profile Delete Profile Izbriši profil Reset Profiles Vrati profile na početno + Arrowheads Vrhovi strelica + Edit Arrowheads Uredi vrhove strelica + Edit Arrowheads… Uredi vrhove strelica… + Reload Arrowheads Ponovo učitaj vrhove strelica Stroke Options Opcije poteza Width Profiles Profili širine Apply Graphic Style Primijeni grafički stil diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..4ad2acfe23 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3507,6 +3507,10 @@ digits Malayalam Malaiàlam No settings found No s'ha trobat cap paràmetre Reset Printing and Proofing Restableix impressió i prova Reset Profiles Restableix els perfils + Arrowheads Puntes de fletxa + Edit Arrowheads Edita les puntes de fletxa + Edit Arrowheads… Edita les puntes de fletxa… + Reload Arrowheads Torna a carregar les puntes de fletxa Reset Rotate View Restableix la rotació de la vista Reset Ruler Origin Restableix l’origen de les regles Reset the rows to the artwork's colors Restableix les files als colors de l’obra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..75a65e2b2b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3870,6 +3870,10 @@ theme Appearance Motiv Add to Profiles Přidat do profilů Delete Profile Odstranit profil Reset Profiles Obnovit profily + Arrowheads Šipky + Edit Arrowheads Upravit šipky + Edit Arrowheads… Upravit šipky… + Reload Arrowheads Znovu načíst šipky Stroke Options Možnosti tahu Width Profiles Profily šířky Apply Graphic Style Použít grafický styl diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..171447e2f3 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3594,6 +3594,10 @@ theme Appearance Udseende Add to Profiles Føj til profiler Delete Profile Slet profil Reset Profiles Nulstil profiler + Arrowheads Pilespidser + Edit Arrowheads Redigér pilespidser + Edit Arrowheads… Redigér pilespidser… + Reload Arrowheads Genindlæs pilespidser Stroke Options Konturindstillinger Width Profiles Breddeprofiler Apply Graphic Style Anvend grafikstil diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..b926704b9c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2791,6 +2791,10 @@ digits Malayalam Malayalam Search Preferences Voreinstellungen durchsuchen No settings found Keine Einstellungen gefunden Reset Profiles Profile zurücksetzen + Arrowheads Pfeilspitzen + Edit Arrowheads Pfeilspitzen bearbeiten + Edit Arrowheads… Pfeilspitzen bearbeiten… + Reload Arrowheads Pfeilspitzen neu laden Rotate View Ansicht drehen Reset Rotate View Ansichtsdrehung zurücksetzen Rotate View to Selection Ansicht nach Auswahl drehen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..d9048c58b2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3423,6 +3423,10 @@ digits Malayalam Μαλαγιάλαμ Reset OpenType Features Επαναφορά χαρακτηριστικών OpenType Reset Panel Επαναφορά πίνακα Reset Profiles Επαναφορά προφίλ + Arrowheads Αιχμές βελών + Edit Arrowheads Επεξεργασία αιχμών βελών + Edit Arrowheads… Επεξεργασία αιχμών βελών… + Reload Arrowheads Επαναφόρτωση αιχμών βελών Reset Rotate View Επαναφορά περιστροφής προβολής Rotate View to Selection Περιστροφή προβολής ως προς την επιλογή Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..d246f64bab 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2833,6 +2833,10 @@ digits Malayalam Malayálam Search Preferences Buscar en preferencias No settings found No se encontraron ajustes Reset Profiles Restablecer perfiles + Arrowheads Puntas de flecha + Edit Arrowheads Editar puntas de flecha + Edit Arrowheads… Editar puntas de flecha… + Reload Arrowheads Volver a cargar puntas de flecha Reset Rotate View Restablecer rotación de vista Rotate View to Selection Rotar vista según la selección Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..ec528e38d7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3794,6 +3794,10 @@ theme Appearance Ulkoasu Add to Profiles Lisää profiileihin Delete Profile Poista profiili Reset Profiles Palauta profiilit + Arrowheads Nuolenkärjet + Edit Arrowheads Muokkaa nuolenkärkiä + Edit Arrowheads… Muokkaa nuolenkärkiä… + Reload Arrowheads Lataa nuolenkärjet uudelleen Stroke Options Viivan asetukset Width Profiles Leveysprofiilit Apply Graphic Style Käytä grafiikkatyyliä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..e82137bdde 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2825,6 +2825,10 @@ digits Malayalam Malayalam Search Preferences Rechercher dans les préférences No settings found Aucun réglage trouvé Reset Profiles Réinitialiser les profils + Arrowheads Pointes de flèche + Edit Arrowheads Modifier les pointes de flèche + Edit Arrowheads… Modifier les pointes de flèche… + Reload Arrowheads Recharger les pointes de flèche Reset Rotate View Réinitialiser la rotation de l’affichage Rotate View to Selection Faire pivoter l’affichage selon la sélection Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..12fad0afa3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3811,6 +3811,10 @@ antiAlias Sharp शार्प No settings found कोई सेटिंग नहीं मिली Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें Reset Profiles प्रोफ़ाइल रीसेट करें + Arrowheads एरोहेड्स + Edit Arrowheads एरोहेड्स संपादित करें + Edit Arrowheads… एरोहेड्स संपादित करें… + Reload Arrowheads एरोहेड्स फिर से लोड करें Reset Rotate View व्यू घुमाएँ रीसेट करें Reset Ruler Origin शासकीय उत्पत्ति रीसेट करें Reset Symbol Instance सिंबल इंस्टेंस रीसेट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..8bd9b3730c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3377,6 +3377,10 @@ mark-style Japanese Japán No settings found Nincs ilyen beállítás Reset Printing and Proofing Printing and Proofing visszaállítása Reset Profiles Profilok visszaállítása + Arrowheads Nyílhegyek + Edit Arrowheads Nyílhegyek szerkesztése + Edit Arrowheads… Nyílhegyek szerkesztése… + Reload Arrowheads Nyílhegyek újratöltése Reset Rotate View Nézetforgatás visszaállítása Rotate View to Selection Nézet elforgatása a kijelöléshez Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..5665633eb8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3594,6 +3594,10 @@ theme Appearance Tampilan Add to Profiles Tambahkan ke profil Delete Profile Hapus profil Reset Profiles Atur ulang profil + Arrowheads Mata panah + Edit Arrowheads Edit mata panah + Edit Arrowheads… Edit mata panah… + Reload Arrowheads Muat ulang mata panah Stroke Options Opsi goresan Width Profiles Profil lebar Apply Graphic Style Terapkan gaya grafis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..e881a370fa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2867,6 +2867,10 @@ digits Malayalam Malayalam Search Preferences Cerca nelle preferenze No settings found Nessuna impostazione trovata Reset Profiles Reimposta profili + Arrowheads Punte di freccia + Edit Arrowheads Modifica punte di freccia + Edit Arrowheads… Modifica punte di freccia… + Reload Arrowheads Ricarica punte di freccia Rotate View Ruota vista Reset Rotate View Reimposta rotazione vista Rotate View to Selection Ruota vista in base alla selezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..84ca0e4650 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2864,6 +2864,10 @@ mark-style Japanese 日本式 Search Preferences 環境設定を検索 No settings found 設定が見つかりません Reset Profiles プロファイルをリセット + Arrowheads 矢印 + Edit Arrowheads 矢印を編集 + Edit Arrowheads… 矢印を編集… + Reload Arrowheads 矢印を再読み込み Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Reset to Default 初期設定に戻す Reset to White, Black 白、黒にリセット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..17bea31b37 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3433,6 +3433,10 @@ theme Appearance იერსახე Add to Profiles პროფილებში დამატება Delete Profile პროფილის წაშლა Reset Profiles პროფილების ჩამოყრა + Arrowheads ისრის წვერები + Edit Arrowheads ისრის წვერების რედაქტირება + Edit Arrowheads… ისრის წვერების რედაქტირება… + Reload Arrowheads ისრის წვერების ხელახლა ჩატვირთვა Stroke Options კონტურის პარამეტრები Width Profiles სიგანის პროფილები Apply Graphic Style გრაფიკული სტილის გამოყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..990684f2bc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2853,6 +2853,10 @@ mark-style Japanese 일본식 Search Preferences 환경 설정 검색 No settings found 설정을 찾을 수 없음 Reset Profiles 프로필 재설정 + Arrowheads 화살촉 + Edit Arrowheads 화살촉 편집 + Edit Arrowheads… 화살촉 편집… + Reload Arrowheads 화살촉 다시 불러오기 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Reset to Default 기본값으로 재설정 Reset to White, Black 흰색, 검정으로 재설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..15d0f9024a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3608,6 +3608,10 @@ theme Appearance Vormgeving Add to Profiles Aan profielen toevoegen Delete Profile Profiel verwijderen Reset Profiles Profielen opnieuw instellen + Arrowheads Pijlpunten + Edit Arrowheads Pijlpunten bewerken + Edit Arrowheads… Pijlpunten bewerken… + Reload Arrowheads Pijlpunten opnieuw laden Stroke Options Lijnopties Width Profiles Breedteprofielen Apply Graphic Style Grafische stijl toepassen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..f21124fd30 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3733,6 +3733,10 @@ theme Appearance Wygląd Add to Profiles Dodaj do profili Delete Profile Usuń profil Reset Profiles Resetuj profile + Arrowheads Groty strzałek + Edit Arrowheads Edytuj groty strzałek + Edit Arrowheads… Edytuj groty strzałek… + Reload Arrowheads Wczytaj ponownie groty strzałek Stroke Options Opcje obrysu Width Profiles Profile szerokości Apply Graphic Style Zastosuj styl graficzny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..5b57e5962e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2370,6 +2370,10 @@ digits Malayalam Malaiala Search Preferences Pesquisar nas Preferências No settings found Nenhuma configuração encontrada Reset Profiles Redefinir Perfis + Arrowheads Pontas de Seta + Edit Arrowheads Editar Pontas de Seta + Edit Arrowheads… Editar Pontas de Seta… + Reload Arrowheads Recarregar Pontas de Seta Rotate View Girar Visualização Reset Rotate View Redefinir Visualização Girada Rotate View to Selection Girar Visualização para a Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..e6611285f7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2862,6 +2862,10 @@ digits Malayalam Малаялам Search Preferences Поиск в настройках No settings found Настройки не найдены Reset Profiles Сбросить профили + Arrowheads Стрелки + Edit Arrowheads Редактировать стрелки + Edit Arrowheads… Редактировать стрелки… + Reload Arrowheads Перезагрузить стрелки Rotate View Повернуть вид Reset Rotate View Сбросить поворот вида Rotate View to Selection Повернуть вид по выделению diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..5a310e2829 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2769,6 +2769,10 @@ digits Malayalam Malayalam Search Preferences Sök i inställningar No settings found Inga inställningar hittades Reset Profiles Återställ profiler + Arrowheads Pilspetsar + Edit Arrowheads Redigera pilspetsar + Edit Arrowheads… Redigera pilspetsar… + Reload Arrowheads Läs in pilspetsar igen Reset Rotate View Återställ Rotera vy Rotate View to Selection Rotera vyn efter markeringen Reset the rows to the artwork's colors Återställ raderna till teckningens färger diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..a10b459531 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2638,6 +2638,10 @@ digits Malayalam Malayalam Search Preferences Tercihlerde Ara No settings found Ayar bulunamadı Reset Profiles Profilleri Sıfırla + Arrowheads Ok Uçları + Edit Arrowheads Ok Uçlarını Düzenle + Edit Arrowheads… Ok Uçlarını Düzenle… + Reload Arrowheads Ok Uçlarını Yeniden Yükle Reset Rotate View Döndürme Görünümünü Sıfırla Rotate View to Selection Görünümü Seçime Döndür Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..b420758cfa 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2859,6 +2859,10 @@ digits Malayalam Малаялам Search Preferences Пошук у налаштуваннях No settings found Налаштування не знайдено Reset Profiles Скинути профілі + Arrowheads Стрілки + Edit Arrowheads Редагувати стрілки + Edit Arrowheads… Редагувати стрілки… + Reload Arrowheads Перезавантажити стрілки Rotate View Повернути вид Reset Rotate View Скинути поворот виду Rotate View to Selection Повернути вид за виділенням diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..db91921932 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3140,6 +3140,10 @@ mark-style Japanese Kiểu Nhật Search Preferences Tìm trong tùy chọn No settings found Không tìm thấy cài đặt Reset Profiles Đặt lại hồ sơ màu + Arrowheads Đầu mũi tên + Edit Arrowheads Chỉnh sửa đầu mũi tên + Edit Arrowheads… Chỉnh sửa đầu mũi tên… + Reload Arrowheads Tải lại đầu mũi tên Rotate View Xoay chế độ xem Reset Rotate View Đặt lại xoay chế độ xem Rotate View to Selection Xoay chế độ xem theo vùng chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..56b4b7bcd6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2733,6 +2733,10 @@ digits Malayalam 马拉雅拉姆数字 Search Preferences 搜索首选项 No settings found 未找到设置 Reset Profiles 重置配置文件 + Arrowheads 箭头 + Edit Arrowheads 编辑箭头 + Edit Arrowheads… 编辑箭头… + Reload Arrowheads 重新载入箭头 Reset Rotate View 重置旋转视图 Rotate View to Selection 按所选对象旋转视图 Reset the rows to the artwork's colors 将各行重置为图稿的颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..35b2f69cc4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3811,6 +3811,10 @@ digits Malayalam 馬來亞拉姆數字 Search Preferences 搜尋偏好設定 No settings found 找不到設定 Reset Profiles 重設描述檔 + Arrowheads 箭頭 + Edit Arrowheads 編輯箭頭 + Edit Arrowheads… 編輯箭頭… + Reload Arrowheads 重新載入箭頭 Rotate View 旋轉檢視 Reset Rotate View 重設旋轉檢視 Rotate View to Selection 依選取範圍旋轉檢視 diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index f6ece0ac55..7be5b65e7c 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -100,10 +100,37 @@ pub(crate) fn edited_dash_pattern(fields: &[Option; 6], previous: &[f64]) - out } -fn arrow_label(a: Option) -> &'static str { +fn arrow_label(a: Option<&Arrowhead>) -> &'static str { a.map_or("None", Arrowhead::label) } +/// The name an arrowhead menu shows for `a`: a built-in head's in the interface language, a +/// custom head's own. +fn arrow_name(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => c.name.clone(), + _ => tl!(arrow_label(a)).to_string(), + } +} + +/// The preview cache key of `a`: its name, and for a custom head its art. +fn arrow_key(a: Option<&Arrowhead>) -> String { + match a { + Some(Arrowhead::Custom(c)) => { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for s in &c.path.subpaths { + (s.anchors.len(), s.closed).hash(&mut h); + for p in s.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]) { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + format!("custom:{}:{:x}", c.name, h.finish()) + } + _ => format!("{a:?}"), + } +} + /// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the /// selected characters' stroke. fn set(app: &mut VectorcraftApp, p: Value) { @@ -272,14 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Arrowheads. - let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow)); + let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow.clone()), st.as_ref().and_then(|s| s.end_arrow.clone())); + // The custom heads of the arrowheads file follow the built-in ones. + let custom: Vec = app.session.arrowheads.heads().iter().cloned().map(Arrowhead::Custom).collect(); ui.horizontal(|ui| { row_label(ui, tl!("Arrowheads:")); - if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) { - set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))})); + // A custom head goes with its art, as document.inspect reports it. + if let Some(a) = arrow_dropdown(ui, "arrow-start", sa.as_ref(), &custom, true) { + set(app, json!({ "startArrow": a })); } - if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) { - set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))})); + if let Some(a) = arrow_dropdown(ui, "arrow-end", ea.as_ref(), &custom, false) { + set(app, json!({ "endArrow": a })); } if widgets::icon_button_enabled(ui, "arrow-left-right", tl!("Swap start and end arrowheads"), false, st.is_some(), 22.0).clicked() { app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok(); @@ -360,14 +390,15 @@ pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Op /// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the /// left (`start`) or right end. -fn paint_arrow(ui: &Ui, r: Rect, a: Option, start: bool, color: Color32) { - if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) { +fn paint_arrow(ui: &Ui, r: Rect, a: Option<&Arrowhead>, start: bool, color: Color32) { + if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{}:{start}", arrow_key(a)), r.size(), |w, h| arrow_doc(a.cloned(), start, w, h)) { ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color); } } -/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice). -fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> Option> { +/// Dropdown whose button and items show drawn arrowhead previews: None, the built-in heads, then +/// the `custom` ones. Returns Some(choice). +fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arrowhead], start: bool) -> Option> { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); @@ -380,7 +411,8 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> egui::Popup::menu(&resp).id(egui::Id::unique(("arrow-pop", id))).show(|ui| { ui.set_min_width(170.0); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) { + let built_in = Arrowhead::ALL; + for a in std::iter::once(None).chain(built_in.iter().map(Some)).chain(custom.iter().map(Some)) { let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click()); if a == cur { ui.painter().rect_filled(row, 0.0, t.row_selected); @@ -391,12 +423,12 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> ui.painter().text( row.left_center() + vec2(66.0, 0.0), egui::Align2::LEFT_CENTER, - tl!(arrow_label(a)), + arrow_name(a), egui::FontId::proportional(11.5), t.text, ); if rr.clicked() { - out = Some(a); + out = Some(a.cloned()); ui.close(); } } @@ -500,6 +532,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Reset Profiles"), crate::menus::enabled(app, "stroke.widthProfile.reset"), false) { app.run("stroke.widthProfile.reset", json!({})).ok(); } + ui.separator(); + // Opens the arrowheads file, whose symbols are the custom heads; saving it reloads them. + if menu_item(ui, tl!("Edit Arrowheads…"), crate::menus::enabled(app, "stroke.arrowheads.edit"), false) { + app.run("stroke.arrowheads.edit", json!({})).ok(); + } } #[cfg(test)] @@ -647,7 +684,7 @@ mod tests { assert!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar"); let lens = WidthProfile::lens(); assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice())); - assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); + assert_eq!(arrow_label(Some(&Arrowhead::CircleOpen)), "Circle (open)"); } #[test] diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index e37b6e75b7..c085ccf385 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -100,9 +100,15 @@ fn every_arrowhead_preview_draws_its_head() { }; let plain = (ink(None, false), ink(None, true)); for a in Arrowhead::ALL { - assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end"); - assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start"); + assert!(ink(Some(a.clone()), false) > plain.0 + 20, "{a:?} at the end"); + assert!(ink(Some(a.clone()), true) > plain.1 + 20, "{a:?} at the start"); } + // So does a custom head: a 4 × 4 square behind its tip. + let pts = [(0.0, -2.0), (4.0, -2.0), (4.0, 2.0), (0.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let square = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true)); + let custom = Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Square".into(), square))); + assert!(ink(Some(custom.clone()), false) > plain.0 + 20); + assert!(ink(Some(custom), true) > plain.1 + 20); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..c6a9d363e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3741,6 +3741,31 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou `arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of 0.25 pt or more) at right angles and spreads twice as wide. +Custom arrowheads come from the arrowheads file, `Arrowheads.vectorcraft` next to the preferences (none on the web or +in runs with `VECTORCRAFT_NO_PREFS`). `stroke.arrowheads.edit` (Stroke panel menu › Edit Arrowheads…) opens it, and +first creates it with one example head when it is missing. Each symbol in the file is one head, named after the +symbol and listed after the built-in heads in both arrowhead menus. These rules are VectorCraft's own: a head points +right, the symbol's registration point is its tip, and the art's height is drawn as wide as the built-in heads (four +head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its +gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is +one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity +masks do not change the shape. The file is read when the app starts and again each time VectorCraft saves it; +`stroke.arrowheads.reload` reads it again. A file larger than 64 MB is not read, at most 256 symbols are read, and a +symbol that paints nothing, reaches more than 64 head weights from its tip, has more than 65,536 anchors in its art or +in the shapes it paints, or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file` +path, the `skipped` symbols and any read `error`. + +`stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art, +so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file. +`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. The +help page [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes +defining custom arrowheads in a separate arrowheads file. + +```json +{"name":"run_command","arguments":{"command":"stroke.arrowheads.list","params":{}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"endArrow":{"custom":"Example Arrowhead"}}}} +``` + ## Stroke bounds and clicks Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..15326c0347 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -406,6 +406,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control bar's Global Edit Options dropdown opens the same options; rotated copies and highlighting matches before the start remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | +| Stroke panel: custom arrowheads after the built-in ones in both arrowhead menus, from the arrowheads file that the panel menu's Edit Arrowheads… opens; saving the file reloads the menus, and a stroke keeps a copy of its custom head's art | done | [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) (Customize arrowheads); the file's layout (one symbol per head, pointing right, tip at the registration point, height drawn as wide as the built-in heads) is VectorCraft's own; `stroke.arrowheads.edit`, `stroke.arrowheads.list`, `stroke.arrowheads.reload`, `stroke.set` `{custom: name}`; engine `tests::arrowheads`, `stroke::tests_arrows::custom_heads_draw_their_own_art_at_the_tip` | | Control bar: Paragraph: link and Align left/center/right while type is selected (#5372) | done | After the Character controls, as the public documentation has the Control panel format paragraphs ([Paragraph panel overview](https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html), [Align text](https://helpx.adobe.com/illustrator/using/align-text.html)); the link opens the Paragraph panel in a popover, as Character: does with the Character panel. The bar trims from its right end: X/Y/W/H, Distribute, Align, area type vertical alignment, then these buttons, then the link. The three-button set is our choice for room; all seven alignments are in the popover. `panels/paragraph/tests.rs` headless tests (`the_control_bar_shows_the_paragraph_alignment_of_the_selected_type`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right`) | | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) |