Live Paint: Merge keeps a painted edge's look on every piece an added path cuts

Merge carried each old edge's paint only to the new edge nearest the old
edge's middle, so when an added path cut a repainted edge, the other
piece went back to its source's stroke. Every new edge whose middle lies
on an old edge now takes that edge's paint, with its rows fitted to its
part of the edge, as Gap Options does.
This commit is contained in:
dotCooCoo
2026-11-14 14:56:41 -08:00
parent 09110ce7bf
commit de5a46a2ab
3 changed files with 47 additions and 21 deletions
+24 -19
View File
@@ -85,6 +85,9 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
/// How far, in points, the middle of an edge Merge makes may be from the old edge it lies on.
const ON_OLD_EDGE: f64 = 1e-3;
/// What Merge carries from a face or edge to the part it makes again in its place, another path:
/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep
/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind.
@@ -125,18 +128,11 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
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, outline) for every edge.
// Paint to carry over: (point inside face, paint) for faces that draw; edges are matched to the
// old edges below.
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, 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), pd.clone()))
})
.collect();
let old_parts = old.clone();
let mut sources = source_nodes(&old);
for r in roots {
if *r != target {
@@ -162,15 +158,24 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
carried.onto(Arc::make_mut(f));
}
}
// 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)
{
let e = Arc::make_mut(e);
carried.onto(e);
fit_to_old_edge(e, &outline);
// Each edge whose middle lies on an old edge, every piece of an edge an added path cuts
// included, takes the old edge's paint, with its rows fitted to its part of the old edge.
if let Some(ch) = g.children_mut() {
for c in ch.iter_mut().filter(|c| c.name.as_deref() == Some(EDGE_NAME)) {
let Some(mid) = c
.path_data()
.and_then(|pd| pd.subpaths.first())
.filter(|sp| sp.segment_count() > 0)
.map(|sp| kurbo::ParamCurve::eval(&sp.segment(0), 0.5))
else {
continue;
};
let Some(was) = edge_near(&old_parts, mid, ON_OLD_EDGE) else { continue };
let e = Arc::make_mut(c);
Carried::of(was).onto(e);
if let Some(outline) = was.path_data() {
fit_to_old_edge(e, outline);
}
}
}
let mut merged = Beside::replacing(d, roots);
+21
View File
@@ -2812,6 +2812,27 @@ fn live_paint_gap_options_and_merge_fit_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");
// A repainted edge that Merge cuts keeps its paint on every piece.
let mut s = session();
let l = line(&mut s, (0.0, 50.0), (100.0, 50.0));
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 50], "color": "#ff0000", "width": 3})).unwrap();
let cut = line(&mut s, (50.0, 0.0), (50.0, 100.0));
s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap();
let gn = node(&s, g);
let red = Paint::solid(Color::from_hex("#ff0000").unwrap());
let mut on_line: Vec<(f64, bool)> = vectorcraft_tools::builder::edges(&gn)
.iter()
.filter_map(|e| {
let sp = e.path_data()?.subpaths.first()?;
let (a, z) = (sp.anchors.first()?.p, sp.anchors.last()?.p);
((a.y - 50.0).abs() < 1e-6 && (z.y - 50.0).abs() < 1e-6)
.then(|| ((a.x.min(z.x) * 1e3).round() / 1e3, e.appearance.stroke().is_some_and(|st| st.paint == red)))
})
.collect();
on_line.sort_by(|p, q| p.0.total_cmp(&q.0));
assert_eq!(on_line, vec![(0.0, true), (50.0, true)], "both pieces keep the edge's paint");
}
/// 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` 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
When `livePaint.gapOptions` or `livePaint.merge` cuts an edge into pieces, each piece takes the edge's paint, with
its 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
`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