From 3e73ad85818917026650b22e2acfac31db3c011c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:01:29 +0700 Subject: [PATCH 1/3] M3.61: width profile library: Add to Profiles, Delete Profile, Reset Profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stroke panel's Profile list now holds saved profiles next to the built-ins, kept with the preferences (Prefs.width_profiles). Add to Profiles… names the selected stroke's variable width, Delete Profile removes the selected stroke's saved one (built-ins can't be deleted), Reset Profiles removes them all. Three more generated built-ins (Pinch, Teardrop, Wave). The dropdown (Stroke panel and Control bar) lists saved profiles and draws a custom profile's own silhouette. Commands: stroke.widthProfile.add/delete/reset/list; stroke.set {profile} takes a built-in id or a saved name. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 23 ++- crates/doc/src/lib.rs | 4 +- crates/engine/src/cmd/stroke.rs | 160 +++++++++++++++++- crates/engine/src/lib.rs | 7 + crates/engine/src/tests_widthprofiles.rs | 140 +++++++++++++++ crates/ui-egui/src/chrome.rs | 3 +- crates/ui-egui/src/panels/appearance.rs | 2 +- crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/stroke.rs | 81 +++++---- .../ui-egui/src/panels/tests_strokedepth.rs | 89 ++++++++++ crates/ui-egui/src/tests_labels.rs | 2 +- docs/mcp.md | 14 ++ 12 files changed, 482 insertions(+), 45 deletions(-) create mode 100644 crates/engine/src/tests_widthprofiles.rs create mode 100644 crates/ui-egui/src/panels/tests_strokedepth.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 0475830e..7ae42d58 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -106,9 +106,12 @@ pub struct ProfilePreset { } impl WidthProfile { - /// Width factor at `t` (average of both sides), linear between points. + /// (left, right) width factors at `t`, linear between points. pub fn at(&self, t: f64) -> (f64, f64) { - let p = &self.points; + Self::at_points(&self.points, t) + } + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { if p.is_empty() { return (1.0, 1.0); } @@ -125,11 +128,17 @@ impl WidthProfile { (l.1, l.2) } /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). - pub const PRESETS: [ProfilePreset; 4] = [ + pub const PRESETS: [ProfilePreset; 7] = [ ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }, ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }, ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }, ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // Full width at both ends, pinched to a quarter in the middle. + ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] }, + // A round head a fifth of the way along, then a long taper to the end. + ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // Two swells between narrow necks. + ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; /// The built-in profile with this id. pub fn preset(id: &str) -> Option { @@ -155,6 +164,14 @@ impl WidthProfile { } } +/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the +/// preferences. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedProfile { + pub name: String, + pub profile: WidthProfile, +} + /// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Effect { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 349a38c9..01190778 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -34,8 +34,8 @@ pub(crate) mod skip { } pub use appearance::{ - Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, - WidthProfile, + Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign, + StrokeLayer, WidthProfile, }; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index b926fd4d..5f734181 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -1,9 +1,9 @@ //! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes. //! With type selected it edits the characters' stroke (weight, cap, join, miter limit, dashes). -use serde_json::Value; +use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile}; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -18,7 +18,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|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", 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?} Without a stroke item, type takes weight, cap, join, miterLimit 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", + "{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|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), 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?} Without a stroke item, type takes weight, cap, join, miterLimit 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", has_doc, stroke_set ), @@ -31,6 +31,42 @@ pub fn specs() -> Vec { has_doc, stroke_advanced ), + cmd!( + "stroke.widthProfile.add", + "Add to Profiles", + [], + None, + "{name?} save the selected stroke's variable width to the Profile list (kept with the preferences) under `name` (default \"Width Profile N\"; unique, not a built-in's) → {name}", + can_add_profile, + profile_add + ), + cmd!( + "stroke.widthProfile.delete", + "Delete Profile", + [], + None, + "{name?} remove a saved profile from the Profile list (default: the selected stroke's); built-in profiles can't be deleted, and strokes keep their widths → {deleted}", + has_saved_profiles, + profile_delete + ), + cmd!( + "stroke.widthProfile.reset", + "Reset Profiles", + [], + None, + "{} remove every saved profile, leaving the built-ins → {removed: count}", + has_saved_profiles, + profile_reset + ), + cmd!( + query "stroke.widthProfile.list", + "Width Profiles", + [], + None, + "{} → {profiles: [{id (stroke.set `profile`), label, builtIn, points: [[t, left, right]…]}], current: the selected stroke's profile id or name, \"custom\" when it isn't listed, null without a stroke}", + always, + profile_list + ), ] } @@ -154,8 +190,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let profile = match str_param(p, "profile") { None => None, - Some("uniform") => Some(None), - Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)), + Some(id) => Some(s.resolve_profile(id).ok_or_else(|| bad(C, format!("unknown profile {id} (see stroke.widthProfile.list)")))?), }; edit_items(s, &ids, item, C, "Stroke", false, |n, index| { if index.is_none() @@ -314,3 +349,118 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { })?; ok() } + +/// One row of the Profile list: a built-in profile or a saved one. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProfileEntry<'a> { + /// What `stroke.set {profile}` takes: a built-in's id, a saved profile's name. + pub id: &'a str, + pub label: &'a str, + pub points: &'a [(f64, f64, f64)], + pub built_in: bool, +} + +impl ProfileEntry<'_> { + /// Does this row describe `p` (`None`: the plain stroke, the Uniform row)? + pub fn matches(&self, p: Option<&WidthProfile>) -> bool { + match p { + None => self.built_in && self.id == "uniform", + Some(p) => p.points == self.points, + } + } +} + +impl Session { + /// The Profile list: the built-in profiles, then the saved ones. + pub fn profile_entries(&self) -> impl Iterator> { + let built_in = WidthProfile::PRESETS.iter().map(|p| ProfileEntry { id: p.id, label: p.label, points: p.points, built_in: true }); + let saved = + self.prefs.width_profiles.iter().map(|p| ProfileEntry { id: &p.name, label: &p.name, points: &p.profile.points, built_in: false }); + built_in.chain(saved) + } + + /// The Profile list row showing `p` (`None`: the plain stroke), if it is listed. + pub fn profile_entry(&self, p: Option<&WidthProfile>) -> Option> { + self.profile_entries().find(|e| e.matches(p)) + } + + /// The profile `stroke.set {profile: key}` applies: `Some(None)` for "uniform" (the plain + /// stroke), else a built-in's id or a saved profile's name; `None` when nothing is called `key`. + pub fn resolve_profile(&self, key: &str) -> Option> { + let e = self.profile_entries().find(|e| e.id == key)?; + Some((!e.matches(None)).then(|| WidthProfile { points: e.points.to_vec() })) + } + + /// The name Add to Profiles suggests: the first free "Width Profile N". + pub fn next_profile_name(&self) -> String { + (1..).map(|n| format!("Width Profile {n}")).find(|n| !self.profile_name_taken(n)).unwrap_or_default() + } + + /// Does a listed profile have `name` as its id or label (ignoring case)? + fn profile_name_taken(&self, name: &str) -> bool { + self.profile_entries().any(|e| e.id.eq_ignore_ascii_case(name) || e.label.eq_ignore_ascii_case(name)) + } + + /// The variable width Add to Profiles saves: the shown stroke's profile, when it isn't listed. + fn profile_to_add(&self) -> std::result::Result { + let p = self.shown_stroke().and_then(|s| s.profile).ok_or("select a stroke with a variable width")?; + match self.profile_entry(Some(&p)) { + Some(e) => Err(format!("the selected stroke's profile is already listed as {}", e.label)), + None => Ok(p), + } + } +} + +fn can_add_profile(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + s.profile_to_add().map(|_| ()) +} + +fn has_saved_profiles(s: &Session) -> std::result::Result<(), String> { + if s.prefs.width_profiles.is_empty() { Err("no saved profiles".into()) } else { Ok(()) } +} + +fn profile_add(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthProfile.add"; + let profile = s.profile_to_add().map_err(|e| bad(C, e))?; + let name = match str_param(p, "name").map(str::trim) { + None => s.next_profile_name(), + Some("") => return Err(bad(C, "the name is empty")), + Some(n) if s.profile_name_taken(n) => return Err(bad(C, format!("a profile is already called {n}"))), + Some(n) => n.to_string(), + }; + s.prefs.width_profiles.push(SavedProfile { name: name.clone(), profile }); + Ok(json!({ "name": name })) +} + +fn profile_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthProfile.delete"; + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => { + let shown = s.shown_stroke().and_then(|st| st.profile); + let entry = shown.as_ref().and_then(|p| s.profile_entry(Some(p))); + entry.map(|e| e.id.to_string()).ok_or_else(|| bad(C, "give a name, or select a stroke with a saved profile"))? + } + }; + if WidthProfile::PRESETS.iter().any(|b| b.id == name || b.label == name) { + return Err(bad(C, format!("{name} is built in and can't be deleted"))); + } + let i = s.prefs.width_profiles.iter().position(|sp| sp.name == name).ok_or_else(|| bad(C, format!("no saved profile called {name}")))?; + s.prefs.width_profiles.remove(i); + Ok(json!({ "deleted": name })) +} + +fn profile_reset(s: &mut Session, _: &Value) -> Result { + let removed = std::mem::take(&mut s.prefs.width_profiles).len(); + Ok(json!({ "removed": removed })) +} + +fn profile_list(s: &mut Session, _: &Value) -> Result { + let profiles: Vec = s + .profile_entries() + .map(|e| json!({"id": e.id, "label": e.label, "builtIn": e.built_in, "points": e.points.iter().map(|&(t, l, r)| [t, l, r]).collect::>()})) + .collect(); + let current = s.shown_stroke().map(|st| s.profile_entry(st.profile.as_ref()).map_or("custom", |e| e.id).to_string()); + Ok(json!({ "profiles": profiles, "current": current })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 96fbd8c8..c23d2172 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -322,6 +322,10 @@ pub struct Prefs { /// edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub flattener_presets: Vec, + /// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a + /// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub width_profiles: Vec, } impl Default for Prefs { @@ -441,6 +445,7 @@ impl Default for Prefs { }, eyedropper: Default::default(), flattener_presets: vec![], + width_profiles: vec![], } } } @@ -861,4 +866,6 @@ mod tests_tileedge; #[cfg(test)] mod tests_tints; #[cfg(test)] +mod tests_widthprofiles; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_widthprofiles.rs b/crates/engine/src/tests_widthprofiles.rs new file mode 100644 index 00000000..65af9c6f --- /dev/null +++ b/crates/engine/src/tests_widthprofiles.rs @@ -0,0 +1,140 @@ +//! The width profile library (`stroke.widthProfile.*`): saved profiles kept with the preferences +//! next to the built-in ones, applied by name with `stroke.set {profile}`. + +use serde_json::{Value, json}; +use vectorcraft_doc::WidthProfile; + +use super::*; + +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 profile(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.clone() +} + +fn custom() -> WidthProfile { + WidthProfile { points: vec![(0.0, 0.2, 0.2), (0.3, 1.6, 0.8), (1.0, 0.5, 0.5)] } +} + +/// Give the line `p` as its variable width (as the Width tool would). +fn set_profile(s: &mut Session, id: NodeId, p: WidthProfile) { + s.edit("Width", |d, _| { + d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p); + Ok(()) + }) + .unwrap(); +} + +fn ids(list: &Value) -> Vec<&str> { + list["profiles"].as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap()).collect() +} + +#[test] +fn add_list_apply_delete_reset() { + let (mut s, id) = session_with_line(); + let built_in: Vec<&str> = WidthProfile::PRESETS.iter().map(|p| p.id).collect(); + let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap(); + assert_eq!(ids(&list), built_in); + assert_eq!(list["current"], "uniform"); + // Nothing to save from a plain stroke, or from a built-in profile. + assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err()); + s.execute("stroke.set", &json!({"profile": "lens"})).unwrap(); + assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err()); + + set_profile(&mut s, id, custom()); + assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], "custom"); + let undo_depth = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("stroke.widthProfile.add", &json!({})).unwrap()["name"], "Width Profile 1"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth, "the library is not part of the document"); + // Listed after the built-ins, and now the stroke's profile. + let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap(); + assert_eq!(ids(&list).last(), Some(&"Width Profile 1")); + assert_eq!(list["current"], "Width Profile 1"); + let row = list["profiles"].as_array().unwrap().last().unwrap().clone(); + assert_eq!((row["builtIn"].clone(), row["points"][1].clone()), (json!(false), json!([0.3, 1.6, 0.8]))); + // The same profile can't be saved twice. + assert!(s.execute("stroke.widthProfile.add", &json!({"name": "Again"})).is_err()); + + // Apply it by name to another stroke. + let other = NodeId(s.execute("shape.line", &json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150})).unwrap()["id"].as_u64().unwrap()); + assert!(profile(&s, other).is_none()); + s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).unwrap(); + assert_eq!(profile(&s, other), Some(custom())); + assert!(s.execute("stroke.set", &json!({"profile": "Nope"})).is_err()); + + // Delete it (the selected stroke's): strokes keep their widths. + assert_eq!(s.execute("stroke.widthProfile.delete", &json!({})).unwrap()["deleted"], "Width Profile 1"); + assert_eq!(ids(&s.execute("stroke.widthProfile.list", &json!({})).unwrap()), built_in); + assert_eq!(profile(&s, other), Some(custom())); + assert!(s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).is_err()); + + // Reset removes every saved profile. + s.execute("stroke.widthProfile.add", &json!({"name": " Ribbon "})).unwrap(); + set_profile(&mut s, other, WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 1.0)] }); + s.execute("stroke.widthProfile.add", &json!({"name": "Twist"})).unwrap(); + assert_eq!(s.prefs.width_profiles.iter().map(|p| p.name.as_str()).collect::>(), ["Ribbon", "Twist"]); + assert_eq!(s.execute("stroke.widthProfile.reset", &json!({})).unwrap()["removed"], 2); + assert!(s.prefs.width_profiles.is_empty()); + assert!(s.execute("stroke.widthProfile.reset", &json!({})).is_err(), "nothing left to reset"); +} + +#[test] +fn built_ins_cant_be_deleted_and_names_stay_unique() { + let (mut s, id) = session_with_line(); + set_profile(&mut s, id, custom()); + for taken in ["lens", "Taper Start", "UNIFORM"] { + assert!(s.execute("stroke.widthProfile.add", &json!({"name": taken})).is_err(), "{taken}"); + } + assert!(s.execute("stroke.widthProfile.add", &json!({"name": " "})).is_err()); + s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap(); + for b in ["lens", "Lens", "uniform", "wave"] { + let e = s.execute("stroke.widthProfile.delete", &json!({"name": b})).unwrap_err().to_string(); + assert!(e.contains("built in"), "{b}: {e}"); + } + // With a built-in profile selected, Delete has nothing to delete. + s.execute("stroke.set", &json!({"profile": "pinch"})).unwrap(); + assert!(s.execute("stroke.widthProfile.delete", &json!({})).is_err()); + assert!(s.execute("stroke.widthProfile.delete", &json!({"name": "Other"})).is_err()); + assert_eq!(s.next_profile_name(), "Width Profile 1"); + assert_eq!(s.execute("stroke.widthProfile.delete", &json!({"name": "Mine"})).unwrap()["deleted"], "Mine"); +} + +#[test] +fn saved_profiles_round_trip_with_the_preferences() { + let (mut s, id) = session_with_line(); + set_profile(&mut s, id, custom()); + s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap(); + let json = s.prefs.to_json(); + assert_eq!(json["widthProfiles"][0]["name"], "Mine"); + let back: Prefs = serde_json::from_value(json).unwrap(); + assert_eq!(back, s.prefs); + // Without saved profiles nothing is written, and older preference files load with none. + assert!(Prefs::default().to_json().get("widthProfiles").is_none()); + let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap(); + assert!(old.width_profiles.is_empty()); + // Setting and resetting other preferences keeps them. + s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 3})).unwrap(); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.width_profiles.len(), 1); + // A restarted session (preferences applied from the saved file) lists them again. + let mut fresh = Session::new(); + fresh.apply_prefs(back); + assert!(fresh.resolve_profile("Mine").is_some_and(|p| p == Some(custom()))); +} + +#[test] +fn every_built_in_applies_and_the_params_doc_names_them() { + let (mut s, id) = session_with_line(); + let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap(); + for p in WidthProfile::PRESETS { + assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id); + s.execute("stroke.set", &json!({"profile": p.id})).unwrap(); + assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], p.id); + assert_eq!(profile(&s, id).map(|w| w.points), (p.id != "uniform").then(|| p.points.to_vec())); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ef049c66..ddeb3e7d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -127,8 +127,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options"); stroke_panel::popover(app, &link); stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); - let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref())); - if let Some(id) = stroke_panel::profile_dropdown(ui, profile) { + if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) { app.run("stroke.set", json!({"profile": id})).ok(); } ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 32458375..6e471654 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -599,7 +599,7 @@ fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) { if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) { let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0)); if pr.right() <= r.right() { - paint_profile(ui, pr, Some(p), t.text); + paint_profile(ui, pr, Some(&p.points), t.text); } } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2e88821a..d0ea14cb 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -441,4 +441,6 @@ mod tests_freeform; #[cfg(test)] mod tests_stroke; #[cfg(test)] +mod tests_strokedepth; +#[cfg(test)] mod tests_strokeux; diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index c52019b0..361cbea4 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -3,7 +3,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; use vectorcraft_engine::inspect::StrokeMixed; use super::{character, current_stroke, pstate, set_pstate, stroke_mixed}; @@ -14,9 +14,9 @@ use crate::{VectorcraftApp, icons}; pub const WEIGHT_PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; -/// The silhouette drawn for profile `id` (None draws the plain uniform bar). -fn profile_of(id: &str) -> Option { - WidthProfile::preset(id).filter(|_| id != "uniform") +/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke. +fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> { + (points != WidthProfile::PRESETS[0].points).then_some(points) } /// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip). @@ -269,10 +269,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Profile. - let pid = WidthProfile::id_of(st.as_ref().and_then(|s| s.profile.as_ref())); ui.horizontal(|ui| { row_label(ui, "Profile:"); - if let Some(id) = profile_dropdown(ui, pid) { + if let Some(id) = profile_dropdown(app, ui, st.as_ref().and_then(|s| s.profile.as_ref())) { set(app, json!({"profile": id})); } let can_flip = st.as_ref().is_some_and(|s| s.profile.is_some()); @@ -350,15 +349,15 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> out } -/// Draw a width profile's silhouette into `r`. -pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color32) { +/// Draw a width profile's silhouette (its points; None: the plain bar) into `r`. +pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color: Color32) { let n = 32; let half = r.height() / 2.0 - 1.0; let mut top = vec![]; let mut bot = vec![]; for i in 0..=n { let tt = i as f64 / n as f64; - let (l, rr) = prof.map(|p| p.at(tt)).unwrap_or((0.35, 0.35)); + let (l, rr) = points.map_or((0.35, 0.35), |p| WidthProfile::at_points(p, tt)); let x = r.left() + tt as f32 * r.width(); top.push(pos2(x, r.center().y - l as f32 * half)); bot.push(pos2(x, r.center().y + rr as f32 * half)); @@ -376,31 +375,41 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color ui.painter().add(egui::Shape::mesh(mesh)); } -pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { +/// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button +/// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the +/// id or name picked. +pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside); let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0)); - paint_profile(ui, body, profile_of(cur).as_ref(), t.text_strong); + paint_profile(ui, body, cur.and_then(|p| silhouette(&p.points)), t.text_strong); icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), t.icon); let resp = resp.on_hover_text("Variable Width Profile"); let mut out = None; egui::Popup::menu(&resp).show(|ui| { - for ProfilePreset { id, label, .. } in WidthProfile::PRESETS { - let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click()); - if id == cur { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if rr.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + // A divider between the built-in profiles and the saved ones. + let mut divide = true; + for e in app.session.profile_entries() { + if !e.built_in && std::mem::take(&mut divide) { + ui.separator(); + } + let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click()); + if e.matches(cur) { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if rr.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), silhouette(e.points), t.text_strong); + ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, e.label, egui::FontId::proportional(11.5), t.text); + if rr.clicked() { + out = Some(e.id.to_string()); + ui.close(); + } } - paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), profile_of(id).as_ref(), t.text_strong); - ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.5), t.text); - if rr.clicked() { - out = Some(id); - ui.close(); - } - } + }); }); out } @@ -410,10 +419,20 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { set_pstate(ui.ctx(), "stroke-hide-options", !hidden); } - menu_item(ui, "Add to Profiles", false, false); - menu_item(ui, "Delete Profile", false, false); - if menu_item(ui, "Reset Profile", current_stroke(app).is_some_and(|s| s.profile.is_some()), false) { - set(app, json!({"profile": "uniform"})); + ui.separator(); + if menu_item(ui, "Add to Profiles…", crate::menus::enabled(app, "stroke.widthProfile.add"), false) { + let name = app.session.next_profile_name(); + let p = json!({"command": "stroke.widthProfile.add", "label": "Variable Width Profile", "params": {"name": name}}); + app.run("ui.paramDialog", p).ok(); + } + // Deletes the selected stroke's saved profile. + let shown = current_stroke(app).and_then(|s| s.profile); + let saved = shown.as_ref().and_then(|p| app.session.profile_entry(Some(p))).is_some_and(|e| !e.built_in); + if menu_item(ui, "Delete Profile", saved, false) { + app.run("stroke.widthProfile.delete", json!({})).ok(); + } + if menu_item(ui, "Reset Profiles", crate::menus::enabled(app, "stroke.widthProfile.reset"), false) { + app.run("stroke.widthProfile.reset", json!({})).ok(); } } @@ -437,9 +456,9 @@ mod tests { #[test] fn profile_silhouettes_and_arrow_labels() { - assert!(profile_of("uniform").is_none()); - assert_eq!(profile_of("lens"), Some(WidthProfile::lens())); - assert!(profile_of("custom").is_none()); + 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)"); } diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs new file mode 100644 index 00000000..8792c184 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -0,0 +1,89 @@ +//! Headless frames of the Stroke panel's width profile library: the ≡ menu's Add to Profiles…, +//! Delete Profile and Reset Profiles, and the Profile dropdown listing saved profiles. + +use serde_json::json; +use vectorcraft_doc::WidthProfile; + +use super::tests_appearance::{click, frame_events, run, text_rect}; +use super::*; + +fn app_with_line() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "shape.line", json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})); + app +} + +fn custom() -> WidthProfile { + WidthProfile { points: vec![(0.0, 0.3, 0.3), (0.6, 1.4, 1.4), (1.0, 0.0, 0.0)] } +} + +/// Give the selected line `p` as its variable width. +fn set_profile(app: &mut VectorcraftApp, p: WidthProfile) { + app.session + .edit("Width", |d, sel| { + let id = sel.objects[0]; + d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p); + Ok(()) + }) + .unwrap(); +} + +fn panel_and_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + stroke::show(app, ui); + stroke::menu(app, ui); +} + +fn profile_points(app: &VectorcraftApp) -> Option> { + current_stroke(app).and_then(|s| s.profile).map(|p| p.points) +} + +#[test] +fn menu_saves_deletes_and_resets_profiles() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + set_profile(&mut app, custom()); + let t = frame_events(&ctx, &mut app, vec![], panel_and_menu); + click(&ctx, &mut app, text_rect(&t, " Add to Profiles…").center(), panel_and_menu); + // A name dialog (OK runs stroke.widthProfile.add). + let d = app.ui.dialog.clone().expect("name dialog"); + assert_eq!((d.str("__command"), d.str("name")), ("stroke.widthProfile.add".to_string(), "Width Profile 1".to_string())); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mine")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.width_profiles[0].name, "Mine"); + // Saved profiles don't trip the Preferences dialog, which only edits preferences. + crate::prefs_dialog::open(&mut app, None); + crate::prefs_dialog::confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.width_profiles.len(), 1); + + // Delete Profile removes the selected stroke's saved profile; the stroke keeps its widths. + let t = frame_events(&ctx, &mut app, vec![], panel_and_menu); + click(&ctx, &mut app, text_rect(&t, " Delete Profile").center(), panel_and_menu); + assert!(app.session.prefs.width_profiles.is_empty()); + assert_eq!(profile_points(&app), Some(custom().points)); + + run(&mut app, "stroke.widthProfile.add", json!({"name": "A"})); + let t = frame_events(&ctx, &mut app, vec![], panel_and_menu); + click(&ctx, &mut app, text_rect(&t, " Reset Profiles").center(), panel_and_menu); + assert!(app.session.prefs.width_profiles.is_empty()); +} + +#[test] +fn dropdown_lists_saved_profiles_and_applies_them() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + set_profile(&mut app, custom()); + run(&mut app, "stroke.widthProfile.add", json!({"name": "Mine"})); + run(&mut app, "stroke.set", json!({"profile": "uniform"})); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + // The dropdown sits right of the "Profile:" label. + let label = text_rect(&t, "Profile:"); + let button = egui::pos2(label.right() + ctx.global_style().spacing.item_spacing.x + 50.0, label.center().y); + click(&ctx, &mut app, button, stroke::show); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + for p in WidthProfile::PRESETS { + text_rect(&t, p.label); + } + click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show); + assert_eq!(profile_points(&app), Some(custom().points)); +} diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index ee71d88e..092299d4 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -60,7 +60,7 @@ fn library_submenus_are_disabled_placeholders() { #[test] fn stroke_profiles_have_descriptive_names() { let labels: Vec<&str> = vectorcraft_doc::WidthProfile::PRESETS.iter().map(|p| p.label).collect(); - assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]); + assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End", "Pinch", "Teardrop", "Wave"]); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 718fa6a4..07d51b99 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -484,4 +484,18 @@ and flat-colour regions, plus `regions` (bounds, and `id` for objects) for the ` ```json {"name":"run_command","arguments":{"command":"flattener.preview","params":{"highlight":"allRasterized","preset":"low"}}} + +## Width profiles + +The Profile list holds the built-in variable-width profiles (`uniform`, `lens`, `taperStart`, `taperEnd`, `pinch`, +`teardrop`, `wave`) and profiles saved from strokes; saved ones are kept with the preferences, not in the document +(no undo step). `stroke.widthProfile.list` returns every row (`id`, `label`, `builtIn`, `points` as `[t, left, +right]`) and `current`, the selected stroke's profile (`"custom"` when it isn't listed). +`stroke.widthProfile.add {name?}` saves the selected stroke's variable width (default name "Width Profile N"), +`stroke.widthProfile.delete {name?}` removes a saved one (default: the selected stroke's; built-ins can't be deleted) +and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` takes a built-in id or a saved name. + +```json +{"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"Ribbon"}}} ``` From fdad11a99c732c0c23f475f570ea17c178c869d8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:17:05 +0700 Subject: [PATCH 2/3] M3.62: thirty more generated arrowheads with rendered previews Arrowhead gains 30 original shapes generated in code, appended after the first ten so saved names stay stable: Barbed, Half Arrow (left/right), Concave, Double Arrow, Chevron, Feather, Swallowtail, Reverse Triangle, Triangle to Bar, Kite, Leaf, Drop, Half Circle, Oval, Target, Tag (+ open), Diamond (open), Hexagon (+ open), Star, Cross, Plus, Double Bar, Dot on Bar, Slash, Double Slash, Bracket and Fork. Each is drawn in local units of the head's weight inside the usual 4 x 4 box, with its own stop for the stroke under it. Arrowhead::label() names them in the menus. The Stroke panel's arrowhead dropdowns render every head as the canvas draws it (cached textures, a shared widgets::doc_preview that the Brushes panel now uses too) in a scrolling list. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 166 +++++++++++++++++- crates/effects/src/stroke.rs | 2 + crates/effects/src/stroke/arrow.rs | 162 ++++++++++++++++- crates/effects/src/stroke/tests_arrows.rs | 83 +++++++++ crates/engine/src/cmd/stroke.rs | 2 +- crates/ui-egui/src/panels/brushes.rs | 55 ++---- crates/ui-egui/src/panels/stroke.rs | 111 ++++++------ .../ui-egui/src/panels/tests_strokedepth.rs | 31 ++++ crates/ui-egui/src/widgets.rs | 30 ++++ docs/mcp.md | 8 + 10 files changed, 549 insertions(+), 101 deletions(-) create mode 100644 crates/effects/src/stroke/tests_arrows.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 7ae42d58..775fa1b1 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -48,6 +48,7 @@ impl Dash { } } +/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Arrowhead { Triangle, @@ -60,21 +61,130 @@ pub enum Arrowhead { Diamond, Arrow, ArrowOpen, + Barbed, + /// One barb, on the left of the direction the head points. + HalfArrowLeft, + /// One barb, on the right of the direction the head points. + HalfArrowRight, + Concave, + DoubleBar, + Feather, + DotOnBar, + Chevron, + DoubleArrow, + Target, + Star, + Cross, + Plus, + Hexagon, + HexagonOpen, + Tag, + TagOpen, + HalfCircle, + Drop, + Slash, + DoubleSlash, + DiamondOpen, + TriangleReverse, + Swallowtail, + Bracket, + Fork, + Leaf, + Kite, + TriangleBar, + Oval, } impl Arrowhead { - pub const ALL: [Arrowhead; 10] = [ + /// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks). + pub const ALL: [Arrowhead; 40] = [ Arrowhead::Arrow, Arrowhead::ArrowOpen, + Arrowhead::Barbed, + Arrowhead::Concave, + Arrowhead::DoubleArrow, + Arrowhead::HalfArrowLeft, + Arrowhead::HalfArrowRight, + Arrowhead::Chevron, + Arrowhead::Feather, + Arrowhead::Swallowtail, Arrowhead::Triangle, Arrowhead::TriangleOpen, + Arrowhead::TriangleReverse, + Arrowhead::TriangleBar, + Arrowhead::Kite, + Arrowhead::Leaf, + Arrowhead::Drop, Arrowhead::Circle, Arrowhead::CircleOpen, + Arrowhead::HalfCircle, + Arrowhead::Oval, + Arrowhead::Target, Arrowhead::Square, Arrowhead::SquareOpen, + Arrowhead::Tag, + Arrowhead::TagOpen, Arrowhead::Diamond, + Arrowhead::DiamondOpen, + Arrowhead::Hexagon, + Arrowhead::HexagonOpen, + Arrowhead::Star, + Arrowhead::Cross, + Arrowhead::Plus, Arrowhead::Bar, + Arrowhead::DoubleBar, + Arrowhead::DotOnBar, + Arrowhead::Slash, + Arrowhead::DoubleSlash, + Arrowhead::Bracket, + Arrowhead::Fork, ]; + + /// The name the arrowhead menus show. + pub fn label(self) -> &'static str { + match self { + Arrowhead::Triangle => "Triangle", + Arrowhead::TriangleOpen => "Triangle (open)", + Arrowhead::Circle => "Circle", + Arrowhead::CircleOpen => "Circle (open)", + Arrowhead::Square => "Square", + Arrowhead::SquareOpen => "Square (open)", + Arrowhead::Bar => "Bar", + Arrowhead::Diamond => "Diamond", + Arrowhead::Arrow => "Arrow", + Arrowhead::ArrowOpen => "Arrow (open)", + Arrowhead::Barbed => "Barbed", + Arrowhead::HalfArrowLeft => "Half Arrow (left)", + Arrowhead::HalfArrowRight => "Half Arrow (right)", + Arrowhead::Concave => "Concave", + Arrowhead::DoubleBar => "Double Bar", + Arrowhead::Feather => "Feather", + Arrowhead::DotOnBar => "Dot on Bar", + Arrowhead::Chevron => "Chevron", + Arrowhead::DoubleArrow => "Double Arrow", + Arrowhead::Target => "Target", + Arrowhead::Star => "Star", + Arrowhead::Cross => "Cross", + Arrowhead::Plus => "Plus", + Arrowhead::Hexagon => "Hexagon", + Arrowhead::HexagonOpen => "Hexagon (open)", + Arrowhead::Tag => "Tag", + Arrowhead::TagOpen => "Tag (open)", + Arrowhead::HalfCircle => "Half Circle", + Arrowhead::Drop => "Drop", + Arrowhead::Slash => "Slash", + Arrowhead::DoubleSlash => "Double Slash", + Arrowhead::DiamondOpen => "Diamond (open)", + Arrowhead::TriangleReverse => "Reverse Triangle", + Arrowhead::Swallowtail => "Swallowtail", + Arrowhead::Bracket => "Bracket", + Arrowhead::Fork => "Fork", + Arrowhead::Leaf => "Leaf", + Arrowhead::Kite => "Kite", + Arrowhead::TriangleBar => "Triangle to Bar", + Arrowhead::Oval => "Oval", + } + } } /// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under @@ -880,6 +990,60 @@ mod tests { assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]); } + #[test] + fn arrowhead_names_are_stable_and_every_one_is_listed_once() { + // Saved files store these names: they may only ever be appended to. + const SAVED: [&str; 40] = [ + "Triangle", + "TriangleOpen", + "Circle", + "CircleOpen", + "Square", + "SquareOpen", + "Bar", + "Diamond", + "Arrow", + "ArrowOpen", + "Barbed", + "HalfArrowLeft", + "HalfArrowRight", + "Concave", + "DoubleBar", + "Feather", + "DotOnBar", + "Chevron", + "DoubleArrow", + "Target", + "Star", + "Cross", + "Plus", + "Hexagon", + "HexagonOpen", + "Tag", + "TagOpen", + "HalfCircle", + "Drop", + "Slash", + "DoubleSlash", + "DiamondOpen", + "TriangleReverse", + "Swallowtail", + "Bracket", + "Fork", + "Leaf", + "Kite", + "TriangleBar", + "Oval", + ]; + 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!(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"); + } + #[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/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 9a4ca346..7dc1362b 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -196,4 +196,6 @@ pub(crate) fn segments(els: &[PathEl]) -> Vec { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_arrows; +#[cfg(test)] mod tests_fit; diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index 080ddeb3..1a5c9009 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -1,14 +1,16 @@ -//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide. -//! Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. The -//! stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, in -//! the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner +//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw` +//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. +//! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, +//! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by //! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset -//! past the end point. +//! past the end point. Heads added after the first ten are drawn in local units ([`local`]). use std::borrow::Cow; -use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; +use std::f64::consts::{FRAC_PI_2, PI}; + +use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2}; use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer}; use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit}; @@ -185,6 +187,10 @@ 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), + _ => match local_stop(kind) { + Stop::At(u) => (u * hw).max(cap), + Stop::Wall(back) => hollow(back * hw), + }, } } @@ -224,6 +230,150 @@ fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { let t = bar(hw); polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]); } + _ => { + // Local x runs back from the tip, y across to the right of `dir`; a unit is `hw`. + p = local(kind); + p.apply_affine(Affine::new([-dir.x * hw, -dir.y * hw, n.x * hw, n.y * hw, tip.x, tip.y])); + } + } + p +} + +/// Where the stroke ends under a head drawn in local units. +enum Stop { + /// This many units back from the tip (or as far as the cap needs). + At(f64), + /// In the back wall of a ring whose back is this many units from the tip. + Wall(f64), +} + +/// Flat-to-flat length of the hexagons (their corners touch the sides of the 4-unit box). +const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; +/// Flattening tolerance of the curved local heads, in units. +const LOCAL_TOL: f64 = 1e-3; + +fn local_stop(kind: Arrowhead) -> Stop { + match kind { + Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), + Arrowhead::HexagonOpen => Stop::Wall(HEX), + Arrowhead::DoubleBar | Arrowhead::DotOnBar => Stop::At(0.375), + Arrowhead::Bracket => Stop::At(0.3), + Arrowhead::Fork => Stop::At(2.7), + Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3), + Arrowhead::Swallowtail => Stop::At(1.4), + Arrowhead::Chevron => Stop::At(1.0), + Arrowhead::Slash => Stop::At(1.2), + Arrowhead::DoubleSlash => Stop::At(1.05), + Arrowhead::DoubleArrow => Stop::At(1.7), + Arrowhead::HalfCircle => Stop::At(1.0), + Arrowhead::Concave => Stop::At(2.6), + Arrowhead::Hexagon => Stop::At(HEX / 2.0), + _ => Stop::At(2.0), + } +} + +/// 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), y across it (−2 to 2, positive to the right of the direction +/// the head points). Every shape is generated here. +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)); + let circle = |p: &mut BezPath, c: (f64, f64), r: f64| p.extend(kurbo::Circle::new(c, r).path_elements(LOCAL_TOL)); + // A regular polygon of `n` corners around `c`, the first at angle `a0`, alternating radii. + let star = |c: (f64, f64), radii: &[f64], n: usize, a0: f64| -> Vec<(f64, f64)> { + (0..n).map(|i| (a0 + i as f64 * 2.0 * PI / n as f64, radii[i % radii.len()])).map(|(a, r)| (c.0 + r * a.cos(), c.1 + r * a.sin())).collect() + }; + // A plus of arms `h` long and `k` thick round (2, 0), turned by `turn`. + let plus = |h: f64, k: f64, turn: f64| -> Vec<(f64, f64)> { + let arm = [(k, h), (k, k), (h, k)]; + let (sin, cos) = turn.sin_cos(); + (0..4) + .flat_map(|q| arm.iter().map(move |&(x, y)| [(x, y), (y, -x), (-x, -y), (-y, x)][q])) + .map(|(x, y)| (2.0 + x * cos - y * sin, x * sin + y * cos)) + .collect() + }; + let hexagon = star((HEX / 2.0, 0.0), &[2.0], 6, PI / 6.0); + match kind { + Arrowhead::Barbed => poly(&mut p, &[(0.0, 0.0), (3.6, 2.0), (2.6, 0.55), (4.0, 0.55), (4.0, -0.55), (2.6, -0.55), (3.6, -2.0)]), + Arrowhead::HalfArrowLeft => poly(&mut p, &[(0.0, 0.0), (4.0, -2.0), (3.0, -0.5), (3.0, 0.5), (1.0, 0.5)]), + Arrowhead::HalfArrowRight => poly(&mut p, &[(0.0, 0.0), (4.0, 2.0), (3.0, 0.5), (3.0, -0.5), (1.0, -0.5)]), + Arrowhead::Concave => { + p.move_to((0.0, 0.0)); + p.quad_to((2.4, 0.4), (4.0, 2.0)); + p.line_to((4.0, -2.0)); + p.quad_to((2.4, -0.4), (0.0, 0.0)); + p.close_path(); + } + Arrowhead::DoubleBar => { + poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]); + poly(&mut p, &[(1.5, -2.0), (2.25, -2.0), (2.25, 2.0), (1.5, 2.0)]); + } + Arrowhead::Feather => poly(&mut p, &[(0.0, 0.5), (1.4, 2.0), (4.0, 2.0), (2.6, 0.5), (2.6, -0.5), (4.0, -2.0), (1.4, -2.0), (0.0, -0.5)]), + Arrowhead::DotOnBar => { + poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]); + circle(&mut p, (2.5, 0.0), 1.25); + } + Arrowhead::Chevron => poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 2.0), (2.0, 0.0), (4.0, -2.0), (2.0, -2.0)]), + Arrowhead::DoubleArrow => poly(&mut p, &[(0.0, 0.0), (2.2, 1.8), (1.9, 0.5), (4.0, 2.0), (3.4, 0.0), (4.0, -2.0), (1.9, -0.5), (2.2, -1.8)]), + Arrowhead::Target => { + circle(&mut p, (2.0, 0.0), 2.0); + p.extend(kurbo::Circle::new((2.0, 0.0), 2.0 - ring(1.0)).to_path(LOCAL_TOL).reverse_subpaths()); + circle(&mut p, (2.0, 0.0), 0.75); + } + Arrowhead::Star => poly(&mut p, &star((2.0, 0.0), &[2.0, 0.85], 10, PI)), + Arrowhead::Cross => poly(&mut p, &plus(2.2, 0.4, PI / 4.0)), + Arrowhead::Plus => poly(&mut p, &plus(1.9, 0.4, 0.0)), + Arrowhead::Hexagon => poly(&mut p, &hexagon), + Arrowhead::HexagonOpen => ring_poly(&mut p, &hexagon), + Arrowhead::Tag => poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]), + Arrowhead::TagOpen => ring_poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]), + Arrowhead::HalfCircle => { + p.move_to((2.0, 2.0)); + p.extend(kurbo::Arc::new((2.0, 0.0), (2.0, 2.0), FRAC_PI_2, PI, 0.0).append_iter(LOCAL_TOL)); + p.close_path(); + } + Arrowhead::Drop => { + // Straight sides from the tip, tangent to a round back. + let (c, r) = (Point::new(2.6, 0.0), 1.4); + let a = PI - (r / c.x).acos(); + p.move_to((0.0, 0.0)); + p.line_to(c + Vec2::from_angle(a) * r); + p.extend(kurbo::Arc::new(c, (r, r), a, -2.0 * a, 0.0).append_iter(LOCAL_TOL)); + p.close_path(); + } + Arrowhead::Slash => poly(&mut p, &[(0.0, 2.0), (0.8, 2.0), (2.4, -2.0), (1.6, -2.0)]), + Arrowhead::DoubleSlash => { + poly(&mut p, &[(0.0, 2.0), (0.7, 2.0), (2.1, -2.0), (1.4, -2.0)]); + poly(&mut p, &[(1.6, 2.0), (2.3, 2.0), (3.7, -2.0), (3.0, -2.0)]); + } + Arrowhead::DiamondOpen => ring_poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 0.0), (2.0, -2.0)]), + Arrowhead::TriangleReverse => poly(&mut p, &[(0.0, 2.0), (4.0, 0.0), (0.0, -2.0)]), + Arrowhead::Swallowtail => poly(&mut p, &[(0.0, 2.0), (4.0, 2.0), (2.8, 0.0), (4.0, -2.0), (0.0, -2.0)]), + Arrowhead::Bracket => poly(&mut p, &[(0.0, 2.0), (2.0, 2.0), (2.0, 1.4), (0.6, 1.4), (0.6, -1.4), (2.0, -1.4), (2.0, -2.0), (0.0, -2.0)]), + Arrowhead::Fork => poly(&mut p, &[(0.0, 2.0), (3.0, 2.0), (3.0, -2.0), (0.0, -2.0), (0.0, -1.4), (2.4, -1.4), (2.4, 1.4), (0.0, 1.4)]), + Arrowhead::Leaf => { + p.move_to((0.0, 0.0)); + p.quad_to((2.0, 3.2), (4.0, 0.0)); + p.quad_to((2.0, -3.2), (0.0, 0.0)); + p.close_path(); + } + Arrowhead::Kite => poly(&mut p, &[(0.0, 0.0), (1.3, 1.7), (4.0, 0.0), (1.3, -1.7)]), + Arrowhead::TriangleBar => { + poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)]) + } + Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)), + // The first ten are drawn by `shape`. + Arrowhead::Triangle + | Arrowhead::TriangleOpen + | Arrowhead::Circle + | Arrowhead::CircleOpen + | Arrowhead::Square + | Arrowhead::SquareOpen + | Arrowhead::Bar + | Arrowhead::Diamond + | Arrowhead::Arrow + | Arrowhead::ArrowOpen => {} } p } diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs new file mode 100644 index 00000000..14e2fa23 --- /dev/null +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -0,0 +1,83 @@ +//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut +//! arms reach a little further back), without self-intersections, and covering the end of the +//! stroke under it. + +use kurbo::{BezPath, PathEl, Point, Shape}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer}; + +use super::stroke_pieces; + +/// A 2 pt line along +x ending at (100, 0) with `kind` at its end, tip on the end point: heads +/// of weight 2, so 8 long and 8 wide. +fn head(kind: Arrowhead) -> super::Arrow { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + st.end_arrow = Some(kind); + st.arrow_align = ArrowAlign::Tip; + let mut l = BezPath::new(); + l.move_to((0.0, 0.0)); + l.line_to((100.0, 0.0)); + stroke_pieces(&l, &st).heads.remove(0) +} + +/// The outline's closed contours as polygons (curves flattened). +fn contours(bp: &BezPath) -> Vec> { + let mut out: Vec> = vec![]; + kurbo::flatten(bp.iter(), 1e-3, |el| match el { + PathEl::MoveTo(p) => out.push(vec![p]), + PathEl::LineTo(p) => out.last_mut().unwrap().push(p), + PathEl::ClosePath => { + let c = out.last_mut().unwrap(); + if c.len() > 1 && c[0].distance(*c.last().unwrap()) < 1e-9 { + c.pop(); + } + } + _ => unreachable!("flattened"), + }); + out +} + +/// Do segments `a` and `b` cross at a point inside both (touching ends don't count)? +fn cross(a: (Point, Point), b: (Point, Point)) -> bool { + let orient = |p: Point, q: Point, r: Point| (q - p).cross(r - p); + let (d1, d2) = (orient(b.0, b.1, a.0), orient(b.0, b.1, a.1)); + let (d3, d4) = (orient(a.0, a.1, b.0), orient(a.0, a.1, b.1)); + d1 * d2 < -1e-12 && d3 * d4 < -1e-12 +} + +#[test] +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); + assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); + let b = h.outline.bounding_box(); + assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}"); + let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect(); + assert!(edges.len() >= 3, "{kind:?}"); + for (i, a) in edges.iter().enumerate() { + for b in &edges[i + 1..] { + assert!(!cross(*a, *b), "{kind:?}: {a:?} crosses {b:?}"); + } + } + } +} + +#[test] +fn the_stroke_ends_inside_every_head() { + for kind in Arrowhead::ALL { + let h = head(kind); + // 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); + } +} + +#[test] +fn half_arrows_barb_on_their_own_side() { + // Pointing along +x on a y-down page, left is −y. + assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y0 < -3.0); + assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5); + assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0); +} diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 5f734181..03e805da 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -18,7 +18,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|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), 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?} Without a stroke item, type takes weight, cap, join, miterLimit 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", + "{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|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), 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?} Without a stroke item, type takes weight, cap, join, miterLimit 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", has_doc, stroke_set ), diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 1b159cb5..66bc7da4 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -1,7 +1,6 @@ //! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush //! applies it to the selected paths (and makes it the Paintbrush's current brush). -use std::cell::RefCell; use std::collections::HashMap; use egui::{Sense, Ui, vec2}; @@ -26,43 +25,25 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option Option { - thread_local! { - static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); - static CACHE: RefCell> = RefCell::new(HashMap::new()); - } - let ppp = ui.ctx().pixels_per_point() as f64; - let (w, h) = (size.x as f64, size.y as f64); - let key = format!("{w}x{h}@{ppp}:{def}"); - if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { - return Some(t); - } - let mut doc = Document::new(w, h); - doc.unknown.insert("brushes".into(), json!([def])); - let name = def["name"].as_str()?.to_string(); - let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); - ap.stroke_mut()?.brush = Some(name); - // A gentle S-curve across the swatch. - let mut bp = vectorcraft_geom::BezPath::new(); - let (x0, x1) = (h * 0.5, w - h * 0.5); - bp.move_to((x0, h * 0.5)); - bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5)); - let id = doc.alloc_id(); - let l = doc.layers[0].id; - doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; - let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false)); - let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = ui.ctx().load_texture(format!("brush-{key}"), color, egui::TextureOptions::LINEAR); - CACHE.with(|c| { - let mut c = c.borrow_mut(); - if c.len() > 256 { - c.clear(); - } - c.insert(key, tex.clone()); - }); - Some(tex) + widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| { + let mut doc = Document::new(w, h); + doc.unknown.insert("brushes".into(), json!([def])); + let name = def["name"].as_str()?.to_string(); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut()?.brush = Some(name); + // A gentle S-curve across the swatch. + let mut bp = vectorcraft_geom::BezPath::new(); + let (x0, x1) = (h * 0.5, w - h * 0.5); + bp.move_to((x0, h * 0.5)); + bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5)); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; + Some(doc) + }) } fn selected_brush(app: &VectorcraftApp) -> Option { diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 361cbea4..fcfa29e3 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -1,9 +1,10 @@ //! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line -//! with three dash/gap pairs, arrowheads with drawn previews, arrow scale, width profiles. +//! with three dash/gap pairs, arrowheads with rendered previews, arrow scale, width profiles. -use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile}; use vectorcraft_engine::inspect::StrokeMixed; use super::{character, current_stroke, pstate, set_pstate, stroke_mixed}; @@ -64,14 +65,8 @@ pub fn dash_pattern(fields: &[Option; 6]) -> Vec { out } -fn arrow_label(a: Option) -> String { - match a { - None => "None".into(), - Some(a) => { - let s = format!("{a:?}"); - s.replace("Open", " (open)") - } - } +fn arrow_label(a: Option) -> &'static str { + a.map_or("None", Arrowhead::label) } /// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the @@ -284,31 +279,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } -/// Draw an arrowhead preview: a line with the head at the left (`start`) or right end. +/// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left +/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). +pub(super) fn arrow_doc(a: Option, start: bool, w: f64, h: f64) -> Option { + let mut doc = Document::new(w, h); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5); + let st = ap.stroke_mut()?; + // Heads of weight 2.5 pt: 10 pt long and wide. + st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0); + st.arrow_align = ArrowAlign::Tip; + if start { + st.start_arrow = a + } else { + st.end_arrow = a + } + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((3.0, h / 2.0)); + bp.line_to((w - 3.0, h / 2.0)); + let (id, l) = (doc.alloc_id(), doc.layers[0].id); + doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?; + Some(doc) +} + +/// 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) { - let y = r.center().y; - let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) }; - let s = Stroke::new(1.5, color); - let line_from = if a.is_some() { tip_x + back * 8.0 } else { tip_x }; - let other = if start { r.right() - 4.0 } else { r.left() + 4.0 }; - ui.painter().line_segment([pos2(line_from, y), pos2(other, y)], s); - let Some(a) = a else { return }; - let p = |dx: f32, dy: f32| -> Pos2 { pos2(tip_x + back * dx, y + dy) }; - let solid = |pts: Vec| egui::Shape::convex_polygon(pts, color, Stroke::NONE); - let open = |pts: Vec| egui::Shape::closed_line(pts, s); - let shape = match a { - Arrowhead::Triangle => solid(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]), - Arrowhead::TriangleOpen => open(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]), - Arrowhead::Arrow => solid(vec![p(0.0, 0.0), p(10.0, -5.0), p(7.0, 0.0), p(10.0, 5.0)]), - Arrowhead::ArrowOpen => egui::Shape::line(vec![p(9.0, -5.0), p(0.0, 0.0), p(9.0, 5.0)], s), - Arrowhead::Circle => egui::Shape::circle_filled(p(4.5, 0.0), 4.5, color), - Arrowhead::CircleOpen => egui::Shape::circle_stroke(p(4.5, 0.0), 4.0, s), - Arrowhead::Square => egui::Shape::rect_filled(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, color), - Arrowhead::SquareOpen => egui::Shape::rect_stroke(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, s, StrokeKind::Middle), - Arrowhead::Diamond => solid(vec![p(0.0, 0.0), p(5.0, -5.0), p(10.0, 0.0), p(5.0, 5.0)]), - Arrowhead::Bar => egui::Shape::line_segment([p(0.0, -6.0), p(0.0, 6.0)], Stroke::new(2.0, color)), - }; - ui.painter().add(shape); + if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, 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). @@ -323,28 +321,29 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" }); let mut out = None; egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| { - ui.set_min_width(140.0); - let opts: Vec> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect(); - for a in opts { - let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click()); - if a == cur { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if rr.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); + 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 (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); + } else if rr.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); + ui.painter().text( + row.left_center() + vec2(66.0, 0.0), + egui::Align2::LEFT_CENTER, + arrow_label(a), + egui::FontId::proportional(11.5), + t.text, + ); + if rr.clicked() { + out = Some(a); + ui.close(); + } } - paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong); - ui.painter().text( - row.left_center() + vec2(66.0, 0.0), - egui::Align2::LEFT_CENTER, - arrow_label(a), - egui::FontId::proportional(11.5), - t.text, - ); - if rr.clicked() { - out = Some(a); - ui.close(); - } - } + }); }); out } diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs index 8792c184..e37b6e75 100644 --- a/crates/ui-egui/src/panels/tests_strokedepth.rs +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -87,3 +87,34 @@ fn dropdown_lists_saved_profiles_and_applies_them() { click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show); assert_eq!(profile_points(&app), Some(custom().points)); } + +#[test] +fn every_arrowhead_preview_draws_its_head() { + use vectorcraft_doc::Arrowhead; + // Rendered on transparent: the head adds ink to its half of the preview. + let ink = |a: Option, start: bool| { + let d = stroke::arrow_doc(a, start, 56.0, 22.0).unwrap(); + let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(0.0, 0.0, 56.0, 22.0), 2.0, false); + let xs = if start { 0..56 } else { 56..112 }; + xs.flat_map(|x| (0..44).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).count() + }; + 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"); + } +} + +#[test] +fn arrowhead_dropdown_offers_the_new_heads() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + // The end-arrowhead dropdown is the second 64 pt button right of the "Arrowheads:" label. + let label = text_rect(&t, "Arrowheads:"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + click(&ctx, &mut app, text_rect(&t, "Barbed").center(), stroke::show); + assert_eq!(current_stroke(&app).and_then(|s| s.end_arrow), Some(vectorcraft_doc::Arrowhead::Barbed)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d2711516..c50f25c7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1027,3 +1027,33 @@ pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) { egui::DragAndDrop::set_payload(ui.ctx(), drag()); } } + +/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes, +/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when +/// the texture for `key` at this size isn't cached yet. +pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option) -> Option { + use std::cell::RefCell; + use std::collections::HashMap; + thread_local! { + static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + let ppp = ui.ctx().pixels_per_point() as f64; + let (w, h) = (size.x as f64, size.y as f64); + let key = format!("{w}x{h}@{ppp}:{key}"); + if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + return Some(t); + } + let doc = build(w, h)?; + let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false)); + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 256 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) +} diff --git a/docs/mcp.md b/docs/mcp.md index 07d51b99..6df362c0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -499,3 +499,11 @@ and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` {"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}} {"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"Ribbon"}}} ``` + +## Arrowheads + +`stroke.set {startArrow, endArrow}` takes any of 40 generated heads by name (the params doc lists them; `null` for +none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`, +`Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`, +the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`, +`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide. From c1dc860f1dc3d96e41122858e78881bddef1cca8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:54:39 +0700 Subject: [PATCH 3/3] M3.63: visual bounds and clicks take in the whole stroke Paths and compound paths now measure their visual bounds along their shape: the body (inside/outside alignment, the width profile's widest point), the tips of the miter spikes the limit keeps, projecting caps' corners and the arrowheads' reach. A rectangle's right-angle miters stay within half the weight (Fit to Selected Art, Rasterize and all-art SVG export get tighter, exact boxes); Appearance::outset is the conservative reach for anything else. Clicks hit what the strokes paint: outside strokes outside the path (0.8 x the weight out), inside ones inside, the profile's width where clicked, caps, spikes and heads. Click culling keeps a cheap box (hit test 0.34 ms per click on the 50k-path budget). The subpath/tangent helpers move from the stroke geometry into vectorcraft_geom::bez so both share them; a cross-check test holds every painted point of the real stroke geometry inside the bounds and hittable. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 98 ++++-- crates/doc/src/hit.rs | 30 +- crates/doc/src/lib.rs | 1 + crates/doc/src/node.rs | 31 +- crates/doc/src/reach.rs | 431 +++++++++++++++++++++++ crates/effects/src/stroke.rs | 73 +--- crates/effects/src/stroke/tests_reach.rs | 92 +++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_strokereach.rs | 76 ++++ crates/geom/src/bez.rs | 100 ++++++ crates/geom/src/lib.rs | 1 + crates/svg/tests/roundtrip.rs | 4 +- docs/mcp.md | 7 + 13 files changed, 839 insertions(+), 107 deletions(-) create mode 100644 crates/doc/src/reach.rs create mode 100644 crates/effects/src/stroke/tests_reach.rs create mode 100644 crates/engine/src/tests_strokereach.rs create mode 100644 crates/geom/src/bez.rs diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 775fa1b1..ec556018 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -366,6 +366,10 @@ pub struct StrokeLayer { pub overprint: bool, } +/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are +/// cut square a little behind its 4 × 4 box). +pub(crate) const HEAD_REACH: f64 = 4.6; + fn ten() -> f64 { 10.0 } @@ -403,16 +407,50 @@ impl StrokeLayer { } } /// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least - /// a quarter point. A head of weight `hw` is `4·hw` long and wide. + /// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide. pub fn arrow_weight(&self, end: bool) -> f64 { let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 }; (self.width * pct / 100.0).max(0.25) } - /// How far the arrowheads can reach from the path's end points (0 without heads): the - /// head's diagonal, or with [`ArrowAlign::Extend`] its length plus the cap past the end. + /// 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, |_| HEAD_REACH * 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 { - let reach = |head: Option, end: bool| head.map_or(0.0, |_| 4.5 * self.arrow_weight(end) + self.width / 2.0); - reach(self.start_arrow, false).max(reach(self.end_arrow, true)) + self.head_reach(false).max(self.head_reach(true)) + } + /// The widest the width profile makes the stroke, as a factor of its weight (1 without one). + pub fn profile_max(&self) -> f64 { + self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0) + } + /// The stroke's body across a path that is `closed` or open: how far it reaches from the path + /// at full width (half the weight centred; the whole weight on one side when aligned inside or + /// outside a closed path, which open paths ignore), and for aligned strokes which side it + /// paints (`Some(true)`: inside). + pub fn body(&self, closed: bool) -> (f64, Option) { + match self.align { + StrokeAlign::Outside if closed => (self.width, Some(false)), + StrokeAlign::Inside if closed => (self.width, Some(true)), + _ => (self.width / 2.0, None), + } + } + /// How far the body paints outside a path that is `closed` or open, at the profile's widest. + pub fn side_reach(&self, closed: bool) -> f64 { + match self.body(closed) { + (_, Some(true)) => 0.0, + (half, _) => half * self.profile_max(), + } + } + /// The farthest this stroke can paint from any path: its body, miter spikes up to the miter + /// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]). + pub fn reach(&self) -> f64 { + let side = self.side_reach(true).max(self.side_reach(false)); + let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 }; + let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + (side * miter.max(cap)).max(self.arrow_reach()) } /// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]). pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect { @@ -656,22 +694,17 @@ impl Appearance { Some(i) => self.items.get_mut(i).map(AppearanceItem::effects_mut), } } - /// Largest distance the painted area extends beyond the geometry (for visual bounds). + /// The visible strokes that paint something. + pub fn painted_strokes(&self) -> impl Iterator { + self.items.iter().filter_map(|i| match i { + AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(s), + _ => None, + }) + } + /// Largest distance the painted area can extend beyond any geometry (see + /// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`]. pub fn outset(&self) -> f64 { - self.items - .iter() - .filter_map(|i| match i { - AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some( - match s.align { - StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 }, - StrokeAlign::Outside => s.width, - StrokeAlign::Inside => 0.0, - } - .max(s.arrow_reach()), - ), - _ => None, - }) - .fold(0.0, f64::max) + self.painted_strokes().map(StrokeLayer::reach).fold(0.0, f64::max) } /// Scale stroke weights (Scale Strokes & Effects). pub fn scale_strokes(&mut self, s: f64) { @@ -1067,8 +1100,9 @@ mod tests { st.arrow_scale = (100.0, 200.0); assert_eq!(st.arrow_weight(false), 4.0); assert_eq!(st.arrow_weight(true), 8.0); + assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0)); // A 32 pt head whose tip sits up to its length (plus the cap) past the end point. - assert_eq!(a.outset(), 4.5 * 8.0 + 2.0); + assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0); a.stroke_mut().unwrap().width = 0.01; assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size"); } @@ -1080,8 +1114,28 @@ mod tests { assert_eq!(a.outset(), 5.0); a.stroke_mut().unwrap().align = StrokeAlign::Outside; assert_eq!(a.outset(), 10.0); + // Open paths stroke inside-aligned strokes centred. a.stroke_mut().unwrap().align = StrokeAlign::Inside; - assert_eq!(a.outset(), 0.0); + assert_eq!(a.outset(), 5.0); + assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true)))); + } + + #[test] + fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() { + let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + // Miter joins can spike out to the limit × half the weight. + assert_eq!(a.outset(), 10.0); + let st = a.stroke_mut().unwrap(); + st.join = LineJoin::Bevel; + st.cap = LineCap::Square; + assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner"); + let st = a.stroke_mut().unwrap(); + st.cap = LineCap::Round; + st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] }); + assert_eq!(st.profile_max(), 2.5); + assert_eq!(a.outset(), 2.5); + a.stroke_mut().unwrap().paint = Paint::None; + assert_eq!(a.outset(), 0.0, "an unpainted stroke"); } #[test] diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 84e46d0d..bacce0bb 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -99,7 +99,7 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec Option { + if !opt.outline && n.appearance.stroke_hit(bp, rule, p, opt.tol) { + return Some(HitKind::Stroke); + } + stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline) +} + fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { match &n.kind { NodeKind::Path { path, rule, .. } => { let bp = path.to_bezpath(); - let sw = n.appearance.stroke_width(); - if stroke_contains(&bp, if opt.outline { 0.0 } else { sw }, opt.tol, p) { - return Some(if opt.outline || sw == 0.0 { HitKind::Outline } else { HitKind::Stroke }); + if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) { + return Some(k); } let filled = !n.appearance.fill_paint().is_none(); if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) { @@ -131,15 +140,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { } None } - NodeKind::Compound { children, rule } => { - let mut bp = vectorcraft_geom::BezPath::new(); - for c in children { - if let Some(pd) = c.path_data() { - bp.extend(pd.to_bezpath()); - } - } - if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) { - return Some(HitKind::Stroke); + NodeKind::Compound { rule, .. } => { + let bp = n.stroke_path()?; + if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) { + return Some(k); } (!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 01190778..6059ad79 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -13,6 +13,7 @@ pub mod node; pub mod overprint; pub mod pattern; mod pixels; +mod reach; pub mod selection; pub mod swatches; pub mod text; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index fe28a65f..4761eecc 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -426,6 +426,30 @@ impl Node { _ => None, } } + /// The path a path's or compound path's own strokes follow (a compound's members' together). + pub fn stroke_path(&self) -> Option { + match &self.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { children, .. } => { + let mut bp = vectorcraft_geom::BezPath::new(); + children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath())); + Some(bp) + } + _ => None, + } + } + /// A quick box round everything the object can paint, for culling clicks: paths and compound + /// paths grow their geometric bounds by the farthest their strokes can reach from any path + /// ([`Appearance::outset`]) instead of measuring along their shape. + pub fn reach_bounds(&self) -> Option { + match &self.kind { + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let o = self.appearance.outset(); + self.geometric_bounds().map(|b| b.inflate(o, o)) + } + _ => self.visual_bounds(), + } + } /// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. pub fn geometric_bounds(&self) -> Option { match &self.kind { @@ -449,7 +473,8 @@ impl Node { NodeKind::Repeat(r) => r.bounds(), } } - /// Visual bounds (includes stroke outset). + /// Visual bounds: what the object paints, its strokes included (paths and compound paths + /// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). pub fn visual_bounds(&self) -> Option { match &self.kind { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { @@ -469,6 +494,10 @@ impl Node { let o = crate::live::max_outset(content); self.geometric_bounds().map(|b| b.inflate(o, o)) } + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let b = self.geometric_bounds()?; + Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default())) + } _ => { let o = self.appearance.outset(); self.geometric_bounds().map(|b| b.inflate(o, o)) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs new file mode 100644 index 00000000..12965821 --- /dev/null +++ b/crates/doc/src/reach.rs @@ -0,0 +1,431 @@ +//! Where a path's strokes paint, from the path's geometry without building their outlines: the +//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes +//! up to the miter limit, projecting caps and arrowheads. + +use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; + +use crate::appearance::HEAD_REACH; +use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; + +/// Arc-length accuracy for placing a point along a profile (document points). +const ARCLEN_ACCURACY: f64 = 1e-3; + +/// The segments of a subpath that have length (zero-length ones, all of whose points coincide, +/// have no direction). +fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec { + let moves = |s: &PathSeg| match s { + PathSeg::Line(l) => l.p1 != l.p0, + PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0, + PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0, + }; + segments(els).into_iter().filter(moves).collect() +} + +/// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end. +fn ends(segs: &[PathSeg]) -> Option<[(Point, Vec2); 2]> { + let (first, last) = (segs.first()?, segs.last()?); + Some([(first.start(), -tangent(first, 0.0)), (last.end(), tangent(last, 1.0))]) +} + +/// The joints between consecutive segments (and, `closed`, where the subpath closes): the point +/// and the unit tangents in and out. +fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator + '_ { + let n = segs.len(); + let pairs = if closed && n > 1 { n } else { n.saturating_sub(1) }; + (0..pairs).map(move |i| { + let (a, b) = (&segs[i], &segs[(i + 1) % n]); + (a.end(), tangent(a, 1.0), tangent(b, 0.0)) + }) +} + +/// The miter spike at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its +/// path: its tip and the corner's outer offset points, or `None` when the path doesn't turn there +/// or the miter limit bevels it. +fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[Point; 3]> { + let cos = t_in.dot(t_out); + // cos(half the turn) is sin(half the angle between the segments): the miter is side / it. + let half = ((1.0 + cos) / 2.0).max(0.0).sqrt(); + if cos >= CORNER_COS || half * limit < 1.0 { + return None; + } + let out = unit(t_in - t_out); + // The offset of each segment on the outer side of the turn. + let outer = |t: Vec2| { + let n = Vec2::new(-t.y, t.x); + if n.dot(out) >= 0.0 { n } else { -n } + }; + Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side]) +} + +/// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as +/// the stroke geometry places them), each with `true` for the end head. +fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> { + if st.start_arrow.is_none() && st.end_arrow.is_none() { + return vec![]; + } + let subs = subpaths(bp); + let open_ends = |sub: Option<&(std::ops::Range, bool)>| sub.filter(|s| !s.1).and_then(|s| ends(&drawn(&bp.elements()[s.0.clone()]))); + let mut out = vec![]; + if st.start_arrow.is_some() + && let Some([(at, dir), _]) = open_ends(subs.first()) + { + out.push((at, dir, false)); + } + if st.end_arrow.is_some() + && let Some([_, (at, dir)]) = open_ends(subs.last()) + { + out.push((at, dir, true)); + } + out +} + +/// (left, right) width factors where `p`'s nearest point on the subpath `segs` sits (segment `i` +/// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the +/// segment is a corner. The stroke draws the sides there straight to the corner's join and inner +/// meeting point, which can lie outside the exact width all along the segment. +fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) { + let n = segs.len(); + let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); + let total: f64 = lens.iter().sum::().max(1e-12); + let before = lens[..i].iter().sum::(); + let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total); + let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]); + let starts = n > 1 && corner((i + n - 1) % n, i); + let ends = n > 1 && corner(i, (i + 1) % n); + // A closed subpath's last segment ends where it starts. + let end = if i + 1 == n { 0.0 } else { before + lens[i] }; + for (is, at) in [(starts, before), (ends, end)] { + if is { + let (l, r) = profile.at(at / total); + w = (w.0.max(l), w.1.max(r)); + } + } + w +} + +/// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it? +fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { + let n = pts.len(); + // Signed distances from each edge's line. + let (lo, hi) = (0..n).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), i| { + let e = pts[(i + 1) % n] - pts[i]; + let d = e.cross(p - pts[i]) / e.hypot().max(1e-12); + (lo.min(d), hi.max(d)) + }); + lo >= -tol || hi <= tol +} + +impl StrokeLayer { + /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, + /// or an alignment that depends on whether the path is closed? Else it reaches evenly round it. + fn needs_shape(&self) -> bool { + self.join == LineJoin::Miter + || self.cap == LineCap::Square + || self.start_arrow.is_some() + || self.end_arrow.is_some() + || self.align != StrokeAlign::Center + } + + /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown + /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of + /// projecting caps and the reach of the arrowheads round their end points. + pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { + let els = bp.elements(); + let mut out = bounds; + let mut side_max: f64 = 0.0; + // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. + let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); + for (r, closed) in subpaths(bp) { + let side = self.side_reach(closed); + side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + if side <= 0.0 { + continue; + } + let segs = drawn(&els[r]); + if self.join == LineJoin::Miter { + for (at, t_in, t_out) in joints(&segs, closed) { + if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { + out = out.union_pt(tip); + } + } + } + if self.cap == LineCap::Square && !closed { + for (at, dir) in ends(&segs).into_iter().flatten() { + let n = Vec2::new(-dir.y, dir.x) * side; + out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); + } + } + } + for (at, _, end) in head_ends(self, bp) { + let r = self.head_reach(end); + out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); + } + out.union(bounds.inflate(side_max, side_max)) + } + + /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and + /// outside strokes of closed paths count on their own side only (and within `tol` of the + /// path); a width profile counts at its width where `p` is nearest. + pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { + let reach = self.reach() + tol; + if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) { + return false; + } + if head_ends(self, bp).into_iter().any(|(at, out, end)| self.head_contains(at, out, end, p, tol)) { + return true; + } + let els = bp.elements(); + // Aligned strokes paint on one side of the path only. + let on_side = |region: Option, d: f64| region.is_none_or(|inside| d <= tol || fill_contains(bp, rule, p) == inside); + // Every dash gets the caps, as wide as the stroke where the dash ends: round ones reach as + // far as the widest point, projecting ones a corner's diagonal. + let dash_caps = match self.cap { + LineCap::Butt => None, + _ if !self.dash.as_ref().is_some_and(Dash::is_dashed) => None, + LineCap::Round => Some(1.0), + LineCap::Square => Some(std::f64::consts::SQRT_2), + }; + for (r, closed) in subpaths(bp) { + let segs = drawn(&els[r]); + let (half, region) = self.body(closed); + let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); + // The nearest point on this subpath. + let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)); + if let Some((i, nr)) = near + && nr.distance_sq.sqrt() <= full + tol + { + let d = nr.distance_sq.sqrt(); + let factor = if dash_caps.is_some() { + full / half.max(1e-12) + } else { + self.profile.as_ref().map_or(1.0, |pr| { + let (l, rt) = profile_at(pr, &segs, closed, i, nr.t); + // Nearest to a segment's end, `p` is beside a join or past a cap, which + // spans the widths of both sides. + let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9; + // Left of the direction of travel (y down) reads the left width. + let tn = tangent(&segs[i], nr.t); + let left = (p - segs[i].eval(nr.t)).dot(Vec2::new(tn.y, -tn.x)) >= 0.0; + if at_end { + l.max(rt) + } else if left { + l + } else { + rt + } + }) + }; + if d <= half * factor.max(0.0) + tol && on_side(region, d) { + return true; + } + } + if full <= 0.0 { + continue; + } + if self.join == LineJoin::Miter + && joints(&segs, closed).any(|(at, t_in, t_out)| { + miter(at, t_in, t_out, full, self.miter_limit).is_some_and(|[a, tip, b]| in_convex(&[at, a, tip, b], p, tol)) + }) + && on_side(region, f64::INFINITY) + { + return true; + } + if self.cap == LineCap::Square + && !closed + && ends(&segs).into_iter().flatten().any(|(at, dir)| { + let n = Vec2::new(-dir.y, dir.x) * full; + in_convex(&[at + n, at + n + dir * full, at - n + dir * full, at - n], p, tol) + }) + { + return true; + } + } + false + } + + /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path + /// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With + /// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap) + /// 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 hw = self.arrow_weight(end); + let v = p - at; + match self.arrow_align { + ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol, + ArrowAlign::Extend => { + let along = v.dot(out); + v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol + } + } + } +} + +impl Appearance { + /// The visual bounds of a path with geometric bounds `bounds` painted with this appearance: + /// the bounds with what each visible stroke paints round it ([`StrokeLayer::bounds_on`]). + /// `bp` (the path) is only built when a stroke needs its shape. + pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect { + let mut path = Some(bp); + let mut shape = None; + self.painted_strokes().fold(bounds, |acc, s| { + if !s.needs_shape() { + let o = s.side_reach(false); + return acc.union(bounds.inflate(o, o)); + } + let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default()); + acc.union(s.bounds_on(bp, bounds)) + }) + } + + /// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within + /// `tol` of `p` ([`StrokeLayer::hits`])? + pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { + self.painted_strokes().any(|s| s.hits(bp, rule, p, tol)) + } +} + +#[cfg(test)] +mod tests { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_geom::{PathData, shapes}; + + use super::*; + use crate::{Arrowhead, Document, Node, StrokeAlign, hit}; + + fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); + f(&mut st); + st + } + + fn line() -> BezPath { + shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)).to_bezpath() + } + + /// A thin triangle whose 22.6° apex at (100, 20) miters about 5 times half the weight out. + fn triangle() -> BezPath { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((100.0, 20.0)); + bp.line_to((0.0, 40.0)); + bp.close_path(); + bp + } + + fn square() -> BezPath { + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath() + } + + #[test] + fn a_bigger_arrowhead_grows_the_bounds() { + let l = line(); + let b = l.bounding_box(); + let at = |scale: f64| { + stroke(2.0, |s| { + s.join = LineJoin::Round; + s.end_arrow = Some(Arrowhead::Triangle); + s.arrow_scale = (100.0, scale); + }) + }; + let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b)); + assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}"); + // A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside. + assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}"); + // The start has no head. + assert_eq!(at(300.0).head_reach(false), 0.0); + } + + #[test] + fn rectangles_keep_exact_bounds_and_sharp_corners_spike_up_to_the_limit() { + let sq = square(); + let b = sq.bounding_box(); + let st = stroke(4.0, |_| {}); + assert_eq!(st.bounds_on(&sq, b), b.inflate(2.0, 2.0), "right-angle miters stay in the box"); + // A sharp spike: a thin triangle's apex miters far out unless the limit bevels it. + let tri = triangle(); + let tb = tri.bounding_box(); + let spiked = st.bounds_on(&tri, tb); + assert!(spiked.x1 > 100.0 + 10.0, "{spiked:?}"); + let mut low = st.clone(); + low.miter_limit = 2.0; + assert!(low.bounds_on(&tri, tb).x1 <= 100.0 + 2.0 + 1e-9, "beveled"); + // Projecting caps reach past the ends; inside strokes stay in. + let l = line(); + let cap = stroke(4.0, |s| s.cap = LineCap::Square).bounds_on(&l, l.bounding_box()); + assert_eq!((cap.x0, cap.x1, cap.y1), (-2.0, 102.0, 2.0)); + assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Inside).bounds_on(&sq, b), b); + assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); + } + + #[test] + fn clicks_hit_outside_strokes_on_their_side_only() { + let sq = square(); + let w = 10.0; + let st = stroke(w, |s| s.align = StrokeAlign::Outside); + assert!(st.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5), "0.8·w outside"); + assert!(!st.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5), "nothing painted inside"); + assert!(st.hits(&sq, FillRule::NonZero, Point::new(0.3, 50.0), 0.5), "on the path"); + let inside = stroke(w, |s| s.align = StrokeAlign::Inside); + assert!(inside.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5)); + assert!(!inside.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5)); + // A centred stroke reaches half its weight each side. + let centre = stroke(w, |_| {}); + assert!(centre.hits(&sq, FillRule::NonZero, Point::new(-4.0, 50.0), 0.5)); + assert!(!centre.hits(&sq, FillRule::NonZero, Point::new(-8.0, 50.0), 0.5)); + } + + #[test] + fn clicks_follow_the_width_profile() { + let l = line(); + let lens = stroke(10.0, |s| s.profile = Some(WidthProfile::lens())); + assert!(lens.hits(&l, FillRule::NonZero, Point::new(50.0, 4.8), 0.1), "the lens's widest point"); + assert!(!lens.hits(&l, FillRule::NonZero, Point::new(5.0, 4.8), 0.1), "its thin end"); + // A profile wider than the weight on one side. + let wide = stroke(10.0, |s| s.profile = Some(WidthProfile { points: vec![(0.0, 3.0, 1.0), (1.0, 3.0, 1.0)] })); + // Left of travel (+x, y down) is −y. + assert!(wide.hits(&l, FillRule::NonZero, Point::new(50.0, -14.0), 0.1)); + assert!(!wide.hits(&l, FillRule::NonZero, Point::new(50.0, 14.0), 0.1)); + assert!(wide.bounds_on(&l, l.bounding_box()).y0 <= -15.0); + } + + #[test] + fn clicks_hit_heads_caps_and_spikes() { + let l = line(); + let head = stroke(2.0, |s| { + s.end_arrow = Some(Arrowhead::Triangle); + s.arrow_scale = (100.0, 300.0); + }); + // The 24 pt head sits past the end in extend mode, 12 pt either side of the line. + assert!(head.hits(&l, FillRule::NonZero, Point::new(110.0, 6.0), 0.1)); + assert!(!head.hits(&l, FillRule::NonZero, Point::new(50.0, 6.0), 0.1)); + let mut tip = head.clone(); + tip.arrow_align = ArrowAlign::Tip; + assert!(tip.hits(&l, FillRule::NonZero, Point::new(90.0, 6.0), 0.1) && !tip.hits(&l, FillRule::NonZero, Point::new(130.0, 0.0), 0.1)); + let cap = stroke(10.0, |s| s.cap = LineCap::Square); + assert!(cap.hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "the cap's corner"); + assert!(!stroke(10.0, |_| {}).hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "a butt end"); + let tri = triangle(); + let spike = stroke(4.0, |_| {}); + assert!(spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 20.0), 0.1), "in the miter spike"); + assert!(!spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 23.0), 0.1)); + } + + #[test] + fn document_hit_testing_and_visual_bounds_use_the_whole_stroke() { + let mut d = Document::new(300.0, 300.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0); + ap.stroke_mut().unwrap().align = StrokeAlign::Outside; + let sq = PathData::from_bezpath(&shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)).to_bezpath()); + d.insert(Some(l), 0, Node::path(id, sq, ap)).unwrap(); + let opt = hit::HitOptions { tol: 1.0, ..Default::default() }; + let h = hit::hit_test(&d, Point::new(92.0, 150.0), opt).expect("0.8·w outside the path"); + assert_eq!((h.leaf, h.kind), (id, hit::HitKind::Stroke)); + assert!(hit::hit_test(&d, Point::new(108.0, 150.0), opt).is_none(), "an unfilled interior"); + assert_eq!(d.node(id).unwrap().visual_bounds(), Some(Rect::new(90.0, 90.0, 210.0, 210.0))); + } +} diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 7dc1362b..22c9187c 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -17,8 +17,9 @@ mod width; use std::borrow::Cow; -use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2}; +use kurbo::{BezPath, PathEl, PathSeg}; use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer}; +pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit}; pub use arrow::Arrow; pub use dash::{Dashed, Dot, dash, dot_outline}; @@ -27,9 +28,6 @@ pub use width::width_outline; /// Arc-length accuracy for dashing and trimming (document points). const ARCLEN_ACCURACY: f64 = 1e-6; -/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it -/// and variable-width strokes give it the stroke's join. -const CORNER_COS: f64 = 0.9998; /// The geometry one stroke paints, in the coordinate space of its path. #[derive(Clone, Debug)] @@ -119,40 +117,6 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B } } -/// Do segments `a` and `b` (`b` following `a`) meet at a corner? -pub(crate) fn kink(a: &PathSeg, b: &PathSeg) -> bool { - tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS -} - -/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point. -pub(crate) fn tangent(s: &PathSeg, t: f64) -> Vec2 { - let d = match s { - PathSeg::Line(l) => l.p1 - l.p0, - PathSeg::Quad(q) => { - let v = q.deriv().eval(t).to_vec2(); - if v.hypot() > 1e-9 { v } else { q.p2 - q.p0 } - } - PathSeg::Cubic(c) => { - let v = c.deriv().eval(t).to_vec2(); - if v.hypot() > 1e-9 { - v - } else if t < 0.5 { - // The first control point that differs from the start gives the direction. - [c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|v| v.hypot() > 1e-9).unwrap_or_default() - } else { - [c.p1, c.p0].into_iter().map(|p| c.p3 - p).find(|v| v.hypot() > 1e-9).unwrap_or_default() - } - } - }; - unit(d) -} - -/// `v` normalised, or +x when it has no length. -pub(crate) fn unit(v: Vec2) -> Vec2 { - let h = v.hypot(); - if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) } -} - /// Append `seg` to `out` (which must already have a current point at its start). pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) { match seg { @@ -162,40 +126,11 @@ pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) { } } -/// Element ranges of the subpaths of `bp` (each with whether it is closed). -pub(crate) fn subpaths(bp: &BezPath) -> Vec<(std::ops::Range, bool)> { - let els = bp.elements(); - let mut out = vec![]; - let mut start = 0; - for (i, el) in els.iter().enumerate() { - match el { - PathEl::MoveTo(_) if i > start => { - out.push((start..i, false)); - start = i; - } - PathEl::ClosePath => { - out.push((start..i + 1, true)); - start = i + 1; - } - _ => {} - } - } - if start < els.len() { - out.push((start..els.len(), false)); - } - // A lone MoveTo (or a MoveTo straight after a ClosePath) draws nothing. - out.retain(|(r, _)| r.len() > 1); - out -} - -/// The segments of one subpath (`els` starts with its MoveTo; a ClosePath adds the closing line). -pub(crate) fn segments(els: &[PathEl]) -> Vec { - kurbo::segments(els.iter().copied()).collect() -} - #[cfg(test)] mod tests; #[cfg(test)] mod tests_arrows; #[cfg(test)] mod tests_fit; +#[cfg(test)] +mod tests_reach; diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs new file mode 100644 index 00000000..859d8b1c --- /dev/null +++ b/crates/effects/src/stroke/tests_reach.rs @@ -0,0 +1,92 @@ +//! The document's stroke bounds and clicks (`vectorcraft_doc` reach) against the geometry the +//! stroke really paints: every painted point is inside the visual bounds and hits. + +use kurbo::{BezPath, Point, Rect, Shape}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::{FillRule, PathData}; + +use super::outline_region; + +fn paths() -> Vec { + let mut curve = BezPath::new(); + curve.move_to((10.0, 60.0)); + curve.curve_to((30.0, 0.0), (70.0, 120.0), (90.0, 40.0)); + let mut zigzag = BezPath::new(); + zigzag.move_to((10.0, 20.0)); + zigzag.line_to((40.0, 70.0)); + zigzag.line_to((55.0, 15.0)); + zigzag.line_to((90.0, 60.0)); + let mut tri = BezPath::new(); + tri.move_to((10.0, 10.0)); + tri.line_to((90.0, 30.0)); + tri.line_to((10.0, 50.0)); + tri.close_path(); + vec![curve, zigzag, tri, Rect::new(20.0, 20.0, 80.0, 70.0).to_path(0.1)] +} + +/// Check `st` along `bp`: the painted region's box is inside the bounds, and points of the +/// region (on a 1.5 pt grid) hit within a quarter point, as the painted outlines are flattened. +/// Variable widths are drawn straight between their offsets at the path's samples and inner +/// corners, which beside sharp corners can paint up to about a point past the exact width there. +fn check(bp: &BezPath, st: &StrokeLayer, what: &str) { + let region = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, st).to_bezpath(); + if region.elements().is_empty() { + return; + } + let painted = region.bounding_box(); + let bounds = st.bounds_on(bp, bp.bounding_box()); + assert!(bounds.inflate(0.05, 0.05).contains_rect(painted), "{what}: paints {painted:?} outside {bounds:?}"); + let tol = if st.profile.is_some() { 1.0 } else { 0.25 }; + // Off the outline's own coordinates, so no sample sits level with a vertex. + let (mut x, mut checked) = (painted.x0 + 0.371, 0); + while x <= painted.x1 { + let mut y = painted.y0 + 0.293; + while y <= painted.y1 { + let p = Point::new(x, y); + if fill_contains(®ion, FillRule::EvenOdd, p) { + assert!(st.hits(bp, FillRule::NonZero, p, tol), "{what}: painted {p:?} doesn't hit"); + checked += 1; + } + y += 1.5; + } + x += 1.5; + } + assert!(checked > 0, "{what}: no samples"); +} + +#[test] +fn bounds_and_clicks_cover_what_every_stroke_paints() { + let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; + for (pi, bp) in paths().iter().enumerate() { + for align in [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside] { + for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] { + for (join, limit) in [(LineJoin::Miter, 10.0), (LineJoin::Miter, 2.0), (LineJoin::Round, 10.0), (LineJoin::Bevel, 10.0)] { + for (fi, profile) in profiles.iter().enumerate() { + for dash in [None, Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false })] { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 6.0); + (st.align, st.cap, st.join, st.miter_limit, st.profile, st.dash) = + (align, cap, join, limit, profile.clone(), dash.clone()); + check(bp, &st, &format!("path {pi} {align:?} {cap:?} {join:?} {limit} profile {fi} dashed {}", dash.is_some())); + } + } + } + } + } + } +} + +#[test] +fn bounds_and_clicks_cover_every_arrowhead() { + let bp = &paths()[0]; + for kind in Arrowhead::ALL { + 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); + check(bp, &st, &format!("{kind:?} {align:?} {scale}%")); + } + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c23d2172..f5ad1530 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -846,6 +846,8 @@ mod tests_proxyitems; #[cfg(test)] mod tests_strokegeom; #[cfg(test)] +mod tests_strokereach; +#[cfg(test)] mod tests_strokeux; #[cfg(test)] mod tests_stylepanel; diff --git a/crates/engine/src/tests_strokereach.rs b/crates/engine/src/tests_strokereach.rs new file mode 100644 index 00000000..9db74845 --- /dev/null +++ b/crates/engine/src/tests_strokereach.rs @@ -0,0 +1,76 @@ +//! Visual bounds and clicks take in the whole stroke: arrowheads, outside alignment, width +//! profiles (through the Selection tool and the commands that use visual bounds). + +use serde_json::json; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn id_of(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards. +fn click(s: &mut Session, x: f64, y: f64) -> Vec { + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); + } + s.active().unwrap().selection.objects.clone() +} + +/// The artboard after Fit to Selected Art (which fits the selection's visual bounds). +fn fitted(s: &mut Session) -> vectorcraft_geom::Rect { + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + s.doc().unwrap().doc.artboards[0].rect +} + +#[test] +fn a_300_percent_arrowhead_grows_the_visual_bounds() { + let mut s = session(); + s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2, "join": "round", "endArrow": "Triangle"})).unwrap(); + let plain = fitted(&mut s); + s.execute("stroke.setAdvanced", &json!({"arrowScale": [100, 300]})).unwrap(); + let big = fitted(&mut s); + // The 24 pt head reaches past the end and 12 pt to each side of the line. + assert!(big.x1 > plain.x1 + 15.0 && big.y1 >= 212.0 && big.y0 <= 188.0, "{plain:?} → {big:?}"); + assert_eq!(big.x0, plain.x0, "the start has no head"); +} + +#[test] +fn a_click_outside_an_outside_stroke_selects_it() { + let mut s = session(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("stroke.set", &json!({"weight": 20, "align": "outside"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + // 0.8 × the weight outside the path: past where a centred stroke (plus the tolerance) reaches. + assert_eq!(click(&mut s, 84.0, 200.0), [r]); + s.execute("select.none", &json!({})).unwrap(); + assert!(click(&mut s, 116.0, 200.0).is_empty(), "nothing paints inside"); + s.execute("stroke.set", &json!({"ids": [r.0], "align": "center"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert!(click(&mut s, 84.0, 200.0).is_empty(), "a centred stroke doesn't reach"); +} + +#[test] +fn a_click_at_the_lens_maximum_selects_its_stroke() { + let mut s = session(); + let l = id_of(&s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap()); + s.execute("stroke.set", &json!({"weight": 20, "profile": "lens"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(click(&mut s, 200.0, 209.0), [l], "the widest point"); + s.execute("select.none", &json!({})).unwrap(); + assert!(click(&mut s, 110.0, 209.0).is_empty(), "the thin end"); + // Fitted bounds take the profile in too. + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + assert_eq!(fitted(&mut s).y1, 210.0); +} diff --git a/crates/geom/src/bez.rs b/crates/geom/src/bez.rs new file mode 100644 index 00000000..408f3abd --- /dev/null +++ b/crates/geom/src/bez.rs @@ -0,0 +1,100 @@ +//! Walking a [`BezPath`] as strokes see it: its subpaths (open or closed), their segments, unit +//! tangents and corners. Shared by the stroke geometry (`vectorcraft-effects`) and the visual +//! bounds and hit testing of strokes (`vectorcraft-doc`). + +use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2}; + +/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it, +/// variable-width strokes give it the stroke's join and miter spikes grow from it. +pub const CORNER_COS: f64 = 0.9998; + +/// Element ranges of the subpaths of `bp` (each with whether it is closed). +pub fn subpaths(bp: &BezPath) -> Vec<(std::ops::Range, bool)> { + let els = bp.elements(); + let mut out = vec![]; + let mut start = 0; + for (i, el) in els.iter().enumerate() { + match el { + PathEl::MoveTo(_) if i > start => { + out.push((start..i, false)); + start = i; + } + PathEl::ClosePath => { + out.push((start..i + 1, true)); + start = i + 1; + } + _ => {} + } + } + if start < els.len() { + out.push((start..els.len(), false)); + } + // A lone MoveTo (or a MoveTo straight after a ClosePath) draws nothing. + out.retain(|(r, _)| r.len() > 1); + out +} + +/// The segments of one subpath (`els` starts with its MoveTo; a ClosePath adds the closing line). +pub fn segments(els: &[PathEl]) -> Vec { + kurbo::segments(els.iter().copied()).collect() +} + +/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point. +pub fn tangent(s: &PathSeg, t: f64) -> Vec2 { + let d = match s { + PathSeg::Line(l) => l.p1 - l.p0, + PathSeg::Quad(q) => { + let v = q.deriv().eval(t).to_vec2(); + if v.hypot() > 1e-9 { v } else { q.p2 - q.p0 } + } + PathSeg::Cubic(c) => { + let v = c.deriv().eval(t).to_vec2(); + if v.hypot() > 1e-9 { + v + } else if t < 0.5 { + // The first control point that differs from the start gives the direction. + [c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|v| v.hypot() > 1e-9).unwrap_or_default() + } else { + [c.p1, c.p0].into_iter().map(|p| c.p3 - p).find(|v| v.hypot() > 1e-9).unwrap_or_default() + } + } + }; + unit(d) +} + +/// `v` normalised, or +x when it has no length. +pub fn unit(v: Vec2) -> Vec2 { + let h = v.hypot(); + if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) } +} + +/// Do segments `a` and `b` (`b` following `a`) meet at a corner? +pub fn kink(a: &PathSeg, b: &PathSeg) -> bool { + tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subpaths_and_tangents() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.line_to((10.0, 10.0)); + bp.close_path(); + bp.move_to((20.0, 0.0)); + bp.curve_to((20.0, 0.0), (30.0, 0.0), (30.0, 10.0)); + let subs = subpaths(&bp); + assert_eq!(subs, vec![(0..4, true), (4..6, false)]); + let segs = segments(&bp.elements()[subs[0].0.clone()]); + assert_eq!(segs.len(), 3, "the close adds a segment"); + assert!(kink(&segs[0], &segs[1])); + let c = segments(&bp.elements()[subs[1].0.clone()]); + // A handle on its anchor: the direction comes from the next control point. + assert_eq!(tangent(&c[0], 0.0), Vec2::new(1.0, 0.0)); + assert_eq!(tangent(&c[0], 1.0), Vec2::new(0.0, 1.0)); + assert_eq!(unit(Vec2::ZERO), Vec2::new(1.0, 0.0)); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index bc828e71..79ed3e2f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -7,6 +7,7 @@ //! Coordinates are document points (1/72 in), y pointing down. #![forbid(unsafe_code)] +pub mod bez; pub mod hit; pub mod path; pub mod recognize; diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 15b89d4f..1a3339d5 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -440,9 +440,9 @@ fn options_decimals_minify_responsive_artboard() { assert!(s.contains("M10.12 20"), "{s}"); let s = export(&d, &ExportOptions::default()); assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("M10.123 20"), "{s}"); - // All art bounds. + // All art bounds: the rectangle and half its 1 pt stroke (right-angle miters stay inside). let s = export(&d, &ExportOptions { artboard: None, ..Default::default() }); - assert!(s.contains("viewBox=\"0 0 43.877 34\""), "{s}"); + assert!(s.contains("viewBox=\"0 0 40.877 31\""), "{s}"); let s = export(&d, &ExportOptions { object_ids: false, ..Default::default() }); assert!(!s.contains("id=\"Layer_1\"")); } diff --git a/docs/mcp.md b/docs/mcp.md index 6df362c0..ff037cce 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -507,3 +507,10 @@ none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Rig `Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`, the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`, `Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide. + +## Stroke bounds and clicks + +Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take +in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an +inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter +limit. A rectangle's right-angle miters stay within half the weight of its edges.