fix: Pathfinder results keep the source's name and SVG id (#1293)

This commit is contained in:
Ikki Nishihara
2026-10-12 15:17:01 +07:00
committed by bflatastic
parent 18e538ec94
commit c85570c8b2
6 changed files with 183 additions and 12 deletions
+15 -5
View File
@@ -310,10 +310,11 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
let label = pf_label(op);
let ids = s.edit(label, |d, sel| {
// Build the stack (back → front) with a style per entry.
// Build the stack (back → front); names are separate from paint/style identity.
let mut styles: Vec<Style> = vec![];
let mut shapes: Vec<po::Shape> = vec![];
let mut push = |mut path: PathData, rule: FillRule, st: Style| {
let mut names: Vec<Option<String>> = vec![];
let mut push = |mut path: PathData, rule: FillRule, st: Style, name: Option<String>| {
if path.is_empty() {
return;
}
@@ -332,6 +333,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
}
};
shapes.push(po::Shape::new(path, rule, key as u64));
names.push(name);
};
for id in &roots {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
@@ -347,11 +349,12 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
// A group acts as one shape (the union of its leaves) with its top leaf's style.
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
push(po::unite_all(&refs), FillRule::NonZero, style_of(top));
let name = n.name.clone().or_else(|| lv.iter().find_map(|l| l.name.clone()));
push(po::unite_all(&refs), FillRule::NonZero, style_of(top), name);
} else {
for l in &lv {
if let Some((p, r)) = node_path(l) {
push(p, r, style_of(l));
push(p, r, style_of(l), n.name.clone().or_else(|| l.name.clone()));
}
}
}
@@ -371,7 +374,14 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
let r =
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
let style = styles.get(r.key as usize).ok_or_else(|| EngineError::Other(format!("{label}: lost track of a shape's style")))?;
shape_node_styled(d, r.path, style)
let mut n = shape_node_styled(d, r.path, style);
n.name = match op {
// A subtraction keeps its surviving source's identity, even when unnamed.
PathfinderOp::MinusFront => names.first().cloned().flatten(),
PathfinderOp::MinusBack => names.last().cloned().flatten(),
_ => names.into_iter().flatten().next(),
};
n
} else {
let mut children = vec![];
for r in results {
+155
View File
@@ -205,6 +205,161 @@ fn unite_with_group() {
assert!((area_of(&s, r[0]) - (100.0 + 100.0 + 200.0 - 25.0 - 25.0)).abs() < 1.0);
}
fn name_sources(s: &mut Session, a: NodeId, b: NodeId, names: [Option<&str>; 2]) {
s.edit("Name sources", |d, _| {
for (id, name) in [(a, names[0]), (b, names[1])] {
d.node_mut(id).unwrap().name = name.map(str::to_owned);
}
Ok(())
})
.unwrap();
}
fn named_nodes<'a>(n: &'a vectorcraft_doc::Node, name: &str, out: &mut Vec<&'a vectorcraft_doc::Node>) {
if n.name.as_deref() == Some(name) {
out.push(n);
}
if let Some(children) = n.children() {
for c in children {
named_nodes(c, name, out);
}
}
}
fn check_svg_name(s: &Session, id: NodeId, name: &str) {
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
assert_eq!(svg.matches(&format!("id=\"{name}\"")).count(), 1, "{svg}");
let imported = vectorcraft_svg::import(&svg).unwrap();
let mut matches = vec![];
for layer in &imported.layers {
named_nodes(layer, name, &mut matches);
}
assert_eq!(matches.len(), 1, "{name}: {svg}");
assert!(close(matches[0].geometric_bounds().unwrap(), bounds(s, id), 1e-2));
assert_eq!(matches[0].appearance.fill_paint(), node(s, id).appearance.fill_paint());
}
#[test]
fn pathfinder_shape_names_follow_paint_order_without_changing_output_or_undo() {
for op in ["minusFront", "minusBack", "unite", "intersect", "exclude"] {
let mut s = session();
let (a, b) = two_rects(&mut s);
let untouched = rect(&mut s, 300.0, 300.0, 30.0, 20.0);
name_sources(&mut s, a, b, [Some("base-plate"), Some("blade")]);
s.edit("Source opacity and blend", |d, _| {
d.node_mut(a).unwrap().opacity = 0.4;
d.node_mut(b).unwrap().opacity = 0.8;
d.node_mut(b).unwrap().blend = vectorcraft_color::BlendMode::Multiply;
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let cmd = format!("object.pathfinder.{op}");
let id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
let expected = if op == "minusBack" { "blade" } else { "base-plate" };
let mut result = node(&s, id);
assert_eq!(result.name.as_deref(), Some(expected), "{op}");
assert_eq!(node(&s, untouched), *before.node(untouched).unwrap());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1);
check_svg_name(&s, id, expected);
let after = s.doc().unwrap().doc.clone();
let after_selection = s.doc().unwrap().selection.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before);
assert_eq!(s.doc().unwrap().selection, selection);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *after);
assert_eq!(s.doc().unwrap().selection, after_selection);
s.execute("edit.undo", &json!({})).unwrap();
name_sources(&mut s, a, b, [None, None]);
let bare_id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
result.name = None;
assert_eq!(result, node(&s, bare_id), "names must not change geometry or appearance: {op}");
}
}
#[test]
fn pathfinder_shape_names_do_not_borrow_a_cutters_name_for_an_unnamed_source() {
for names in [[None, Some("blade")], [Some("base-plate"), None], [None, None]] {
for op in ["minusFront", "minusBack", "unite", "intersect", "exclude"] {
let mut s = session();
let (a, b) = two_rects(&mut s);
name_sources(&mut s, a, b, names);
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
let expected = match op {
"minusFront" => names[0],
"minusBack" => names[1],
_ => names[0].or(names[1]),
};
let id = ids(&s.execute(&format!("object.pathfinder.{op}"), &json!({})).unwrap())[0];
assert_eq!(node(&s, id).name.as_deref(), expected, "{op}: {names:?}");
}
}
}
#[test]
fn pathfinder_shape_names_keep_group_and_compound_identity_once() {
for (compound, count, root_name) in
[(false, 1, Some("root-identity")), (false, 2, Some("root-identity")), (true, 2, Some("root-identity")), (false, 2, None)]
{
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
let mut children = vec![a.0];
if count == 2 {
children.push(rect(&mut s, 60.0, 0.0, 40.0, 100.0).0);
}
s.execute("select.set", &json!({"ids": children})).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.edit("Name grouped source", |d, _| {
d.node_mut(a).unwrap().name = Some("leaf-identity".into());
let appearance = d.node(a).unwrap().appearance.clone();
let n = d.node_mut(group).unwrap();
n.name = root_name.map(str::to_owned);
if compound {
n.kind = NodeKind::Compound { children: n.children().unwrap().to_vec(), rule: FillRule::NonZero };
n.appearance = appearance;
}
Ok(())
})
.unwrap();
let cutter = rect(&mut s, 35.0, -10.0, 30.0, 120.0);
name_sources(&mut s, group, cutter, [root_name, Some("blade")]);
s.execute("select.set", &json!({"ids": [cutter.0, group.0]})).unwrap();
let id = ids(&s.execute("object.pathfinder.minusFront", &json!({})).unwrap())[0];
let n = node(&s, id);
let expected = root_name.unwrap_or("leaf-identity");
assert_eq!(n.name.as_deref(), Some(expected));
assert!((area_of(&s, id) - 3500.0 * f64::from(count)).abs() < 1.0);
assert!(n.children().is_none_or(|children| children.iter().all(|c| c.name.is_none())));
check_svg_name(&s, id, expected);
}
}
#[test]
fn pathfinder_fragment_names_do_not_change_style_merging() {
for op in ["divide", "trim", "merge", "crop", "outline"] {
let mut s = session();
let (a, b) = two_rects(&mut s);
set_fill(&mut s, a, Color::rgb(0.0, 0.0, 1.0));
name_sources(&mut s, a, b, [Some("base-plate"), Some("blade")]);
let cmd = format!("object.pathfinder.{op}");
let id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
let result = node(&s, id);
assert!(result.name.is_none());
assert!(result.children().unwrap().iter().all(|c| c.name.is_none()));
if op == "merge" {
assert_eq!(result.children().unwrap().len(), 1);
}
s.execute("edit.undo", &json!({})).unwrap();
name_sources(&mut s, a, b, [None, None]);
let bare_id = ids(&s.execute(&cmd, &json!({})).unwrap())[0];
assert_eq!(result, node(&s, bare_id), "{op}");
}
}
#[test]
fn offset_rect_grows_bounds() {
let mut s = session();