Files
vectorcraft/crates/engine/src/cmd/object/align.rs
T
dotCooCoo 5ac7d97d85 Guides: ruler guides are document objects on their layers
Ruler guides were a list kept beside the layers. Each guide is now an
object of its layer or sublayer, always directly in it and above the
layer's art. These behave as on main:

- Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All,
  Select All, the art bounds, clicks and marquees, and a layer's
  Pathfinder and Transform effects leave them out. The Layers panel
  lists them as guide rows of their layer, the highest guide.list
  index first.
- Deleting a layer deletes its guides, and merging layers keeps their
  guides in the merged layer.
- The guide commands number guides as guide.list does, in the order
  they were made.

New: each guide takes an id from the document's object ids. An object
command given a guide's id fails with an error, and node.move cannot
put a guide into a group or out of its layers. A guide made while an
opacity mask, a symbol or a pattern is edited moves to the top
visible, unlocked layer when editing ends.

File format: native files still save the guides in the document's
guides list, in every file version, so older builds read them. Each
entry may now also carry the guide's id, name, locked and hidden
flags; older builds ignore those fields. Opening a file puts each
saved guide on its layer (on the top visible, unlocked layer when the
file names none or a layer it does not have), and each saved guide
counts against the object limit for opening a file. A ruler guide node
that a file holds below a layer's art moves up to the layer's guides,
and one anywhere else (in a group or another object, beside the
layers, or in a symbol) is removed. Ruler guides read from an .ai file
become guide objects on the layer they were on, numbered in the order
the file lists them.
2026-11-11 09:52:28 -08:00

586 lines
27 KiB
Rust

