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
+1 -1
View File
@@ -226,7 +226,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Link to Symbol",
[],
None,
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
has_selection,
symbol_break_link
),
+11 -3
View File
@@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it
/// when the art has a mask of its own.
/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after
/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on
/// the art, or on a group around it when the art has a mask of its own; a group added around the
/// art takes the instance's box angle.
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
@@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
if let Some((color, amount)) = brush::stain(inst) {
brush::tint_node(&mut art, &color, amount);
}
let art = d.place_symbol_art(symbol, &art, *xf)?;
let mut art = d.place_symbol_art(symbol, &art, *xf)?;
let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf);
if turn != 0.0 {
art.turn_boxes(turn);
}
let mut art = d.reid(&art);
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
if !matches!(art.kind, NodeKind::Group { .. }) {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
art.bbox_angle = inst.bbox_angle;
}
art.opacity *= inst.opacity;
art.blend = inst.blend;
@@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
if art.extras().mask.is_some() {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
art.bbox_angle = inst.bbox_angle;
}
art.extras_mut().mask = Some(mask.clone());
}
+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:?}");
}
}