//! Rotate (R), Reflect (O), Scale (S) and Shear tools. //! //! The reference point defaults to the centre of the selection. A click sets it (shown as a cyan //! target); a drag transforms about it with a live preview (Shift constrains to 45° / uniform, Alt //! at release makes a copy). Alt-click sets the point and opens the tool's dialog; double-click //! or Return opens the dialog too. A clicked or Alt-clicked point snaps to the nearest anchor or //! centre (Snap to Point / Smart Guides), labelled while hovering, so transforms pivot exactly //! on a corner. use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::target_overlays; use crate::bbox::rotate_for_drag; use crate::guides::snap_pick; use crate::select::{matrix_json, selection_bounds}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TransformKind { Rotate, Reflect, Scale, Shear, } impl TransformKind { pub fn id(self) -> &'static str { match self { TransformKind::Rotate => "rotate", TransformKind::Reflect => "reflect", TransformKind::Scale => "scale", TransformKind::Shear => "shear", } } fn label(self) -> &'static str { match self { TransformKind::Rotate => "Rotate", TransformKind::Reflect => "Reflect", TransformKind::Scale => "Scale", TransformKind::Shear => "Shear", } } /// Dialog fields (kinds/keys match the UI dialogs). fn dialog_fields(self, origin: Point) -> Value { let o = json!([origin.x, origin.y]); match self { TransformKind::Rotate => json!({ "angle": 0, "origin": o }), TransformKind::Reflect => json!({ "axis": "vertical", "origin": o }), TransformKind::Scale => json!({ "sx": 100, "sy": 100, "origin": o }), TransformKind::Shear => json!({ "angle": 0, "axis": "horizontal", "origin": o }), } } } #[derive(Clone, Copy, Debug)] struct Drag { start: Point, /// The pressed point snapped to an anchor or centre (becomes the reference point on a click). pick: Point, origin: Point, began: bool, bounds: Rect, last: Affine, } pub struct TransformTool { kind: TransformKind, /// Custom reference point and the selection it was set for. origin: Option<(Point, Vec)>, drag: Option, measure: Option<(Point, String)>, /// Snap feedback while hovering ("anchor" / "center" label). hover: Vec, } fn about(o: Point, a: Affine) -> Affine { Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2()) } /// 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)) } impl TransformTool { pub fn new(kind: TransformKind) -> Self { Self { kind, origin: None, drag: None, measure: None, hover: vec![] } } /// The effective reference point: the custom one if it belongs to the current selection, /// otherwise the selection centre. pub fn reference_point(&self, cx: &ToolContext) -> Option { if let Some((p, ids)) = &self.origin && *ids == cx.selection.objects && !ids.is_empty() { return Some(*p); } selection_bounds(cx).map(|b| b.center()) } /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. pub fn matrix_for(&self, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { match self.kind { TransformKind::Rotate => { let (a, deg) = rotate_for_drag(o, start, p, shift); (a, format!("{:.1}°", -deg)) } TransformKind::Reflect => { let mut v = p - o; if shift { v = vectorcraft_geom::constrain_angle(v, 45.0); } let theta = if v.hypot() < 1e-9 { std::f64::consts::FRAC_PI_2 } else { v.y.atan2(v.x) }; (reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees()))) } TransformKind::Scale => { let d0 = start - o; let d1 = p - o; let ratio = |a: f64, b: f64| if a.abs() > 1e-6 { b / a } else { 1.0 }; let (mut sx, mut sy) = (ratio(d0.x, d1.x), ratio(d0.y, d1.y)); if shift { let l2 = d0.hypot2(); let k = if l2 > 1e-12 { d1.dot(d0) / l2 } else { 1.0 }; sx = k; sy = k; } let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s }; let (sx, sy) = (clamp(sx), clamp(sy)); (about(o, Affine::scale_non_uniform(sx, sy)), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) } TransformKind::Shear => { // The shear axis follows the dominant drag direction (Shift keeps it strictly on // that axis, which this always does). let d = p - start; let horizontal = d.x.abs() >= d.y.abs(); let lever = |a: f64, fallback: f64| if a.abs() > 1e-3 { a } else { fallback.max(1.0) }; if horizontal { let k = d.x / lever(start.y - o.y, bounds.height() / 2.0); (about(o, Affine::new([1.0, 0.0, k, 1.0, 0.0, 0.0])), format!("{:.1}°", (-k.atan()).to_degrees())) } else { let k = d.y / lever(start.x - o.x, bounds.width() / 2.0); (about(o, Affine::new([1.0, k, 0.0, 1.0, 0.0, 0.0])), format!("{:.1}°", (-k.atan()).to_degrees())) } } } } fn dialog(&self, o: Point) -> Action { Action::Dialog(self.kind.id().into(), self.kind.dialog_fields(o)) } } impl Tool for TransformTool { fn id(&self) -> &'static str { self.kind.id() } fn busy(&self) -> bool { self.drag.is_some_and(|d| d.began) } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; match ev.kind { PointerKind::Down => { let Some(bounds) = selection_bounds(cx) else { return vec![] }; self.hover.clear(); let (pick, _) = snap_pick(cx, p); if ev.mods.alt { self.origin = Some((pick, cx.selection.objects.clone())); self.drag = None; return vec![self.dialog(pick)]; } let origin = self.reference_point(cx).unwrap_or(bounds.center()); self.drag = Some(Drag { start: p, pick, origin, began: false, bounds, last: Affine::IDENTITY }); vec![] } PointerKind::Drag => { let Some(mut d) = self.drag else { return vec![] }; let mut out = vec![]; if !d.began { if p.distance(d.start) < cx.tol(3.0) { return out; } d.began = true; out.push(Action::Begin(self.kind.label().into())); } let (m, text) = self.matrix_for(d.origin, d.start, p, ev.mods.shift, d.bounds); 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 } PointerKind::Up => { let Some(d) = self.drag.take() else { return vec![] }; self.measure = None; 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] } else { // A click sets the reference point. self.origin = Some((d.pick, cx.selection.objects.clone())); vec![] } } PointerKind::DoubleClick => { self.drag = None; match self.reference_point(cx) { Some(o) => vec![self.dialog(o)], None => vec![], } } PointerKind::Move => { self.hover = if self.drag.is_none() && !cx.selection.is_empty() { snap_pick(cx, p).1 } else { vec![] }; vec![] } } } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { match key { ToolKey::Escape if self.busy() => { self.drag = None; self.measure = None; vec![Action::Cancel] } ToolKey::Enter => self.reference_point(cx).map(|o| vec![self.dialog(o)]).unwrap_or_default(), _ => vec![], } } fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; let reference = match self.drag { Some(d) => Some(d.origin), None => self.reference_point(cx), }; if let Some(r) = reference { o.extend(target_overlays(r, cx.tol(8.0))); } o.extend(self.hover.iter().cloned()); if let (Some(d), TransformKind::Reflect | TransformKind::Rotate) = (self.drag, self.kind) && d.began && let Some((p, _)) = &self.measure { o.push(Overlay::Line { a: d.origin, b: *p, color: super::CYAN, dashed: true }); } if let Some((p, t)) = &self.measure { o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() }); } o } fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { if cx.selection.is_empty() { return Cursor::NotAllowed; } match self.kind { TransformKind::Rotate if self.busy() => Cursor::Rotate, _ => Cursor::Crosshair, } } fn options(&self) -> Value { match &self.origin { Some((p, _)) => json!({ "origin": [p.x, p.y] }), None => json!({ "origin": null }), } } fn set_option(&mut self, key: &str, value: &Value) { if key == "origin" { self.origin = value.as_array().and_then(|a| Some((Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?), vec![]))); } } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] } } } #[cfg(test)] mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { PointerEvent::new(kind, x, y) } fn alt() -> Mods { Mods { alt: true, ..Default::default() } } fn matrix(a: &Action) -> Affine { let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") }; assert_eq!(c, "object.transform"); let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]) } #[test] fn rotate_drag_about_center_single_undo() { let (d, id) = doc_with_rect(); let mut s = Selection::default(); s.add(id); let p = paint(); let cx = cx(&d, &s, &p); let mut t = TransformTool::new(TransformKind::Rotate); assert!(t.pointer(&cx, &ev(PointerKind::Down, 250.0, 150.0)).is_empty()); let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 250.0)); assert_eq!(a[0], Action::Begin("Rotate".into())); // Centre (150,150) is fixed; (250,150) → (150,250). let m = matrix(&a[1]); assert!((m * Point::new(150.0, 150.0)).distance(Point::new(150.0, 150.0)) < 1e-9); assert!((m * Point::new(250.0, 150.0)).distance(Point::new(150.0, 250.0)) < 1e-9); let up = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 250.0)); assert_eq!(up.last(), Some(&Action::Commit)); } #[test] fn click_sets_reference_point_then_drag_uses_it() { let (d, id) = doc_with_rect(); let mut s = Selection::default(); s.add(id); let p = paint(); let cx = cx(&d, &s, &p); let mut t = TransformTool::new(TransformKind::Scale); t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0)); assert!(t.pointer(&cx, &ev(PointerKind::Up, 100.0, 100.0)).is_empty()); assert_eq!(t.reference_point(&cx), Some(Point::new(100.0, 100.0))); assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { a, .. } if a.y == 100.0))); t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)); let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0)); let m = matrix(&a[1]); assert!((m * Point::new(100.0, 100.0)).distance(Point::new(100.0, 100.0)) < 1e-9); assert!((m * Point::new(200.0, 200.0)).distance(Point::new(300.0, 250.0)) < 1e-9); } #[test] fn scale_shift_is_uniform() { let t = TransformTool::new(TransformKind::Scale); let (m, _) = t.matrix_for(Point::new(150.0, 150.0), Point::new(200.0, 200.0), Point::new(250.0, 240.0), true, Rect::ZERO); let c = m.as_coeffs(); assert!((c[0] - c[3]).abs() < 1e-9 && c[0] > 1.0); } #[test] fn alt_click_opens_dialog_and_alt_release_copies() { let (d, id) = doc_with_rect(); let mut s = Selection::default(); s.add(id); let p = paint(); let cx = cx(&d, &s, &p); let mut t = TransformTool::new(TransformKind::Reflect); let a = t.pointer(&cx, &ev(PointerKind::Down, 120.0, 130.0).with_mods(alt())); assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [120.0, 130.0]}))]); t.pointer(&cx, &ev(PointerKind::Up, 120.0, 130.0).with_mods(alt())); t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 200.0)); let up = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 200.0).with_mods(alt())); assert!(matches!(&up[0], Action::Preview(_, v) if v["copy"] == true)); assert_eq!(up[1], Action::Commit); let mut t = TransformTool::new(TransformKind::Shear); assert!( matches!(&t.pointer(&cx, &ev(PointerKind::DoubleClick, 0.0, 0.0))[0], Action::Dialog(k, v) if k == "shear" && v["axis"] == "horizontal") ); let mut t = TransformTool::new(TransformKind::Rotate); assert!(matches!(&t.key(&cx, ToolKey::Enter, Mods::default())[0], Action::Dialog(k, v) if k == "rotate" && v["angle"] == 0)); } #[test] fn alt_click_and_click_snap_the_reference_point_to_anchors_and_centres() { let (d, id) = doc_with_rect(); let mut s = Selection::default(); s.add(id); let p = paint(); let mut c = cx(&d, &s, &p); // Alt-click 3 px from the top-left corner: the reflect pivots exactly on the corner. let mut t = TransformTool::new(TransformKind::Reflect); let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt())); assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [100.0, 100.0]}))]); // A plain click near the bottom-right corner, then near the centre. let mut t = TransformTool::new(TransformKind::Rotate); t.pointer(&c, &ev(PointerKind::Down, 198.0, 203.0)); t.pointer(&c, &ev(PointerKind::Up, 198.0, 203.0)); assert_eq!(t.reference_point(&c), Some(Point::new(200.0, 200.0))); t.pointer(&c, &ev(PointerKind::Down, 151.0, 148.0)); t.pointer(&c, &ev(PointerKind::Up, 151.0, 148.0)); assert_eq!(t.reference_point(&c), Some(Point::new(150.0, 150.0))); // Hovering a corner labels it before the click. t.pointer(&c, &ev(PointerKind::Move, 199.0, 101.0)); assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == Point::new(200.0, 100.0)))); t.pointer(&c, &ev(PointerKind::Move, 175.0, 120.0)); assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { .. }))); // Snapping off: the point stays where it was clicked. c.snap_to_point = false; c.smart_guides = false; let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt())); assert!(matches!(&a[0], Action::Dialog(_, v) if v["origin"] == json!([102.0, 98.0]))); } #[test] fn reflect_vertical_axis_mirrors_x() { let t = TransformTool::new(TransformKind::Reflect); let o = Point::new(150.0, 150.0); let (m, _) = t.matrix_for(o, Point::new(150.0, 100.0), Point::new(150.0, 50.0), false, Rect::ZERO); assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9); } #[test] fn shear_horizontal_keeps_origin_row() { let t = TransformTool::new(TransformKind::Shear); let o = Point::new(150.0, 200.0); let (m, _) = t.matrix_for(o, Point::new(150.0, 100.0), Point::new(200.0, 100.0), false, Rect::new(100.0, 100.0, 200.0, 200.0)); assert!((m * Point::new(100.0, 200.0)).distance(Point::new(100.0, 200.0)) < 1e-9); assert!((m * Point::new(150.0, 100.0)).distance(Point::new(200.0, 100.0)) < 1e-9); } #[test] fn nothing_selected_does_nothing() { let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); let mut t = TransformTool::new(TransformKind::Rotate); assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty()); assert!(t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 150.0)).is_empty()); assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::NotAllowed); } }