mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user