From be4f60ff85f9b727261ee925cacda19aee1fbe03 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 09:42:06 +0200 Subject: [PATCH] fix: A selected graphic's Transform values are the graphic's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a placed graphic selected, transform.set acted on the graphic but the Control bar and Properties still read the frame's values: after setting the image of a -45° frame to 0°, a spinner step sent -44, and a 200 pt frame holding a 400 pt image showed W 200, so 201 halved the image. transform.info now reports the graphic's values at the top level in that case, and sel_info gives the graphic's bounds. Also: `id` names the target explicitly, like `ids`, so it isn't taken as a content selection; and a content selection of locked frames records no empty undo step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 54 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/panels/mod.rs | 39 ++++++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 28da3bc8e..dbe378e2d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1189,15 +1189,32 @@ fn values_json(m: Affine) -> Value { json!({"scaleX": r(v.scale_x * 100.0), "scaleY": r(v.scale_y * 100.0), "rotation": r(v.rotation), "shear": r(v.shear)}) } +/// Whether `transform.set` / `transform.info` act on the placed graphic in each frame: `content` +/// when given, else whether the selection is content (Direct Selection) when no `ids` or `id` name +/// the targets. +fn content_mode(s: &Session, p: &Value) -> Result { + if let Some(c) = p.get("content").and_then(Value::as_bool) { + return Ok(c); + } + let explicit = p.get("ids").is_some() || p.get("id").is_some(); + Ok(!explicit && s.doc()?.selection.content) +} + fn transform_info(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let totals = s.prefs.transformations_are_totals; + let content = content_mode(s, p)?; let d = &s.doc()?.doc; let id = *ids.first().ok_or_else(|| bad("transform.info", "nothing selected"))?; let (_, m) = measured_xf(d, id, totals).ok_or_else(|| bad("transform.info", "no such object"))?; let mut out = values_json(m); if let Some(g) = d.item(id).and_then(Item::graphic) { - out["content"] = values_json(if totals { m * g.xf } else { g.xf }); + let c = values_json(if totals { m * g.xf } else { g.xf }); + // With the graphic selected, its values are the ones the Transform fields show and set. + if content { + out = c.clone(); + } + out["content"] = c; } Ok(out) } @@ -1251,8 +1268,7 @@ fn transform_values(s: &mut Session, p: &Value, ids: &[ItemId], rf: usize) -> Re fn transform_set(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; // Direct Selection of a placed graphic (or `content`): the graphic moves inside its frame. - let content = p.get("content").and_then(Value::as_bool).unwrap_or(p.get("ids").is_none() && s.doc()?.selection.content); - if content { + if content_mode(s, p)? { return transform_content(s, p, &ids); } if ["rotation", "shear", "scaleX", "scaleY"].iter().any(|k| p.get(*k).is_some()) { @@ -1337,6 +1353,10 @@ fn transform_content(s: &mut Session, p: &Value, ids: &[ItemId]) -> Result Option { let mut b: Option = None; for id in &st.selection.items { let it = d.item(*id)?; - let ib = if with_stroke { it.visible_bounds() } else { it.bounds() }; + // Direct Selection of a placed graphic: its bounds, as the Transform fields then set. + let ib = match it.graphic() { + Some(g) if st.selection.content => { + let full = d.find(*id).map_or(designcraft_geom::Affine::IDENTITY, |l| d.parent_xf(&l)); + (full * it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)) + } + _ if with_stroke => it.visible_bounds(), + _ => it.bounds(), + }; b = Some(b.map_or(ib, |r| r.union(ib))); } let b = b?; @@ -698,3 +706,32 @@ mod font_menu_ui_tests { } } } + +#[cfg(test)] +mod content_selection_tests { + use serde_json::json; + + /// With a placed graphic selected (Direct Selection), X/Y/W/H are the graphic's: what the + /// Transform fields then set. + #[test] + fn a_selected_graphic_shows_its_own_bounds() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].clone(); + let png = designcraft_render::Rendered { width: 400, height: 400, pixels: vec![200u8; 400 * 400 * 4] }.to_png(); + let b64 = designcraft_engine::cmd::base64_encode(&png); + app.run("file.place", json!({"base64": b64, "name": "a.png", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + let frame = super::sel_info(&app).unwrap().page_rect; + assert!((frame.width() - 200.0).abs() < 1e-6, "{frame:?}"); + app.run("selection.set", json!({"ids": [f], "content": true})).unwrap(); + // Placing fitted it to 50%: back to its own 400 pt. + app.run("transform.set", json!({"scaleX": 100, "scaleY": 100})).unwrap(); + let g = super::sel_info(&app).unwrap().page_rect; + assert!((g.width() - 400.0).abs() < 1e-6, "the graphic's width, not the frame's: {g:?}"); + // So a width typed from it scales the graphic by that ratio, not by half. + app.run("transform.set", json!({"width": 401})).unwrap(); + let after = super::sel_info(&app).unwrap().page_rect; + assert!((after.width() - 401.0).abs() < 1e-6, "{after:?}"); + } +}