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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 03:41:25 +07:00
parent 5afc78a100
commit 65f0f34cd3
13 changed files with 256 additions and 43 deletions

View File

@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<Point> {
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<Value> {
ok()
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Rect> {
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<Rect> {
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<bool> {
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<Rect> {
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<Value> {
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<Value> {
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<Value> {
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
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)
}

View File

@ -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;

View File

@ -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");
}

View File

@ -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)
}

View File

@ -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,
}
}
}

View File

@ -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<Rect> {
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<vectorcraft_doc::NodeId> = 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) {

View File

@ -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 {

View File

@ -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())]

View File

@ -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);

View File

@ -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;
};

View File

@ -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<String> {
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
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));

View File

@ -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.

View File

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