From 4d4acd97c3429fca67d4a4507fd9fef14de6fa98 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:49:39 -0800 Subject: [PATCH] Live Paint: a loop cut from a self-crossing closed path keeps no brush A closed path that crosses itself is cut into closed loops. The loop holding the path's first anchor counted as the whole path, so it kept the path's width profile and brush. A closed edge now counts as the whole path only when it is as long as the path. --- crates/engine/src/cmd/buildcmds.rs | 6 ++++-- crates/engine/src/tests/build.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 7c404571a5..07b178bacc 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -576,8 +576,9 @@ impl Track { /// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the /// edge's first segment places it, and its ends are taken where they lie that far along the source, /// so a path that passes a point twice is told apart. An edge that runs against the direction of its -/// source is reversed first, and a closed edge is started at the anchor where its source starts -/// (one with no anchor there does not count as running the whole subpath). `None` when the edge is +/// source is reversed first, and a closed edge as long as its closed source is started at the anchor +/// where its source starts (one with no anchor there, or a shorter loop cut from a source that +/// crosses itself, does not count as running the whole subpath). `None` when the edge is /// not a single piece found on the source. fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> { use kurbo::{ParamCurve, ParamCurveArclen}; @@ -630,6 +631,7 @@ fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, b // Start it at the anchor where the source starts, so settings that follow the path begin there. let start = track.segs.first().map(|s| s.start())?; let moved = edge_closed + && (length - track.len()).abs() <= ON_SOURCE.max(track.len() * 1e-4) && edge.subpaths.first_mut().is_some_and(|sp| match sp.anchors.iter().position(|a| a.p.distance(start) <= ON_SOURCE) { Some(k) => { sp.anchors.rotate_left(k); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index e503dfe2fc..f45644d951 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2830,6 +2830,22 @@ fn live_paint_make_starts_a_closed_edge_where_its_path_starts() { assert_eq!(e.appearance.items, rows, "clockwise {clockwise}, start {start}"); } } + // A closed path that crosses itself is cut into loops, and the loop that holds the path's + // first anchor is still only part of the path: it keeps no brush. + let mut s = session(); + let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y})); + let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.brush = Some("Charcoal".into()); + }); + set_rows(&mut s, z, vec![row]); + s.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let edges = vectorcraft_tools::builder::edges(&gn); + assert!(edges.len() > 1, "the crossing cuts the path, got {} edge", edges.len()); + assert!(edges.iter().all(|e| e.appearance.stroke().is_some_and(|st| st.brush.is_none())), "no loop is the whole path"); } /// When Make cuts a stroke whose only setting that depends on direction is a gradient along or