From 6da6b4440f0f97b8921606d228921132c78f085d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 12:45:31 -0700 Subject: [PATCH] Shape Builder: a split stroke keeps its path's direction and gradients With In Merge Mode, Clicking Stroke Splits the Path on, the run a click split off could run against its path. A click on a rectangle's top side gave a side from (100, 0) to (0, 0) on a rectangle that runs from (0, 0) to (100, 0) there, so arrowheads and other settings that follow a stroke's direction moved to the other end. The run now goes the way the path goes. The two paths of a split, and what an Alt-click or a merge leaves of a path, had a gradient fitted to the path's bounds fitted again to their own bounds. They now keep it where it was on the path. A run that left 0.05 pt or less of a path still split it, into the run and a speck. Such a path now does not split, and the tool shows no scissors over it. Docs: docs/mcp.md (Shape Builder Tool Options). --- crates/engine/src/cmd/buildcmds.rs | 14 ++- crates/engine/src/tests_build.rs | 141 +++++++++++++++++++++++++++++ crates/tools/src/builder.rs | 25 +++-- docs/mcp.md | 10 +- 4 files changed, 174 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 28c3ca118..ec0ea9259 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Shape Builder", [], None, - "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it goes directly above it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", + "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", has_selection_or_ids, sb_merge ), @@ -283,10 +283,14 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { && let Some((i, run)) = map.stroke_at(&outlines, q, tol) && let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i)) && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn)) - && !rest.is_empty() + && rest.length() > b::outline_snap(drawn) { // The path keeps the rest of it, and a copy of it with the run under the click goes - // directly above it; the other objects stay as they are. + // directly above it; the other objects stay as they are. Gradients fitted to the + // path's bounds keep that fit on both. + if let Some(n) = d.node_mut(leaf.id) { + n.pin_gradients(); + } let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect(); let mut replaced = Beside::replacing(d, &[leaf.id]); let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?; @@ -423,7 +427,8 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec) -> Result /// What is left of path or compound path `src`: a copy of it with a fresh id and the geometry `path` /// (a path for one subpath, else a compound path, as [`shape_node`] makes them). It keeps the rest -/// of `src`: its look, name, opacity mask and attributes, and its fill rule unless `rule` is given. +/// of `src`: its look, with gradients fitted to its bounds kept where they were on it, its name, +/// opacity mask and attributes, and its fill rule unless `rule` is given. fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option) -> Node { let own = match &src.kind { NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, @@ -431,6 +436,7 @@ fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option) }; let shaped = shape_node(d, path, None); let mut n = src.clone(); + n.pin_gradients(); n.id = shaped.id; n.kind = shaped.kind; if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut n.kind { diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 22c00bfa9..8b305b08a 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -825,6 +825,33 @@ fn shape_builder_click_on_a_whole_open_path_does_not_split_it() { assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); } +/// A click on a stroke whose run would leave no more of the path than the cut tolerance (0.05 pt +/// for a 100 pt path) does not split it: here the line's last 0.01 pt is all that would be left. +/// The command refuses the click as outside every region, and the tool shows no scissors and no +/// highlight there and runs no command on a click. +#[test] +fn shape_builder_click_on_a_stroke_does_not_split_off_all_but_a_speck() { + let mut s = session(); + let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100.01, "y": 0}]})).unwrap(); + let p = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [p.0]})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + let err = s.execute("shapeBuilder.merge", &json!({"points": [[50, 1]], "splitStroke": true})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("no region under the pointer"), "{err}"); + assert_eq!((layer_children(&s), shapes_of(&s, &[p])), (1, vec![(false, 3, 100.01)])); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap(); + let journal = s.journal.len(); + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 1.0), v).unwrap(); + assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v)); + assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, 1.0), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 1.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 1.0), v).unwrap(); + assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command"); +} + /// The stroke a click splits ends at the points where other paths cross it as well as at the /// path's anchors: A's right side inside B, then a line piece inside a square. A click away from /// every stroke merges and an erase click deletes, as they do without the option. @@ -860,6 +887,46 @@ fn shape_builder_stroke_split_stops_at_crossings_and_anchors() { assert_eq!(lengths, vec![400.0, 40.0, 100.0], "the square, the line's two loose ends, the piece"); } +/// The run a click splits off goes the way its path goes, so arrowheads and the other settings that +/// follow a stroke's direction stay at the ends they were at: a line and a rectangle, each as drawn +/// and reversed. +#[test] +fn shape_builder_stroke_split_keeps_the_path_direction() { + let ends = |s: &Session, id: NodeId| { + let (p, _) = outline(&node(s, id)); + let sp = &p.subpaths[0]; + (sp.anchors.first().unwrap().p, sp.anchors.last().unwrap().p) + }; + let pt = vectorcraft_geom::Point::new; + for reverse in [false, true] { + let mut s = session(); + let (a, l) = square_and_line(&mut s); + if reverse { + s.execute("path.reverse", &json!({"ids": [l.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + } + let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "splitStroke": true})).unwrap(); + let split = NodeId(r["split"].as_u64().unwrap()); + let want = if reverse { (pt(50.0, 100.0), pt(50.0, 0.0)) } else { (pt(50.0, 0.0), pt(50.0, 100.0)) }; + assert_eq!(ends(&s, split), want, "the line, reversed: {reverse}"); + + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + if reverse { + s.execute("path.reverse", &json!({"ids": [a.0]})).unwrap(); + } + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let (p, _) = outline(&node(&s, a)); + let sp = &p.subpaths[0]; + let n = sp.anchors.len(); + // The rectangle's top side, from the anchor the path reaches it at. + let want = (0..n).map(|k| (sp.anchors[k].p, sp.anchors[(k + 1) % n].p)).find(|(x, y)| x.y == 0.0 && y.y == 0.0).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap(); + let split = NodeId(r["split"].as_u64().unwrap()); + assert_eq!(ends(&s, split), want, "the rectangle, reversed: {reverse}"); + } +} + /// A split replaces only the path it splits: the two paths take its place in the stacking order and /// in its group, and the other selected objects stay as they are. A path in a Live Paint group does /// not split. @@ -932,6 +999,43 @@ fn shape_builder_stroke_split_with_swatches_paints_the_stroke() { assert_eq!(node(&s, out[0]).appearance.stroke_paint(), before, "the rest keeps its stroke"); } +/// Gives `id` a red-to-blue linear gradient, fitted to its bounds, as its fill and its stroke, and +/// returns that fill and stroke fixed where they fit `id` now. +fn paint_with_gradients(s: &mut Session, id: NodeId) -> (Paint, Paint) { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("t", |d, _| { + let a = &mut d.node_mut(id).unwrap().appearance; + a.set_fill(gradient.clone()); + a.set_stroke(gradient); + Ok(()) + }) + .unwrap(); + let mut n = node(s, id); + n.pin_gradients(); + (n.appearance.fill_paint(), n.appearance.stroke_paint()) +} + +/// The two paths a split leaves keep the gradients of the path they come from where they were on +/// it: a gradient fitted to the rectangle's bounds is not fitted again to the side split off or to +/// what is left. +#[test] +fn shape_builder_stroke_split_keeps_the_gradients_where_they_were() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let want = paint_with_gradients(&mut s, a); + // The rectangle's left side. + let r = s.execute("shapeBuilder.merge", &json!({"points": [[-1, 50]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!(node(&s, out[1]).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 0.0, 100.0)); + for i in out { + let n = node(&s, i); + assert_eq!((n.appearance.fill_paint(), n.appearance.stroke_paint()), want, "{i:?}"); + } +} + /// A red even-odd compound path, square A (0..100) with the square H (25..75) inside it, selected. fn even_odd_ring(s: &mut Session) -> NodeId { let a = rect(s, 0.0, 0.0, 100.0, 100.0); @@ -989,6 +1093,43 @@ fn shape_builder_stroke_split_and_erase_keep_the_name_and_opacity_mask() { assert!(kept(&s, &out[..1]), "A opened where B crosses it"); } +/// What is left of a path after an Alt-click or a merge, and the pieces of an open path that an +/// Alt-click leaves, keep the path's gradients where they were on it: A without its region outside +/// B, B without the regions merged into A, and the line without its piece inside the square. +#[test] +fn shape_builder_erase_and_merge_keep_the_gradients_where_they_were() { + let mut s = session(); + let (a, b) = two(&mut s); + let want = paint_with_gradients(&mut s, a); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]], "erase": true})).unwrap(); + let out = ids(&r); + assert_eq!(node(&s, out[0]).geometric_bounds().unwrap(), Rect::new(50.0, 0.0, 100.0, 100.0), "A inside B"); + assert_eq!((node(&s, out[0]).appearance.fill_paint(), node(&s, out[0]).appearance.stroke_paint()), want); + + let mut s = session(); + let (a, b) = two(&mut s); + let want = paint_with_gradients(&mut s, b); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + let merged = NodeId(r["merged"].as_u64().unwrap()); + let rest = ids(&r).into_iter().find(|i| *i != merged).unwrap(); + assert_eq!(node(&s, rest).geometric_bounds().unwrap(), Rect::new(100.0, 0.0, 150.0, 100.0), "B outside A"); + assert_eq!((node(&s, rest).appearance.fill_paint(), node(&s, rest).appearance.stroke_paint()), want); + + let mut s = session(); + let (a, l) = square_and_line(&mut s); + let want = paint_with_gradients(&mut s, l); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "erase": true})).unwrap(); + let ends: Vec = ids(&r).into_iter().filter(|i| !outline(&node(&s, *i)).0.is_closed()).collect(); + assert_eq!(ends.len(), 2, "the line's two loose ends"); + for i in ends { + let n = node(&s, i); + assert_eq!((n.appearance.fill_paint(), n.appearance.stroke_paint()), want, "{i:?}"); + } +} + /// The tool with In Merge Mode, Clicking Stroke Splits the Path: over a stroke, the pointer shows /// scissors and the run a click splits is drawn in the highlight color (none with Highlight Stroke /// When Editable off), and a click splits it. Off, the same click leaves the rectangle as it was. diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 8b0650beb..bc6235b59 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -319,10 +319,11 @@ impl BuilderMap { /// line piece or edge nearest `p` within `tol` (as [`BuilderMap::hit`] finds it), from the /// anchor or crossing point before `p` to the one after it on the path it lies on. `outlines` /// are the inputs' outlines as they are drawn (a filled open path without the edge that closes - /// it in the map). Returns the input's index and the run; None when no piece is that close, - /// when the run is not on the outline as drawn, when it would be a whole closed subpath, when - /// it is too short to cut out (see [`po::cut_out`]), or when it is all of the path (a lone - /// line), which leaves nothing to split off. + /// it in the map). Returns the input's index and the run, going the way the path goes; None + /// when no piece is that close, when the run is not on the outline as drawn, when it would be a + /// whole closed subpath, when it is too short to cut out (see [`po::cut_out`]), or when it is + /// all of the path (a lone line) or leaves no more of it than [`outline_snap`], which leaves + /// nothing to split off. pub fn stroke_at(&self, outlines: &[PathData], p: Point, tol: f64) -> Option<(usize, SubPath)> { use kurbo::ParamCurve as _; let piece = match self.hit(p, Some(tol))? { @@ -334,18 +335,26 @@ impl BuilderMap { let drawn = outlines.get(i)?; let stops: Vec = drawn.anchors().map(|(.., a)| a.p).collect(); let snap = outline_snap(drawn); - let run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?; + let mut run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?; let n = run.segment_count(); if n == 0 { return None; } let mid = run.segment(n / 2).eval(0.5); drawn.nearest(mid).filter(|n| n.4 <= snap)?; - // A run that cannot be cut out, or that is all of the path (such as a lone line), leaves - // nothing to split off. - if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| !rest.is_empty()) { + // A run that cannot be cut out, or that is all of the path (such as a lone line) or all + // but a speck of it, leaves nothing to split off. + if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| rest.length() > snap) { return None; } + // Reverse the run when the path's parameter goes down along it. + let seg = run.segment(n / 2); + if let (Some(a), Some(b)) = (drawn.nearest(seg.eval(0.25)), drawn.nearest(seg.eval(0.75))) + && a.0 == b.0 + && (b.1 as f64 + b.2) < (a.1 as f64 + a.2) + { + run.reverse(); + } Some((i, run)) } diff --git a/docs/mcp.md b/docs/mcp.md index 916ecfd9d..ba0c3e2b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4574,10 +4574,12 @@ Clicking Stroke Splits the Path; off), `highlightStroke` (Highlight Stroke When (`#rrggbb` or a layer color's name; `#ff4f4f`, the layer color Red). With `clickStrokeSplitsPath` on, in merge mode, the pointer shows scissors within 4 screen pixels of a stroke, `highlightStroke` draws the run of stroke under it in `highlightColor`, and a click splits that run off its path: the run goes from the anchor or crossing point before the -pointer to the one after it, the path keeps the rest of it, and a copy of the path with the run goes directly above it. -A lone rectangle's side is such a run. A lone line is a run that is all of its path, so the pointer shows no scissors -over it and a click on it does not split it. A release up to 3 screen pixels from the press is a click when the pointer -went no farther during the drag. +pointer to the one after it, the path keeps the rest of it, and a copy of the path with the run, in the path's +direction, goes directly above it. Both keep the path's gradients where they were on it. +A lone rectangle's side is such a run. A lone line is a run that is all of its path, and a run that would leave 0.05 pt +or less of its path (a ten-thousandth of the larger side of a path over 500 pt) counts as all of it, so the pointer +shows no scissors over it and a click on it does not split it. A release up to 3 screen pixels from the press is a +click when the pointer went no farther during the drag. With `pickColorFrom` `swatches`, the run takes the current fill as its stroke. The tool sends `openFilledAsClosed`, `pickColorFrom`, the gap length (`gap`, 0 with Gap Detection off) and, for such a click, `splitStroke: true` with `shapeBuilder.merge`, which takes them all as params, and `shapeBuilder.regions` takes `openFilledAsClosed` and `gap`.