//! 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, Point, Rect, Vec2}; use super::edit::{duplicate_in, selected_roots}; use super::opacitymask::percent; use super::*; use crate::EngineError; pub fn specs() -> Vec { vec![ cmd!( "object.transform", "Transform", [], None, "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); 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?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}", 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?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}", 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), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences)", has_selection, scale ), cmd!( "object.reflect", "Reflect…", ["Object", "Transform"], None, "{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}", has_selection, reflect ), cmd!( "object.shear", "Shear…", ["Object", "Transform"], None, "{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}", has_selection, shear ), cmd!("object.transformAgain", "Transform Again", ["Object", "Transform"], Some("Cmd+D"), "{}", has_selection, transform_again), cmd!( "object.nudge", "Nudge", [], None, "{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} 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"), "{}", 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?, 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), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes)}", 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)}", has_multi, 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)}", has_multi, distribute_spacing ), cmd!( "object.setBounds", "Set Bounds", [], None, "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (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)", 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…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `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)", has_selection, set_live_shape ), ] } fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { if let Some(o) = point_param(p, "origin") { return Ok(o); } 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 { let on = bool_or(p, "patterns", s.prefs.transform_pattern_tiles); let d = &s.doc()?.doc; let mut uses = false; for n in ids.iter().filter_map(|id| d.node(*id)) { n.walk(&mut |c| uses |= vectorcraft_doc::pattern::uses_pattern(c, None)); } if uses { s.note_journal("patterns", json!(on)); } Ok(on) } /// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]; /// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Records /// Transform Again. pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value) -> Result { let copy = bool_or(p, "copy", false); let areas = bool_or(p, "typeAreas", false); let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; sc.patterns = transform_patterns(s, p, &ids)?; let ids = s.edit(label, |d, sel| { let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; for id in &targets { if let Some(n) = d.node_mut(*id) && !(areas && resize_type_area(n, xf)) { n.transform(xf, sc); } } Ok(targets) })?; let st = s.doc_mut()?; if st.interaction.is_none() { st.last_transform = Some((xf, copy)); st.last_perspective = None; } Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } /// 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.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 { let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, }; apply_transform(s, "Transform", ids, m, p) } fn move_cmd(s: &mut Session, p: &Value) -> Result { 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) } fn nudge(s: &mut Session, p: &Value) -> Result { 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() { return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy })); } if s.doc()?.selection.is_empty() { return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) })); } let ids = selected_roots(s)?; apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p) } 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 { 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) } fn scale(s: &mut Session, p: &Value) -> Result { 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) } fn reflect(s: &mut Session, p: &Value) -> Result { 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) } _ => Affine::scale_non_uniform(-1.0, 1.0), }; apply_transform(s, "Reflect", ids, about(o, m), p) } fn shear(s: &mut Session, p: &Value) -> Result { 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 m = if str_param(p, "axis") == Some("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) } fn transform_again(s: &mut Session, _: &Value) -> Result { if let Some(again) = s.doc()?.last_perspective.clone() { return super::distortcmds::transform_again(s, &again); } let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?; let ids = selected_roots(s)?; apply_transform(s, "Transform Again", ids, m, &json!({ "copy": copy })) } enum Arrange { Front, Forward, Backward, Back, } fn arrange(s: &mut Session, how: Arrange) -> Result { 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", }; s.edit(label, |d, _| { // Arrange within each parent independently (selections may span layers/groups). let mut by_parent: Vec<(Option, Vec)> = 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)?; } } } Ok(()) })?; ok() } fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { 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()))?; s.edit("Send to Current Layer", |d, _| { for id in &ids { d.move_node(*id, Some(layer), usize::MAX)?; } Ok(()) })?; ok() } fn group(s: &mut Session, _: &Value) -> Result { 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 { let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) }; let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; let gid = d.alloc_id(); d.insert(par, idx + 1, Node::group(gid, vec![]))?; for id in ids { d.move_node(*id, Some(gid), usize::MAX)?; } Ok(gid) } fn ungroup(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; s.edit("Ungroup", |d, sel| { let mut new_sel = 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 children = children.clone(); let clip = *clip; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; let opacity = n.opacity; d.remove(*id)?; for (k, c) in children.into_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 *= opacity; new_sel.push(c.id); d.insert(par, idx + k, c)?; } } sel.set(new_sel); Ok(()) })?; ok() } fn lock(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; 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 { 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 { let ids = collect_flag(&s.doc()?.doc, |n| n.locked); 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 { let ids = selected_roots(s)?; 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 { let ids = collect_flag(&s.doc()?.doc, |n| !n.visible); 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 { let ids = selected_roots(s)?; let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) }; let id = s.edit("Make Compound Path", |d, sel| { let appearance = d.node(top).map(|n| n.appearance.clone()).unwrap_or_default(); let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; let mut paths: Vec> = vec![]; for id in &ids { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; match &n.kind { NodeKind::Path { .. } => paths.push(Arc::new(n)), NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()), NodeKind::Group { .. } => { n.walk(&mut |c| { if matches!(c.kind, NodeKind::Path { .. }) { paths.push(Arc::new(c.clone())) } }); } _ => return Err(EngineError::Other("compound paths can only contain paths".into())), } } let cid = d.alloc_id(); let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); c.appearance = appearance; d.insert(par, idx + 1, c)?; for id in &ids { d.remove(*id)?; } let ch = d.node_mut(cid).and_then(|n| n.children_mut()).ok_or(EngineError::NoNode(cid))?; for mut p in paths { let pn = Arc::make_mut(&mut p); pn.appearance = Appearance::default(); if let NodeKind::Path { live, .. } = &mut pn.kind { *live = None; } ch.push(p); } sel.set([cid]); Ok(cid) })?; Ok(json!({ "id": id.0 })) } fn compound_release(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; s.edit("Release Compound Path", |d, sel| { let mut out = vec![]; for id in &ids { let Some(n) = d.node(*id).cloned() else { continue }; let NodeKind::Compound { children, .. } = &n.kind else { continue }; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; d.remove(*id)?; for (k, c) in children.iter().enumerate() { let mut c = (**c).clone(); c.appearance = n.appearance.clone(); out.push(c.id); d.insert(par, idx + k, c)?; } } 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); 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 { 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 (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; let gid = d.alloc_id(); d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?; d.move_node(top, Some(gid), 0)?; for id in &ids[..ids.len() - 1] { d.move_node(*id, Some(gid), usize::MAX)?; } sel.set([gid]); Ok(gid) })?; Ok(json!({ "id": gid.0 })) } fn clip_release(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; 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 { 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 { 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()?; if st.doc.node(id).is_none_or(|n| !n.is_container()) { return Err(bad("object.isolate", "only groups and layers can be isolated")); } st.isolation = Some(id); st.selection.clear(); st.revision += 1; ok() } fn exit_isolation(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { super::distortcmds::finish_edit_text(st, i); st.selection.set([i]); } 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) -> Result)>> { 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 { 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, }; s.edit("Object Properties", |d, _| { 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) => {} } } }); } } 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 { 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 { 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 { match str_param(p, "bounds") { None => Ok(s.prefs.use_preview_bounds), Some("preview") => Ok(true), Some("geometric") => Ok(false), Some(b) => Err(bad(cmd, format!("bounds must be \"preview\" or \"geometric\", not `{b}`"))), } } /// `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() } /// 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 { let st = s.doc()?; match align_to(s, p) { Some("artboard") => { let b = st.doc.bounds_of(ids, preview).unwrap_or_default(); let i = st.doc.artboard_at(b.center()).unwrap_or(0); Ok(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())), } } fn align(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.align")?; let r = reference_rect(s, p, &ids, preview)?; let h = str_param(p, "horizontal"); let v = str_param(p, "vertical"); let key = s.doc()?.selection.key; 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() }; s.edit("Align", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dv), false); } } Ok(()) })?; ok() } fn distribute(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.distribute")?; let mut items = items_bounds(&s.doc()?.doc, &ids, preview); let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) { (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(); s.edit("Distribute", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dv), false); } } Ok(()) })?; ok() } fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let horiz = str_param(p, "axis") != Some("vertical"); let preview = preview_bounds(s, p, "object.distributeSpacing")?; 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 spacing = p.get("spacing").and_then(Value::as_f64).filter(|g| g.is_finite()); 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::()) / (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: the others // are spaced from it. let key = s.doc()?.selection.key; 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(); s.edit("Distribute Spacing", |d, _| { for (id, dv) in &moves { if let Some(n) = d.node_mut(*id) { n.transform(Affine::translate(*dv), false); } } Ok(()) })?; ok() } fn set_bounds(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; 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. let v = if s.prefs.use_preview_bounds { d.oriented_bounds(&ids, true).map_or(g, |b| b.rect) } else { g }; let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1]; let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; let rp = vectorcraft_geom::reference_point(v, refi); let mut w = f64_or(p, "width", v.width()); let mut h = f64_or(p, "height", v.height()); if bool_or(p, "proportional", false) { if p.get("width").is_some() && v.width() > 0.0 { h = v.height() * w / v.width(); } else if p.get("height").is_some() && v.height() > 0.0 { w = v.width() * h / v.height(); } } // The geometric scale that gives the visual size: margins grow by the strokes' scale `f` // (exact for proportional scales, which scale the visual box alike). 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 }; let (lx, ly) = (ratio(w, v.width(), 0.0), ratio(h, v.height(), 0.0)); let f0 = f(if g.width() > 1e-9 { lx } else { 1.0 }, if g.height() > 1e-9 { ly } else { 1.0 }); let sx = ratio(w, g.width(), (m[0] + m[2]) * f0); let sy = ratio(h, g.height(), (m[1] + m[3]) * f0); 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 the visual one by `f`. let gp = vectorcraft_geom::reference_point(g, refi); let new_rp = scale * gp + (rp - gp) * f(sx, sy); // x, y place the reference point on the page. let page = frame.to_doc() * rp; let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p) } fn expand_shape(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; 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>> { 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::>().map(Some) } fn set_live_shape(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let corners = corners_param(p)?; let kind = p .get("kind") .filter(|v| !v.is_null()) .map(|v| { serde_json::from_value::(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); 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 { .. }), Some(n)) = (live.as_mut(), sides) { l.set_sides(n); *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_ref()) else { continue }; 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_ref()) && c.sources() != layout { *anchors = c.anchors_of(&selected); } } Ok(()) })?; ok() }