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.
This commit is contained in:
dotCooCoo
2026-11-14 13:15:07 -08:00
parent 4d4acd97c3
commit 09110ce7bf
4 changed files with 53 additions and 23 deletions
+17 -8
View File
@@ -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<Track> = 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<Track> = 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();
}
+11 -7
View File
@@ -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);
+23 -6
View File
@@ -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<f64> = 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<f64> = 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
+2 -2
View File
@@ -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