From 13db157d36f66cdfa748d2ee9a78e5349a617fc8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 11:30:19 +0700 Subject: [PATCH] M8.7: Width tool edits compound-path strokes, shows every subpath's points, own cursors - A compound path's stroke lives on the compound: the tool edits it through any member, and stroke.widthPoint.* / stroke.widthProfile.set given a member edit the compound's profile. - Width points show on every subpath the stroke runs along (not just the first) for the hovered and the selected stroke. - Cursors: the Width tool's own glyphs off a stroke, over a stroke (a drag adds a point) and over a width point or handle (a drag moves or widens it). - Hint-bar text for the tool. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/distortcmds.rs | 22 ++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_widthtool.rs | 52 +++++++ crates/tools/src/distort.rs | 2 +- crates/tools/src/distort/width.rs | 195 ++++++++++++++++++++++---- crates/tools/src/lib.rs | 6 + crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/chrome.rs | 12 ++ crates/ui-egui/src/cursors.rs | 30 ++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_widthtool.rs | 29 ++++ docs/mcp.md | 2 + 12 files changed, 318 insertions(+), 38 deletions(-) create mode 100644 crates/engine/src/tests_widthtool.rs create mode 100644 crates/ui-egui/src/tests_widthtool.rs diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 2cae046b..e7ef87a4 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile} use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect}; use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds}; use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; -use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, warp_from_rest, warp_node_with}; +use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, stroke_owner, warp_from_rest, warp_node_with}; use super::edit::{duplicate_in, selected_roots}; use super::*; @@ -194,10 +194,16 @@ fn uniform() -> WidthProfile { /// Width points closer than this (in t) are at the same place: a discontinuous point. const SAME_T: f64 = 1e-6; -/// The weighted stroke of `id` that width point commands edit. -fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> { +/// The stroke of `id` that width point commands edit (a compound path's for its members). +fn stroke_mut(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> { + let id = stroke_owner(d, id); let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?; + n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into())) +} + +/// [`stroke_mut`], which must have a weight. +fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> { + let st = stroke_mut(d, id)?; if st.width <= 0.0 { return Err(EngineError::Other("the stroke has no weight".into())); } @@ -316,8 +322,7 @@ fn width_point_remove(s: &mut Session, p: &Value) -> Result { indices.sort_unstable(); indices.dedup(); s.edit("Delete Width Point", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?; + let st = stroke_mut(d, id)?; let fits = |pr: &&mut WidthProfile| indices.last().is_some_and(|i| *i < pr.points.len()); let prof = st.profile.as_mut().filter(fits).ok_or_else(|| bad(C, "no such width point"))?; for i in indices.iter().rev() { @@ -351,7 +356,10 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad(C, "points must be an array or null")), }; - let ids = targets(s, p)?; + let doc = &s.doc()?.doc; + let mut ids: Vec = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect(); + ids.sort_unstable(); + ids.dedup(); s.edit("Width Profile", |d, _| { for id in &ids { let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a39b093f..fefb612a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1376,4 +1376,6 @@ mod tests_widthpoints; #[cfg(test)] mod tests_widthprofiles; #[cfg(test)] +mod tests_widthtool; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_widthtool.rs b/crates/engine/src/tests_widthtool.rs new file mode 100644 index 00000000..53be26b3 --- /dev/null +++ b/crates/engine/src/tests_widthtool.rs @@ -0,0 +1,52 @@ +//! The Width tool on compound paths: the stroke is the compound's, so its members are edited +//! through it, by the tool and by the width point commands given a member. + +use serde_json::json; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::*; + +/// Two 200 pt lines made into a compound path with a 10 pt stroke → (compound, members). +fn compound() -> (Session, NodeId, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap(); + let members: Vec = [200, 300] + .iter() + .map(|y| NodeId(s.execute("shape.line", &json!({"x1": 100, "y1": y, "x2": 300, "y2": y})).unwrap()["id"].as_u64().unwrap())) + .collect(); + s.execute("select.set", &json!({"ids": [members[0].0, members[1].0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 10})).unwrap(); + (s, c, members) +} + +fn profile(s: &Session, id: NodeId) -> Option> { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|st| st.profile.as_ref().map(|p| p.points.clone())) +} + +#[test] +fn the_width_tool_edits_a_compound_paths_stroke() { + let (mut s, c, members) = compound(); + assert!(s.doc().unwrap().doc.node(members[1]).is_some(), "members keep their ids"); + let v = ViewInfo::default(); + s.select_tool("width", v).unwrap(); + // Drag out from the second member's middle: 20 pt either side. + for (k, y) in [(PointerKind::Down, 301.0), (PointerKind::Drag, 310.0), (PointerKind::Drag, 320.0), (PointerKind::Up, 320.0)] { + s.pointer(&PointerEvent::new(k, 200.0, y), v).unwrap(); + } + let p = profile(&s, c).expect("the compound's stroke has the width point"); + assert!(p.iter().any(|q| (q.0 - 0.5).abs() < 1e-3 && (q.1 - 4.0).abs() < 1e-6), "{p:?}"); + assert_eq!(s.journal.last().unwrap().1["id"], json!(c.0)); +} + +#[test] +fn width_point_commands_given_a_member_edit_the_compound() { + let (mut s, c, members) = compound(); + s.execute("stroke.widthPoint.set", &json!({"id": members[0].0, "t": 0.25, "left": 10, "right": 10})).unwrap(); + assert_eq!(profile(&s, c).unwrap().len(), 3); + s.execute("stroke.widthPoint.remove", &json!({"id": members[1].0, "index": 1})).unwrap(); + assert_eq!(profile(&s, c).unwrap().len(), 2); + s.execute("stroke.widthProfile.set", &json!({"ids": [members[0].0, members[1].0], "points": [[0, 0, 0], [1, 2, 2]]})).unwrap(); + assert_eq!(profile(&s, c).unwrap(), vec![(0.0, 0.0, 0.0), (1.0, 2.0, 2.0)]); +} diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index dc0391bb..1aaa2270 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -27,7 +27,7 @@ use crate::Tool; pub use liquify::LiquifyTool; pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool}; pub use puppet::{PinSet, PuppetWarpTool, mesh_for, mesh_input, rest_and_pins, warp_from_rest}; -pub use width::WidthTool; +pub use width::{WidthTool, stroke_owner}; /// Create a distortion tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index 7a5207e0..90223011 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -12,6 +12,10 @@ //! Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in points), //! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids, //! points}`, and commit on release; Delete runs `stroke.widthPoint.remove {id, indices}`. +//! +//! A compound path's stroke is the compound's: its members are edited through it, and the width +//! points show on every subpath of the stroke (one profile runs along each). The cursor says what a +//! drag does: add a width point on a stroke, or move or widen the one under it. use serde_json::json; use vectorcraft_doc::{Document, NodeId, NodeKind, StrokeLayer}; @@ -28,6 +32,9 @@ const SAME_T: f64 = 1e-6; /// Where the pointer is relative to a stroked path. #[derive(Clone, Copy, Debug, PartialEq)] struct Spot { + /// The object whose stroke it is (a compound path for its members). + owner: NodeId, + /// The path under the pointer. id: NodeId, sub: usize, /// Fraction along the subpath. @@ -93,12 +100,31 @@ struct Drag { pub struct WidthTool { hover: Option, drag: Option, - /// Selected width points: the path and their positions t. + /// Selected width points: the stroke's owner and their positions t. selected: Option<(NodeId, Vec)>, } -fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> { - doc.node(id)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none()) +/// The object whose stroke path `id` draws with: its compound path, if it is a member of one. +pub fn stroke_owner(doc: &Document, id: NodeId) -> NodeId { + doc.parent_of(id).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id) +} + +/// The visible weighted stroke of `owner`. +fn stroke_of(doc: &Document, owner: NodeId) -> Option<&StrokeLayer> { + doc.node(owner)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none()) +} + +/// The paths a stroke owner draws: itself, or a compound path's members. +fn paths_of(doc: &Document, owner: NodeId) -> Vec { + let mut out = vec![]; + if let Some(n) = doc.node(owner) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { guide: false, .. }) { + out.push(c.id); + } + }); + } + out } /// Side widths (points) of a stroke at fraction `t`. @@ -116,7 +142,8 @@ impl WidthTool { } let n = cx.doc.node(id)?; let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None }; - let st = stroke_of(cx.doc, id)?; + let owner = stroke_owner(cx.doc, id); + let st = stroke_of(cx.doc, owner)?; let widest = st.profile.as_ref().map_or(1.0, |pr| pr.points.iter().map(|q| q.1.max(q.2)).fold(1.0, f64::max)); let reach = widest * st.width / 2.0 + cx.tol(4.0); if !path.bounds()?.inflate(reach, reach).contains(p) { @@ -127,10 +154,11 @@ impl WidthTool { return None; } let (left, right) = sides_at(st, t); - Some((d, Spot { id, sub, t, p: q, n: left_normal(tan), left, right })) + Some((d, Spot { owner, id, sub, t, p: q, n: left_normal(tan), left, right })) }; let best_of = |ids: &mut dyn Iterator| ids.filter_map(consider).min_by(|a, b| a.0.total_cmp(&b.0)); - if let Some(b) = best_of(&mut cx.selection.objects.iter().copied()) { + // The selected paths first (a selected compound path: its members). + if let Some(b) = best_of(&mut cx.selection.objects.iter().flat_map(|id| paths_of(cx.doc, *id))) { return Some(b.1); } let mut all = vec![]; @@ -142,9 +170,9 @@ impl WidthTool { best_of(&mut all.into_iter().rev()).map(|b| b.1) } - /// Width points of `id` on subpath `sub`. - fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec { - let Some(st) = stroke_of(cx.doc, id) else { return vec![] }; + /// Width points of `owner`'s stroke on subpath `sub` of path `id`. + fn points_of(cx: &ToolContext, owner: NodeId, id: NodeId, sub: usize) -> Vec { + let Some(st) = stroke_of(cx.doc, owner) else { return vec![] }; let Some(pr) = &st.profile else { return vec![] }; let Some(NodeKind::Path { path, .. }) = cx.doc.node(id).map(|n| &n.kind) else { return vec![] }; let Some(sp) = path.subpaths.get(sub) else { return vec![] }; @@ -158,11 +186,20 @@ impl WidthTool { .collect() } + /// Width points of `owner`'s stroke on every subpath it draws. + fn all_points(cx: &ToolContext, owner: NodeId) -> Vec { + let subpaths = |id: NodeId| match cx.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, .. }) => path.subpaths.len(), + _ => 0, + }; + paths_of(cx.doc, owner).into_iter().flat_map(|id| (0..subpaths(id)).flat_map(move |sub| Self::points_of(cx, owner, id, sub))).collect() + } + /// The width point part nearest to `p` within a few pixels: a centre (`None`) or the end of a /// left (`Some(true)`) or right (`Some(false)`) handle. fn hit(cx: &ToolContext, spot: &Spot, p: Point) -> Option<(WPoint, Option)> { let tol = cx.tol(5.0); - Self::points_of(cx, spot.id, spot.sub) + Self::points_of(cx, spot.owner, spot.id, spot.sub) .into_iter() .flat_map(|w| [(w, None, w.p), (w, Some(true), w.p + w.n * w.left), (w, Some(false), w.p - w.n * w.right)]) .map(|(w, part, q)| (q.distance(p), w, part)) @@ -217,7 +254,7 @@ impl WidthTool { /// The command previewing drag `d`. fn preview(d: &Drag) -> Action { - let id = d.spot.id.0; + let id = d.spot.owner.0; if let Some(g) = &d.group { let (dt, dl, dr) = (d.t - d.spot.t, (d.left - d.spot.left) / g.half, (d.right - d.spot.right) / g.half); let mut pts: Vec<[f64; 3]> = g @@ -260,8 +297,8 @@ impl Tool for WidthTool { PointerKind::DoubleClick => { let Some(spot) = Self::spot_at(cx, p) else { return vec![] }; let Some((w, _)) = Self::hit(cx, &spot, p) else { return vec![] }; - self.selected = Some((spot.id, vec![w.t])); - vec![Action::Dialog("widthPoint".into(), json!({"id": spot.id.0, "index": w.index}))] + self.selected = Some((spot.owner, vec![w.t])); + vec![Action::Dialog("widthPoint".into(), json!({"id": spot.owner.0, "index": w.index}))] } PointerKind::Down => { let Some(spot) = Self::spot_at(cx, p) else { @@ -278,20 +315,20 @@ impl Tool for WidthTool { Some(left) => Mode::Width(ev.mods.alt.then_some(left)), }; if ev.mods.shift { - if !self.toggle(spot.id, w.t) { + if !self.toggle(spot.owner, w.t) { return vec![]; } - } else if mode == Mode::Copy || !self.is_selected(spot.id, w.t) { - self.selected = Some((spot.id, vec![w.t])); + } else if mode == Mode::Copy || !self.is_selected(spot.owner, w.t) { + self.selected = Some((spot.owner, vec![w.t])); } - let others = Self::points_of(cx, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect(); - let group = self.group(cx, spot.id, mode); + let others = Self::points_of(cx, spot.owner, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect(); + let group = self.group(cx, spot.owner, mode); let spot = Spot { t: w.t, p: w.p, n: w.n, left: w.left, right: w.right, ..spot }; Drag { spot, index: Some(w.index), mode, left: w.left, right: w.right, t: w.t, group, others, moved: false } } None => { let side = (p - spot.p).dot(spot.n) >= 0.0; - self.selected = Some((spot.id, vec![spot.t])); + self.selected = Some((spot.owner, vec![spot.t])); let mode = Mode::Width(ev.mods.alt.then_some(side)); Drag { spot, index: None, mode, left: spot.left, right: spot.right, t: spot.t, group: None, others: vec![], moved: false } } @@ -337,11 +374,11 @@ impl Tool for WidthTool { let dt = d.t - d.spot.t; self.selected = match (d.group.is_some(), d.moved, self.selected.take()) { // A click on one of several selected points selects just that one. - (true, false, _) if !ev.mods.shift => Some((d.spot.id, vec![d.spot.t])), + (true, false, _) if !ev.mods.shift => Some((d.spot.owner, vec![d.spot.t])), // Points moved together stay selected. (true, _, Some((id, ts))) if d.mode == Mode::Move => Some((id, ts.into_iter().map(|t| (t + dt).clamp(0.0, 1.0)).collect())), (true, _, sel) => sel, - (false, _, _) => Some((d.spot.id, vec![d.t])), + (false, _, _) => Some((d.spot.owner, vec![d.t])), }; vec![Action::Commit] } @@ -372,9 +409,10 @@ impl Tool for WidthTool { let mut out = vec![]; let r = cx.tol(4.0); let focus = self.drag.as_ref().map(|d| d.spot).or(self.hover); - let show = |id: NodeId, sub: usize, out: &mut Vec| { - for w in Self::points_of(cx, id, sub) { - let sel = self.is_selected(id, w.t); + // Every subpath's width points of the stroke under the pointer, and of the selected ones'. + let show = |owner: NodeId, out: &mut Vec| { + for w in Self::all_points(cx, owner) { + let sel = self.is_selected(owner, w.t); out.push(Overlay::Line { a: w.p + w.n * w.left, b: w.p - w.n * w.right, color: BLUE, dashed: false }); out.push(Overlay::Path { path: diamond(w.p, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false }); out.push(Overlay::Handle { p: w.p + w.n * w.left, color: BLUE }); @@ -382,12 +420,12 @@ impl Tool for WidthTool { } }; if let Some(s) = focus { - show(s.id, s.sub, &mut out); + show(s.owner, &mut out); } - if let Some((id, _)) = &self.selected - && focus.is_none_or(|s| s.id != *id) + if let Some((owner, _)) = &self.selected + && focus.is_none_or(|s| s.owner != *owner) { - show(*id, 0, &mut out); + show(*owner, &mut out); } if let Some(d) = &self.drag { let q = d.spot.p; @@ -400,8 +438,16 @@ impl Tool for WidthTool { } out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { - Cursor::Crosshair + fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.drag.is_some() { + return Cursor::WidthPoint; + } + // The stroke under the pointer, as the last move found it (no search every frame). + match self.hover { + Some(spot) if Self::hit(cx, &spot, p).is_some() => Cursor::WidthPoint, + Some(_) => Cursor::WidthAdd, + None => Cursor::Width, + } } fn busy(&self) -> bool { self.drag.is_some() @@ -417,6 +463,7 @@ mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::{Appearance, Node, Selection, WidthProfile}; + use vectorcraft_geom::Point; use vectorcraft_geom::{PathData, SubPath}; fn doc_line() -> (Document, NodeId) { @@ -554,6 +601,94 @@ mod tests { assert_eq!((cmd, v["t"].as_f64(), v["index"].as_u64()), ("stroke.widthPoint.set", Some(0.5), Some(1))); } + /// A compound path of two 200 pt lines (y = 200 and 300) with a 10 pt stroke on the compound + /// (its members carry none), a width point at t = 0.5 → (doc, compound, second member). + fn doc_compound() -> (Document, NodeId, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = |id, y| { + let path = PathData::single(SubPath::polyline(&[Point::new(100.0, y), Point::new(300.0, y)], false)); + std::sync::Arc::new(Node::path(id, path, Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0))) + }; + let mut comp = Node::new(c, NodeKind::Compound { children: vec![line(a, 200.0), line(b, 300.0)], rule: Default::default() }); + comp.appearance = Appearance::default_art(); + let st = comp.appearance.stroke_mut().unwrap(); + st.width = 10.0; + st.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (1.0, 1.0, 1.0)] }); + d.insert(Some(l), 0, comp).unwrap(); + (d, c, b) + } + + #[test] + fn compound_path_strokes_are_edited_through_the_compound() { + let (d, c, b) = doc_compound(); + assert_eq!(stroke_owner(&d, b), c); + let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint()); + let cx = cx(&d, &s, &p); + let mut t = WidthTool::default(); + // Drag out from the second member: the compound's stroke gets the width point. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 301.0, Mods::default())), vec![Action::Begin("Width Point".into())]); + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 320.0, Mods::default())); + let (cmd, v) = preview(&acts); + assert_eq!((cmd, &v["id"]), ("stroke.widthPoint.set", &json!(c.0))); + t.pointer(&cx, &ev(PointerKind::Up, 150.0, 320.0, Mods::default())); + // The width point in the middle of the first member selects (and deletes) the compound's. + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0, Mods::default())); + assert!(t.claims_key(&cx, ToolKey::Delete)); + assert_eq!( + t.key(&cx, ToolKey::Delete, Mods::default()), + vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": c.0, "indices": [1]}))] + ); + } + + #[test] + fn width_points_show_on_every_subpath() { + let (d, c, _) = doc_compound(); + let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint()); + let cx = cx(&d, &s, &p); + let mut t = WidthTool::default(); + let handles = |t: &WidthTool| t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Handle { .. })).count(); + // Hovering one member shows the 3 width points on both (two handle ends each). + t.pointer(&cx, &ev(PointerKind::Move, 150.0, 302.0, Mods::default())); + assert_eq!(handles(&t), 3 * 2 * 2); + // A selected point shows them on every subpath when the pointer is elsewhere. + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Move, 450.0, 450.0, Mods::default())); + assert_eq!(handles(&t), 3 * 2 * 2); + // One path of two subpaths: both. + let (mut d2, id) = doc_line(); + let n = d2.node_mut(id).unwrap(); + if let NodeKind::Path { path, .. } = &mut n.kind { + path.subpaths.push(SubPath::polyline(&[Point::new(100.0, 260.0), Point::new(300.0, 260.0)], false)); + } + n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] }); + let s2 = Selection { objects: vec![id], ..Default::default() }; + let cx2 = crate::testutil::cx(&d2, &s2, &p); + let mut t = WidthTool::default(); + t.pointer(&cx2, &ev(PointerKind::Down, 200.0, 260.0, Mods::default())); + t.pointer(&cx2, &ev(PointerKind::Up, 200.0, 260.0, Mods::default())); + t.pointer(&cx2, &ev(PointerKind::Move, 450.0, 450.0, Mods::default())); + let ends: Vec = t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Handle { p, .. } = o { Some(*p) } else { None }).collect(); + assert!(ends.iter().any(|q| q.y < 230.0) && ends.iter().any(|q| q.y > 230.0), "both subpaths: {ends:?}"); + } + + #[test] + fn the_cursor_says_what_a_drag_does() { + let (d, _) = doc_three_points(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = WidthTool::default(); + assert_eq!(t.cursor(&c, Point::new(400.0, 400.0), Mods::default()), Cursor::Width); + t.pointer(&c, &ev(PointerKind::Move, 170.0, 201.0, Mods::default())); + assert_eq!(t.cursor(&c, Point::new(170.0, 201.0), Mods::default()), Cursor::WidthAdd, "on the stroke: adds a point"); + t.pointer(&c, &ev(PointerKind::Move, 150.0, 200.0, Mods::default())); + assert_eq!(t.cursor(&c, Point::new(150.0, 200.0), Mods::default()), Cursor::WidthPoint, "on a width point"); + assert_eq!(t.cursor(&c, Point::new(150.0, 195.0), Mods::default()), Cursor::WidthPoint, "on a handle end"); + } + #[test] fn double_click_on_a_width_point_opens_width_point_edit() { let (d, id) = doc_three_points(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f95fc3fc..26da4e87 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -312,6 +312,12 @@ pub enum Cursor { Slice, /// The Slice Selection tool: the arrow with a slice badge. SliceSelect, + /// The Width tool away from strokes. + Width, + /// The Width tool over a stroke: a drag adds a width point. + WidthAdd, + /// The Width tool over a width point or a handle end: a drag moves or widens it. + WidthPoint, /// The Blend tool away from art: a crosshair with a hollow square. Blend, /// The Blend tool over an object it can blend: a crosshair with a filled square. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5366a926..5be03d7a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -394,6 +394,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::RemoveStop => C::NotAllowed, Cursor::Slice => C::Crosshair, Cursor::SliceSelect => C::Default, + Cursor::Width | Cursor::WidthAdd => C::Crosshair, + Cursor::WidthPoint => C::Move, Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair, } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3dc3952f..dd48603e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -490,6 +490,18 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { ], "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], "artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)], + "width" => &[ + ("Drag", true), + (" a stroke to add a width point | ", false), + ("Alt+Drag", true), + (" to change one side | ", false), + ("Shift+Click", true), + (" to select more points | ", false), + ("Double-click", true), + (" a point to edit it | ", false), + ("Delete", true), + (" to remove points", false), + ], "puppetWarp" => &[ ("Click", true), (" the art to add a pin | ", false), diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index cf8f272a..7ac1903b 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -133,6 +133,33 @@ fn slice(p: &Painter, o: Pos2) { line(p, b + vec2(8.0, 6.0), b + vec2(12.0, 12.0)); } +/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point), +/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point +/// (a drag moves or widens it). +fn width(p: &Painter, o: Pos2, badge: &str) { + arrow(p, o, true); + let b = o + vec2(11.0, 18.0); + let top: Vec = (0..=8) + .map(|i| { + let t = i as f32 / 8.0; + b + vec2(12.0 * t, -3.5 * (std::f32::consts::PI * t).sin()) + }) + .collect(); + let mut lens = top.clone(); + lens.extend(top.iter().rev().map(|q| pos2(q.x, 2.0 * b.y - q.y))); + p.add(Shape::closed_line(lens.clone(), Stroke::new(3.0, HALO))); + p.add(Shape::closed_line(lens, Stroke::new(1.2, INK))); + match badge { + "+" => { + let c = b + vec2(16.0, -6.0); + line(p, c - vec2(3.0, 0.0), c + vec2(3.0, 0.0)); + line(p, c - vec2(0.0, 3.0), c + vec2(0.0, 3.0)); + } + "point" => line(p, b + vec2(6.0, -6.0), b + vec2(6.0, 6.0)), + _ => {} + } +} + fn ibeam(p: &Painter, o: Pos2) { line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0)); line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0)); @@ -185,6 +212,9 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { arrow(painter, p, false); slice_badge(painter, p + vec2(11.0, 14.0)); } + Cursor::Width => width(painter, p, ""), + Cursor::WidthAdd => width(painter, p, "+"), + Cursor::WidthPoint => width(painter, p, "point"), Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c), _ => return false, } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 93745c45..bdbbbfba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,8 @@ mod tests_sysclip; mod tests_sysclip_emf; #[cfg(test)] mod tests_transparencygrid; +#[cfg(test)] +mod tests_widthtool; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/tests_widthtool.rs b/crates/ui-egui/src/tests_widthtool.rs new file mode 100644 index 00000000..4a59ccc9 --- /dev/null +++ b/crates/ui-egui/src/tests_widthtool.rs @@ -0,0 +1,29 @@ +//! The Width tool in the UI: its cursors and hint. + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_tools::Cursor; + +use crate::{VectorcraftApp, chrome, tests_labels::painted_text}; + +#[test] +fn the_width_tool_has_its_own_cursors() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.painter().clone(); + for c in [Cursor::Width, Cursor::WidthAdd, Cursor::WidthPoint] { + assert!(crate::cursors::paint(&painter, c, egui::pos2(50.0, 50.0)), "{c:?} is drawn"); + } + }); + out.textures_delta.clear(); + assert!(out.shapes.len() > 3, "{} shapes", out.shapes.len()); +} + +#[test] +fn the_hint_bar_explains_the_width_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "width"})).unwrap(); + let text = painted_text(&mut app, chrome::hint_bar).replace('\n', ""); + assert!(text.contains("add a width point") && text.contains("one side") && text.contains("remove points"), "{text}"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 98f81bca..4c6f725a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -873,6 +873,8 @@ points}` replaces them all, as dragging several Shift-selected points with the W With the Width tool, `press_key` Delete or Backspace removes the selected width points (`handledBy: "tool"`); with none selected the key clears the selected objects as usual (`edit.clear`). The Puppet Warp tool's selected pins go the same way. +A compound path's stroke is the compound's: width point commands given one of its members edit the compound's +profile, which runs along every subpath (the Width tool shows each subpath's points). `object.puppetWarp {ids?, pins, moved, angles?, expand?}` warps art as rigidly as possible so that each pin (a point on the art) lands on its `moved` point. `angles` (degrees, or null for a free pin; as long as `pins`) holds the turn