diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 50d79b9c..ba8eb4b9 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use serde::{Deserialize, Serialize}; -use crate::{Document, NodeId}; +use crate::{Document, NodeId, NodeKind}; /// (subpath index, anchor index) inside a path. pub type AnchorRef = (usize, usize); @@ -125,6 +125,25 @@ impl Selection { pub fn partial(&self, id: NodeId) -> Option<&BTreeSet> { self.anchors.get(&id) } + /// The objects selected whole: those with no direct-selected anchors. + pub fn whole(&self) -> Vec { + self.objects.iter().copied().filter(|id| !self.anchors.contains_key(id)).collect() + } + /// With direct-selected anchors, what a transform of them measures from: the box around those + /// anchors and the objects selected whole (`visual`: with their strokes). `None` without any. + pub fn anchor_bounds(&self, doc: &Document, visual: bool) -> Option { + if self.anchors.is_empty() { + return None; + } + let mut acc = doc.bounds_of(&self.whole(), visual); + for (id, set) in &self.anchors { + let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue }; + for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) { + acc = vectorcraft_geom::union_opt(acc, Some(vectorcraft_geom::Rect::from_points(a.p, a.p))); + } + } + acc + } /// Which direction handles belong to selected segments; both for an anchor selection. pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) { let Some(segments) = self.segments.get(&id) else { return (true, true) }; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index f5f59169..6097cb48 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "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)", + "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, 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 ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "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)}", + "{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. 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 ), @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "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}", + "{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}. 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 ), @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "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)", + "{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). 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 ), @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "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)}", + "{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. 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 ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "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)}", + "{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}. 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 ), @@ -223,6 +223,13 @@ 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) = 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())) } @@ -263,7 +270,10 @@ pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> /// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]; /// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Outside an /// interaction, records `xf` and the options it used for Transform Again. -pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value) -> Result { +/// +/// `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 { 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() }; @@ -273,7 +283,12 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf // 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 none || (!copy && xf == Affine::IDENTITY) { + let ids = if anchors && !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 { s.edit(label, |d, sel| { @@ -316,16 +331,16 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool { 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)?, + 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) + apply_transform(s, "Transform", ids, m, p, anchors) } 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) + 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 { @@ -348,7 +363,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result { 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) + apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true) } fn about(o: Point, a: Affine) -> Affine { @@ -364,7 +379,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result { } // 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) + apply_transform(s, "Rotate", ids, a, p, true) } fn scale(s: &mut Session, p: &Value) -> Result { @@ -375,7 +390,7 @@ fn scale(s: &mut Session, p: &Value) -> Result { 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) + apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true) } fn reflect(s: &mut Session, p: &Value) -> Result { @@ -394,7 +409,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result { 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) + apply_transform(s, "Reflect", ids, about(o, m), p, true) } fn shear(s: &mut Session, p: &Value) -> Result { @@ -403,7 +418,7 @@ fn shear(s: &mut Session, p: &Value) -> Result { 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) + 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 @@ -417,7 +432,7 @@ fn transform_again(s: &mut Session, p: &Value) -> Result { 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) + apply_transform(s, "Transform Again", ids, m, &options, true) } enum Arrange { @@ -1311,10 +1326,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { // 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); + 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))); - apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p) + apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false) } fn expand_shape(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 70697dd6..0e18e15f 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -168,35 +168,38 @@ fn close(s: &mut Session, p: &Value) -> Result { pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); - let st = s.doc()?; - let sel = st.selection.clone(); - let scale_strokes = s.prefs.scale_strokes; - let _ = scale_strokes; - // Objects selected whole (with no entry in `sel.anchors`) move as objects, and their pattern - // tiles move with them when `patterns` (default: General › Transform Pattern Tiles) is on. - let whole: Vec = sel.objects.iter().copied().filter(|id| !sel.anchors.contains_key(id)).collect(); + // Objects selected whole move as objects, and their pattern tiles move with them when + // `patterns` (default: General › Transform Pattern Tiles) is on. + let whole = s.doc()?.selection.whole(); let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() }; - s.edit("Move", |doc, _| { + transform_anchors(s, "Move", vectorcraft_geom::Affine::translate(d), sc)?; + ok() +} + +/// Apply `xf` to the direct-selected anchors, their handles with them, and (with `sc`) to the +/// objects selected whole (those with no entry in the selection's `anchors`). +pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_geom::Affine, sc: Scaling) -> Result<()> { + let sel = s.doc()?.selection.clone(); + s.edit(label, |doc, _| { for id in &sel.objects { match sel.anchors.get(id) { Some(set) => { let path = path_mut(doc, *id)?; for &(si, ai) in set { if let Some(a) = path.anchor_mut(si, ai) { - a.translate(d); + *a = a.transform(xf); } } } None => { if let Some(n) = doc.node_mut(*id) { - n.transform(vectorcraft_geom::Affine::translate(d), sc); + n.transform(xf, sc); } } } } Ok(()) - })?; - ok() + }) } fn set_handle(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad1bda90..ec7dba73 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -764,6 +764,60 @@ fn a_transform_that_moves_nothing_records_no_undo_step() { assert_eq!(count(&s), n + 1); } +/// #1408: with anchors selected with Direct Selection, the Transform commands and tools transform +/// those anchors alone, about the centre of their own box; a copy still copies the whole object. +#[test] +fn transforms_act_on_direct_selected_anchors() { + let mut s = session(); + let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true}); + let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap()); + let points = |s: &Session| -> Vec<(f64, f64)> { + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") }; + path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect() + }; + let top = json!({"id": id.0, "anchors": [[0, 0], [0, 1]]}); + s.execute("select.anchors", &top).unwrap(); + let cases = [ + ("object.scale", json!({"sx": 50}), [(25.0, 0.0), (75.0, 0.0)]), + ("object.scale", json!({"sx": 50, "origin": [0, 100]}), [(0.0, 50.0), (50.0, 50.0)]), + ("object.rotate", json!({"angle": 90}), [(50.0, 50.0), (50.0, -50.0)]), + ("object.reflect", json!({"axis": "vertical"}), [(100.0, 0.0), (0.0, 0.0)]), + ("object.shear", json!({"angle": 45, "axis": "vertical", "origin": [0, 0]}), [(0.0, 0.0), (100.0, 100.0)]), + ("object.move", json!({"dx": 10, "dy": -10}), [(10.0, -10.0), (110.0, -10.0)]), + ("object.transform", json!({"matrix": [1, 0, 0, 1, 5, 5], "anchors": true}), [(5.0, 5.0), (105.0, 5.0)]), + ]; + for (cmd, p, [a, b]) in cases { + s.execute(cmd, &p).unwrap(); + assert_eq!(points(&s), [a, b, (100.0, 100.0), (0.0, 100.0)], "{cmd} {p}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.anchors", &top).unwrap(); + } + // Transform Again repeats it on the anchors. + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + s.execute("object.transformAgain", &json!({})).unwrap(); + assert_eq!(points(&s), [(20.0, 0.0), (120.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.anchors", &top).unwrap(); + // Without `anchors`, object.transform (the Selection tool's) takes the whole path, and so does a copy. + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 5]})).unwrap(); + assert_eq!(points(&s), [(5.0, 5.0), (105.0, 5.0), (105.0, 105.0), (5.0, 105.0)]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.anchors", &top).unwrap(); + let copies = s.execute("object.scale", &json!({"sx": 50, "copy": true})).unwrap(); + assert_eq!(copies["ids"].as_array().unwrap().len(), 1); + assert_eq!(points(&s), [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "the original stays"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.anchors", &top).unwrap(); + // The Scale tool: a drag from (100, 50) to (75, 50) halves the top edge about its centre (50, 0). + let v = ViewInfo::default(); + s.select_tool("scale", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 75.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 75.0, 50.0), v).unwrap(); + assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); +} + /// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and /// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to /// Current Layer on objects already on top of the current layer. diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index e2887d11..260d886f 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -87,6 +87,12 @@ fn about(o: Point, a: Affine) -> Affine { Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) } +/// The transform `m` of the selection (of a copy with `copy`): of the anchors selected with Direct +/// Selection alone, when there are some. +fn preview(m: Affine, copy: bool) -> Action { + Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": copy, "anchors": true })) +} + /// Reflection across the line through the origin at angle `theta` (radians, screen space). pub fn reflect_matrix(o: Point, theta: f64) -> Affine { about(o, Affine::rotate(theta) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-theta)) @@ -106,7 +112,8 @@ impl TransformTool { { return Some(*p); } - selection_box(cx).map(|b| b.center()) + // Anchors selected with Direct Selection transform alone, about their own centre. + cx.selection.anchor_bounds(cx.doc, cx.preview_bounds).map(|b| b.center()).or_else(|| selection_box(cx).map(|b| b.center())) } /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. @@ -209,14 +216,14 @@ impl Tool for TransformTool { d.last = m; self.drag = Some(d); self.measure = Some((p, text)); - out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": ev.mods.alt }))); + out.push(preview(m, ev.mods.alt)); out } PointerKind::Up => { let Some(d) = self.end_drag() else { return vec![] }; if d.began { // The Alt state at release decides whether the result is a copy. - vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit] + vec![preview(d.last, ev.mods.alt), Action::Commit] } else { // A click sets the reference point; with Alt, it opens the dialog too. self.origin = Some((d.pick, cx.selection.objects.clone()));