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"}}} +```