Files
vectorcraft/crates/engine/src/cmd/object.rs
T

1996 lines
95 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Object menu: transforms, arrange, group, lock/hide, compound paths, clipping masks, isolation,
//! align & distribute, object properties.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::corners::set_corners;
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
use super::opacitymask::percent;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.transform",
"Transform",
[],
None,
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
has_doc,
transform
),
cmd!(
"object.move",
"Move…",
["Object", "Transform"],
Some("Cmd+Shift+M"),
"{dx, dy, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
move_cmd
),
cmd!(
"object.rotate",
"Rotate…",
["Object", "Transform"],
None,
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} → {ids}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
rotate
),
cmd!(
"object.scale",
"Scale…",
["Object", "Transform"],
None,
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
scale
),
cmd!(
"object.reflect",
"Reflect…",
["Object", "Transform"],
None,
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
reflect
),
cmd!(
"object.shear",
"Shear…",
["Object", "Transform"],
None,
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
has_selection,
shear
),
cmd!(
"object.transformAgain",
"Transform Again",
["Object", "Transform"],
Some("Cmd+D"),
"{copy?, strokes?, corners?, patterns?, typeAreas?: bool (as object.transform's; default: the value the transform it repeats used, else object.transform's default)} the last transform's matrix on the selection (after a perspective move or scale: that plane-space transform, without these params)",
has_selection,
transform_again
),
cmd!(
"object.nudge",
"Nudge",
[],
None,
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
has_selection_or_guides,
nudge
),
cmd!("object.arrange.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| arrange(
s,
Arrange::Front
)),
cmd!("object.arrange.bringForward", "Bring Forward", ["Object", "Arrange"], Some("Cmd+]"), "{}", has_selection, |s, _| arrange(
s,
Arrange::Forward
)),
cmd!("object.arrange.sendBackward", "Send Backward", ["Object", "Arrange"], Some("Cmd+["), "{}", has_selection, |s, _| arrange(
s,
Arrange::Backward
)),
cmd!("object.arrange.sendToBack", "Send to Back", ["Object", "Arrange"], Some("Cmd+Shift+["), "{}", has_selection, |s, _| arrange(
s,
Arrange::Back
)),
cmd!("object.arrange.sendToCurrentLayer", "Send to Current Layer", ["Object", "Arrange"], None, "{}", has_selection, send_to_current_layer),
cmd!("object.group", "Group", ["Object"], Some("Cmd+G"), "{} → {id}", has_selection, group),
cmd!("object.ungroup", "Ungroup", ["Object"], Some("Cmd+Shift+G"), "{}", has_selection, ungroup),
cmd!("object.lock", "Selection", ["Object", "Lock"], Some("Cmd+2"), "{}", has_selection, lock),
cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", has_doc, unlock_all),
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
cmd!(
"object.compoundPath.release",
"Release",
["Object", "Compound Path"],
Some("Cmd+Alt+Shift+8"),
"{} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath",
has_selection,
compound_release
),
cmd!(
"object.clippingMask.make",
"Make",
["Object", "Clipping Mask"],
Some("Cmd+7"),
"{} the topmost selected object (a path, compound path or text, which loses its paint) clips the others: compound holes, even-odd fills and glyph outlines clip as drawn. Paint given to the clipping path later (paint commands with its id) shows: its fill behind the clipped art, its stroke over it → {id} of the clip group",
has_multi,
clip_make
),
cmd!(
"object.clippingMask.release",
"Release",
["Object", "Clipping Mask"],
Some("Cmd+Alt+7"),
"{} the selected clip groups become plain groups; their clipping path (path, compound path or text) stays, with the paint it has (none unless painted after Make)",
has_selection,
clip_release
),
cmd!(
"object.clippingMask.editContents",
"Edit Contents",
["Object", "Clipping Mask"],
None,
"{} select the clipped art of the selected clip groups → {count}",
has_selection,
|s, _| clip_edit(s, false)
),
cmd!(
"object.clippingMask.editMask",
"Edit Clipping Path",
["Object", "Clipping Mask"],
None,
"{} select the clipping paths of the selected clip groups → {count}",
has_selection,
|s, _| clip_edit(s, true)
),
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id}", has_doc, isolate),
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
cmd!(
"object.setProps",
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}",
has_doc,
set_props
),
cmd!(
"object.align",
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
has_selection,
align
),
cmd!(
"object.distribute",
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)",
can_distribute,
distribute
),
cmd!(
"object.distributeSpacing",
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)",
can_distribute,
distribute_spacing
),
cmd!(
"object.setBounds",
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
has_selection,
set_bounds
),
cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape),
cmd!(
"object.setLiveShape",
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
]
}
fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
if let Some(o) = point_param(p, "origin") {
return Ok(o);
}
// Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre.
let st = s.doc()?;
if !bool_or(p, "copy", false)
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
{
return Ok(b.center());
}
s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
}
/// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from
/// its `strokes` and `corners` params, else the preferences. The values join the command's journal
/// entry, so replaying it scales the same way whatever the preferences are then.
pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
let strokes = bool_or(p, "strokes", s.prefs.scale_strokes);
let corners = bool_or(p, "corners", s.prefs.scale_corners);
s.note_journal("strokes", json!(strokes));
s.note_journal("corners", json!(corners));
Scaling {
strokes,
effects: strokes.then_some(vectorcraft_render::effects::scale_effect),
keep_type_strokes: !strokes,
keep_corners: !corners,
..Scaling::default()
}
}
/// Transform Patterns: do the pattern fills and strokes of `ids` transform with them (`patterns`
/// param, else General › Transform Pattern Tiles)? Noted in the journal when they use a pattern,
/// so a replay moves their tiles alike whatever the preference is then.
pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result<bool> {
let on = bool_or(p, "patterns", s.prefs.transform_pattern_tiles);
let d = &s.doc()?.doc;
let mut uses = false;
let paths = d.index_paths(ids.iter().copied());
for n in ids.iter().filter_map(|id| paths.get(id)).filter_map(|p| d.node_at(p)) {
n.walk(&mut |c| uses |= vectorcraft_doc::pattern::uses_pattern(c, None));
}
if uses {
s.note_journal("patterns", json!(on));
}
Ok(on)
}
/// Transform Objects (`objects` param, on unless it is false): off, a transform leaves the art
/// where it is and, with Transform Patterns, moves only its pattern tiles.
pub(crate) fn transforms_objects(p: &Value) -> bool {
bool_or(p, "objects", true)
}
/// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off.
pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) {
if objects {
n.transform(xf, sc);
} else if sc.patterns {
vectorcraft_doc::pattern::transform_patterns_only(n, xf);
}
}
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
/// `patterns`: see [`transform_patterns`]; `objects`: see [`transforms_objects`]; `typeAreas`:
/// see [`resize_type_area`]). Outside an
/// interaction, records `xf` and the options it used for Transform Again. In an interaction whose
/// document is still its snapshot, a transform with `copy` keeps its copies ([`preview_copies`]),
/// and the next one that copies the same objects of the same snapshot transforms those copies.
///
/// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no
/// copy, only those anchors transform, their handles with them, and the objects selected whole.
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value, anchors: bool) -> Result<Value> {
let copy = bool_or(p, "copy", false);
let areas = bool_or(p, "typeAreas", false);
let objects = transforms_objects(p);
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
sc.patterns = transform_patterns(s, p, &ids)?;
// A transform of ids that name no object, or an identity transform without a copy, moves
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
// With a copy, the reply lists the copies, and there are none.
let d = &s.doc()?.doc;
let none = d.index_paths(ids.iter().copied()).is_empty();
let ids = if anchors && objects && !copy && !s.doc()?.selection.anchors.is_empty() {
if xf != Affine::IDENTITY {
super::path::transform_anchors(s, label, xf, sc)?;
}
ids
} else if none || (!copy && xf == Affine::IDENTITY) {
if copy { vec![] } else { ids }
} else {
let kept = if copy { preview_copies(s, &ids)? } else { None };
s.edit(label, |d, sel| {
let targets = match &kept {
// The document becomes the snapshot with the kept copies, untransformed, and the
// loop below transforms only the copies.
Some(k) => {
*d = Document::clone(&k.doc);
sel.clone_from(&k.selection);
k.ids.clone()
}
None if copy => duplicate_in(d, sel, &ids, Affine::IDENTITY)?,
None => ids.clone(),
};
// Transforms don't move nodes in the tree, so the paths found up front stay right.
let paths = d.index_paths(targets.iter().copied());
for id in &targets {
if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p))
&& !(objects && areas && resize_type_area(n, xf))
{
transform_node(n, xf, sc, objects);
}
}
Ok(targets)
})?
};
let st = s.doc_mut()?;
if st.interaction.is_none() {
let mut options = json!({ "copy": copy, "typeAreas": areas, "patterns": sc.patterns, "objects": objects });
if Scaling::factor(xf).is_some() {
options["strokes"] = json!(sc.strokes);
options["corners"] = json!(!sc.keep_corners);
}
st.last_transform = Some((xf, options));
st.last_perspective = None;
}
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Returns the copies of `roots` for a transform with `copy` in an interaction whose document is
/// still its snapshot, as in a preview ([`Session::preview`] puts the snapshot back first). These
/// are the copies the interaction keeps when they were made of that snapshot for the same `roots`;
/// otherwise this copies `roots` with [`duplicate_in`] in a clone of the snapshot, untransformed,
/// and the interaction keeps that clone. It returns `None` outside an interaction, and when the
/// document is no longer the snapshot, as for a command run in a typing session or a script batch
/// after an earlier edit there: the transform then copies the current document.
fn preview_copies(s: &mut Session, roots: &[NodeId]) -> Result<Option<Arc<crate::PreviewCopies>>> {
let st = s.doc_mut()?;
let Some(it) = &mut st.interaction else { return Ok(None) };
if !Arc::ptr_eq(&st.doc, &it.doc) {
return Ok(None);
}
if let Some(k) = &it.copies
&& Arc::ptr_eq(&k.from, &it.doc)
&& k.roots == roots
{
return Ok(Some(k.clone()));
}
let mut doc = it.doc.clone();
let mut selection = st.selection.clone();
let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?;
let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids });
it.copies = Some(k.clone());
Ok(Some(k))
}
/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text
/// reflows at its size. False for anything else (and type in perspective), which transforms as
/// usual.
fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
if n.extras().perspective.is_some() {
return false;
}
let NodeKind::Text(t) = &mut n.kind else { return false };
super::typecmd::reshape_area_with(t, |t| t.transform_area(xf))
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let (ids, anchors) = match ids_param(p, "ids") {
Some(v) => (v, false),
None => (selected_roots(s)?, bool_or(p, "anchors", false)),
};
apply_transform(s, "Transform", ids, m, p, anchors)
}
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true)
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
if !s.doc()?.selection.anchors.is_empty() {
// When the anchors move by 0, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 {
return ok();
}
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy, "patterns": p.get("patterns") }));
}
if s.doc()?.selection.is_empty() {
let copy = bool_or(p, "copy", false);
// When guides move by 0 without a copy, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 && !copy {
return ok();
}
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
}
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true)
}
fn about(o: Point, a: Affine) -> Affine {
Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2())
}
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let mut angle = f64_or(p, "angle", 0.0);
if bool_or(p, "absolute", false) {
angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids));
}
// Angles are counter-clockwise; y is down, so negate.
let a = about(o, Affine::rotate(-angle.to_radians()));
apply_transform(s, "Rotate", ids, a, p, true)
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
let sy = f64_or(p, "sy", sx * 100.0) / 100.0;
if sx == 0.0 || sy == 0.0 {
return Err(bad("object.scale", "scale must be non-zero"));
}
apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true)
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let m = match p.get("axis") {
Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0),
Some(Value::Number(n)) => {
let t = -n.as_f64().unwrap_or(90.0).to_radians();
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
}
None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0),
Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0),
Some(v) => {
let given = v.as_str().map_or_else(|| v.to_string(), str::to_string);
return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`")));
}
};
apply_transform(s, "Reflect", ids, about(o, m), p, true)
}
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical");
let m = if vertical { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
apply_transform(s, "Shear", ids, about(o, m), p, true)
}
/// The last transform's matrix on the selection, with the options `last_transform` keeps for it; an
/// option that `p` sets to true or false (a recorded Transform Again's) takes precedence.
fn transform_again(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(again) = s.doc()?.last_perspective.clone() {
return super::distortcmds::transform_again(s, &again);
}
let (m, mut options) = s.doc()?.last_transform.clone().ok_or_else(|| EngineError::Other("no previous transform".into()))?;
if let (Some(o), Some(given)) = (options.as_object_mut(), p.as_object()) {
o.extend(given.iter().filter(|(_, v)| v.is_boolean()).map(|(k, v)| (k.clone(), v.clone())));
}
let ids = selected_roots(s)?;
apply_transform(s, "Transform Again", ids, m, &options, true)
}
enum Arrange {
Front,
Forward,
Backward,
Back,
}
fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
let mut ids = selected_roots(s)?;
if matches!(how, Arrange::Forward | Arrange::Front) {
ids.reverse();
}
let label = match how {
Arrange::Front => "Bring to Front",
Arrange::Forward => "Bring Forward",
Arrange::Backward => "Send Backward",
Arrange::Back => "Send to Back",
};
// When nothing changes order, the document and its undo history stay as they are.
super::overprint::edit_counted(s, label, |d| {
let mut moved = 0;
// Arrange within each parent independently (selections may span layers/groups).
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
for id in &ids {
let par = d.parent_of(*id);
match by_parent.iter_mut().find(|(p, _)| *p == par) {
Some((_, v)) => v.push(*id),
None => by_parent.push((par, vec![*id])),
}
}
for (par, group) in by_parent {
for (k, id) in group.iter().enumerate() {
let Some((_, idx, len)) = d.position(*id) else { continue };
if len == 0 {
continue;
}
let last = len - 1;
let to = match how {
Arrange::Front => last.saturating_sub(k),
Arrange::Back => k.min(last),
Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx),
Arrange::Backward => idx.saturating_sub(1).max(k.min(idx)),
};
if to != idx {
d.move_node(*id, par, to)?;
moved += 1;
}
}
}
Ok(moved)
})?;
ok()
}
fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// Ids are reused after undo: the remembered current layer must still be a layer.
let st = s.doc()?;
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
// When the current layer is hidden or locked, or is inside a hidden or locked layer, the
// command fails with the error that `DocState::target_parent` gives for new art.
if !st.doc.is_editable(layer) {
return Err(EngineError::Other("the target layer is locked or hidden".into()));
}
// When the selected objects are already the topmost in the current layer, in paint order, the
// document and its undo history stay as they are.
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
return ok();
}
s.edit("Send to Current Layer", |d, _| {
let moved = d.remove_many(&ids)?;
d.children_mut(Some(layer))?.extend(moved);
Ok(())
})?;
ok()
}
fn group(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let gid = s.edit("Group", |d, sel| {
let gid = group_nodes(d, &ids)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
if ids.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let gid = d.alloc_id();
Ok(d.wrap(ids, |children| Node::group(gid, children))?)
}
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no group selected, the document, the selection and the undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
return ok();
}
s.edit("Ungroup", |d, sel| {
let mut new_sel = vec![];
let mut ungrouped = vec![];
for id in &ids {
let Some(n) = d.node(*id) else { continue };
let NodeKind::Group { children, clip } = &n.kind else {
new_sel.push(*id);
continue;
};
let mut out = Vec::with_capacity(children.len());
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
// The clipping path keeps its paint (none, unless painted after Make).
if *clip
&& k == 0
&& let NodeKind::Path { clipping, .. } = &mut c.kind
{
*clipping = false;
}
c.opacity *= n.opacity;
new_sel.push(c.id);
out.push(c);
}
ungrouped.push((*id, out));
}
d.insert_beside(ungrouped, true)?;
sel.set(new_sel);
Ok(())
})?;
ok()
}
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already locked, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Lock", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = true;
}
}
sel.clear();
Ok(())
})?;
ok()
}
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if !n.is_layer() && f(n) {
v.push(n.id)
}
});
v
}
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
// With nothing locked, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Unlock All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = false;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already hidden, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Hide", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = false;
}
}
sel.clear();
Ok(())
})?;
ok()
}
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
// With nothing hidden, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Show All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = true;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let id = s.edit("Make Compound Path", |d, sel| {
d.node(backmost).ok_or(EngineError::NoNode(backmost))?;
let mut units: Vec<Vec<Arc<Node>>> = vec![];
let mut appearance = None;
for id in &ids {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) {
return Err(EngineError::Other("compound paths can only contain paths".into()));
}
compound_members(n, &mut appearance, &mut units);
}
let cid = d.alloc_id();
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
c.appearance = appearance.unwrap_or_default();
let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?;
// The paths in front run against the backmost one, so where they overlap it they cut holes
// under the non-zero fill rule (#1197). A compound path's paths turn together, by its
// first: its own holes (a letter's counters) stay holes.
let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum());
let first = |unit: &[Arc<Node>]| unit.first().map_or(0.0, |p| direction(p));
let back = units.first().map_or(0.0, |u| first(u));
for (i, unit) in units.into_iter().enumerate() {
let flip = i > 0 && back != 0.0 && first(&unit) == back;
for mut p in unit {
let pn = Arc::make_mut(&mut p);
pn.appearance = Appearance::default().into();
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
*live = None;
if flip {
path.reverse();
}
}
ch.push(p);
}
}
d.wrap(&ids, |_| c)?;
sel.set([cid]);
Ok(cid)
})?;
Ok(json!({ "id": id.0 }))
}
/// The paths in `n` (a path, a compound path, or a group searched in depth) onto `out`, in paint
/// order: each path alone, a compound path's paths together. Into `paint` goes the appearance of
/// the backmost found so far: the path's own, or its compound path's, which is what painted it. A
/// group has no paint of its own to give: a group of outlined type made a compound path that drew
/// nothing.
fn compound_members(n: &Node, paint: &mut Option<vectorcraft_doc::Shared<Appearance>>, out: &mut Vec<Vec<Arc<Node>>>) {
match (&n.kind, n.children()) {
(NodeKind::Path { .. }, _) => out.push(vec![Arc::new(n.clone())]),
(NodeKind::Compound { .. }, Some(paths)) if !paths.is_empty() => out.push(paths.clone()),
(_, children) => return children.into_iter().flatten().for_each(|c| compound_members(c, paint, out)),
}
paint.get_or_insert_with(|| n.appearance.clone());
}
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
fn releases(n: &Node) -> bool {
match &n.kind {
NodeKind::Compound { .. } => true,
NodeKind::Path { path, .. } => path.subpaths.len() > 1,
_ => false,
}
}
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no compound path selected, the document, the selection and the undo history stay as
// they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) {
return ok();
}
s.edit("Release Compound Path", |d, sel| {
let mut out = vec![];
let mut released = vec![];
for id in &ids {
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
let mut parts = vec![];
match &n.kind {
NodeKind::Compound { children, .. } => {
for c in children {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
out.push(c.id);
parts.push(c);
}
}
// Each subpath becomes a path of its own with the path's paint, in the same
// stacking order; the first keeps the path's id.
NodeKind::Path { path, .. } => {
for (k, sp) in path.subpaths.iter().enumerate() {
let mut c = if k == 0 { n.clone() } else { d.reid(&n) };
if let NodeKind::Path { path, live, .. } = &mut c.kind {
(*path, *live) = (PathData::single(sp.clone()), None);
}
out.push(c.id);
parts.push(c);
}
}
_ => {}
}
released.push((*id, parts));
}
d.insert_beside(released, true)?;
sel.set(out);
Ok(())
})?;
ok()
}
/// Make `top` (a path, compound path or text object) the clipping path: it loses its paint (it
/// stays unpainted after Release).
pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> {
as_clipping_path(d.node_mut(top).ok_or(EngineError::NoNode(top))?)
}
/// Turn path, compound path or text `c` into a clipping path (its paint goes).
pub(super) fn as_clipping_path(c: &mut Node) -> Result<()> {
if !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into()));
}
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
match &mut c.kind {
NodeKind::Path { clipping, .. } => *clipping = true,
NodeKind::Text(t) => {
for r in &mut t.runs {
(r.style.fill, r.style.stroke) = (Paint::None, Paint::None);
}
}
_ => {}
}
Ok(())
}
/// Stop clip group or clipped layer `id` clipping; its clipping path stays, with its paint.
pub(super) fn release_clip(d: &mut Document, id: NodeId) {
let Some(n) = d.node_mut(id).filter(|n| n.clips()) else { return };
n.set_clips(false);
let clip = n.children().and_then(|c| c.first()).map(|c| c.id);
if let Some(c) = clip.and_then(|c| d.node_mut(c))
&& let NodeKind::Path { clipping, .. } = &mut c.kind
{
*clipping = false;
}
}
fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(&top) = ids.last() else { return Err(EngineError::Other("nothing selected".into())) };
let gid = s.edit("Make Clipping Mask", |d, sel| {
make_clipping_path(d, top)?;
let gid = d.alloc_id();
// The topmost object, the clipping path, goes first, and the others follow in paint order.
d.wrap(&ids, |mut children| {
children.rotate_right(1);
Node::new(gid, NodeKind::Group { children, clip: true })
})?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no clip group selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) {
return ok();
}
s.edit("Release Clipping Mask", |d, _| {
for id in &ids {
release_clip(d, *id);
}
Ok(())
})?;
ok()
}
/// Selects the clipping path (`mask`) or the clipped contents of every selected clip group (or of the
/// clip group containing a selected object), like Illustrator's Edit Contents / Edit Clipping Path toggle.
fn clip_edit(s: &mut Session, mask: bool) -> Result<Value> {
let st = s.doc()?;
let mut ids = vec![];
for id in st.selection.objects.iter().copied() {
let mut cur = Some(id);
while let Some(c) = cur {
if let Some(NodeKind::Group { children, clip: true }) = st.doc.node(c).map(|n| &n.kind) {
if mask {
ids.extend(children.first().map(|c| c.id));
} else {
ids.extend(children.iter().skip(1).map(|c| c.id));
}
break;
}
cur = st.doc.position(c).and_then(|(par, _, _)| par);
}
}
ids.dedup();
if ids.is_empty() {
return Err(EngineError::Other("select a clip group".into()));
}
s.select(|_, sel| sel.set(ids.iter().copied()))?;
Ok(json!({ "count": ids.len() }))
}
fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id")
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let st = s.doc_mut()?;
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
// isolated: a double-click on it edits its text.
if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) {
return Err(bad("object.isolate", "type objects can't be isolated"));
}
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
st.isolation = Some(isolation);
st.selection.clear();
st.revision += 1;
ok()
}
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
super::distortcmds::finish_edit_text(st, i);
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
st.selection.set([target]);
}
st.revision += 1;
ok()
}
/// The most data entries `object.setProps` takes at once.
const MAX_DATA: usize = 256;
/// `object.setProps`'s `data`: each key (a data-* attribute's name: letters, digits, `-`, `_`,
/// `.`) with its value, or `None` to remove it.
fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<String>)>> {
const C: &str = "object.setProps";
if m.len() > MAX_DATA {
return Err(bad(C, format!("data takes at most {MAX_DATA} keys at once")));
}
m.iter()
.map(|(k, v)| {
let k = k.strip_prefix("data-").unwrap_or(k);
if k.is_empty() || !k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) || k == "name" || k.starts_with("vc-") {
return Err(bad(C, format!("data key `{k}`: letters, digits, -, _ and . (not name or vc-…)")));
}
let v = match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
};
Ok((k.to_string(), v))
})
.collect()
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
let blend = match str_param(p, "blend") {
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
None => None,
};
let data = match p.get("data") {
None | Some(Value::Null) => None,
Some(Value::Object(m)) => Some(data_param(m)?),
Some(v) => return Err(bad("object.setProps", format!("data must be an object of key: value, not {v}"))),
};
let knockout = match p.get("knockout") {
Some(v) => Some(
Knockout::from_value(v)
.ok_or_else(|| bad("object.setProps", format!("knockout must be \"on\", \"off\", \"neutral\" or a bool, not {v}")))?,
),
None => None,
};
let hides = p.get("visible").and_then(Value::as_bool) == Some(false) || p.get("locked").and_then(Value::as_bool) == Some(true);
// When every object already has every value given, the document and its undo history stay as
// they are.
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let has_values = |n: &Node| {
str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty()))
&& flag("visible").is_none_or(|v| n.visible == v)
&& flag("locked").is_none_or(|v| n.locked == v)
&& opacity.is_none_or(|v| n.opacity == v)
&& blend.is_none_or(|b| n.blend == b)
&& flag("isolate").is_none_or(|v| n.isolate == v)
&& knockout.is_none_or(|k| n.knockout == k)
&& flag("knockoutShape").is_none_or(|v| n.knockout_shape == v)
&& data.iter().flatten().all(|(k, v)| {
n.extras().attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref()
})
};
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
// Hidden or locked objects can't stay selected.
if hides {
s.select(|d, sel| sel.deselect_uneditable(d))?;
}
return ok();
}
s.edit("Object Properties", |d, sel| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(v) = str_param(p, "name") {
n.name = if v.is_empty() { None } else { Some(v.to_string()) };
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
n.visible = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
n.locked = v;
}
if let Some(v) = opacity {
n.opacity = v;
}
if let Some(b) = blend {
n.blend = b;
}
if let Some(v) = p.get("isolate").and_then(Value::as_bool) {
n.isolate = v;
}
if let Some(k) = knockout {
n.knockout = k;
}
if let Some(v) = p.get("knockoutShape").and_then(Value::as_bool) {
n.knockout_shape = v;
}
if let Some(changes) = &data {
n.edit_attrs(|a| {
for (k, v) in changes {
match (a.data.iter().position(|(key, _)| key == k), v) {
(Some(i), Some(v)) => {
if let Some(e) = a.data.get_mut(i) {
e.1 = v.clone();
}
}
(Some(i), None) => {
a.data.remove(i);
}
(None, Some(v)) => a.data.push((k.clone(), v.clone())),
(None, None) => {}
}
}
});
}
}
// Hidden or locked objects can't stay selected.
if hides {
sel.deselect_uneditable(d);
}
Ok(())
})?;
ok()
}
impl Session {
/// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure:
/// visual bounds (strokes included) with Use Preview Bounds on, else geometric bounds.
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
}
/// [`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> {
self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds)
}
}
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
match choice(p, cmd, "bounds", &["preview", "geometric"])? {
None => Ok(s.prefs.use_preview_bounds),
Some(b) => Ok(b == "preview"),
}
}
/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or
/// null. Any other value is an error that lists `values`.
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"])?))
}
/// `ids` with their bounds (`preview`: visual bounds).
fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> {
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// Move each object of `moves` by its offset, 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)]) -> Result<Value> {
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) {
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);
}
}
Ok(())
})?;
ok()
}
/// What `object.align` aligns to: `to`, else the key object 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.key.map(|_| "key"))
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
let st = s.doc()?;
match align_to(s, p) {
Some("artboard") => {
let b = st.doc.bounds_of(ids, preview).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()))?;
st.doc.bounds_of(&[k], preview).ok_or(EngineError::NoNode(k))
}
_ => st.doc.bounds_of(ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
}
}
/// 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 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))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.align")?;
let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
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 = reference_rect(s, p, &ids, preview)?;
let key = key_root(s, &ids);
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
items_bounds(d, &ids, preview)
.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()
};
apply_moves(s, "Align", p, &ids, &moves)
}
/// [`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 = super::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.
let r = if align_to(s, p) == Some("artboard") { reference_rect(s, p, ids, false)? } else { bounds };
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 = super::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);
}
}
}
Ok(())
})?;
ok()
}
/// Distribute needs two or more objects, or anchor points selected with Direct Selection (they
/// may all be on one path).
fn can_distribute(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|st| !st.selection.anchors.is_empty()) { Ok(()) } else { has_multi(s) }
}
/// [`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 = super::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) = super::path::path_mut(d, id)?.anchor_mut(si, ai) {
a.translate(if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) });
}
}
Ok(())
})?;
ok()
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let preview = preview_bounds(s, 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 mut items = items_bounds(&s.doc()?.doc, &ids, preview);
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")),
};
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
let n = items.len();
if n < 3 {
return ok();
}
let first = key(&items[0].1, horiz);
let last = key(&items[n - 1].1, horiz);
let moves: Vec<(NodeId, Vec2)> = items
.iter()
.enumerate()
.map(|(i, (id, r))| {
let target = first + (last - first) * i as f64 / (n - 1) as f64;
let delta = target - key(r, horiz);
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
apply_moves(s, "Distribute", p, &ids, &moves)
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?;
let spacing = p
.get("spacing")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().filter(|g| g.is_finite()).ok_or_else(|| bad("object.distributeSpacing", "`spacing` must be a number")))
.transpose()?;
if !s.doc()?.selection.anchors.is_empty() {
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
}
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let n = items.len();
let &[(_, first), .., (_, last)] = items.as_slice() else {
return Err(EngineError::Other("select two or more objects to distribute".into()));
};
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let gap = match spacing {
Some(g) => g,
None => {
let span = if horiz { last.x1 - first.x0 } else { last.y1 - first.y0 };
(span - items.iter().map(|i| size(&i.1)).sum::<f64>()) / (n - 1) as f64
}
};
let mut pos = start(&first);
let mut deltas = vec![];
for (id, r) in &items {
deltas.push((*id, pos - start(r)));
pos += size(r) + gap;
}
// With a spacing (aligning to a key object) the key object stays where it is, with the
// compound path or selected group it is in: the others are spaced from it.
let key = key_root(s, &ids);
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
apply_moves(s, "Distribute Spacing", p, &ids, &moves)
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let st = s.doc()?;
if transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
{
return set_anchor_bounds(s, p, ids, b);
}
let d = &s.doc()?.doc;
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
// the math below runs in its frame.
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
let g = frame.rect;
// With Use Preview Bounds the values measure the visual box, whose margins around the
// geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of
// `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their
// weight when an instance scales, so the rest of the margins stay.
let (v, vs) = if s.prefs.use_preview_bounds {
(d.oriented_bounds(&ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g))
} else {
(g, g)
};
let margins = |b: Rect| [g.x0 - b.x0, g.y0 - b.y0, b.x1 - g.x1, b.y1 - g.y1];
let (m, all) = (margins(vs), margins(v));
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(v, refi);
let (w, h) = wanted_size(p, v);
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
// exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the
// scales where it misses.
let strokes = bool_or(p, "strokes", s.prefs.scale_strokes);
let ratio = |size: f64, geo: f64, margin: f64| if geo > 1e-9 { (size - margin) / geo } else { 1.0 };
let f = |sx: f64, sy: f64| if strokes { (sx * sy).abs().sqrt() } else { 1.0 };
// The strokes' scale `f0`: the size left after the margins in symbols' art, which stay, over
// `vs`, whose margins scale.
let (lx, ly) = (ratio(w - fixed[0] - fixed[2], vs.width(), 0.0), ratio(h - fixed[1] - fixed[3], vs.height(), 0.0));
let f0 = f(if g.width() > 1e-9 { lx } else { 1.0 }, if g.height() > 1e-9 { ly } else { 1.0 });
// When a size of 0 or more is smaller than the margins and some of them stay, the geometry
// gets no width or height: the visual box is then as small as it can be. The margins in
// symbols' art stay, and so does every margin when strokes do not scale.
let fit = |size: f64, geo: f64, scaling: f64, kept: f64| {
let s = ratio(size, geo, scaling * f0 + kept);
let stays = if strokes { kept } else { scaling + kept };
if s < 0.0 && size >= 0.0 && stays > 0.0 { 0.0 } else { s }
};
let sx = fit(w, g.width(), m[0] + m[2], fixed[0] + fixed[2]);
let sy = fit(h, g.height(), m[1] + m[3], fixed[1] + fixed[3]);
let scale = about(rp, Affine::scale_non_uniform(sx, sy));
// Move the new visual box's reference point to x, y (default: where it was): the geometric
// box's reference point moves with the art, the margin from it to `vs`'s by `f`, and the
// margin from that one to the visual box's stays.
let gp = vectorcraft_geom::reference_point(g, refi);
let sp = vectorcraft_geom::reference_point(vs, refi);
let new_rp = scale * gp + (sp - gp) * f(sx, sy) + (rp - sp);
// x, y place the reference point on the page.
let page = frame.to_doc() * rp;
// When every value given is the current one, the transform is the identity. Computing it can
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false);
}
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
// The scales and the point above can miss the size or the reference point when the margins of
// the objects differ, the members of groups and the objects in symbols' art included, and with
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
// by one ([`Spans::measure`]), and the scales solved for.
let solved = if s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0 {
Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp))
} else {
None
};
let (scale, new_rp) = solved.map_or((scale, new_rp), |(sx, sy, at)| (about(rp, Affine::scale_non_uniform(sx, sy)), at));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
}
/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or
/// with `proportional` follows the one given.
fn wanted_size(p: &Value, b: Rect) -> (f64, f64) {
let mut w = f64_or(p, "width", b.width());
let mut h = f64_or(p, "height", b.height());
if bool_or(p, "proportional", false) {
if p.get("width").is_some() && b.width() > 0.0 {
h = b.height() * w / b.width();
} else if p.get("height").is_some() && b.height() > 0.0 {
w = b.width() * h / b.height();
}
}
(w, h)
}
/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of
/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only
/// the anchors, their handles with them, and those objects move or scale.
fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec<NodeId>, b: Rect) -> Result<Value> {
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(b, refi);
let (w, h) = wanted_size(p, b);
// A single anchor, or anchors in a line, have no size to scale on that side.
let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 };
let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height())));
let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y));
apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true)
}
/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with
/// symbol instances measured without the strokes in their art
/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]).
fn scaling_box(d: &Document, ids: &[NodeId], frame: &OrientedBox) -> Option<Rect> {
let symbols = d.symbol_boxes().without_art_strokes();
let to_local = frame.to_doc().inverse();
ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| {
let b = if frame.angle == 0.0 { n.visual_bounds_with(&symbols) } else { n.bounds_in(&symbols, to_local, true) };
vectorcraft_geom::union_opt(acc, b)
})
}
/// One axis of an object that [`set_bounds`] sizes, square to its frame: the geometric box runs
/// from `lo` to `hi`, and the visual box reaches past each end (before, after) by a margin that
/// scales with the strokes and one that stays.
#[derive(Clone, Copy, Debug)]
struct Span {
lo: f64,
hi: f64,
scaling: [f64; 2],
kept: [f64; 2],
}
impl Span {
/// The ends of the visual box after a scale by `s` about `c`, with the margins that scale
/// grown by `k`. A negative scale turns the box over, and its margins with it.
fn scaled(self, c: f64, s: f64, k: f64) -> (f64, f64) {
let ([sb, sa], [kb, ka]) = (self.scaling, self.kept);
let (before, after) = (sb * k + kb, sa * k + ka);
let (a, b) = (c + s * (self.lo - c), c + s * (self.hi - c));
if s < 0.0 { (b - after, a + before) } else { (a - before, b + after) }
}
}
/// The objects that [`set_bounds`] sizes, measured one by one square to its frame.
struct Spans {
x: Vec<Span>,
y: Vec<Span>,
/// Whether strokes scale. The margins that scale grow by the square root of the product of the
/// two scales; with strokes that do not scale, every margin stays.
strokes: bool,
}
impl Spans {
/// The objects of `ids` that have a box, measured one by one as [`Document::oriented_bounds`]
/// measures them square to `frame`, each plain group member by member and each symbol instance
/// that [`open`] opens object by object in its art. Past [`MAX_SPANS`] parts, each object of
/// `ids` is measured as one. The margins of the visual box without the strokes in symbols' art
/// ([`scaling_box`]) scale with the strokes, and the rest stay. With `strokes` false, every
/// margin stays.
fn measure(d: &Document, ids: &[NodeId], frame: &OrientedBox, strokes: bool) -> Self {
let (symbols, without, art) = (d.symbol_boxes(), d.symbol_boxes().without_art_strokes(), d.symbol_boxes().quick());
let to_local = frame.to_doc().inverse();
let measure = |n: &Node, s: &vectorcraft_doc::SymbolBoxes, visual: bool| match (frame.angle == 0.0, visual) {
(true, false) => n.geometric_bounds_with(s),
(true, true) => n.visual_bounds_with(s),
(false, _) => n.bounds_in(s, to_local, visual),
};
let roots: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect();
let mut parts = vec![];
if !roots.iter().all(|n| open(n, 0.0, d, &symbols, to_local, &mut parts)) {
parts = roots.iter().map(|n| Part::Object(n, 0.0)).collect();
}
let mut out = Self { x: vec![], y: vec![], strokes };
for part in parts {
let (g, v, vs) = match part {
Part::Object(n, grow) => {
let Some(g) = measure(n, &symbols, false) else { continue };
let v = measure(n, &symbols, true).unwrap_or(g).inflate(grow, grow);
let vs = if strokes { measure(n, &without, true).unwrap_or(g).inflate(grow, grow) } else { g };
(g, v, vs)
}
// Measured as the instance's own box measures its art (`Node::visual_bounds_with`):
// the strokes at their own weight, and the instances in it by their art boxes.
Part::Art(n, xf, grow, kept) => {
let mut placed = n.clone();
placed.transform(xf, false);
let Some(g) = placed.geometric_bounds_with(&art) else { continue };
let v = placed.visual_bounds_with(&art).map_or(g, |v| v.union(g)).inflate(kept + grow, kept + grow);
let vs = if strokes { g.inflate(grow, grow) } else { g };
(g, v, vs)
}
};
out.x.push(Span { lo: g.x0, hi: g.x1, scaling: [g.x0 - vs.x0, vs.x1 - g.x1], kept: [vs.x0 - v.x0, v.x1 - vs.x1] });
out.y.push(Span { lo: g.y0, hi: g.y1, scaling: [g.y0 - vs.y0, vs.y1 - g.y1], kept: [vs.y0 - v.y0, v.y1 - vs.y1] });
}
out
}
/// The visual box after a scale by `sx`, `sy` about `c`.
fn visual(&self, c: Point, sx: f64, sy: f64) -> Option<Rect> {
let k = if self.strokes { (sx * sy).abs().sqrt() } else { 1.0 };
let boxes = self.x.iter().zip(&self.y).map(|(x, y)| {
let ((x0, x1), (y0, y1)) = (x.scaled(c.x, sx, k), y.scaled(c.y, sy, k));
Rect::new(x0, y0, x1, y1)
});
boxes.reduce(|a, b| a.union(b))
}
/// The scales along x and y that give the visual box the size `size` with a scale about `c`,
/// or come as near to it as the margins that stay allow ([`Self::solve`]), and the point that
/// goes to the reference point's place: point `refi` of the visual box they give. Returns
/// `None`, which keeps `closed` (the scales and the point of [`set_bounds`]'s closed form), when
/// the scales of `closed` put point `refi` at the point of `closed` and either give the size
/// `size` or are the scales [`Self::solve`] finds, to within rounding (1e-12 of the largest
/// value compared, or of 1).
fn refit(&self, c: Point, refi: usize, size: [f64; 2], closed: (f64, f64, Point)) -> Option<(f64, f64, Point)> {
let ([w, h], (sx, sy, at)) = (size, closed);
let b = self.visual(c, sx, sy)?;
let p = vectorcraft_geom::reference_point(b, refi);
let tolerance = 1e-12 * [b.x0, b.y0, b.x1, b.y1, w, h, at.x, at.y].iter().fold(1.0, |m: f64, v| m.max(v.abs()));
let near = |a: f64, b: f64| (a - b).abs() <= tolerance;
let placed = near(p.x, at.x) && near(p.y, at.y);
if placed && near(b.width(), w) && near(b.height(), h) {
return None;
}
let (tx, ty) = self.solve(w, h);
let same = |a: f64, b: f64| (a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0);
if placed && same(tx, sx) && same(ty, sy) {
return None;
}
let b = self.visual(c, tx, ty)?;
Some((tx, ty, vectorcraft_geom::reference_point(b, refi)))
}
/// The scales of 0 or more along x and y that give the visual box the size `w` × `h`, each
/// found by [`scale_across`]. With strokes that scale, the margins that scale grow by `k`,
/// found where it is the square root of the product of the two scales.
fn solve(&self, w: f64, h: f64) -> (f64, f64) {
let at = |k: f64| (scale_across(&self.x, k, w), scale_across(&self.y, k, h));
if !self.strokes {
return at(1.0);
}
// The scales get smaller as `k` grows, so the square root of their product minus `k` is at
// least 0 at `k` 0 and at most 0 at `k` equal to that root. Bisection finds the `k` between
// the two where it is 0.
let (sx, sy) = at(0.0);
let (mut lo, mut hi) = (0.0, (sx * sy).sqrt());
if hi.is_nan() || hi <= 0.0 {
return (sx, sy);
}
for _ in 0..200 {
let mid = lo + (hi - lo) / 2.0;
if mid <= lo || mid >= hi {
break;
}
let (sx, sy) = at(mid);
if (sx * sy).sqrt() > mid {
lo = mid;
} else {
hi = mid;
}
}
let miss = |k: f64| {
let (sx, sy) = at(k);
((sx * sy).sqrt() - k).abs()
};
at(if miss(lo) < miss(hi) { lo } else { hi })
}
}
/// Most parts [`Spans::measure`] opens the objects it sizes into: past it, each object is one part.
const MAX_SPANS: usize = 4096;
/// What [`Spans::measure`] measures as one span.
enum Part<'a> {
/// An object of the selection, or a member of a plain group in it, and the outset of the
/// strokes of the plain groups around it, which scale with its own.
Object(&'a Node, f64),
/// An object in the art of a symbol instance and the transform that places it square to the
/// frame. The outset of the strokes of the instance and of the plain groups around the instance
/// scales with the strokes; the outset of the strokes of the plain groups around the object in
/// the art stays, as the strokes in the art do.
Art(&'a Node, Affine, f64, f64),
}
/// Adds the parts that [`Spans::measure`] measures `n` by to `parts`, `grow` being the outset of
/// the strokes of the plain groups around it ([`Part::Object`]). A plain group ([`opens`]) adds the
/// parts of each member. A symbol instance without effects or an opacity mask of its own adds the
/// objects of its art, with plain groups there opened too ([`Part::Art`]), when its symbol's art
/// measures it, it is not in perspective, and its transform keeps the art square to the frame (a
/// turned or slanted instance is measured by the box around its turned art box). Anything else is
/// one part. Returns false when the parts pass [`MAX_SPANS`].
fn open<'a>(n: &'a Node, grow: f64, d: &'a Document, symbols: &vectorcraft_doc::SymbolBoxes, to_local: Affine, parts: &mut Vec<Part<'a>>) -> bool {
if opens(n) {
match &n.kind {
NodeKind::Group { children, .. } => {
let grow = grow + n.appearance.outset();
return children.iter().all(|c| open(c, grow, d, symbols, to_local, parts));
}
NodeKind::SymbolInstance { symbol, xf } => {
let xf = to_local * *xf;
if n.projection().is_none()
&& square(xf)
&& symbols.art_bounds(symbol).is_some()
&& let Some(art) = d.symbols.iter().find(|s| s.name == *symbol)
{
return open_art(&art.art, xf, grow + n.appearance.outset(), 0.0, parts);
}
}
_ => {}
}
}
parts.push(Part::Object(n, grow));
parts.len() <= MAX_SPANS
}
/// [`open`] for `n` in symbol art that `xf` places square to the frame: a plain group adds the parts
/// of each member, and anything else, a symbol instance included, is one part. `kept` is the outset
/// of the strokes of the plain groups around `n` in the art, and `grow` that of the instance and the
/// plain groups around it.
fn open_art<'a>(n: &'a Node, xf: Affine, grow: f64, kept: f64, parts: &mut Vec<Part<'a>>) -> bool {
if let NodeKind::Group { children, .. } = &n.kind
&& opens(n)
{
let kept = kept + n.appearance.outset();
return children.iter().all(|c| open_art(c, xf, grow, kept, parts));
}
parts.push(Part::Art(n, xf, grow, kept));
parts.len() <= MAX_SPANS
}
/// Whether [`open`] opens `n`: a group without a clip, a Shaper construction, effects or an opacity
/// mask of its own, or a symbol instance without effects or an opacity mask of its own.
fn opens(n: &Node) -> bool {
let visible = |effects: &[vectorcraft_doc::Effect]| effects.iter().any(|e| e.visible);
let effects = visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| visible(i.effects()));
let kind = match n.kind {
NodeKind::Group { clip, .. } => !clip && n.extras().shaper.is_none(),
NodeKind::SymbolInstance { .. } => true,
_ => false,
};
kind && !effects && n.extras().mask.is_none()
}
/// Whether `a` takes boxes square to the frame to boxes square to it: it scales, mirrors, turns by a
/// quarter turn and moves, and nothing else, to within rounding.
fn square(a: Affine) -> bool {
let [m0, m1, m2, m3, ..] = a.as_coeffs();
let tolerance = 1e-9 * [m0, m1, m2, m3].iter().fold(0.0, |m: f64, v| m.max(v.abs()));
m1.abs() <= tolerance && m2.abs() <= tolerance || m0.abs() <= tolerance && m3.abs() <= tolerance
}
/// The largest scale of 0 or more at which `spans`, with the margins that scale grown by `k`,
/// reach across `size` together. Returns 1 when their geometric boxes reach 1e-9 or less across
/// together, as the closed form in [`set_bounds`] does, and 0 when the spans reach farther than
/// `size` at the scale 0.
fn scale_across(spans: &[Span], k: f64, size: f64) -> f64 {
let o = spans.iter().map(|s| s.lo).fold(f64::INFINITY, f64::min);
let across = spans.iter().map(|s| s.hi).fold(f64::NEG_INFINITY, f64::max) - o;
// How far the spans reach together after a scale by `s` about `o`, and how fast that changes
// with `s`: the far geometric end minus the near one, of the objects that reach farthest at the
// two ends.
let reach = |s: f64| {
let (mut start, mut end, mut slope_start, mut slope_end) = (f64::INFINITY, f64::NEG_INFINITY, 0.0, 0.0);
for sp in spans {
let (a, b) = sp.scaled(o, s, k);
if a < start {
(start, slope_start) = (a, sp.lo - o);
}
if b > end {
(end, slope_end) = (b, sp.hi - o);
}
}
(end - start, slope_end - slope_start)
};
if across.is_nan() || across <= 1e-9 {
return 1.0;
}
let least = reach(0.0).0;
if least.is_nan() || least > size {
return 0.0;
}
// `reach` is convex in `s` and at most `size` at 0, so it equals `size` at one largest scale.
// Newton's method, started at a larger scale, reaches it one linear piece of `reach` at a
// time; bisection finishes the search when a step makes no progress.
let (mut lo, mut hi) = (0.0, size / across);
for _ in 0..64 {
if reach(hi).0 >= size {
break;
}
hi = 2.0 * hi + 1.0;
}
for _ in 0..64 {
let (r, slope) = reach(hi);
if r <= size {
return hi;
}
let next = hi - (r - size) / slope;
if next.is_nan() || next <= lo || next >= hi {
break;
}
hi = next;
}
for _ in 0..64 {
let mid = lo + (hi - lo) / 2.0;
if mid <= lo || mid >= hi {
break;
}
if reach(mid).0 <= size {
lo = mid;
} else {
hi = mid;
}
}
if size - reach(lo).0 <= reach(hi).0 - size { lo } else { hi }
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no live shape selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
return ok();
}
s.edit("Expand Shape", |d, _| {
for id in &ids {
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*live = None;
}
}
Ok(())
})?;
ok()
}
/// The `corners` of `object.setLiveShape`: anchor indices of a path's uncut outline.
fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) };
let err = || bad("object.setLiveShape", format!("`corners` must list anchor indices (0, 1, 2…), not {v}"));
let list = v.as_array().ok_or_else(err)?;
list.iter().map(|k| k.as_u64().and_then(|k| usize::try_from(k).ok()).ok_or_else(err)).collect::<Result<_>>().map(Some)
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
let items = match p.get("items").filter(|v| !v.is_null()) {
None => None,
Some(Value::Array(a)) => Some(
a.iter()
.map(|it| {
let id = it
.get("id")
.and_then(Value::as_u64)
.map(NodeId)
.ok_or_else(|| bad("object.setLiveShape", format!("each of `items` is {{id, corners?}} with an object id, not {it}")))?;
Ok((id, corners_param(it)?))
})
.collect::<Result<Vec<_>>>()?,
),
Some(v) => return Err(bad("object.setLiveShape", format!("`items` must be an array of {{id, corners?}}, not {v}"))),
};
let ids = match &items {
Some(items) => items.iter().map(|(id, _)| *id).collect(),
None => targets(s, p)?,
};
let shared = corners_param(p)?;
let corners_of = |id: NodeId| match &items {
Some(items) => items.iter().find(|(i, _)| *i == id).and_then(|(_, c)| c.clone()),
None => shared.clone(),
};
let kind = p
.get("kind")
.filter(|v| !v.is_null())
.map(|v| {
serde_json::from_value::<CornerKind>(v.clone())
.map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
})
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// A number that must be `what` (None when absent).
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && ok(*x))
.map(Some)
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))),
}
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
// ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
let pie_of = |pie: (f64, f64)| {
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
};
// When every value given is the current one, the document and its undo history stay as they
// are.
let st = s.doc()?;
let has_values = |id: &NodeId| {
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
match live.as_deref() {
Some(l @ LiveShape::Polygon { sides: now, .. })
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
&& (!equal || l.polygon_sides_equal())
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
{
return false;
}
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
_ => {}
}
if radius.is_none() && kind.is_none() {
return true;
}
let Some(c) = LiveCorners::new(path, live.as_deref()) else { return true };
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
// Setting corners folds a scale left in a rectangle's transform into its size, which
// changes it.
live.as_deref().is_none_or(|l| l.folded() == *l)
&& picked
.iter()
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
};
if ids.iter().all(has_values) {
return ok();
}
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
if let Some(l @ LiveShape::Polygon { .. }) = live.as_deref_mut()
&& (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal)
{
if let Some(n) = sides {
l.set_sides(n);
}
if equal {
l.make_sides_equal();
}
if let Some(a) = polygon_angle {
l.set_polygon_angle(a);
}
// A side length sets the radius that gives it (with the new side count).
if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) {
l.set_polygon_radius(r);
}
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_deref_mut()
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
*pie = pie_of(*pie);
}
*path = l.to_path();
}
if radius.is_none() && kind.is_none() {
continue;
}
let partial = sel.anchors.get_mut(id);
// The corners to edit, the Direct-Selected ones and the anchor layout before the edit.
let Some(c) = LiveCorners::new(path, live.as_deref()) else { continue };
let corners = corners_of(*id);
let picked = match &corners {
Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => {
let n = c.base.anchor_count();
let past: Vec<_> = k.range(n..).collect();
return Err(bad("object.setLiveShape", format!("object {id} has anchors 0–{} for `corners`, not {past:?}", n.saturating_sub(1))));
}
Some(k) => k.clone(),
None => c.picked(partial.as_deref()),
};
let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec());
set_corners(path, live, &picked, radius, kind);
// Direct-Selected corners stay selected as they gain or lose anchors.
if let (Some(anchors), Some(selected)) = (partial, selected)
&& let Some(c) = LiveCorners::new(path, live.as_deref())
&& c.sources() != layout
{
*anchors = c.anchors_of(&selected);
}
}
Ok(())
})?;
ok()
}