mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
Shape Builder: a merge replaces only the paths it uses, where they are
Squashed from #4094 onto main.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
//! and the traced filled paths above it, mapped with the image's transform. Expand drops the
|
||||
//! image (a plain group of paths remains); Release puts the image back in place of the group.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -51,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -301,6 +302,89 @@ fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option<FillRule>)
|
||||
n
|
||||
}
|
||||
|
||||
/// Put each list of `outs` in place of the leaf of `leaves` at the same index (none: the leaf stays
|
||||
/// as it is), then remove the groups under `roots` that this leaves empty. A Live Paint or Shaper
|
||||
/// group (the outermost one under the root) is replaced as a whole, by the lists of its leaves,
|
||||
/// with a copy of each leaf that stays and of each hidden or locked object in it. Other objects
|
||||
/// under the roots, such as type, hidden or locked objects and clipping paths, stay where they are. Returns what to select: each root
|
||||
/// still there, or what went in its place.
|
||||
fn replace_leaves(d: &mut Document, roots: &[NodeId], leaves: &[Node], outs: Vec<Option<Vec<Node>>>) -> Result<Vec<NodeId>> {
|
||||
let listed: HashSet<NodeId> = roots.iter().copied().collect();
|
||||
let paths = d.index_paths(leaves.iter().map(|l| l.id));
|
||||
// Each leaf's unit (the leaf, or the group replaced as a whole) and the groups from the unit's
|
||||
// parent up to its root, innermost first.
|
||||
let mut places: Vec<(NodeId, Vec<NodeId>)> = vec![];
|
||||
for leaf in leaves {
|
||||
let anc = paths.get(&leaf.id).and_then(|p| d.ancestry_at(p)).unwrap_or_default();
|
||||
let below = anc.iter().position(|a| listed.contains(a)).and_then(|r| anc.get(r..)).unwrap_or_default();
|
||||
let whole = below.iter().position(|a| d.node(*a).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()));
|
||||
let at = whole.unwrap_or(below.len().saturating_sub(1));
|
||||
let unit = below.get(at).copied().unwrap_or(leaf.id);
|
||||
places.push((unit, below.get(..at).unwrap_or_default().iter().rev().copied().collect()));
|
||||
}
|
||||
// (unit, groups above it, what goes in place of each of its leaves, whether it changes); a
|
||||
// unit's leaves are next to each other, in paint order.
|
||||
type Unit = (NodeId, Vec<NodeId>, Vec<(NodeId, Vec<Node>)>, bool);
|
||||
let mut units: Vec<Unit> = vec![];
|
||||
for ((leaf, out), (unit, above)) in leaves.iter().zip(outs).zip(places) {
|
||||
let changed = out.is_some();
|
||||
let list = match out {
|
||||
Some(list) => list,
|
||||
None if unit != leaf.id => vec![d.reid(leaf)],
|
||||
None => vec![],
|
||||
};
|
||||
match units.last_mut() {
|
||||
Some(u) if u.0 == unit => {
|
||||
u.2.push((leaf.id, list));
|
||||
u.3 |= changed;
|
||||
}
|
||||
_ => units.push((unit, above, vec![(leaf.id, list)], changed)),
|
||||
}
|
||||
}
|
||||
units.retain(|u| u.3);
|
||||
let ids: Vec<NodeId> = units.iter().map(|u| u.0).collect();
|
||||
let mut replaced = Beside::replacing(d, &ids);
|
||||
let mut put: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
|
||||
let mut emptied = vec![];
|
||||
for (unit, above, lists, _) in units {
|
||||
// A group replaced as a whole keeps a copy of each hidden or locked object in it, where it was.
|
||||
let members: Vec<Node> = d.node(unit).map(|n| b::builder_members(n).into_iter().cloned().collect()).unwrap_or_default();
|
||||
let mut at: HashMap<NodeId, usize> = HashMap::new();
|
||||
for (k, l) in lists.iter().enumerate() {
|
||||
at.entry(l.0).or_insert(k);
|
||||
}
|
||||
let mut lists: Vec<Option<Vec<Node>>> = lists.into_iter().map(|l| Some(l.1)).collect();
|
||||
let mut list = vec![];
|
||||
for m in &members {
|
||||
if let Some(l) = at.remove(&m.id).and_then(|k| lists.get_mut(k)).and_then(Option::take) {
|
||||
list.extend(l);
|
||||
} else if (m.locked || !m.visible) && m.id != unit {
|
||||
list.push(d.reid(m));
|
||||
}
|
||||
}
|
||||
list.extend(lists.into_iter().flatten().flatten());
|
||||
if list.is_empty() {
|
||||
emptied.push(above);
|
||||
}
|
||||
put.insert(unit, list.iter().map(|n| n.id).collect());
|
||||
replaced.put(d, unit, list)?;
|
||||
}
|
||||
replaced.finish(d)?;
|
||||
// A group goes only when nothing is left in it.
|
||||
for above in emptied {
|
||||
for g in above {
|
||||
match d.node(g) {
|
||||
None => continue,
|
||||
Some(n) if !n.is_layer() && n.children().is_some_and(|c| c.is_empty()) => {
|
||||
d.remove(g)?;
|
||||
}
|
||||
Some(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(roots.iter().flat_map(|r| put.get(r).cloned().unwrap_or_else(|| d.node(*r).map(|_| vec![*r]).unwrap_or_default())).collect())
|
||||
}
|
||||
|
||||
// ---------- Live Paint ----------
|
||||
|
||||
/// The source nodes a root contributes (a Live Paint group contributes its own sources).
|
||||
|
||||
@@ -112,22 +112,24 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let covered =
|
||||
if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) };
|
||||
let style_src = covered.or(map.shapes.len().checked_sub(1));
|
||||
let mut new_nodes: Vec<Node> = vec![];
|
||||
// What goes in place of each object the map was made from (none: it stays as it is).
|
||||
let mut outs: Vec<Option<Vec<Node>>> = vec![];
|
||||
let mut merged_id = None;
|
||||
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
||||
let mut out = None;
|
||||
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
|
||||
if !pieces.is_empty() {
|
||||
// An open path loses the pieces deleted and those between two merged (or deleted) regions.
|
||||
let keep: Vec<usize> =
|
||||
pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect();
|
||||
if keep.len() == pieces.len() {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else {
|
||||
if keep.len() != pieces.len() {
|
||||
let mut kept = vec![];
|
||||
for k in keep {
|
||||
if let Some((l, _)) = map.lines.get(k) {
|
||||
new_nodes.push(remnant(d, leaf, l.path.clone(), None));
|
||||
kept.push(remnant(d, leaf, l.path.clone(), None));
|
||||
}
|
||||
}
|
||||
out = Some(kept);
|
||||
}
|
||||
} else {
|
||||
// A shape loses the regions deleted or merged, then the edges deleted (each one on
|
||||
@@ -160,23 +162,24 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cut = true;
|
||||
}
|
||||
}
|
||||
if !in_union && !cut {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else if !rest.is_empty() {
|
||||
if in_union || cut {
|
||||
// What is left of its own outline keeps its fill rule; united faces fill with non-zero.
|
||||
new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero)));
|
||||
out = Some(if rest.is_empty() { vec![] } else { vec![remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))] });
|
||||
}
|
||||
}
|
||||
// The merged shape goes in place of the object it takes its look from, so it stays in
|
||||
// that object's group (a clip group too).
|
||||
if !erase && !union.is_empty() && Some(i) == style_src {
|
||||
let mut m = shape_node(d, union.clone(), Some(leaf));
|
||||
if let Some(f) = &fill {
|
||||
m.appearance.set_fill(f.clone());
|
||||
}
|
||||
merged_id = Some(m.id);
|
||||
new_nodes.push(m);
|
||||
out.get_or_insert_with(|| vec![leaf.clone()]).push(m);
|
||||
}
|
||||
outs.push(out);
|
||||
}
|
||||
let new_ids = replace_roots(d, &roots, new_nodes)?;
|
||||
let new_ids = replace_leaves(d, &roots, &leaves, outs)?;
|
||||
sel.set(new_ids.iter().copied());
|
||||
Ok((new_ids, merged_id, touched, None))
|
||||
})?;
|
||||
|
||||
@@ -297,6 +297,85 @@ fn shape_builder_fill_option_and_untouched_shapes_kept() {
|
||||
assert_eq!(outline(&node(&s, sq)).0.anchor_count(), 4);
|
||||
}
|
||||
|
||||
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
|
||||
s.doc().unwrap().doc.parent_of(id)
|
||||
}
|
||||
|
||||
/// The merge replaces only the shapes it uses: a selected type object stays as it was.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_selected_type_object() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 300, "y": 300, "text": "Label"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, t.0]})).unwrap();
|
||||
let before = node(&s, t);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [125, 50]]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(t), Some(&before));
|
||||
assert_eq!(layer_children(&s), 2, "the merged shape and the type");
|
||||
assert!(ids(&r).contains(&t), "{r}");
|
||||
}
|
||||
|
||||
/// A hidden member of a selected group stays hidden in its group, with the merged shape.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_hidden_group_member() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let c = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.hide", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [125, 50]]})).unwrap();
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert_eq!((parent(&s, c), node(&s, c).visible, parent(&s, merged)), (Some(g), false, Some(g)));
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A locked member of a selected group is neither cut nor moved, and does not cut the others.
|
||||
#[test]
|
||||
fn shape_builder_merge_leaves_a_locked_group_member() {
|
||||
let mut s = session();
|
||||
let (_, b) = two(&mut s);
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let before = node(&s, b);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(b), Some(&before));
|
||||
assert_eq!(parent(&s, b), Some(g));
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert!((area(&s, merged) - 10000.0).abs() < 1.0, "A whole: {}", area(&s, merged));
|
||||
}
|
||||
|
||||
/// In a clip group, the clipping path stays and the merged shape stays inside the group.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_clip_groups_clipping_path() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let m = rect(&mut s, 0.0, 0.0, 120.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, m.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"ids": [g.0], "points": [[25, 50], [125, 50]]})).unwrap();
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let NodeKind::Group { children, clip: true } = &gn.kind else { panic!("a clip group: {:?}", gn.kind) };
|
||||
assert_eq!(children.first().map(|c| c.id), Some(m), "the clipping path");
|
||||
assert_eq!(parent(&s, merged), Some(g));
|
||||
}
|
||||
|
||||
/// A group goes when deleting regions leaves it empty, and stays while anything is left in it.
|
||||
#[test]
|
||||
fn shape_builder_erase_removes_only_a_group_it_empties() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]], "erase": true})).unwrap();
|
||||
assert_eq!((ids(&r), node(&s, g).children().unwrap().len()), (vec![g], 2));
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[60, 50], [140, 50]], "erase": true})).unwrap();
|
||||
assert!(ids(&r).is_empty() && s.doc().unwrap().doc.node(g).is_none(), "{r}");
|
||||
assert_eq!(layer_children(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_miss_is_an_error_and_undo_restores() {
|
||||
let mut s = session();
|
||||
@@ -2411,11 +2490,77 @@ fn shape_builder_keeps_the_edges_of_an_unfilled_live_paint_group() {
|
||||
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 before = node(&s, g);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap();
|
||||
let out = ids(&r);
|
||||
// The merge does not use the group, so it stays as it was, with every edge: the outline of
|
||||
// 0..150 × 0..100 and the edges at x = 50 and 100.
|
||||
assert_eq!(s.doc().unwrap().doc.node(g), Some(&before));
|
||||
assert!(ids(&r).contains(&g), "{r}");
|
||||
let edges: Vec<NodeId> = vectorcraft_tools::builder::edges(&before).iter().map(|e| e.id).collect();
|
||||
assert!((stroked_length(&s, &edges) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &edges));
|
||||
}
|
||||
|
||||
/// A locked face of a Live Paint group the merge replaces is not a region, and a copy of it stays,
|
||||
/// still locked.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_locked_live_paint_face() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let locked = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(125.0, 50.0)).unwrap().id;
|
||||
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
assert!(map["regions"].as_array().unwrap().iter().all(|r| !r["sources"].as_array().unwrap().iter().any(|i| *i == locked.0)), "{map}");
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 2, "{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));
|
||||
let kept: Vec<Node> = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| n.locked).collect();
|
||||
assert_eq!(kept.len(), 1, "{r}");
|
||||
assert!((area(&s, kept[0].id) - 5000.0).abs() < 1.0, "{}", area(&s, kept[0].id));
|
||||
}
|
||||
|
||||
/// A hidden face of a Live Paint group the merge replaces is not a region, and a copy of it stays,
|
||||
/// still hidden.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_hidden_live_paint_face() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(125.0, 50.0)).unwrap().id;
|
||||
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 2, "{r}");
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
let kept: Vec<Node> = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| !n.visible).collect();
|
||||
assert_eq!(kept.len(), 1, "{r}");
|
||||
assert!((area(&s, kept[0].id) - 5000.0).abs() < 1.0, "{}", area(&s, kept[0].id));
|
||||
}
|
||||
|
||||
/// A locked face without a fill and a locked edge without a stroke, in a Live Paint group the
|
||||
/// merge replaces, each keep a copy, still locked.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_unpainted_locked_live_paint_members() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [125, 50], "none": true})).unwrap();
|
||||
let n = node(&s, g);
|
||||
let face = vectorcraft_tools::builder::face_at(&n, Point::new(125.0, 50.0)).unwrap().id;
|
||||
let edge = vectorcraft_tools::builder::visible_edge_near(&n, Point::new(150.0, 50.0), 4.0).unwrap().id;
|
||||
let names = (node(&s, face).name, node(&s, edge).name);
|
||||
assert_ne!(names.0, names.1);
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(edge).unwrap().appearance.set_stroke(Paint::None);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.lock", &json!({"ids": [face.0, edge.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
let kept: Vec<Node> = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| n.locked).collect();
|
||||
let faces = kept.iter().filter(|n| n.name == names.0 && n.appearance.fill_paint().is_none() && (area(&s, n.id) - 5000.0).abs() < 1.0).count();
|
||||
let edges = kept.iter().filter(|n| n.name == names.1 && n.appearance.stroke_paint().is_none()).count();
|
||||
assert_eq!((kept.len(), faces, edges), (2, 1, 1), "{r}");
|
||||
}
|
||||
|
||||
/// A plain group whose name starts with "Live Paint" is not a Live Paint group: the Shape Builder
|
||||
@@ -2439,7 +2584,9 @@ fn shape_builder_takes_the_paths_of_a_plain_group_named_live_paint() {
|
||||
assert_eq!(map["regions"].as_array().unwrap().len(), 2, "{map}");
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap();
|
||||
let out = ids(&r);
|
||||
assert!(out.iter().any(|i| (area(&s, *i) - 10000.0).abs() < 1.0), "the group's rectangle is kept: {r}");
|
||||
// The merge keeps the unused group whole, so its rectangle is inside an id of the reply.
|
||||
let kept_area = |n: &Node| n.children().map_or_else(|| area(&s, n.id), |c| c.iter().map(|c| area(&s, c.id)).sum());
|
||||
assert!(out.iter().any(|i| (kept_area(&node(&s, *i)) - 10000.0).abs() < 1.0), "the group's rectangle is kept: {r}");
|
||||
// A stroke of the group's rectangle splits as a plain path's does.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
|
||||
@@ -38,9 +38,9 @@ use live_paint::LpHover;
|
||||
use live_paint::lp_color;
|
||||
pub use live_paint::{LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool};
|
||||
pub use lookup::{
|
||||
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, drawn_outlines, edge_near, edges, face_at, faces, is_live_paint,
|
||||
leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, sources,
|
||||
visible_edge_near, visible_face_at,
|
||||
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces,
|
||||
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
|
||||
sources, visible_edge_near, visible_face_at,
|
||||
};
|
||||
pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots};
|
||||
/// Small, Medium and Large gap lengths in points.
|
||||
|
||||
@@ -126,28 +126,48 @@ 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 {
|
||||
/// [`leaf_shapes`]; with `builder` (the Shape Builder's inputs), a Live Paint group contributes
|
||||
/// only its filled faces and its stroked edges, and locked objects contribute nothing.
|
||||
fn collect_leaves<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) {
|
||||
collect_members(n, builder, out);
|
||||
if builder {
|
||||
out.retain(|m| m.visible && !m.locked);
|
||||
}
|
||||
}
|
||||
|
||||
/// The leaves the Shape Builder takes from `n` ([`collect_leaves`]), in paint order, with each
|
||||
/// hidden or locked object it skips in its place.
|
||||
pub fn builder_members(n: &Node) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
collect_members(n, true, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`collect_leaves`] with, for `builder`, each hidden or locked object in place of what it holds.
|
||||
fn collect_members<'a>(n: &'a Node, builder: bool, out: &mut Vec<&'a Node>) {
|
||||
if !n.visible && !builder {
|
||||
return;
|
||||
}
|
||||
if builder && (!n.visible || n.locked) {
|
||||
out.push(n);
|
||||
return;
|
||||
}
|
||||
if n.extras().shaper.is_some() {
|
||||
for c in n.children().into_iter().flatten().skip(1) {
|
||||
collect_leaves(c, filled_faces, out);
|
||||
collect_members(c, builder, 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 { .. } if builder && is_live_paint(n) => {
|
||||
out.extend(faces(n).into_iter().filter(|f| !f.visible || f.locked || !f.appearance.fill_paint().is_none()).map(|f| &**f));
|
||||
out.extend(edges(n).into_iter().filter(|e| !e.visible || e.locked || !e.appearance.stroke_paint().is_none()).map(|e| &**e));
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children {
|
||||
collect_leaves(c, filled_faces, out);
|
||||
collect_members(c, builder, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -196,13 +216,15 @@ pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec<po::Shape>, V
|
||||
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>) {
|
||||
/// [`shapes_for`]; with `builder`, the leaves [`collect_leaves`] takes for the Shape Builder, and
|
||||
/// nothing from a root in a locked or hidden group or layer.
|
||||
fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], builder: bool) -> (Vec<po::Shape>, Vec<&'a Node>) {
|
||||
let mut lv = vec![];
|
||||
for id in roots {
|
||||
if let Some(n) = doc.node(*id) {
|
||||
collect_leaves(n, filled_faces, &mut lv);
|
||||
if let Some(n) = doc.node(*id)
|
||||
&& (!builder || doc.is_editable(*id))
|
||||
{
|
||||
collect_leaves(n, builder, &mut lv);
|
||||
}
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
@@ -224,7 +246,8 @@ fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Ve
|
||||
/// `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`. 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.
|
||||
/// and its edges without a stroke are left out. Locked objects, and objects in a locked group or
|
||||
/// layer, give nothing.
|
||||
pub 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_of(doc, roots, true);
|
||||
for (s, l) in shapes.iter_mut().zip(&leaves) {
|
||||
|
||||
Reference in New Issue
Block a user