use super::align::choice; use super::{ Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, Point, Result, Scaling, Session, Value, Vec2, bad, bool_or, check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, selected_roots_to_transform, }; fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { 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) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()) { 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 sc = scaling_options(s, p); s.note_journal("strokes", json!(sc.strokes)); s.note_journal("corners", json!(!sc.keep_corners)); sc } /// [`scaling`] without noting the values in the journal. pub(super) fn scaling_options(s: &Session, p: &Value) -> Scaling { let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); let corners = bool_or(p, "corners", s.prefs.scale_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; 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) } /// The on/off params of the transform commands that [`apply_transform`], [`scaling`], /// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file /// check them with [`check_switches`] before they change anything. pub(super) const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"]; /// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. The objects /// in `n` that are aligned to the pixel grid snap to it again ([`super::pixelgrid::keep_pixel_aligned`]). pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { if objects { n.transform(xf, sc); super::pixelgrid::keep_pixel_aligned(n, 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, xf: Affine, p: &Value, anchors: bool) -> Result { apply_transform_moving_guides(s, label, ids, xf, p, anchors, None) } /// [`apply_transform`], with the selected ruler guides moved by `guides` instead of by the /// translation `xf` when given (a nudge along a turned Constrain Angle moves the art along it and /// the guides along the page's axes). fn apply_transform_moving_guides( s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value, anchors: bool, guides: Option, ) -> Result { 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)?; let c = xf.as_coeffs(); let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); let with_guides = objects && p.get("ids").is_none_or(Value::is_null) && is_translation; let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); // 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. // An edit normally preserves the tree positions already found here. Settling an active // typing session can change the document before the edit closure runs, so look up again then. let reindex = copy || (!s.previewing && crate::cmd::typing_in_progress(s)); let d = &s.doc()?.doc; let paths = d.index_paths(ids.iter().copied()); let none = paths.is_empty() && !has_guides; let ids = if anchors && objects && !copy && !s.doc()?.selection.anchors.is_empty() { if xf != Affine::IDENTITY { crate::cmd::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, with_guides)? } 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 => { let guide_copies = if with_guides { let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); (!copies.is_empty()).then_some(copies) } else { None }; let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; if let Some(copies) = guide_copies { sel.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } new_ids } None => ids.clone(), }; // Transforms don't move nodes in the tree, so reuse the paths found for the no-copy // case; copies and a settled typing session need paths from the edited document. let paths = if reindex { d.index_paths(targets.iter().copied()) } else { paths }; 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); } } if objects { for id in &targets { super::pixelgrid::keep_compound_aligned(d, *id, sc); } } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { d.edit_ruler_guide(i, |g| *g = g.moved(delta)); } } 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(LastTransform::Matrix(xf, options)); st.last_perspective = None; } Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } /// Returns the copies of `roots` for a transform or a perspective edit 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 command then copies the current document. pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: bool) -> Result>> { 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); } let has_guide_copies = with_guides && !st.selection.guides.is_empty(); if let Some(k) = &it.copies && Arc::ptr_eq(&k.from, &it.doc) && k.roots == roots && (k.selection.guides.is_empty() != has_guide_copies) { 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)?; if has_guide_copies { let d = Arc::make_mut(&mut doc); let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); selection.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } 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 }; crate::cmd::typecmd::reshape_area_with(t, |t| t.transform_area(xf)) } pub(super) fn transform(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; check_switches(p, &["anchors", "releaseOutside"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; // An object selected in a symbol instance's art stays where the symbol puts it: the selection // is left as it is, and the reply's warning goes to the status bar. if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() { return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() })); } let (ids, anchors) = match ids_param(p, "ids", "object.transform")? { Some(v) => (outermost(&s.doc()?.doc, v), false), None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), }; // Shaper's Construction mode: an original shape moved wholly outside its group's bounds leaves // the group. let moved = if bool_or(p, "releaseOutside", false) && !bool_or(p, "copy", false) && !anchors { crate::cmd::shaper::moving_sources(s, &ids)? } else { vec![] }; // Outside a drag and an undo group, the move and the removal are one undo step, named for the // removal when there is one. let grouped = !moved.is_empty() && s.doc()?.interaction.is_none() && s.doc()?.undo_group.is_none(); if grouped { s.begin_named_undo_group(None); } let r = apply_transform(s, "Transform", ids, m, p, anchors); let released = match &r { Ok(_) if !moved.is_empty() => crate::cmd::shaper::release_outside(s, &moved), _ => Ok(false), }; if grouped && let Some(st) = s.active_mut() { st.undo_group = None; if matches!(released, Ok(true)) && let Some(e) = st.history.undo.last_mut() { e.label = "Remove from Shaper Group".into(); } } released?; r } pub(super) fn move_cmd(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["dx", "dy"], "object.move")?; check_switches(p, &TRANSFORM_SWITCHES, "object.move")?; let ids = selected_roots_to_transform(s)?; apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true) } /// The arrow keys' step `(dx, dy)` (page axes, y down) along the x and y axes turned by General › /// Constrain Angle (`deg`, counter-clockwise on the page): with 30°, → moves 30° up from the /// horizontal, as the reference app's arrow keys follow the constrain angle (users set it to −90° /// to nudge along a view rotated by 90°: /// ). pub(crate) fn along_constrain_angle(dx: f64, dy: f64, deg: f64) -> (f64, f64) { // Whole turns are the page's axes, exactly; quarter turns are exact too. let Some(axes) = vectorcraft_geom::constrain_axes(deg) else { return (dx, dy) }; let v = axes * Point::new(dx, dy); (v.x, v.y) } pub(super) fn nudge(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["dx", "dy"], "object.nudge")?; check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?; check_switches(p, &["big"], "object.nudge")?; let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; let (dx, dy) = (f64_or(p, "dx", 0.0) * k, f64_or(p, "dy", 0.0) * k); // Guides keep to the page's axes; art and anchors move along the constrain angle's. let (gx, gy) = (dx, dy); let (dx, dy) = along_constrain_angle(dx, dy, s.prefs.constrain_angle); 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 crate::cmd::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 gx == 0.0 && gy == 0.0 && !copy { return ok(); } return crate::cmd::docmenu::guide_move(s, &json!({ "dx": gx, "dy": gy, "copy": copy })); } let ids = selected_roots_to_transform(s)?; apply_transform_moving_guides(s, "Move", ids, Affine::translate((dx, dy)), p, true, Some(Vec2::new(gx, gy))) } pub(super) fn about(o: Point, a: Affine) -> Affine { Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) } pub(super) fn rotate(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.rotate")?; check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?; check_switches(p, &["absolute"], "object.rotate")?; let ids = selected_roots_to_transform(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) } pub(super) fn scale(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["sy"], "object.scale")?; check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?; let ids = selected_roots_to_transform(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) } pub(super) fn reflect(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?; let ids = selected_roots_to_transform(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) } pub(super) fn shear(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.shear")?; check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?; let ids = selected_roots_to_transform(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 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. A Transform /// Each is repeated by running it again (its transform is per object, about each one's bounds). pub(super) fn transform_again(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?; if let Some(again) = s.doc()?.last_perspective.clone() { return crate::cmd::distortcmds::transform_again(s, &again); } let last = s.doc()?.last_transform.clone().ok_or_else(|| EngineError::Other("no previous transform".into()))?; let merge = |mut options: Value| { 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()))); } options }; match last { LastTransform::Matrix(m, options) => { let ids = selected_roots_to_transform(s)?; apply_transform(s, "Transform Again", ids, m, &merge(options), true) } LastTransform::Each(params) => crate::cmd::menucmds::transform_each_again(s, &merge(params)), } }