mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 15:57:49 +00:00
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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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, ¶ms).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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user