vectorcraft/crates/tools/src/xform/transform.rs

392 lines
16 KiB
Rust

//! 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.
use drawcraft_doc::NodeId;
use drawcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
use super::target_overlays;
use crate::bbox::rotate_for_drag;
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,
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<NodeId>)>,
drag: Option<Drag>,
measure: Option<(Point, String)>,
}
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 }
}
/// 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<Point> {
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 = drawcraft_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}°", drawcraft_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<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
let Some(bounds) = selection_bounds(cx) else { return vec![] };
if ev.mods.alt {
self.origin = Some((p, cx.selection.objects.clone()));
self.drag = None;
return vec![self.dialog(p)];
}
let origin = self.reference_point(cx).unwrap_or(bounds.center());
self.drag = Some(Drag { start: p, 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.start, 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 => vec![],
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
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<Overlay> {
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)));
}
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<Action> {
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 drawcraft_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<f64> = 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 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);
}
}