use super::transform::transform_patterns;
use super::{
Affine, Document, EngineError, NodeId, OrientedBox, Point, Rect, Result, Scaling, Session, Value, Vec2, bad, check_numbers, check_switches,
effects, has_multi, ok, selected_roots, str_param,
};
use vectorcraft_doc::{NodeKind, TextKind};
impl Session {
/// The bounds of `ids` the Transform panel and the bounding box measure: with Use Preview
/// Bounds on, visual bounds as drawn (strokes and geometry effects included, see
/// [`effects::preview`]), else geometric bounds; with Measure Type by Glyph Bounds on, point
/// type by the ink of its glyphs ([`Session::bounds_measure`]).
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
effects::preview::bounds_of_with(&self.doc().ok()?.doc, ids, self.bounds_measure())
}
/// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding
/// box, see [`Document::oriented_bounds`]).
pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> {
effects::preview::oriented_bounds_with(&self.doc().ok()?.doc, ids, self.bounds_measure())
}
/// How the bounding box, the Transform panel and `object.setBounds` measure objects: Use
/// Preview Bounds, and point type by its glyphs with Measure Type by Glyph Bounds
/// (`typeGlyphBounds`).
pub fn bounds_measure(&self) -> effects::preview::Measure {
effects::preview::Measure { preview: self.prefs.use_preview_bounds, glyphs: self.prefs.type_glyph_bounds }
}
}
/// How Align and Distribute measure each object: by visual bounds as drawn (`preview`, see
/// [`effects::preview::bounds_of`]) or geometric bounds, and point type (`point`) or area type
/// (`area`) by the ink of its glyphs instead of its em box or frame (Align to Glyph Bounds).
#[derive(Clone, Copy, Default)]
struct Measure {
preview: bool,
point: bool,
area: bool,
}
impl Measure {
/// The bounds of `ids` together.
fn bounds(self, d: &Document, ids: &[NodeId]) -> Option<Rect> {
if !(self.point || self.area) {
return effects::preview::bounds_of(d, ids, self.preview);
}
ids.iter().filter_map(|id| self.of(d, *id)).reduce(|a, b| a.union(b))
}
/// The bounds of `id`: with Align to Glyph Bounds on for its kind of type, its glyphs' ink
/// (with the characters' strokes when measuring visual bounds). Type on a path, and type that
/// draws nothing, is measured as before.
fn of(self, d: &Document, id: NodeId) -> Option<Rect> {
if let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind)
&& match t.kind {
TextKind::Point => self.point,
TextKind::Area { .. } => self.area,
TextKind::OnPath { .. } => false,
}
&& let Some(ink) = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds()
{
let r = t.xf.transform_rect_bbox(ink);
return Some(if self.preview { r.inflate(t.char_stroke_outset(), t.char_stroke_outset()) } else { r });
}
effects::preview::bounds_of(d, &[id], self.preview)
}
}
/// How the Align commands measure: preview (visual) bounds from the `bounds` param, else Use
/// Preview Bounds, and type by its glyphs as Align to Glyph Bounds says.
fn measure(s: &Session, p: &Value, cmd: &str) -> Result<Measure> {
let preview = match choice(p, cmd, "bounds", &["preview", "geometric"])? {
None => s.prefs.use_preview_bounds,
Some(b) => b == "preview",
};
Ok(Measure { preview, point: s.prefs.align_glyph_bounds_point, area: s.prefs.align_glyph_bounds_area })
}
/// Param `key` of the Align commands, Shear, Unlock All, Show All, a warp's `orientation` and `text.setFormat`'s lists: one
/// of `values`, or `None` when it is missing or null. Any other value is an error that lists `values`.
pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result<Option<&'a str>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
match v.as_str() {
Some(s) if values.contains(&s) => Ok(Some(s)),
_ => Err(bad(cmd, format!("`{key}` must be one of {}", values.join(", ")))),
}
}
/// `horizontal` and `vertical` of `object.align` and `object.distribute`, each checked by [`choice`].
fn direction<'a>(p: &'a Value, cmd: &str) -> Result<(Option<&'a str>, Option<&'a str>)> {
Ok((choice(p, cmd, "horizontal", &["left", "center", "right"])?, choice(p, cmd, "vertical", &["top", "center", "bottom"])?))
}
/// `spacing` of `object.distribute` and `object.distributeSpacing`: a finite number, or `None`
/// when it is missing or null. Any other value is an error.
fn spacing(p: &Value, cmd: &str) -> Result<Option<f64>> {
p.get("spacing")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad(cmd, "`spacing` must be a number")))
.transpose()
}
/// `ids` with their bounds, measured by `m`.
fn items_bounds(d: &Document, ids: &[NodeId], m: Measure) -> Vec<(NodeId, Rect)> {
ids.iter().filter_map(|id| Some((*id, m.of(d, *id)?))).collect()
}
/// Move each object of `moves` by its offset and each ruler guide of `guides` (id, position)
/// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says
/// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document
/// and its undo history stay as they are.
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(NodeId, f64)]) -> Result<Value> {
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
let guides: Vec<(NodeId, f64)> = {
let d = &s.doc()?.doc;
guides.iter().copied().filter(|&(i, pos)| d.ruler_guide(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect()
};
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() {
return ok();
}
s.edit(label, |d, _| {
for (id, dv) in moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), sc);
super::pixelgrid::keep_pixel_aligned(n, sc);
}
super::pixelgrid::keep_compound_aligned(d, *id, sc);
}
for &(i, pos) in &guides {
d.edit_ruler_guide(i, |g| g.pos = pos);
}
Ok(())
})?;
ok()
}
/// What `object.align` aligns to: `to`, else the key object (or key guide) when there is one,
/// else the selection.
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
str_param(p, "to").or_else(|| s.doc().ok()?.selection.has_key().then_some("key"))
}
/// The key guide `object.align` lines the objects up on (aligning to the key with one set,
/// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical.
fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> {
let st = s.doc().ok()?;
let g = st.doc.ruler_guide(st.selection.key_guide?)?;
(align_to(s, p) == Some("key")).then_some((g.pos, g.vertical))
}
/// Where Align puts the objects, `r`, and the axes it moves them along (x, y): a key guide gives
/// the line across its axis only (a vertical guide fixes x, a horizontal one y), so the other
/// axis stays as it is.
fn align_reference(s: &Session, p: &Value, ids: &[NodeId], m: Measure, own: Option<Rect>) -> Result<(Rect, bool, bool)> {
if let Some((pos, vertical)) = key_guide(s, p) {
return Ok((Rect::new(pos, pos, pos, pos), vertical, !vertical));
}
let r = match own {
Some(b) if align_to(s, p) != Some("artboard") => b,
_ => reference_rect(s, p, ids, m)?,
};
Ok((r, true, true))
}
/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to
/// Selection when no objects are selected.
fn selected_guides_bounds(st: &crate::DocState) -> Option<Rect> {
st.selection.guides.iter().filter_map(|&i| st.doc.ruler_guide(i)).fold(None, |acc: Option<Rect>, g| {
let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) };
Some(acc.map(|a| a.union(r)).unwrap_or(r))
})
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], m: Measure) -> Result<Rect> {
let st = s.doc()?;
match align_to(s, p) {
Some("artboard") => {
let b = m.bounds(&st.doc, ids).or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten()).unwrap_or_default();
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
// it, or with an index the document doesn't have, it uses the artboard under the center
// of the selection, or the first one when no artboard is there.
let named = p.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).and_then(|i| st.doc.artboards.get(i));
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
Ok(named.or(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()))?;
m.of(&st.doc, k).ok_or(EngineError::NoNode(k))
}
_ => {
if ids.is_empty() {
selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into()))
} else {
m.bounds(&st.doc, ids).ok_or_else(|| EngineError::Other("nothing to align".into()))
}
}
}
}
/// Returns the artboard for `to: "artboard"` of `object.distribute` and `object.distributeSpacing`
/// (command `cmd`), the same artboard as `object.align` uses ([`reference_rect`]). It returns
/// `None` for the other choices. A `to` outside the choices is an error. Both commands call it
/// before they space selected anchor points, and they space those points without the artboard.
fn distribute_to(s: &Session, p: &Value, ids: &[NodeId], m: Measure, cmd: &str) -> Result<Option<Rect>> {
match choice(p, cmd, "to", &["selection", "artboard", "key"])? {
Some("artboard") => reference_rect(s, p, ids, m).map(Some),
_ => Ok(None),
}
}
/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the
/// compound path or selected group the key is in. Align to the key object, and Distribute and
/// Distribute Spacing with a spacing, keep it in place.
fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
let st = s.doc().ok()?;
let ancestry = st.doc.ancestry(st.selection.key?)?;
ids.iter().copied().find(|id| ancestry.contains(id))
}
pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let m = measure(s, p, "object.align")?;
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
check_switches(p, &["patterns"], "object.align")?;
check_numbers(p, &["artboard"], "object.align")?;
if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical"));
}
// Anchor points selected with Direct Selection align themselves, as in Illustrator (#1180).
if !s.doc()?.selection.anchors.is_empty() {
return align_anchors(s, p, &ids, (h, v));
}
let (r, along_x, along_y) = align_reference(s, p, &ids, m, None)?;
let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y));
let key = key_root(s, &ids);
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
items_bounds(d, &ids, m)
.into_iter()
.filter(|(id, _)| Some(*id) != key || align_to(s, p) != Some("key"))
.map(|(id, b)| {
let dx = match h {
Some("left") => r.x0 - b.x0,
Some("center") => r.center().x - b.center().x,
Some("right") => r.x1 - b.x1,
_ => 0.0,
};
let dy = match v {
Some("top") => r.y0 - b.y0,
Some("center") => r.center().y - b.center().y,
Some("bottom") => r.y1 - b.y1,
_ => 0.0,
};
(id, Vec2::new(dx, dy))
})
.collect()
};
let guides = guide_moves(s, p, r, h, v)?;
apply_moves(s, "Align", p, &ids, &moves, &guides)
}
/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge,
/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that
/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put.
/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are
/// selected; with objects in the selection they stay put.
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(NodeId, f64)>> {
let st = s.doc()?;
let to = align_to(s, p);
let move_guides = match to {
Some("key" | "artboard") => true,
Some("selection") => st.selection.is_empty(),
_ => false,
};
if !move_guides {
return Ok(vec![]);
}
let at = |vertical: bool| match (vertical, h, v) {
(true, Some("left"), _) => Some(r.x0),
(true, Some("center"), _) => Some(r.center().x),
(true, Some("right"), _) => Some(r.x1),
(false, _, Some("top")) => Some(r.y0),
(false, _, Some("center")) => Some(r.center().y),
(false, _, Some("bottom")) => Some(r.y1),
_ => None,
};
let skip_key = to == Some("key");
Ok(st
.selection
.guides
.iter()
.filter(|&&i| !skip_key || Some(i) != st.selection.key_guide)
.filter_map(|&i| Some((i, at(st.doc.ruler_guide(i)?.vertical)?)))
.collect())
}
/// [`align`] of the selected anchor points (whole paths among them give all theirs): each point
/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard.
fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result<Value> {
let targets = crate::cmd::path::anchor_targets(s)?;
let d = &s.doc()?.doc;
let pts: Vec<Point> = targets
.iter()
.flat_map(|(id, refs)| refs.iter().filter_map(move |(si, ai)| d.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p)))
.collect();
let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else {
return ok();
};
// shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does
// (or to a key guide).
let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(bounds))?;
let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y));
let target = |q: Point| {
let x = match h {
Some("left") => r.x0,
Some("center") => r.center().x,
Some("right") => r.x1,
_ => q.x,
};
let y = match v {
Some("top") => r.y0,
Some("center") => r.center().y,
Some("bottom") => r.y1,
_ => q.y,
};
Point::new(x, y)
};
if pts.iter().all(|q| {
let dv = target(*q) - *q;
dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9
}) {
return ok();
}
s.edit("Align", |d, _| {
for (id, refs) in &targets {
let path = crate::cmd::path::path_mut(d, *id)?;
for &(si, ai) in refs {
if let Some(a) = path.anchor_mut(si, ai) {
a.translate(target(a.p) - a.p);
}
}
snap_anchor_path(d, *id);
}
Ok(())
})?;
ok()
}
/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct
/// Selection (they may all be on one path).
pub(super) fn can_distribute(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) {
Ok(())
} else {
has_multi(s)
}
}
/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide
/// across the axis (#1597), which has no size.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Spaced {
Object(NodeId),
Guide(NodeId),
}
/// The selected objects `ids` with their bounds (measured by `m`), and the selected
/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds.
/// Guides along the axis are left out, so they stay where they are.
fn spaced_items(s: &Session, ids: &[NodeId], m: Measure, horiz: bool) -> Result<Vec<(Spaced, Rect)>> {
let st = s.doc()?;
let objects = items_bounds(&st.doc, ids, m).into_iter().map(|(id, r)| (Spaced::Object(id), r));
let guides = st.selection.guides.iter().filter_map(|&i| {
let g = st.doc.ruler_guide(i).filter(|g| g.vertical == horiz)?;
Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos)))
});
Ok(objects.chain(guides).collect())
}
/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing:
/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when
/// aligning to the key (#2143).
fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option<usize> {
let key = match key_root(s, ids) {
Some(id) => Spaced::Object(id),
None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?,
};
items.iter().position(|(it, _)| *it == key)
}
/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution),
/// aligning to the key. It has no line there, so a spacing from it leaves everything where it
/// is, as Align does.
fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool {
key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz)
}
/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as
/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position.
fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result<Value> {
let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) };
let (mut moves, mut guides) = (vec![], vec![]);
let d = &s.doc()?.doc;
for &(it, delta) in deltas {
match it {
Spaced::Object(id) => moves.push((id, along(delta))),
Spaced::Guide(i) => guides.extend(d.ruler_guide(i).map(|g| (i, g.pos + delta))),
}
}
apply_moves(s, label, p, ids, &moves, &guides)
}
/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them
/// give all theirs): the points between the first and the last along the axis are spaced evenly,
/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis
/// gives the same result.
fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64>) -> Result<Value> {
let targets = crate::cmd::path::anchor_targets(s)?;
let d = &s.doc()?.doc;
let along = |q: Point| if horiz { q.x } else { q.y };
let mut pts: Vec<(NodeId, usize, usize, f64)> = targets
.iter()
.flat_map(|(id, refs)| {
refs.iter().filter_map(move |&(si, ai)| Some((*id, si, ai, along(d.node(*id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p))))
})
.collect();
pts.sort_by(|a, b| a.3.total_cmp(&b.3));
let (Some(first), Some(last)) = (pts.first().map(|q| q.3), pts.last().map(|q| q.3)) else {
return ok();
};
// Two points are the ends already; with a gap the second is placed from the first.
if pts.len() < if gap.is_some() { 2 } else { 3 } {
return ok();
}
let step = gap.unwrap_or((last - first) / (pts.len() - 1) as f64);
let moves: Vec<(NodeId, usize, usize, f64)> =
pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect();
if moves.iter().all(|m| m.3.abs() <= 1e-9) {
return ok();
}
s.edit(label, |d, _| {
for &(id, si, ai, delta) in &moves {
if let Some(a) = crate::cmd::path::path_mut(d, id)?.anchor_mut(si, ai) {
a.translate(if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) });
}
}
for (id, _) in &targets {
snap_anchor_path(d, *id);
}
Ok(())
})?;
ok()
}
/// After anchors of path `id` moved: snap the path, or the compound path that holds it, to the
/// pixel grid again when it is pixel-aligned.
fn snap_anchor_path(d: &mut Document, id: NodeId) {
let top = crate::cmd::path::compound_or_self(d, id);
if let Some(n) = d.node_mut(top) {
super::pixelgrid::snap_if_aligned(n);
}
}
pub(super) fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distribute")?;
check_numbers(p, &["artboard"], "object.distribute")?;
let ids = selected_roots(s)?;
let m = measure(s, p, "object.distribute")?;
let board = distribute_to(s, p, &ids, m, "object.distribute")?;
let spacing = spacing(p, "object.distribute")?;
// Anchor points selected with Direct Selection are distributed themselves, as Align aligns them.
if !s.doc()?.selection.anchors.is_empty() {
return match direction(p, "object.distribute")? {
(None, None) => Err(bad("object.distribute", "give horizontal or vertical")),
(h, _) => distribute_anchors(s, "Distribute", h.is_some(), None),
};
}
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
(Some(h), _) => (
true,
match h {
"left" => |r: &Rect, _| r.x0,
"right" => |r: &Rect, _| r.x1,
_ => |r: &Rect, _| r.center().x,
},
),
(_, Some(v)) => (
false,
match v {
"top" => |r: &Rect, _| r.y0,
"bottom" => |r: &Rect, _| r.y1,
_ => |r: &Rect, _| r.center().y,
},
),
_ => return Err(bad("object.distribute", "give horizontal or vertical")),
};
// The selected guides across the axis are spaced with the objects (#1597). A guide has no
// size, so its left edge, center and right edge are all its line.
let mut items = spaced_items(s, &ids, m, horiz)?;
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
let n = items.len();
if spacing.is_some() && key_guide_along(s, p, horiz) {
return ok();
}
// With a spacing, the key object (or the compound path or selected group it is in) or the key
// guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in
// their order. Without a key the spacing is not used. `from_key` is the key's index in
// `items` and the spacing.
let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g));
// Two items are enough on an artboard, where they go to its edges, and when they are spaced
// from a key.
if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) {
return ok();
}
let (first_box, last_box) = (&items[0].1, &items[n - 1].1);
// `first` and `last` are the lines that the first and last items end on. Without an artboard
// the first and last items stay where they are. On an artboard the first item moves until
// its left (top) edge is on the artboard's left (top) edge, and the last item moves until its
// right (bottom) edge is on the artboard's right (bottom) edge.
let (first, last) = match board {
Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1),
Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1),
None => (key(first_box, horiz), key(last_box, horiz)),
};
let deltas: Vec<(Spaced, f64)> = items
.iter()
.enumerate()
.map(|(i, (it, r))| {
let target = match from_key {
Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64),
None => first + (last - first) * i as f64 / (n - 1) as f64,
};
(*it, target - key(r, horiz))
})
.collect();
apply_spaced(s, "Distribute", p, &ids, horiz, &deltas)
}
pub(super) fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distributeSpacing")?;
check_numbers(p, &["artboard"], "object.distributeSpacing")?;
let ids = selected_roots(s)?;
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let m = measure(s, p, "object.distributeSpacing")?;
let board = distribute_to(s, p, &ids, m, "object.distributeSpacing")?;
let spacing = spacing(p, "object.distributeSpacing")?;
if !s.doc()?.selection.anchors.is_empty() {
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
}
// The selected guides across the axis are spaced with the objects, as items with no size (#1597).
let mut items = spaced_items(s, &ids, m, horiz)?;
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
if spacing.is_some() && key_guide_along(s, p, horiz) {
return ok();
}
let n = items.len();
let &[(_, first), .., (_, last)] = items.as_slice() else {
// Selected guides along the axis have nothing to space.
return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() };
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
// The items start at `lo`, and without a spacing the last one ends at `hi`. These are the
// first item's left (top) edge and the last one's right (bottom) edge, or the artboard's
// edges with Align to Artboard and no spacing.
let (lo, hi) = match board.filter(|_| spacing.is_none()) {
Some(r) if horiz => (r.x0, r.x1),
Some(r) => (r.y0, r.y1),
None if horiz => (first.x0, last.x1),
None => (first.y0, last.y1),
};
let gap = match spacing {
Some(g) => g,
None => (hi - lo - items.iter().map(|i| size(&i.1)).sum::<f64>()) / (n - 1) as f64,
};
let mut pos = lo;
let mut deltas = vec![];
for (it, r) in &items {
deltas.push((*it, pos - start(r)));
pos += size(r) + gap;
}
// With a spacing the key stays where it is: the key object, with the compound path or
// selected group it is in, or the key guide (#2143). The others are spaced from it.
let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1);
let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect();
apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas)
}