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