Add-ons: Arrange Tools — Arrange in Grid, Swap, Replace with Top Object, Set Size of Each, Zoom to Selection

A built-in add-on in Object › Plug-ins › Arrange Tools (#4830, #4831, #4832, #4833, #4834):

- addon.arrange.grid lays the top-level selected objects out in rows and
  columns, a row or a column (stacking order from the back, visual bounds,
  each object centred in its cell), from the selection's top-left or
  centred on the artboard.
- addon.arrange.swap swaps two objects' bounds centres, and with `size`
  their widths and heights.
- addon.arrange.replace puts a copy of the topmost selected object centred
  on each of the others, in its stacking place, kept, stretched or fitted
  in proportion; the replaced objects are deleted unless `keep`.
- addon.arrange.size gives each selected object an exact width and/or
  height about a reference point.
- addon.arrange.zoomSelection (a UI command) fits the active view to the
  selection.

Places and sizes are measured as object.setBounds measures them (its
transform, now also available per object), strokes scale as Scale Strokes
& Effects says, each command is one undo step, and Arrange in Grid,
Replace with Top Object and Set Size of Each open previewed dialogs.
Catalog rows, command sweep cases, docs/mcp.md, parity docs and the
ROADMAP progress log.
This commit is contained in:
bflatastic
2026-10-31 22:22:15 +07:00
parent cf142e7f38
commit ca0785573d
46 changed files with 1269 additions and 34 deletions
+3 -1
View File
@@ -1,6 +1,7 @@
//! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own
//! with commands under `addon.<name>.*` (see `docs/mcp.md`).
mod arrange;
mod cleanup;
mod color;
mod pathtools;
@@ -10,7 +11,8 @@ use super::CommandSpec;
/// The commands of every built-in add-on.
pub fn specs() -> Vec<CommandSpec> {
let mut v = cleanup::specs();
let mut v = arrange::specs();
v.extend(cleanup::specs());
v.extend(color::specs());
v.extend(pathtools::specs());
v.extend(pen::specs());
+272
View File
@@ -0,0 +1,272 @@
//! Arrange Tools (`addon.arrange.*`), a built-in add-on: Arrange in Grid lays the selected objects
//! out in rows and columns, a row or a column; Swap Objects swaps the places (and sizes) of two;
//! Replace with Top Object puts a copy of the topmost selected object in place of each of the
//! others; and Set Size of Each gives every selected object an exact width and height. Places and
//! sizes are measured and set as `object.setBounds` does. Its submenu (Object › Plug-ins › Arrange
//! Tools), its dialogs and Zoom to Selection (a view command) are the UI's.
#[cfg(test)]
mod tests;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_render::effects;
use super::super::edit::selected_roots;
use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns};
use super::super::typecmd::refresh_bounds_of;
use super::super::{CommandSpec, bad, cmd, has_doc, has_multi, has_selection, number_or, switch_or};
use crate::{EngineError, Result, Session};
/// The largest gap, width or height (pt) the commands take.
const LIMIT: f64 = 1e6;
/// The smallest width or height (pt) Set Size of Each takes.
const LEAST: f64 = 0.001;
/// The most columns Arrange in Grid takes.
const MAX_COLUMNS: u64 = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.arrange.grid",
"Arrange in Grid",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{layout?: \"grid\" (rows and columns) | \"row\" (one row) | \"column\" (one column), columns?: 1..10000 (grid; default ⌈√count⌉; never more than the count), gapX?: pt −1000000..1000000 (10), gapY?: pt −1000000..1000000 (gapX), centerOnArtboard?: false (true: the grid is centred on the artboard under the selection's centre, else the first; false: it starts at the selection's top-left)} lay the selected objects (the top-level ones) out in their stacking order from the back, left to right and top to bottom, as one undo step. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, each object is centred in its cell, and everything is measured by visual bounds (strokes and effects as drawn) → {count}",
has_selection,
grid
),
cmd!(
"addon.arrange.swap",
"Swap Objects",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{size?: false (true: each also takes the other's width and height), scaleStrokes?: the Scale Strokes & Effects preference} swap the places (bounds centres) of the two selected objects, measured as object.setBounds measures them (Use Preview Bounds), as one undo step. Off unless exactly two objects are selected",
exactly_two,
swap
),
cmd!(
"addon.arrange.replace",
"Replace with Top Object",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{fit?: \"keep\" (the copy keeps its size) | \"bounds\" (scaled to the replaced object's width and height) | \"proportional\" (scaled to fit inside them, in proportion) (\"keep\"), scaleStrokes?: the Scale Strokes & Effects preference, keep?: false (true: the replaced objects stay, under their copies)} put a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order, as one undo step; sizes and centres are measured as object.setBounds measures them, and the copies are selected → {ids}",
has_multi,
replace
),
cmd!(
"addon.arrange.size",
"Set Size of Each",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{width?: pt, height?: pt (at least one; each 0.001..1000000), proportional?: false (with only one of them given, the other follows in proportion; else it stays), reference?: 0..8 (the 9-point grid row by row from the top-left; 4, the centre: the point of each object that stays put), scaleStrokes?: the Scale Strokes & Effects preference} give each selected object (the top-level ones) that width and height, measured as object.setBounds measures them (Use Preview Bounds), as one undo step → {count}",
has_selection,
size
),
]
}
/// Exactly two objects are selected (Swap Objects).
fn exactly_two(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let n = selected_roots(s).map_err(|e| e.to_string())?.len();
if n == 2 { Ok(()) } else { Err("select exactly two objects".into()) }
}
/// Number param `key` (`default` when left out) within `lo..=hi`.
fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str) -> Result<f64> {
let v = number_or(p, key, default, cmd)?;
if (lo..=hi).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
}
/// Whole-number param `key` within `lo..=hi`, `None` when left out.
fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?;
if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
}
/// A width or height param of Set Size of Each, `None` when left out.
fn size_param(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
p.get(key).filter(|v| !v.is_null()).map(|_| number_in(p, key, 0.0, (LEAST, LIMIT), cmd)).transpose()
}
/// `object.setBounds` params that centre an object on `at`, with Scale Strokes & Effects `strokes`
/// and, when given, a width and height.
fn place(at: Point, size: Option<(f64, f64)>, strokes: bool) -> Value {
let mut q = json!({ "x": at.x, "y": at.y, "reference": 4, "strokes": strokes });
if let Some((w, h)) = size {
q["width"] = json!(w);
q["height"] = json!(h);
}
q
}
/// Transform each object of `xfs` by its own transform as undo step `label`, strokes and effects
/// scaling with Scale Strokes & Effects `strokes`, pattern tiles as Transform Pattern Tiles says.
/// When every transform is (within rounding) the identity, nothing changes.
fn transform_each(s: &mut Session, label: &str, strokes: bool, xfs: &[(NodeId, Affine)]) -> Result<()> {
let still = |xf: &Affine| xf.as_coeffs().iter().zip(Affine::IDENTITY.as_coeffs()).all(|(a, b)| (a - b).abs() <= 1e-9);
if xfs.iter().all(|(_, xf)| still(xf)) {
return Ok(());
}
let p = json!({ "strokes": strokes });
let ids: Vec<NodeId> = xfs.iter().map(|(id, _)| *id).collect();
let mut sc = scaling(s, &p);
sc.patterns = transform_patterns(s, &p, &ids)?;
s.edit(label, |d, _| {
for (id, xf) in xfs {
if let Some(n) = d.node_mut(*id) {
transform_node(n, *xf, sc, true);
}
}
Ok(())
})
}
/// The box of object `id` as `object.setBounds` measures it.
fn box_of(s: &Session, id: NodeId) -> Result<OrientedBox> {
s.transform_box(&[id]).ok_or_else(|| EngineError::Other("no bounds".into()))
}
fn grid(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.grid";
let layout = choice(p, C, "layout", &["grid", "row", "column"])?.unwrap_or("grid");
let columns = whole_in(p, "columns", (1, MAX_COLUMNS), C)?;
let gap_x = number_in(p, "gapX", 10.0, (-LIMIT, LIMIT), C)?;
let gap_y = number_in(p, "gapY", gap_x, (-LIMIT, LIMIT), C)?;
let center = switch_or(p, "centerOnArtboard", false, C)?;
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let items: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], true)?))).collect();
let Some(all) = items.iter().map(|(_, b)| *b).reduce(|a, b| a.union(b)) else { return Ok(json!({ "count": 0 })) };
let n = items.len();
let cols = match layout {
"row" => n,
"column" => 1,
_ => columns.map_or_else(|| (n as f64).sqrt().ceil() as usize, |c| usize::try_from(c).unwrap_or(n)),
}
.clamp(1, n);
let mut widths = vec![0.0_f64; cols];
let mut heights = vec![0.0_f64; n.div_ceil(cols)];
for (i, (_, b)) in items.iter().enumerate() {
if let Some(w) = widths.get_mut(i % cols) {
*w = w.max(b.width());
}
if let Some(h) = heights.get_mut(i / cols) {
*h = h.max(b.height());
}
}
let total = |sizes: &[f64], gap: f64| sizes.iter().sum::<f64>() + gap * sizes.len().saturating_sub(1) as f64;
let origin = if center {
let board = d.artboards.get(d.artboard_at(all.center()).unwrap_or(0)).map_or(all, |a| a.rect);
Point::new(board.center().x - total(&widths, gap_x) / 2.0, board.center().y - total(&heights, gap_y) / 2.0)
} else {
Point::new(all.x0, all.y0)
};
// The middle of each column (row): the sizes and gaps before it, and half its own size.
let middles = |sizes: &[f64], gap: f64, from: f64| -> Vec<f64> {
sizes
.iter()
.scan(from, |at, size| {
let mid = *at + size / 2.0;
*at += size + gap;
Some(mid)
})
.collect()
};
let (xs, ys) = (middles(&widths, gap_x, origin.x), middles(&heights, gap_y, origin.y));
let xfs: Vec<(NodeId, Affine)> = items
.iter()
.enumerate()
.filter_map(|(i, (id, b))| Some((*id, Affine::translate(Point::new(*xs.get(i % cols)?, *ys.get(i / cols)?) - b.center()))))
.collect();
transform_each(s, "Arrange in Grid", false, &xfs)?;
Ok(json!({ "count": n }))
}
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.swap";
let size = switch_or(p, "size", false, C)?;
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
let roots = selected_roots(s)?;
let &[a, b] = roots.as_slice() else { return Err(bad(C, "select exactly two objects")) };
let (ba, bb) = (box_of(s, a)?, box_of(s, b)?);
let to = |o: &OrientedBox| place(o.center(), size.then(|| (o.rect.width(), o.rect.height())), strokes);
let xfs = [(a, object_bounds_transform(s, &to(&bb), a)?), (b, object_bounds_transform(s, &to(&ba), b)?)];
transform_each(s, "Swap Objects", strokes, &xfs)?;
Ok(json!({}))
}
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.replace";
let fit = choice(p, C, "fit", &["keep", "bounds", "proportional"])?.unwrap_or("keep");
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
let keep = switch_or(p, "keep", false, C)?;
let roots = selected_roots(s)?;
let Some((&top, others)) = roots.split_last().filter(|(_, others)| !others.is_empty()) else {
return Err(bad(C, "select at least two objects"));
};
let tb = box_of(s, top)?;
let (tw, th) = (tb.rect.width(), tb.rect.height());
let mut xfs = Vec::with_capacity(others.len());
for &id in others {
let Some(b) = s.transform_box(&[id]) else { continue };
let (w, h) = (b.rect.width(), b.rect.height());
let size = match fit {
"bounds" => Some((w, h)),
// The smaller of the two scales that fit; a side the copy doesn't have doesn't count.
"proportional" => {
[(w, tw), (h, th)].into_iter().filter(|(_, t)| *t > 1e-9).map(|(v, t)| v / t).reduce(f64::min).map(|k| (tw * k, th * k))
}
_ => None,
};
xfs.push((id, object_bounds_transform(s, &place(b.center(), size, strokes), top)?));
}
let p = json!({ "strokes": strokes });
let mut sc = scaling(s, &p);
sc.patterns = transform_patterns(s, &p, &[top])?;
let ids = s.edit("Replace with Top Object", |d, sel| {
let Some(n) = d.node(top).cloned() else { return Ok(vec![]) };
let mut items = Vec::with_capacity(xfs.len());
for (id, xf) in &xfs {
let mut c = d.reid(&n);
transform_node(&mut c, *xf, sc, true);
items.push((*id, vec![c]));
}
let out: Vec<NodeId> = items.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
d.insert_beside(items, !keep)?;
refresh_bounds_of(d, &out);
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn size(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.size";
let width = size_param(p, "width", C)?;
let height = size_param(p, "height", C)?;
if width.is_none() && height.is_none() {
return Err(bad(C, "give `width` or `height`"));
}
let proportional = switch_or(p, "proportional", false, C)?;
let reference = whole_in(p, "reference", (0, 8), C)?.unwrap_or(4);
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
let mut q = json!({ "reference": reference, "strokes": strokes, "proportional": proportional && (width.is_none() || height.is_none()) });
if let Some(w) = width {
q["width"] = json!(w);
}
if let Some(h) = height {
q["height"] = json!(h);
}
let mut xfs = vec![];
for id in selected_roots(s)? {
if s.transform_box(&[id]).is_some() {
xfs.push((id, object_bounds_transform(s, &q, id)?));
}
}
transform_each(s, "Set Size of Each", strokes, &xfs)?;
Ok(json!({ "count": xfs.len() }))
}
@@ -0,0 +1,263 @@
//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off,
//! and bad params refused without a change.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Rect;
use crate::Session;
/// A new document whose new art has no stroke, so visual and geometric bounds agree.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool {
[(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Each of `bad` is refused and leaves the document as it was.
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
for p in bad {
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
}
assert_eq!(steps(s), n, "{cmd}");
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
}
fn enabled(s: &Session, cmd: &str) -> bool {
s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled
}
#[test]
fn grid_lays_objects_out_in_rows_and_columns_from_the_back() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0);
let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0);
let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0);
let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0);
select(&mut s, &[d, c, b, a]);
let n = steps(&s);
// Four objects: two columns. The grid starts at the selection's top-left (10, 5).
let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap();
assert_eq!(r["count"], json!(4));
assert_eq!(steps(&s), n + 1, "one undo step");
// Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred.
assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b));
assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c));
assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d));
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0)));
}
#[test]
fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap();
assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10");
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap();
assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX");
// 20 × 44 centred on the 400 × 300 artboard.
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a));
// In place already: no step.
let n = steps(&s);
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
assert_eq!(steps(&s), n);
// More columns than objects: one row.
s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap();
assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
}
#[test]
fn grid_measures_visual_bounds() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap();
// Visual boxes 24 wide, touching.
assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
}
#[test]
fn swap_trades_places_and_optionally_sizes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0);
select(&mut s, &[a]);
assert!(!enabled(&s, "addon.arrange.swap"));
select(&mut s, &[a, b, c]);
assert!(!enabled(&s, "addon.arrange.swap"));
refuses(&mut s, "addon.arrange.swap", &[json!({})]);
select(&mut s, &[a, b]);
assert!(enabled(&s, "addon.arrange.swap"));
let n = steps(&s);
s.execute("addon.arrange.swap", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1);
// Centres (10, 5) and (120, 120) swap; sizes stay.
assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b));
}
#[test]
fn swap_with_sizes_scales_strokes_as_asked() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width();
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap();
assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b));
}
#[test]
fn replace_puts_a_copy_of_the_top_object_in_each_place() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
select(&mut s, &[top]);
assert!(!enabled(&s, "addon.arrange.replace"));
refuses(&mut s, "addon.arrange.replace", &[json!({})]);
select(&mut s, &[a, b, top]);
let n = steps(&s);
let r = s.execute("addon.arrange.replace", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1);
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 2);
let st = s.doc().unwrap();
assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted");
assert!(st.doc.node(top).is_some(), "the top object stays");
assert_eq!(st.selection.objects, ids, "the copies are selected");
// Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle).
assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0]));
assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1]));
let layer = &s.doc().unwrap().doc.layers[0];
let order: Vec<NodeId> = layer.children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(order, [ids[0], ids[1], top]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_some());
}
#[test]
fn replace_fits_the_copies_and_can_keep_the_originals() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
select(&mut s, &[a, top]);
let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy));
let order: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(order, [a, copy, top], "kept: the copy goes right above it");
s.execute("edit.undo", &json!({})).unwrap();
select(&mut s, &[a, top]);
let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
// 10 × 5 into 20 × 40: twice as large, centred on (10, 20).
assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy));
}
#[test]
fn size_gives_each_object_a_width_and_height() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
select(&mut s, &[a, b]);
let n = steps(&s);
s.execute("addon.arrange.size", &json!({"width": 30})).unwrap();
assert_eq!(steps(&s), n + 1);
// About each centre; the height stays.
assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap();
assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap();
assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a));
}
#[test]
fn bad_params_are_refused() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
select(&mut s, &[a, b]);
refuses(
&mut s,
"addon.arrange.grid",
&[
json!({"layout": "spiral"}),
json!({"columns": 0}),
json!({"columns": 1.5}),
json!({"columns": 1e12}),
json!({"gapX": 1e308}),
json!({"gapY": "10"}),
json!({"centerOnArtboard": "yes"}),
],
);
refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]);
refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]);
refuses(
&mut s,
"addon.arrange.size",
&[
json!({}),
json!({"width": 0}),
json!({"height": -5}),
json!({"width": 1e308}),
json!({"width": "10"}),
json!({"width": 10, "reference": 9}),
json!({"width": 10, "proportional": "yes"}),
],
);
}
#[test]
fn off_without_a_selection() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 20.0, 10.0);
s.execute("select.none", &json!({})).unwrap();
for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size"] {
assert!(!enabled(&s, c), "{c}");
}
assert!(!enabled(&Session::new(), "addon.arrange.swap"));
}
+1 -1
View File
@@ -32,7 +32,7 @@ mod transform;
pub(crate) use align::choice;
pub(crate) use arrange::group_nodes;
pub(crate) use bounds::bounds_rewrite;
pub(crate) use bounds::{bounds_rewrite, object_bounds_transform};
pub use commands::specs;
pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip};
pub(super) use shape::recut;
+17 -5
View File
@@ -6,7 +6,7 @@ use super::{
};
pub(super) fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? {
match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0, true)? {
Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b),
Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false),
}
@@ -32,7 +32,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
if cmd != "object.setBounds" {
return None;
}
let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None };
let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4, true) else { return None };
let mut p = params.as_object().cloned().unwrap_or_default();
for k in ["x", "y", "width", "height", "reference", "proportional"] {
p.remove(k);
@@ -41,14 +41,26 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
Some(("object.transform".to_string(), Value::Object(p)))
}
/// The transform that gives object `id` on its own the place and size `p` asks for, as
/// `object.setBounds` measures them (Use Preview Bounds, `strokes`, `reference`, `proportional`),
/// whatever anchors are direct-selected. The art keeps at least 1e-4 of its width and height.
pub(crate) fn object_bounds_transform(s: &Session, p: &Value, id: NodeId) -> Result<Affine> {
match bounds_transform(s, p, vec![id], 1e-4, false)? {
Bounds::Objects(_, xf) => Ok(xf),
Bounds::Anchors(..) => Err(EngineError::Other("no bounds".into())),
}
}
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
/// art with a width or height below `least` of the one it has keeps `least` of it.
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
/// art with a width or height below `least` of the one it has keeps `least` of it. With `anchors`,
/// direct-selected anchors are what changes ([`Bounds::Anchors`]).
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchors: bool) -> Result<Bounds> {
check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?;
let st = s.doc()?;
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
check_switches(p, &["proportional"], "object.setBounds")?;
if transforms_objects(p)
if anchors
&& transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
{