Files
figcraft/crates/engine/src/cmd/arrange.rs
T
justinjohn0306andClaude Opus 5.5 6a163721dc M3: explicit edits of bound scalar fields detach their variable (#809)
An explicit edit of a bound width, height, opacity, gap, padding, corner
radius, stroke weight, arc or text field reported ok, but normalize wrote
the variable's value straight back. Now the edit sticks and that field's
binding is dropped, as paint edits already do.

- `styles::bound_before` / `detach_edited`: compare each layer-level
  bound field's stored value before and after the edit (`bound_field`),
  so keys written unchanged keep their bindings and fields moved along
  with another (proportions, min/max) lose theirs. A binding the edit
  itself made (`boundVariables`) stays. List and component property
  bindings are left to their own rules.
- Hooked into node.set, transform.resize, object.opacityKeys, text.style
  and `Session::preview` (canvas drags of the selected layers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:33:29 +05:30

608 lines
24 KiB
Rust

//! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms.
use figcraft_doc::*;
use figcraft_geom::{Affine, Point, Rect};
use serde_json::{Value, json};
use super::edit::{roots, z_sorted};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("align.left", "Align left", "{ids?}", true, |s, p| align(s, p, Al::Left)),
spec!("align.hcenter", "Align horizontal centers", "{ids?}", true, |s, p| align(s, p, Al::HCenter)),
spec!("align.right", "Align right", "{ids?}", true, |s, p| align(s, p, Al::Right)),
spec!("align.top", "Align top", "{ids?}", true, |s, p| align(s, p, Al::Top)),
spec!("align.vcenter", "Align vertical centers", "{ids?}", true, |s, p| align(s, p, Al::VCenter)),
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
spec!(
"arrange.spacing",
"Spacing",
"{ids?, horizontal?, vertical?, fromGesture?} (page units; fromGesture uses the interaction snapshot)",
true,
spacing
),
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
spec!("arrange.front", "Bring to front", "{ids?}", true, |s, p| zorder(s, p, Z::Front)),
spec!("arrange.forward", "Bring forward", "{ids?}", true, |s, p| zorder(s, p, Z::Forward)),
spec!("arrange.backward", "Send backward", "{ids?}", true, |s, p| zorder(s, p, Z::Backward)),
spec!("arrange.back", "Send to back", "{ids?}", true, |s, p| zorder(s, p, Z::Back)),
spec!("object.flipH", "Flip horizontal", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| flip(s, p, true)),
spec!("object.flipV", "Flip vertical", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| flip(s, p, false)),
spec!("object.rotate180", "Rotate 180°", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| rotate_by(
s, p, 180.0
)),
spec!("object.rotateLeft", "Rotate 90° left", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| rotate_by(
s, p, 90.0
)),
spec!("object.rotateRight", "Rotate 90° right", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| {
rotate_by(s, p, -90.0)
}),
spec!("transform.move", "Move", "{ids?, dx, dy} (page units)", true, |s, p| {
let (dx, dy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0));
let ids = req_targets("transform.move", s, p)?;
let doc = s.doc_mut()?;
for i in roots(doc, &ids) {
move_world(doc, i, dx, dy)?;
}
ok()
}),
spec!("transform.moveTo", "Move to", "{ids?, x, y} (page coordinates of the selection's top-left)", true, |s, p| {
let ids = req_targets("transform.moveTo", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let b = doc.bounds_of(&ids).ok_or_else(|| bad("transform.moveTo", "nothing to move"))?;
let (dx, dy) = (num_or(p, "x", b.x0) - b.x0, num_or(p, "y", b.y0) - b.y0);
for i in ids {
move_world(doc, i, dx, dy)?;
}
ok()
}),
spec!(
"transform.rotate",
"Rotate",
"{ids?, degrees, cx?, cy? (pivot, page coords; default the selection's rotation origin, else its centre)}",
true,
|s, p| {
let deg = req_num("transform.rotate", p, "degrees")?;
let ids = req_targets("transform.rotate", s, p)?;
let c = turn_pivot(s, &ids)?.unwrap_or_default();
let pivot = Point::new(num_or(p, "cx", c.x), num_or(p, "cy", c.y));
let doc = s.doc_mut()?;
for i in roots(doc, &ids) {
rotate_world(doc, i, pivot, deg)?;
}
ok()
}
),
spec!(
"transform.rotationOrigin",
"Move rotation origin",
"{on? (default: toggles)} — shows the selection's rotation origin to drag (Escape hides it); not saved",
false,
|s, p| {
let on = s.edit_rotation_origin(boolean(p, "on"))?;
Ok(origin_json(s, on))
}
),
spec!(
"transform.setRotationOrigin",
"Set rotation origin",
"{x, y (page coords) | reset: true} — the point the selection turns about (default its centre); kept while the selection lasts, not saved",
false,
|s, p| {
let at = num(p, "x").zip(num(p, "y")).map(|(x, y)| Point::new(x, y)).filter(|_| boolean(p, "reset") != Some(true));
if at.is_none() && boolean(p, "reset") != Some(true) {
return Err(bad("transform.setRotationOrigin", "give `x` and `y`, or `reset: true`"));
}
s.set_rotation_origin(at)?;
let on = s.ds()?.origin_handle().is_some();
Ok(origin_json(s, on))
}
),
spec!(
"transform.scale",
"Scale",
"{ids?, factor, cx?, cy? (pivot; default top-left)} — scales size, strokes, effects, text (the K tool)",
true,
scale_cmd
),
spec!("transform.resize", "Resize", "{ids?, width?, height?, anchor? (tl|t|tr|l|c|r|bl|b|br)}", true, resize_cmd),
]
}
#[derive(Clone, Copy)]
enum Al {
Left,
HCenter,
Right,
Top,
VCenter,
Bottom,
}
/// The selection's rotation origin as `{editing, moved, x, y}` (the centre when not moved).
fn origin_json(s: &Session, editing: bool) -> Value {
let Ok(ds) = s.ds() else { return json!({}) };
let at = ds.origin_point().unwrap_or_default();
json!({"editing": editing, "moved": ds.moved_origin().is_some(), "x": at.x, "y": at.y})
}
/// Move a node by `(dx, dy)` in page units.
pub fn move_world(doc: &mut Document, id: NodeId, dx: f64, dy: f64) -> Result<()> {
let n = doc.get(id)?;
let pw = n.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
let d = pw.inverse() * Affine::translate((dx, dy)) * pw;
let n = doc.get_mut(id)?;
n.transform = d * n.transform;
Ok(())
}
pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> Result<()> {
let world = doc.world_transform(id);
let nw = figcraft_geom::rotate_about(world, pivot, deg);
doc.set_world_transform(id, nw)?;
Ok(())
}
/// Whether aligning `ids` would move anything: a layer alone on the page has nothing to align to.
pub fn can_align(doc: &Document, ids: &[NodeId]) -> bool {
align_target(doc, &roots(doc, ids)).is_some()
}
/// What `roots` align to: one layer to its parent frame, several to their combined bounds.
fn align_target(doc: &Document, roots: &[NodeId]) -> Option<Rect> {
match roots {
[] => None,
[id] => {
let parent = doc.node(*id)?.parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page))?;
Some(doc.world_bounds(parent).unwrap_or_default())
}
_ => Some(doc.bounds_of(roots).unwrap_or_default()),
}
}
fn align(s: &mut Session, p: &Value, a: Al) -> Result<Value> {
let ids = req_targets("align", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let Some(target) = align_target(doc, &ids) else { return ok() };
for i in ids {
let Some(b) = doc.world_bounds(i) else { continue };
let (dx, dy) = match a {
Al::Left => (target.x0 - b.x0, 0.0),
Al::HCenter => (target.center().x - b.center().x, 0.0),
Al::Right => (target.x1 - b.x1, 0.0),
Al::Top => (0.0, target.y0 - b.y0),
Al::VCenter => (0.0, target.center().y - b.center().y),
Al::Bottom => (0.0, target.y1 - b.y1),
};
move_world(doc, i, dx, dy)?;
}
ok()
}
fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let ids = req_targets("distribute", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
if ids.len() < 3 && num(p, "spacing").is_none() {
return ok();
}
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum();
let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() };
let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 };
let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64);
let mut pos = if horizontal { first.1.x0 } else { first.1.y0 };
for (i, b) in items.clone() {
if horizontal {
move_world(doc, i, pos - b.x0, 0.0)?;
pos += b.width() + gap;
} else {
move_world(doc, i, 0.0, pos - b.y0)?;
pos += b.height() + gap;
}
}
ok()
}
/// Tidy up: snap the selection into a grid of rows/columns with even spacing.
fn tidy(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.tidy", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
if items.len() < 2 {
return ok();
}
// Group into rows by vertical centre overlap.
items.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
let mut rows: Vec<Vec<(NodeId, Rect)>> = Vec::new();
for it in items {
match rows.last_mut() {
Some(row) if row.iter().any(|(_, b)| it.1.center().y < b.y1 && it.1.center().y > b.y0) => row.push(it),
_ => rows.push(vec![it]),
}
}
let all: Vec<Rect> = rows.iter().flatten().map(|(_, b)| *b).collect();
let (Some(origin), maxw, maxh) = (
all.iter().copied().reduce(|a, b| a.union(b)),
all.iter().map(Rect::width).fold(0.0, f64::max),
all.iter().map(Rect::height).fold(0.0, f64::max),
) else {
return ok();
};
let gap = num(p, "spacing").unwrap_or_else(|| {
// Keep the average existing horizontal gap (min 10).
10f64.max(
rows.iter()
.filter(|r| r.len() > 1)
.map(|r| {
(r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max)
- r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min)
- r.iter().map(|(_, b)| b.width()).sum::<f64>())
/ (r.len() - 1) as f64
})
.fold(0.0, f64::max),
)
});
for (ri, row) in rows.iter_mut().enumerate() {
row.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
for (ci, (id, b)) in row.iter().enumerate() {
let x = origin.x0 + ci as f64 * (maxw + gap);
let y = origin.y0 + ri as f64 * (maxh + gap);
move_world(doc, *id, x - b.x0, y - b.y0)?;
}
}
ok()
}
fn pack(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("spacing".into(), json!(0.0));
}
distribute(s, &q, horizontal)
}
fn round_px(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.roundToPixels", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
let c = n.transform.as_coeffs();
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4].round(), c[5].round()]);
n.width = n.width.round().max(if n.width > 0.0 { 1.0 } else { 0.0 });
n.height = n.height.round().max(if n.height > 0.0 { 1.0 } else { 0.0 });
}
ok()
}
#[derive(Clone, Copy)]
enum Z {
Front,
Forward,
Backward,
Back,
}
fn zorder(s: &mut Session, p: &Value, z: Z) -> Result<Value> {
let ids = req_targets("arrange", s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let order: Vec<NodeId> = match z {
Z::Front | Z::Forward => ids.iter().rev().copied().collect(),
_ => ids.clone(),
};
for i in order {
let Some(parent) = doc.get(i)?.parent else { continue };
let len = doc.get(parent)?.children.len();
let cur = doc.index_in_parent(i).unwrap_or(0);
let siblings_selected = |k: usize, doc: &Document| doc.node(parent).and_then(|pn| pn.children.get(k)).is_some_and(|c| ids.contains(c));
let target = match z {
Z::Front => len.saturating_sub(1),
Z::Back => 0,
Z::Forward => {
if cur + 1 < len && !siblings_selected(cur + 1, doc) {
cur + 1
} else {
cur
}
}
Z::Backward => {
if cur > 0 && !siblings_selected(cur - 1, doc) {
cur - 1
} else {
cur
}
}
};
doc.reorder(i, target)?;
}
// Front/Back of several keep their relative order.
if matches!(z, Z::Front) {
for i in &ids {
let parent = doc.get(*i)?.parent;
if let Some(pp) = parent {
let len = doc.get(pp)?.children.len();
doc.reorder(*i, len)?;
}
}
}
if matches!(z, Z::Back) {
let mut next: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
for i in &ids {
let Some(pp) = doc.get(*i)?.parent else { continue };
let k = next.entry(pp).or_insert(0);
doc.reorder(*i, *k)?;
*k += 1;
}
}
ok()
}
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
// Editing a vector with points selected: flip them in their box.
let points = crate::vectoredit::transform::transform_in_edit_mode(s, p, |b| {
if horizontal { b.onto(b.width, 0.0, 0.0, b.height) } else { b.onto(0.0, b.height, b.width, 0.0) }
});
if let Some(r) = points {
return r;
}
let ids = req_targets("flip", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let Some(b) = doc.bounds_of(&ids) else { return ok() };
let c = b.center();
let m = if horizontal { Affine::new([-1.0, 0.0, 0.0, 1.0, 2.0 * c.x, 0.0]) } else { Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 2.0 * c.y]) };
for i in ids {
let w = doc.world_transform(i);
doc.set_world_transform(i, m * w)?;
}
ok()
}
fn rotate_by(s: &mut Session, p: &Value, deg: f64) -> Result<Value> {
if let Some(r) = crate::vectoredit::transform::transform_in_edit_mode(s, p, |b| figcraft_geom::rotate_about(Affine::IDENTITY, b.center(), deg)) {
return r;
}
let ids = req_targets("rotate", s, p)?;
let Some(pivot) = turn_pivot(s, &ids)? else { return ok() };
let doc = s.doc_mut()?;
for i in roots(doc, &ids) {
rotate_world(doc, i, pivot, deg)?;
}
ok()
}
/// Where `ids` turn: the selection's moved rotation origin when they are the selection, else the
/// centre of their bounds.
fn turn_pivot(s: &Session, ids: &[NodeId]) -> Result<Option<Point>> {
let ds = s.ds()?;
Ok(ds.moved_origin().filter(|_| ds.selection == ids).or_else(|| ds.doc.bounds_of(&roots(&ds.doc, ids)).map(|b| b.center())))
}
/// Scale a node and everything in it uniformly (the Scale tool): sizes and positions (whatever
/// the children's constraints), strokes, effects, radii, auto layout spacing, min/max sizes,
/// layout grids, ruler guides, tiled images and text sizes.
pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) };
let sub = doc.subtree(id);
let tiles = |paints: &mut Vec<figcraft_color::Paint>| {
for p in paints {
if let figcraft_color::PaintKind::Image(img) = &mut p.kind
&& img.scale_mode == figcraft_color::ImageScaleMode::Tile
{
img.scale *= k;
}
}
};
for (depth, i) in sub.iter().enumerate() {
let n = doc.get_mut(*i)?;
tiles(&mut n.fills);
tiles(&mut n.strokes);
let cl = &mut n.child_layout;
for v in [&mut cl.min_w, &mut cl.max_w, &mut cl.min_h, &mut cl.max_h].into_iter().flatten() {
*v *= k;
}
for g in &mut n.grids {
g.size *= k;
g.gutter *= k;
g.offset *= k;
}
for g in &mut n.guides {
g.offset *= k;
}
n.stroke.weight *= k;
if let Some(sides) = n.stroke.sides.as_mut() {
for v in sides.iter_mut() {
*v *= k;
}
}
for d in &mut n.stroke.dashes {
*d *= k;
}
n.corners.radii = n.corners.radii.map(|r| r * k);
for e in &mut n.effects {
match &mut e.kind {
EffectKind::DropShadow(sh) | EffectKind::InnerShadow(sh) => {
sh.radius *= k;
sh.spread *= k;
sh.offset = (sh.offset.0 * k, sh.offset.1 * k);
}
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => *b = b.scaled(k),
EffectKind::Noise(nz) => {
nz.size *= k;
nz.size_y = nz.size_y.map(|v| v * k);
}
EffectKind::Texture(t) => {
t.size *= k;
t.size_y = t.size_y.map(|v| v * k);
t.radius *= k;
}
EffectKind::Glass(g) => {
g.depth *= k;
g.frost *= k;
}
}
}
if let Some(l) = n.layout.as_mut() {
l.gap *= k;
l.cross_gap *= k;
l.padding = l.padding.map(|x| x * k);
for t in l.column_tracks.iter_mut().chain(l.row_tracks.iter_mut()).filter(|t| t.sizing == Sizing::Fixed) {
t.value *= k;
}
}
if let NodeKind::Text(t) = &mut n.kind {
let scale = |st: &mut TextStyle| {
st.font_size *= k;
if let LineHeight::Pixels(v) = &mut st.line_height {
*v *= k;
}
if let LetterSpacing::Pixels(v) = &mut st.letter_spacing {
*v *= k;
}
st.set_look(|l| {
for m in [&mut l.thickness, &mut l.offset] {
if let LineHeight::Pixels(v) = m {
*v *= k;
}
}
});
};
scale(&mut t.style);
for r in &mut t.runs {
scale(&mut r.style);
}
t.paragraph_spacing *= k;
t.paragraph_indent *= k;
t.list_spacing *= k;
}
if let NodeKind::Vector { network } = &mut n.kind {
network.scale(k, k);
}
n.width *= k;
n.height *= k;
if depth > 0 {
let c = n.transform.as_coeffs();
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]);
}
}
Ok(())
}
fn scale_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let k = req_num("transform.scale", p, "factor")?;
if k <= 0.0 {
return Err(bad("transform.scale", "`factor` must be positive"));
}
let ids = req_targets("transform.scale", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
let b = doc.bounds_of(&ids).unwrap_or_default();
let pivot = Point::new(num_or(p, "cx", b.x0), num_or(p, "cy", b.y0));
for i in ids {
let Some(nb) = doc.world_bounds(i) else { continue };
scale_node(doc, i, k)?;
// Keep relative position to the pivot.
let target = Point::new(pivot.x + (nb.x0 - pivot.x) * k, pivot.y + (nb.y0 - pivot.y) * k);
if let Some(after) = doc.world_bounds(i) {
move_world(doc, i, target.x - after.x0, target.y - after.y0)?;
}
}
ok()
}
pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("transform.resize", s, p)?;
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get(i)?;
let (ow, oh) = (n.width, n.height);
let (w, h) = (num(p, "width").unwrap_or(ow).max(0.0), num(p, "height").unwrap_or(oh).max(0.0));
let fixed_local = Point::new(ow * ax, oh * ay);
let before = n.transform * fixed_local;
// A section's contents stay where they are on the page, wherever its anchor puts it.
let kept = if matches!(n.kind, NodeKind::Section { .. }) { super::props::children_worlds(doc, i) } else { Vec::new() };
super::props::resize_node(doc, i, w, h)?;
let n = doc.get_mut(i)?;
let after = n.transform * Point::new(w * ax, h * ay);
n.transform = Affine::translate(before - after) * n.transform;
if num(p, "width").is_some() {
n.child_layout.sizing_h = Sizing::Fixed;
}
if num(p, "height").is_some() {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
}
super::props::restore_worlds(doc, &kept)?;
}
super::styles::detach_edited(doc, &before);
ok()
}
/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers.
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.spacing", s, p)?;
// A scrub is absolute from the interaction snapshot, even if tracks overlap or cross.
let ds = s.ds()?;
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let layout = crate::selection_spacing::selection_spacing(&base, &ids);
let mut offsets = std::collections::BTreeMap::<NodeId, (Option<f64>, Option<f64>)>::new();
let mut any = false;
for (key, axis, vertical) in [("horizontal", &layout.horizontal, false), ("vertical", &layout.vertical, true)] {
if get(p, key).is_none() {
continue;
}
let value = req_num("arrange.spacing", p, key)?;
if value.abs() > 1_000_000.0 {
return Err(bad("arrange.spacing", "spacing out of range"));
}
let axis = axis.as_ref().ok_or_else(|| bad("arrange.spacing", "selection has no spacing on this axis"))?;
any = true;
for (id, delta) in axis.offsets(value) {
if !delta.is_finite() {
return Err(bad("arrange.spacing", "non-finite spacing result"));
}
let pair = offsets.entry(id).or_default();
if vertical {
pair.1 = Some(delta);
} else {
pair.0 = Some(delta);
}
}
}
if !any {
return Err(bad("arrange.spacing", "pass horizontal or vertical spacing"));
}
let doc = s.doc_mut()?;
let mut moves = Vec::new();
for (id, (dx, dy)) in offsets {
let (Some(before), Some(now)) = (base.world_bounds(id), doc.world_bounds(id)) else { continue };
let (dx, dy) = (dx.map_or(0.0, |d| before.x0 + d - now.x0), dy.map_or(0.0, |d| before.y0 + d - now.y0));
if !dx.is_finite() || !dy.is_finite() {
return Err(bad("arrange.spacing", "non-finite spacing result"));
}
moves.push((id, dx, dy));
}
for (id, dx, dy) in moves {
move_world(doc, id, dx, dy)?;
}
ok()
}