From 13db157d36f66cdfa748d2ee9a78e5349a617fc8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 11:30:19 +0700 Subject: [PATCH 1/2] 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 From fe2259536d43d27bceb547c76aeea63c78e27c09 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 12:01:11 +0700 Subject: [PATCH 2/2] ROADMAP: M8.7 landed Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3a193685..fdcf1e4b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M5 | Performance | ๐ŸŸก background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3ร— faster (50k paths: 722 โ†’ 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15โ€“25 | | M6 | Path operations (Pathfinder, Shape Builder, offsetโ€ฆ) | โœ… mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3โ€“6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | ๐ŸŸก Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45โ€“60 | -| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ๐ŸŸก live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes) | 8โ€“12 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ๐ŸŸก live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes, M8.7 Width tool on compound paths, every subpath, cursors) | 8โ€“12 | | M9 | Live effects (+ 3D & Materials) | ๐ŸŸก 2D effects done incl. Effect โ†’ Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86โ€“135 | | M10 | Brushes, symbols, patterns, Repeat | ๐ŸŸก pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23โ€“37 | | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | ๐ŸŸก | 15โ€“25 |