Shape Builder: uses a Live Paint group's filled faces and stroked edges

With a Live Paint group in the selection, the Shape Builder took every
visible path in the group as an input. Unfilled faces became regions of
their own and came back from a merge as invisible paths. An edge that
closes on itself, such as the outline of a single rectangle, became a
second shape over its face: a click on the face gave the merged shape
the edge's look, a stroke with no fill.

The Shape Builder now takes the group's visible filled faces as shapes
and its visible stroked edges as open lines; a closed edge is opened at
its first anchor. A merge replaces the group with the merged shape, the
filled faces it did not use and the edges, except the edges between
merged faces, as it does for other selected paths and lines. An area
that the edges enclose around an unfilled face is still a region. The
Live Paint commands still collect every face and edge.
This commit is contained in:
dotCooCoo
2026-10-24 23:26:49 +07:00
committed by bflatastic
parent 0921b8629e
commit 62a9c5e240
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;
}