From 4ef0cb76e64d1d3c54f8020a69b000183cd7f6b8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 01:29:32 +0200 Subject: [PATCH 1/2] Transform panel: X/Y/W/H edit the anchors selected with Direct Selection (fixes #1824) --- crates/engine/src/cmd/object.rs | 31 +++++++++++++++++++++++++- crates/engine/src/tests.rs | 26 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 11 ++++++--- crates/ui-egui/src/panels/transform.rs | 23 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 33efe764..3fbe8314 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -202,7 +202,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), @@ -1400,6 +1400,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { fn set_bounds(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; + let st = s.doc()?; + if transforms_objects(p) + && !bool_or(p, "copy", false) + && let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds) + { + return set_anchor_bounds(s, p, ids, b); + } let d = &s.doc()?.doc; // Sizes are measured square to the bounding box, which a rotation turns with the objects: // the math below runs in its frame. @@ -1477,6 +1484,28 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false) } +/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of +/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only +/// the anchors, their handles with them, and those objects move or scale. +fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; + let rp = vectorcraft_geom::reference_point(b, refi); + let mut w = f64_or(p, "width", b.width()); + let mut h = f64_or(p, "height", b.height()); + if bool_or(p, "proportional", false) { + if p.get("width").is_some() && b.width() > 0.0 { + h = b.height() * w / b.width(); + } else if p.get("height").is_some() && b.height() > 0.0 { + w = b.width() * h / b.height(); + } + } + // A single anchor, or anchors in a line, have no size to scale on that side. + let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 }; + let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height()))); + let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y)); + apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true) +} + /// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with /// symbol instances measured without the strokes in their art /// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 41acc014..9d87ed2f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -821,6 +821,32 @@ fn transforms_act_on_direct_selected_anchors() { assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); } +/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H +/// (`object.setBounds`) are the box around those anchors, and only they move or scale. +#[test] +fn set_bounds_acts_on_direct_selected_anchors() { + let mut s = session(); + let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true}); + let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap()); + let points = |s: &Session| -> Vec<(f64, f64)> { + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") }; + path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect() + }; + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap(); + s.execute("object.setBounds", &json!({"x": 130, "reference": 0})).unwrap(); + assert_eq!(points(&s), [(0.0, 0.0), (130.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "only the selected anchor moves"); + s.execute("object.setBounds", &json!({"y": 20, "reference": 4})).unwrap(); + assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (100.0, 100.0), (0.0, 100.0)]); + // Two anchors: W scales their box about the reference point, the others stay. + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]]})).unwrap(); + s.execute("object.setBounds", &json!({"width": 50, "reference": 3})).unwrap(); + assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (50.0, 100.0), (0.0, 100.0)]); + // The path selected whole moves as a whole. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.setBounds", &json!({"x": 10, "reference": 0})).unwrap(); + assert_eq!(points(&s), [(10.0, 0.0), (140.0, 20.0), (60.0, 100.0), (10.0, 100.0)]); +} + /// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and /// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to /// Current Layer on objects already on top of the current layer. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ebc4c123..d211c5c2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -977,8 +977,10 @@ impl VectorcraftApp { self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts) } - /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The - /// canvas and the transform fields read it every frame: it is measured once per revision. + /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]); with + /// anchors selected with Direct Selection, the box around them square to the page, which + /// `object.setBounds` then edits (#1824). The canvas and the transform fields read it every + /// frame: it is measured once per revision. pub fn selection_box(&mut self) -> Option { let st = self.session.active()?; let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); @@ -987,7 +989,10 @@ impl VectorcraftApp { { return b; } - let b = self.session.transform_box(&st.selection.objects); + let b = match st.selection.anchor_bounds(&st.doc, self.session.prefs.use_preview_bounds) { + Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), + None => self.session.transform_box(&st.selection.objects), + }; self.canvas.selection_box = Some((key, b)); b } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index a57faabb..ae9841e7 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -486,6 +486,29 @@ mod tests { assert_eq!(selected_guides(&app), None); } + /// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position, + /// and a new X moves it alone. + #[test] + fn an_anchor_selection_shows_and_moves_its_own_box() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true}); + let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}"); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); + let shown = texts(&mut app, show); + for v in ["120 pt", "30 pt", "0 pt"] { + assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}"); + } + assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}"); + app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap(); + let n = first_selected(&app).unwrap(); + let Some(path) = n.path_data() else { panic!("a path") }; + let xs: Vec = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]); + } + #[test] fn artboard_fields_keep_the_reference_point() { let r = Rect::new(0.0, 0.0, 300.0, 200.0); From 61ef472e71b03334525f2424bab5b080a1d84918 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:17:14 +0700 Subject: [PATCH 2/2] object.setBounds: one helper for the asked width and height --- crates/engine/src/cmd/object.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 3fbe8314..452797f3 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1426,15 +1426,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]]; let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; let rp = vectorcraft_geom::reference_point(v, refi); - let mut w = f64_or(p, "width", v.width()); - let mut h = f64_or(p, "height", v.height()); - if bool_or(p, "proportional", false) { - if p.get("width").is_some() && v.width() > 0.0 { - h = v.height() * w / v.width(); - } else if p.get("height").is_some() && v.height() > 0.0 { - w = v.width() * h / v.height(); - } - } + let (w, h) = wanted_size(p, v); // The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is // exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the // scales where it misses. @@ -1484,12 +1476,9 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false) } -/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of -/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only -/// the anchors, their handles with them, and those objects move or scale. -fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { - let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; - let rp = vectorcraft_geom::reference_point(b, refi); +/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or +/// with `proportional` follows the one given. +fn wanted_size(p: &Value, b: Rect) -> (f64, f64) { let mut w = f64_or(p, "width", b.width()); let mut h = f64_or(p, "height", b.height()); if bool_or(p, "proportional", false) { @@ -1499,6 +1488,16 @@ fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> R w = b.width() * h / b.height(); } } + (w, h) +} + +/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of +/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only +/// the anchors, their handles with them, and those objects move or scale. +fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; + let rp = vectorcraft_geom::reference_point(b, refi); + let (w, h) = wanted_size(p, b); // A single anchor, or anchors in a line, have no size to scale on that side. let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 }; let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height())));