From 65f0f34cd3b049b5d803a1975afcfe99017ee5d4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:41:25 +0700 Subject: [PATCH] M3.67: Use Preview Bounds measures strokes in transforms, the bounding box and Align With Use Preview Bounds on, the Transform panel, Properties and Control bar X/Y/W/H, the Selection tool's bounding box, Align, Distribute and Distribute Spacing measure visual bounds (the whole stroke). setBounds sets the visual box, scaling the path so that kept or scaled strokes land on the typed size. object.align/distribute/distributeSpacing take bounds?: "preview"|"geometric"; the Align flyout item shows and toggles the preference through prefs.set. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/object.rs | 103 +++++++++++++------- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_previewbounds.rs | 117 +++++++++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/lib.rs | 3 + crates/tools/src/select.rs | 11 +-- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/panels/align.rs | 5 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/transform.rs | 34 ++++++- docs/control-protocol.md | 4 + docs/mcp.md | 13 +++ 13 files changed, 256 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/tests_previewbounds.rs diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a34cb4ea..f8316d9c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\"}", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", has_selection, align ), @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\"}", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", has_multi, distribute ), @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis: \"horizontal\"|\"vertical\", spacing?: pt}", + "{axis: \"horizontal\"|\"vertical\", spacing?: pt, bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", has_multi, distribute_spacing ), @@ -175,7 +175,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?} (Transform panel)", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)", has_selection, set_bounds ), @@ -188,7 +188,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { if let Some(o) = point_param(p, "origin") { return Ok(o); } - s.doc()?.doc.bounds_of(ids, false).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) + s.transform_bounds(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) } /// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from @@ -710,34 +710,58 @@ fn set_props(s: &mut Session, p: &Value) -> Result { ok() } -fn reference_rect(s: &Session, p: &Value, ids: &[NodeId]) -> Result { +impl Session { + /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: + /// visual bounds (strokes included) with Use Preview Bounds on, else geometric bounds. + pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { + self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds) + } +} + +/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds. +fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { + match str_param(p, "bounds") { + None => Ok(s.prefs.use_preview_bounds), + Some("preview") => Ok(true), + Some("geometric") => Ok(false), + Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))), + } +} + +/// `ids` with their bounds (`preview`: visual bounds). +fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> { + ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect() +} + +fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { let st = s.doc()?; match str_param(p, "to") { Some("artboard") => { - let b = st.doc.bounds_of(ids, false).unwrap_or_default(); + let b = st.doc.bounds_of(ids, preview).unwrap_or_default(); let i = st.doc.artboard_at(b.center()).unwrap_or(0); Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b)) } Some("key") => { let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?; - st.doc.node(k).and_then(|n| n.geometric_bounds()).ok_or(EngineError::NoNode(k)) + st.doc.bounds_of(&[k], preview).ok_or(EngineError::NoNode(k)) } - _ => st.doc.bounds_of(ids, false).ok_or_else(|| EngineError::Other("nothing to align".into())), + _ => st.doc.bounds_of(ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())), } } fn align(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; - let r = reference_rect(s, p, &ids)?; + let preview = preview_bounds(s, p, "object.align")?; + let r = reference_rect(s, p, &ids, preview)?; let h = str_param(p, "horizontal"); let v = str_param(p, "vertical"); let key = s.doc()?.selection.key; let moves: Vec<(NodeId, Vec2)> = { let d = &s.doc()?.doc; - ids.iter() - .filter(|id| Some(**id) != key || str_param(p, "to") != Some("key")) - .filter_map(|id| { - let b = d.node(*id)?.geometric_bounds()?; + items_bounds(d, &ids, preview) + .into_iter() + .filter(|(id, _)| Some(*id) != key || str_param(p, "to") != Some("key")) + .map(|(id, b)| { let dx = match h { Some("left") => r.x0 - b.x0, Some("center") => r.center().x - b.center().x, @@ -750,7 +774,7 @@ fn align(s: &mut Session, p: &Value) -> Result { Some("bottom") => r.y1 - b.y1, _ => 0.0, }; - Some((*id, Vec2::new(dx, dy))) + (id, Vec2::new(dx, dy)) }) .collect() }; @@ -767,8 +791,8 @@ fn align(s: &mut Session, p: &Value) -> Result { fn distribute(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; - let d0 = &s.doc()?.doc; - let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect(); + let preview = preview_bounds(s, p, "object.distribute")?; + let mut items = items_bounds(&s.doc()?.doc, &ids, preview); let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) { (Some(h), _) => ( true, @@ -818,8 +842,8 @@ fn distribute(s: &mut Session, p: &Value) -> Result { fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let horiz = str_param(p, "axis") != Some("vertical"); - let d0 = &s.doc()?.doc; - let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect(); + let preview = preview_bounds(s, p, "object.distributeSpacing")?; + let mut items = items_bounds(&s.doc()?.doc, &ids, preview); items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); let n = items.len(); let size = |r: &Rect| if horiz { r.width() } else { r.height() }; @@ -851,24 +875,39 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { fn set_bounds(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; - let b = s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + let d = &s.doc()?.doc; + let g = d.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + // With Use Preview Bounds the values measure the visual box, whose margins around the + // geometric box (left, top, right, bottom) scale with the strokes or stay. + let v = if s.prefs.use_preview_bounds { d.bounds_of(&ids, true).unwrap_or(g) } else { g }; + let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1]; 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()); + 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() && 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(); + 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 sx = if b.width() > 1e-9 { w / b.width() } else { 1.0 }; - let sy = if b.height() > 1e-9 { h / b.height() } else { 1.0 }; + // The geometric scale that gives the visual size: margins grow by the strokes' scale `f` + // (exact for proportional scales, which scale the visual box alike). + let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); + let ratio = |size: f64, geo: f64, margin: f64| if geo > 1e-9 { (size - margin) / geo } else { 1.0 }; + let f = |sx: f64, sy: f64| if strokes { (sx * sy).abs().sqrt() } else { 1.0 }; + let (lx, ly) = (ratio(w, v.width(), 0.0), ratio(h, v.height(), 0.0)); + let f0 = f(if g.width() > 1e-9 { lx } else { 1.0 }, if g.height() > 1e-9 { ly } else { 1.0 }); + let sx = ratio(w, g.width(), (m[0] + m[2]) * f0); + let sy = ratio(h, g.height(), (m[1] + m[3]) * f0); let scale = about(rp, Affine::scale_non_uniform(sx, sy)); - let new_rp = scale * rp; - let tx = f64_or(p, "x", new_rp.x) - new_rp.x; - let ty = f64_or(p, "y", new_rp.y) - new_rp.y; + // Move the new visual box's reference point to x, y (default: where it was): the geometric + // box's reference point moves with the art, the margin from it to the visual one by `f`. + let gp = vectorcraft_geom::reference_point(g, refi); + let new_rp = scale * gp + (rp - gp) * f(sx, sy); + let tx = f64_or(p, "x", rp.x) - new_rp.x; + let ty = f64_or(p, "y", rp.y) - new_rp.y; apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a5421b20..25fda4ef 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -904,6 +904,8 @@ mod tests_pattern; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_previewbounds; +#[cfg(test)] mod tests_proxyitems; #[cfg(test)] mod tests_registration; diff --git a/crates/engine/src/tests_previewbounds.rs b/crates/engine/src/tests_previewbounds.rs new file mode 100644 index 00000000..ce75f140 --- /dev/null +++ b/crates/engine/src/tests_previewbounds.rs @@ -0,0 +1,117 @@ +//! Use Preview Bounds: the Transform panel values, the bounding box, Align and Distribute measure +//! visual bounds (strokes included) when it is on. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Rect; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A rectangle with a `weight` pt stroke (0: no stroke). +fn rect(s: &mut Session, x: f64, w: f64, weight: f64) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 100, "width": w, "height": 50}))["id"].as_u64().unwrap()); + if weight > 0.0 { + run(s, "stroke.set", json!({"weight": weight})); + } else { + run(s, "paint.setStroke", json!({"none": true})); + } + id +} + +fn geometric(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn preview(s: &mut Session, on: bool) { + run(s, "prefs.set", json!({"key": "usePreviewBounds", "value": on})); +} + +/// Two rectangles: one with a 10 pt stroke at x = 100, an unstroked one at x = 20; align left. +fn aligned_left(on: bool, p: Value) -> f64 { + let mut s = session(); + let a = rect(&mut s, 100.0, 50.0, 10.0); + rect(&mut s, 20.0, 50.0, 0.0); + preview(&mut s, on); + run(&mut s, "select.all", json!({})); + let mut p = p; + p["horizontal"] = json!("left"); + run(&mut s, "object.align", p); + geometric(&s, a).x0 +} + +#[test] +fn align_left_differs_by_half_the_stroke() { + assert_eq!(aligned_left(false, json!({})), 20.0); + // The 10 pt stroke's outer edge lines up with the other object: the path sits 5 pt in. + assert_eq!(aligned_left(true, json!({})), 25.0); + // `bounds` overrides the preference either way. + assert_eq!(aligned_left(true, json!({"bounds": "geometric"})), 20.0); + assert_eq!(aligned_left(false, json!({"bounds": "preview"})), 25.0); + let mut s = session(); + rect(&mut s, 0.0, 10.0, 0.0); + assert!(s.execute("object.align", &json!({"horizontal": "left", "bounds": "visual"})).is_err()); +} + +#[test] +fn distribute_spacing_measures_preview_bounds() { + let mut s = session(); + let a = rect(&mut s, 0.0, 50.0, 10.0); + let b = rect(&mut s, 200.0, 50.0, 10.0); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + run(&mut s, "object.distributeSpacing", json!({"axis": "horizontal", "spacing": 0})); + // The strokes touch: the paths are a stroke weight apart. + assert_eq!(geometric(&s, b).x0 - geometric(&s, a).x1, 10.0); +} + +#[test] +fn the_w_field_sets_the_visual_width() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 10.0); + preview(&mut s, true); + assert_eq!(s.transform_bounds(&[id]).unwrap().width(), 110.0); + // Strokes scale: the whole visual box doubles, from its left edge. + run(&mut s, "object.setBounds", json!({"width": 220, "reference": 0, "proportional": true, "strokes": true})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 220.0).abs() < 1e-9 && (v.x0 - 95.0).abs() < 1e-9, "{v:?}"); + assert!((geometric(&s, id).width() - 200.0).abs() < 1e-9); + run(&mut s, "edit.undo", json!({})); + // Strokes kept: the path takes the difference. + run(&mut s, "object.setBounds", json!({"width": 220, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 220.0).abs() < 1e-9 && (v.x0 - 95.0).abs() < 1e-9, "{v:?}"); + assert!((geometric(&s, id).width() - 210.0).abs() < 1e-9); + // X places the visual box's reference point. + run(&mut s, "object.setBounds", json!({"x": 0, "reference": 0})); + assert!((s.transform_bounds(&[id]).unwrap().x0).abs() < 1e-9); + assert!((geometric(&s, id).x0 - 5.0).abs() < 1e-9); +} + +#[test] +fn the_bounding_box_handles_sit_on_the_preview_bounds() { + // Drag from the visual right edge (10 pt outside the path) 100 pt to the right. + let drag = |on: bool| { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 20.0); + preview(&mut s, on); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (k, x) in [(PointerKind::Down, 210.0), (PointerKind::Drag, 310.0), (PointerKind::Up, 310.0)] { + s.pointer(&PointerEvent::new(k, x, 125.0), v).unwrap(); + } + geometric(&s, id) + }; + assert!(drag(true).width() > 150.0, "a scale from the visual edge"); + assert_eq!(drag(false).width(), 100.0, "off, the point is no handle"); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d06773ac..8d5bff79 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -84,6 +84,7 @@ impl Session { constrain_angle: self.prefs.constrain_angle, freeform_point: self.selected_freeform_point(), raster_sample: self.prefs.eyedropper.sample_size, + preview_bounds: self.prefs.use_preview_bounds, }; f(self.tool.as_mut(), &cx) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index de189ea3..0bf86726 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -171,6 +171,8 @@ pub struct ToolContext<'a> { /// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper /// samples an image (1, 3 or 5). pub raster_sample: u32, + /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). + pub preview_bounds: bool, } impl ToolContext<'_> { @@ -357,6 +359,7 @@ pub(crate) mod testutil { constrain_angle: 0.0, freeform_point: None, raster_sample: 1, + preview_bounds: false, } } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 174e7e82..5952dca2 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -51,10 +51,10 @@ pub fn matrix_json(a: Affine) -> Value { json!([c[0], c[1], c[2], c[3], c[4], c[5]]) } -/// Selection bounds used for the bounding box (geometric bounds, like Illustrator's default -/// "Use Preview Bounds" off). +/// Selection bounds used for the bounding box: visual bounds with Use Preview Bounds, else +/// geometric bounds. pub fn selection_bounds(cx: &ToolContext) -> Option { - cx.doc.bounds_of(&cx.selection.objects, false) + cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds) } impl SelectionTool { @@ -143,9 +143,8 @@ impl Tool for SelectionTool { } let mut d = move_delta(start, p, m.shift); if !began { - let sel_ids: Vec = cx.selection.objects.clone(); - self.start_bounds = cx.doc.bounds_of(&sel_ids, false); - self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &sel_ids, None)); + self.start_bounds = selection_bounds(cx); + self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None)); } self.guides.clear(); if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e36b1bd4..5406a673 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -915,7 +915,7 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { } // Bounding box with handles (Selection tool). if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() { - let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return }; + let Some(b) = app.session.transform_bounds(&st.selection.objects) else { return }; let color = c32(st.doc.layer_color(st.selection.objects[0])); p.add(Shape::closed_line(xf.quad(b), Stroke::new(1.0, color))); for h in vectorcraft_tools::bbox::Handle::ALL { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ddeb3e7d..50eda91e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -179,7 +179,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); // Transform fields. - let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false)); + let b = app.session.active().and_then(|s| app.session.transform_bounds(&s.selection.objects)); if let Some(b) = b { for (k, lbl, v) in [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index f0567a9a..150247da 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -129,7 +129,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { set_pstate(ui.ctx(), "align-hide-options", !hidden); } - menu_item(ui, "Use Preview Bounds", false, false); + let pb = app.session.prefs.use_preview_bounds; + if menu_item(ui, "Use Preview Bounds", true, pb) { + super::transform::set_pref(app, "usePreviewBounds", !pb); + } let to: AlignTo = pstate(ui.ctx(), "align-to"); if menu_item(ui, "Cancel Key Object", to == AlignTo::Key, false) { set_pstate(ui.ctx(), "align-to", AlignTo::Selection); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9556a2ee..5466f1bb 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -208,7 +208,7 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let units = st.doc.units; - let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { + let Some(b) = app.session.transform_bounds(&st.selection.objects) else { dim_label(ui, "No Selection"); return; }; diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 9bc0b674..a3d05ad6 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -28,7 +28,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; }; let units = st.doc.units; - let bounds = st.doc.bounds_of(&st.selection.objects, false); + let bounds = app.session.transform_bounds(&st.selection.objects); let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); let link: bool = pstate(ui.ctx(), "xf-link"); let has = bounds.is_some(); @@ -172,6 +172,38 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { mod tests { use super::*; + /// One headless frame of the panel (or the Align panel's menu): the texts drawn. + fn texts(app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec { + fn walk(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut v)); + v + } + + #[test] + fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); + app.run("stroke.set", json!({"weight": 10})).unwrap(); + assert!(texts(&mut app, show).iter().any(|t| t == "100 pt")); + assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == " Use Preview Bounds")); + set_pref(&mut app, "usePreviewBounds", true); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "110 pt") && shown.iter().any(|t| t == "60 pt"), "{shown:?}"); + assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); + } + #[test] fn constrain_proportions() { assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f8f144b2..471ddafa 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -161,3 +161,7 @@ have the fields `corners` (Scale Corners) and `strokes` (Scale Strokes & Effects `scaleCorners` and `scaleStrokes`, and `ui.dialog.confirm` keeps them as the preferences (`prefs.set`) before running `object.scale` / `object.transformEach` with them. The Transform panel's checkboxes and flyout item and the Properties panel's checkbox set the same preferences through `prefs.set`. + +Use Preview Bounds: the Align panel flyout's Use Preview Bounds item shows and toggles the `usePreviewBounds` +preference (`prefs.set`); while it is on, the bounding box and the Transform panel, Properties and Control bar +fields measure visual bounds. diff --git a/docs/mcp.md b/docs/mcp.md index 36ecdde2..41427b81 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -626,3 +626,16 @@ size. The journal entry of a scaling command records the `strokes` and `corners` {"name":"run_command","arguments":{"command":"object.scale","params":{"sx":200,"strokes":true}}} {"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}} ``` + +## Use Preview Bounds + +With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel +flyout's Use Preview Bounds), the Transform panel's and Control bar's X, Y, W and H, the bounding box, and +`object.align`, `object.distribute` and `object.distributeSpacing` measure visual bounds, which take in the whole +stroke (see Stroke bounds and clicks); off, they measure the paths. The three Align commands also take +`bounds: "preview"|"geometric"` for one call. `object.setBounds` then sets the visual box: with Scale Strokes & +Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets a 210 pt path. + +```json +{"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","bounds":"preview"}}} +```