//! 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 { 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.reorder", "Reorder selection", "{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)", true, reorder ), 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 { 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 { 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 { 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 { 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::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 = 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::()) / (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 { 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 { 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 { let ids = req_targets("arrange", s, p)?; let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); let order: Vec = 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 = 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 { // 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 { 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> { 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| { 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 { 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 { 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() } /// Reorder visual slots without rebuilding or reparenting layers. fn reorder(s: &mut Session, p: &Value) -> Result { let cmd = "arrange.reorder"; let ids = req_targets(cmd, s, p)?; let source = req_id(cmd, p, "source")?; let target = req_id(cmd, p, "target")?; 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_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?; let positions = layout .positions(source, target, boolean(p, "swap") == Some(true)) .ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?; // Validate every delta before editing; absolute targets always come from the gesture's // snapshot so crossing several slots and returning to the start does not accumulate edits. let doc = s.doc()?; let mut moves = Vec::new(); for (id, at) in positions { let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?; let (dx, dy) = (at.x - now.x0, at.y - now.y0); if !dx.is_finite() || !dy.is_finite() { return Err(bad(cmd, "non-finite reorder result")); } if dx.abs() > 1e-9 || dy.abs() > 1e-9 { let node = doc.get(id)?; let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform; if !transform.as_coeffs().iter().all(|v| v.is_finite()) { return Err(bad(cmd, "non-finite local transform")); } moves.push((id, dx, dy)); } } if !moves.is_empty() { let doc = s.doc_mut()?; for (id, dx, dy) in moves { move_world(doc, id, dx, dy)?; } } ok() } /// Set the distances between smart-selection tracks without rebuilding/reparenting the layers. fn spacing(s: &mut Session, p: &Value) -> Result { 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::, Option)>::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() }