Merge pull request #4042 from dotCooCoo/fix/shape-builder-live-paint-faces

Shape Builder: uses a Live Paint group's filled faces and stroked edges
This commit is contained in:
BFlat
2026-10-24 23:39:31 +07:00
committed by GitHub
2 changed files with 130 additions and 6 deletions
+99
View File
@@ -2100,6 +2100,105 @@ fn a_line_across_a_live_paint_shape_splits_its_face() {
assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap()));
}
/// The total length of the stroked paths among `out` that have no fill.
fn stroked_length(s: &Session, out: &[NodeId]) -> f64 {
out.iter()
.map(|i| node(s, *i))
.filter(|n| n.appearance.fill_paint().is_none() && !n.appearance.stroke_paint().is_none())
.map(|n| outline(&n).0.length())
.sum()
}
/// The Shape Builder takes a selected Live Paint group's filled faces as regions and its stroked
/// edges as lines; its unfilled faces are not regions. The merge replaces the group as it does a
/// path, and the edges keep their strokes, except those between the merged faces.
#[test]
fn shape_builder_uses_the_filled_faces_of_a_live_paint_group() {
let mut s = session();
let g = live(&mut s);
s.execute("livePaint.fill", &json!({"group": g.0, "point": [125, 50], "none": true})).unwrap();
let edges = vectorcraft_tools::builder::edges(&node(&s, g)).len();
let c = rect(&mut s, 160.0, 20.0, 100.0, 20.0);
set_fill(&mut s, c, Color::rgb(0.0, 1.0, 0.0));
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
let out = ids(&r);
let unfilled_faces =
out.iter().map(|i| node(&s, *i)).filter(|n| n.appearance.fill_paint().is_none() && n.appearance.stroke_paint().is_none()).count();
assert_eq!(
(map["regions"].as_array().unwrap().len(), map["lines"].as_array().unwrap().len() >= edges, unfilled_faces),
(4, true, 0),
"the two faces, the rectangle and the area the edges enclose; the edges as lines; no unfilled face comes back"
);
let enclosed: Vec<&Value> = map["regions"].as_array().unwrap().iter().filter(|r| r["sources"].as_array().unwrap().is_empty()).collect();
assert_eq!(enclosed.len(), 1, "{map}");
let bounds: Vec<f64> = enclosed[0]["bounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(bounds.iter().zip([100.0, 0.0, 150.0, 100.0]).all(|(a, b)| (a - b).abs() < 0.5), "{bounds:?}");
assert_eq!(r["regions"], 2);
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 10000.0).abs() < 1.0, "{}", area(&s, merged));
assert!(s.doc().unwrap().doc.node(g).is_none());
// The outline of 0..150 × 0..100 and the edge at x = 100 keep their strokes; the edge at x = 50
// between the merged faces is removed.
assert!((stroked_length(&s, &out) - 600.0).abs() < 1.0, "{}", stroked_length(&s, &out));
}
/// An edge that closes on itself is a line around its face, not a second shape over it.
#[test]
fn shape_builder_takes_a_closed_live_paint_edge_as_a_line() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap());
let group = node(&s, g);
let edges = vectorcraft_tools::builder::edges(&group);
assert_eq!(edges.len(), 1);
assert!(outline(edges[0]).0.subpaths.iter().any(|sp| sp.closed), "the edge closes on itself");
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
// A click on the face keeps its fill on the merged shape and the edge's stroke around it.
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50]]})).unwrap();
let out = ids(&r);
let merged = node(&s, NodeId(r["merged"].as_u64().unwrap()));
assert_eq!(merged.appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert!((stroked_length(&s, &out) - 400.0).abs() < 1.0, "{}", stroked_length(&s, &out));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let regions = map["regions"].as_array().unwrap();
assert_eq!((regions.len(), map["lines"].as_array().unwrap().len()), (1, 1), "{map}");
assert_eq!(regions[0]["sources"].as_array().unwrap().len(), 1, "{map}");
// Unfilled, the face is not a region, and the edge still encloses all of it, closing segment
// included.
s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50], "none": true})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let regions = map["regions"].as_array().unwrap();
assert_eq!(regions.len(), 1, "{map}");
assert!(regions[0]["sources"].as_array().unwrap().is_empty(), "{map}");
assert!((regions[0]["area"].as_f64().unwrap() - 10000.0).abs() < 1.0, "{map}");
}
/// A Live Paint group with no filled face keeps its stroked edges when another selected object is
/// merged.
#[test]
fn shape_builder_keeps_the_edges_of_an_unfilled_live_paint_group() {
let mut s = session();
let g = live(&mut s);
for x in [25, 75, 125] {
s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, 50], "none": true})).unwrap();
}
let c = rect(&mut s, 160.0, 20.0, 100.0, 20.0);
set_fill(&mut s, c, Color::rgb(0.0, 1.0, 0.0));
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap();
let out = ids(&r);
assert!(s.doc().unwrap().doc.node(g).is_none());
// Every edge of the group: the outline of 0..150 × 0..100 and the edges at x = 50 and 100.
assert!((stroked_length(&s, &out) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &out));
}
// ---------- Image Trace ----------
/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2.
+31 -6
View File
@@ -11,6 +11,7 @@
//! stroke only, open). Everything else in the document sees a plain group of paths, so rendering,
//! export, transforms and undo need no special cases.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, TryRecvError};
@@ -135,23 +136,33 @@ pub fn live_paint_at(doc: &Document, p: Point) -> Option<NodeId> {
}
/// Paths, compound paths (as one shape) of a subtree in paint order; guides, clipping paths and
/// hidden objects are skipped. Live Paint groups contribute their faces.
/// hidden objects are skipped. Live Paint groups contribute their faces and edges.
pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
collect_leaves(n, false, out);
}
/// [`leaf_shapes`]; with `filled_faces`, a Live Paint group contributes only its filled faces and
/// its stroked edges.
fn collect_leaves<'a>(n: &'a Node, filled_faces: bool, out: &mut Vec<&'a Node>) {
if !n.visible {
return;
}
if n.extras().shaper.is_some() {
for c in n.children().into_iter().flatten().skip(1) {
leaf_shapes(c, out);
collect_leaves(c, filled_faces, out);
}
return;
}
match &n.kind {
NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {}
NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n),
NodeKind::Group { .. } if filled_faces && is_live_paint(n) => {
out.extend(faces(n).into_iter().filter(|f| f.visible && !f.appearance.fill_paint().is_none()).map(|f| &**f));
out.extend(edges(n).into_iter().filter(|e| e.visible && !e.appearance.stroke_paint().is_none()).map(|e| &**e));
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children {
leaf_shapes(c, out);
collect_leaves(c, filled_faces, out);
}
}
_ => {}
@@ -197,10 +208,16 @@ pub fn drawn_outlines(doc: &Document, leaves: &[&Node]) -> Vec<PathData> {
/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index,
/// with the leaves they came from.
pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Shape>, Vec<&'a Node>) {
shapes_of(doc, roots, false)
}
/// [`shapes_for`]; with `filled_faces`, a Live Paint group contributes only its filled faces and
/// its stroked edges.
fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Vec<po::Shape>, Vec<&'a Node>) {
let mut lv = vec![];
for id in roots {
if let Some(n) = doc.node(*id) {
leaf_shapes(n, &mut lv);
collect_leaves(n, filled_faces, &mut lv);
}
}
let mut shapes = vec![];
@@ -220,10 +237,18 @@ pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Shape>, V
/// Shape Builder inputs: [`shapes_for`], with the open subpaths of filled paths closed where that
/// encloses an area (the fill bounds a region, as if an invisible edge closed it) when
/// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions
/// they cross, and so do filled open paths without `open_filled_as_closed`.
/// they cross, and so do filled open paths without `open_filled_as_closed`. A Live Paint group
/// contributes its filled faces as shapes and its stroked edges as open lines; its unfilled faces
/// and its edges without a stroke are left out.
fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec<po::Shape>, Vec<&'a Node>) {
let (mut shapes, leaves) = shapes_for(doc, roots);
let (mut shapes, leaves) = shapes_of(doc, roots, true);
for (s, l) in shapes.iter_mut().zip(&leaves) {
if named(l, EDGE_NAME) && doc.parent_of(l.id).and_then(|p| doc.node(p)).is_some_and(is_live_paint) {
// A closed edge opens at its first anchor and keeps its closing segment.
let start = BTreeSet::from([0]);
s.path.subpaths = s.path.subpaths.iter().flat_map(|sp| sp.cut_at(&start)).collect();
continue;
}
if !open_filled_as_closed || l.appearance.fill_paint().is_none() {
continue;
}