Merge pull request #7377 from dotCooCoo/fix/symbol-expand-reset-bbox

Symbols: Break Link and Expand keep an instance's bounding box
This commit is contained in:
BFlat
2026-11-14 13:42:23 +07:00
committed by GitHub
10 changed files with 288 additions and 10 deletions
+119
View File
@@ -560,6 +560,125 @@ fn reset_bounding_box_then_release_intertwine() {
reset_then_release("object.intertwine.make", "object.intertwine.release");
}
/// A symbol of two rectangles, the first turned `inner` degrees before the symbol is made, placed
/// as an instance that is then turned 20°.
fn turned_instance(inner: f64) -> (Session, NodeId) {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let b = rect(&mut s, 150.0, 220.0, 100.0, 50.0);
if inner != 0.0 {
sel(&mut s, &[a]);
s.execute("object.rotate", &json!({"angle": inner})).unwrap();
}
sel(&mut s, &[a, b]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
sel(&mut s, &[inst]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
assert!(near(angle(&s, inst), 20.0));
(s, inst)
}
/// Break Link and Expand with the parameters that turn instance `inst` into its art.
fn expand_instance(s: &mut Session, command: &str, inst: NodeId) {
sel(s, &[inst]);
let params = if command == "symbol.breakLink" { json!({"ids": [inst.0]}) } else { json!({"object": true, "fill": false, "stroke": false}) };
s.execute(command, &params).unwrap();
assert!(s.doc().unwrap().doc.node(inst).is_none_or(|n| !matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })), "{command}");
}
/// The angles of the objects inside the document's only top-level object, that object included.
fn art_angles(s: &Session) -> Vec<f64> {
let d = &s.doc().unwrap().doc;
let layer = d.layers.first().unwrap();
let top = layer.children().unwrap();
assert_eq!(top.len(), 1);
let mut out = vec![];
subtree_angles(&top[0], &mut out);
out
}
#[test]
fn reset_bounding_box_then_expand_symbol_instance() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(0.0);
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
assert_eq!(angle(&s, inst), 0.0);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
s.execute("select.all", &json!({})).unwrap();
assert_eq!(selection_box(&s).angle, 0.0, "{command}");
}
}
#[test]
fn expanding_a_turned_symbol_instance_keeps_its_angle() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(0.0);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 20.0)), "{command}: {angles:?}");
}
}
#[test]
fn reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(30.0);
s.execute("object.resetBoundingBox", &json!({})).unwrap();
expand_instance(&mut s, command, inst);
// The turned rectangle keeps the 30° it had in the symbol; the rest stand square.
let mut angles = art_angles(&s);
angles.sort_by(f64::total_cmp);
assert!(near(*angles.last().unwrap(), 30.0) && angles[..angles.len() - 1].iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
}
}
/// The group that an instance's opacity adds around the art of a one-object symbol stands as the
/// instance's box does, turned or reset.
#[test]
fn expanding_a_faded_symbol_instance_gives_its_group_the_instance_box() {
for command in ["symbol.breakLink", "object.expand"] {
for reset in [false, true] {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
sel(&mut s, &[a]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "One"})).unwrap());
sel(&mut s, &[inst]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
s.execute("object.setProps", &json!({"ids": [inst.0], "opacity": 50})).unwrap();
if reset {
s.execute("object.resetBoundingBox", &json!({})).unwrap();
}
expand_instance(&mut s, command, inst);
let want = if reset { 0.0 } else { 20.0 };
let angles = art_angles(&s);
assert!(angles.len() == 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
}
}
}
/// A mirrored instance: turned 170° and then reflected, its box stands 180° from the box its
/// transform gives in one step. The art takes the instance's box, and stands square after a reset.
#[test]
fn expanding_a_reflected_symbol_instance_keeps_its_box() {
for command in ["symbol.breakLink", "object.expand"] {
for reset in [false, true] {
let (mut s, inst) = turned_instance(0.0);
s.execute("object.rotate", &json!({"angle": 150})).unwrap();
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
if reset {
s.execute("object.resetBoundingBox", &json!({})).unwrap();
}
let want = angle(&s, inst);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
}
}
}
#[test]
fn reset_bounding_box_then_release_linked_opacity_mask() {
let mut s = session();
@@ -20,9 +20,18 @@ fn close(a: Rect, b: Rect) -> bool {
/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made
/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size.
fn panel_symbol() -> (Session, NodeId) {
panel_symbol_filled(None)
}
/// [`panel_symbol`] with the frame filled with `fill` when there is one.
fn panel_symbol_filled(fill: Option<vectorcraft_color::Paint>) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)];
if let Some(fill) = fill {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
doc.node_mut(ids[0]).unwrap().appearance = vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0).into();
}
s.select(|_, sel| sel.set(ids)).unwrap();
let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap();
assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25]));
@@ -350,3 +359,39 @@ fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() {
assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole");
}
}
/// [`panel_symbol`] with a gradient on the frame, so that the frame is drawn in a clip group for
/// each cell, its instance resized to 200 × 150 and turned 20°.
fn turned_gradient_panel() -> (Session, NodeId) {
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
let (mut s, id) = panel_symbol_filled(Some(gradient));
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
(s, id)
}
/// The bounding box angles of the objects under the art that replaced an instance, and whether
/// that art holds a clip group.
fn expanded_angles(s: &Session, art: NodeId) -> (Vec<f64>, bool) {
let (mut angles, mut clips) = (vec![], false);
s.doc().unwrap().doc.node(art).unwrap().walk(&mut |c| {
angles.push(c.bbox_angle);
clips |= matches!(c.kind, NodeKind::Group { clip: true, .. });
});
(angles, clips)
}
#[test]
fn break_link_turns_the_cells_of_a_sliced_instance_with_its_box() {
for reset in [false, true] {
let (mut s, _) = turned_gradient_panel();
if reset {
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
}
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
let (angles, clips) = expanded_angles(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert!(clips, "the frame is drawn in clip groups");
let want = if reset { 0.0 } else { 20.0 };
assert!(angles.iter().all(|a| (a - want).abs() < 1e-6), "reset {reset}: {angles:?}");
}
}