From 09110ce7bf2b18ceb063561d3dccd8fb1754e3d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:15:07 -0800 Subject: [PATCH] Live Paint: Merge fits a carried edge look to the piece it lands on When Merge added a path that cut a painted edge, the piece holding the old edge's middle took the old edge's stroke rows unchanged, so its dashes started again and it kept both arrowheads. It now takes them fitted to its part of the old edge, as Gap Options does. --- crates/engine/src/cmd/buildcmds.rs | 25 +++++++++++----- crates/engine/src/cmd/buildcmds/live_paint.rs | 18 +++++++----- crates/engine/src/tests/build.rs | 29 +++++++++++++++---- docs/mcp.md | 4 +-- 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 07b178bacc..63acde9722 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -779,18 +779,27 @@ fn repaint_from(g: &mut Node, old: &Node) { // An edge cut from the old one takes the old edge's dashes, arrowheads, width profile // and brush as they fall on its part of the old edge. if c.name.as_deref() == Some(EDGE_NAME) - && n.appearance.items.iter().any(depends_on_place) && let Some(old_path) = was.path_data() { - let tracks: Vec = old_path.subpaths.iter().map(Track::new).collect(); - let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path)); - let mut a = (*n.appearance).clone(); - for r in &mut a.items { - fit_to_edge(r, run); - } - n.appearance = a.into(); + fit_to_old_edge(&mut n, old_path); } *c = Arc::new(n); } } } + +/// Fit the stroke rows of edge `n`, which it took from an old edge whose outline is `old`, to the +/// part of the old edge that `n` runs, as [`fit_to_edge`] fits a source's rows to an edge cut from +/// it; `n` is reversed when it runs against the old edge. +fn fit_to_old_edge(n: &mut Node, old: &PathData) { + if !n.appearance.items.iter().any(depends_on_place) { + return; + } + let tracks: Vec = old.subpaths.iter().map(Track::new).collect(); + let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path)); + let mut a = (*n.appearance).clone(); + for r in &mut a.items { + fit_to_edge(r, run); + } + n.appearance = a.into(); +} diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 9c3c750996..ac07fb92a3 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,8 @@ use super::{ Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect, - Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json, - number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, fit_to_old_edge, ids_json, + is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, + switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -125,15 +126,15 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result check_live_paint_takes(d, roots)?; let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; // Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge, - // paint) for every edge. + // paint, outline) for every edge. let keep_faces: Vec<(Point, Carried)> = b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect(); - let keep_edges: Vec<(Point, Carried)> = b::edges(&old) + let keep_edges: Vec<(Point, Carried, PathData)> = b::edges(&old) .iter() .filter_map(|e| { let pd = e.path_data()?; let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e))) + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e), pd.clone())) }) .collect(); let mut sources = source_nodes(&old); @@ -161,12 +162,15 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result carried.onto(Arc::make_mut(f)); } } - for (pt, carried) in keep_edges { + // An edge that an added path cuts from an old one takes the old edge's rows fitted to its part. + for (pt, carried, outline) in keep_edges { if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) && let Some(ch) = g.children_mut() && let Some(e) = ch.iter_mut().find(|c| c.id == eid) { - carried.onto(Arc::make_mut(e)); + let e = Arc::make_mut(e); + carried.onto(e); + fit_to_old_edge(e, &outline); } } let mut merged = Beside::replacing(d, roots); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index f45644d951..e6a548647b 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2753,10 +2753,10 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { assert_eq!(edges, vec![rows]); } -/// When Gap Options cuts an edge in two, each piece keeps the edge's dashes going on from where it -/// starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush. +/// When Gap Options or Merge cuts an edge in two, each piece keeps the edge's dashes going on from +/// where it starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush. #[test] -fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { +fn live_paint_gap_options_and_merge_fit_an_edge_look_to_each_piece() { use vectorcraft_doc::{Arrowhead, Dash}; let mut s = session(); let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); @@ -2770,7 +2770,7 @@ fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { let rows = set_rows(&mut s, l, vec![row]); s.execute("select.set", &json!({"ids": [l.0, stub.0]})).unwrap(); let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); - let dashed = |s: &Session| { + let dashed = |s: &Session, g: NodeId| { let mut out: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&node(s, g)) .iter() .filter_map(|e| { @@ -2782,12 +2782,12 @@ fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { out.sort_by(|p, q| p.0.total_cmp(&q.0)); out }; - let before = dashed(&s); + let before = dashed(&s, g); assert_eq!(before.len(), 1, "nothing cuts the line yet"); assert_eq!(vec![AppearanceItem::Stroke(before[0].2.clone())], rows); let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap(); assert_eq!(r["closed"], json!(1)); - let pieces = dashed(&s); + let pieces = dashed(&s, g); let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect(); assert_eq!(ends, vec![(0.0, 50.0), (50.0, 100.0)], "the line is cut where the gap closes"); let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); @@ -2795,6 +2795,23 @@ fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); + // Merge: the piece of the edge that an added path cuts off, which takes the edge's look, gets + // it fitted to where it lies along the edge in the same way. + let mut s = session(); + let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); + set_rows(&mut s, l, rows.clone()); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let cut = line(&mut s, (25.0, 0.0), (25.0, 100.0)); + s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap(); + let pieces = dashed(&s, g); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect(); + assert_eq!(ends, vec![(0.0, 25.0), (25.0, 100.0)], "the added line cuts the edge"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 27.0], "the dashes go on along the line"); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); + assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); } /// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts diff --git a/docs/mcp.md b/docs/mcp.md index 823c8bc577..4a85f8b671 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3614,8 +3614,8 @@ already walks Image Trace. Short agent entry points: the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path. - When `livePaint.gapOptions` cuts an edge into pieces, each piece takes the edge's rows fitted to its part of the - edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which + When `livePaint.gapOptions` or `livePaint.merge` cuts an edge into pieces, a piece that takes the edge's rows takes + them fitted to its part of the edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which `livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried. Expand keeps those face fills. `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity