446 lines
17 KiB
Rust
446 lines
17 KiB
Rust
//! Free Transform tool (E).
|
|
//!
|
|
//! Shows the bounding box with handles. In the default mode handles scale (Shift proportional,
|
|
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
|
|
//! shears along that side. The Perspective Distort and Free Distort modes (tool option `mode`, or
|
|
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
|
|
//! box and preview `object.distort`.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
|
|
|
use super::{BLUE, polygon, rect_corners};
|
|
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
|
use crate::select::{matrix_json, selection_bounds};
|
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum DistortMode {
|
|
/// Scale / rotate / shear / move.
|
|
#[default]
|
|
Free,
|
|
/// Corners move symmetrically along their side.
|
|
Perspective,
|
|
/// Corners and sides move independently.
|
|
Distort,
|
|
}
|
|
|
|
impl DistortMode {
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
DistortMode::Free => "free",
|
|
DistortMode::Perspective => "perspective",
|
|
DistortMode::Distort => "distort",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum Op {
|
|
Move,
|
|
Scale(Handle),
|
|
Shear(Handle),
|
|
Rotate,
|
|
Corners(Handle, DistortMode),
|
|
}
|
|
|
|
impl Op {
|
|
/// The bounding-box handle being dragged, if any.
|
|
fn handle(self) -> Option<Handle> {
|
|
match self {
|
|
Op::Scale(h) | Op::Shear(h) | Op::Corners(h, _) => Some(h),
|
|
Op::Move | Op::Rotate => None,
|
|
}
|
|
}
|
|
|
|
/// The undo step's name.
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Op::Move => "Move",
|
|
Op::Scale(_) => "Scale",
|
|
Op::Shear(_) => "Shear",
|
|
Op::Rotate => "Rotate",
|
|
Op::Corners(..) => "Distort",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct Drag {
|
|
op: Op,
|
|
rect: Rect,
|
|
start: Point,
|
|
began: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct FreeTransformTool {
|
|
pub mode: DistortMode,
|
|
/// The on-canvas "Constrain" toggle (acts like holding Shift).
|
|
pub constrain: bool,
|
|
drag: Option<Drag>,
|
|
/// The current (distorted) quad while dragging corners.
|
|
quad: Option<[Point; 4]>,
|
|
measure: Option<(Point, String)>,
|
|
}
|
|
|
|
/// Shear for dragging side `h` of `r` by `d` (about the opposite side, or the centre with Alt).
|
|
pub fn shear_for_side(r: Rect, h: Handle, d: Vec2, from_center: bool) -> Affine {
|
|
let o = if from_center { r.center() } else { h.opposite().pos(r) };
|
|
let hp = h.pos(r);
|
|
let m = match h {
|
|
Handle::Top | Handle::Bottom => {
|
|
let lever = hp.y - o.y;
|
|
let k = if lever.abs() > 1e-9 { d.x / lever } else { 0.0 };
|
|
Affine::new([1.0, 0.0, k, 1.0, 0.0, 0.0])
|
|
}
|
|
_ => {
|
|
let lever = hp.x - o.x;
|
|
let k = if lever.abs() > 1e-9 { d.y / lever } else { 0.0 };
|
|
Affine::new([1.0, k, 0.0, 1.0, 0.0, 0.0])
|
|
}
|
|
};
|
|
Affine::translate(o.to_vec2()) * m * Affine::translate(-o.to_vec2())
|
|
}
|
|
|
|
/// Corner index (TL, TR, BR, BL) for a corner handle.
|
|
fn corner_index(h: Handle) -> Option<usize> {
|
|
match h {
|
|
Handle::TopLeft => Some(0),
|
|
Handle::TopRight => Some(1),
|
|
Handle::BottomRight => Some(2),
|
|
Handle::BottomLeft => Some(3),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The distorted quad for dragging handle `h` of `r` by `d`.
|
|
pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4] {
|
|
let mut q = rect_corners(r);
|
|
match (corner_index(h), mode) {
|
|
(Some(i), DistortMode::Perspective) => {
|
|
// Move along the dominant axis; the partner corner on that side mirrors the move.
|
|
if d.x.abs() >= d.y.abs() {
|
|
let partner = [1, 0, 3, 2][i];
|
|
q[i].x += d.x;
|
|
q[partner].x -= d.x;
|
|
} else {
|
|
let partner = [3, 2, 1, 0][i];
|
|
q[i].y += d.y;
|
|
q[partner].y -= d.y;
|
|
}
|
|
}
|
|
(Some(i), _) => q[i] += d,
|
|
(None, _) => {
|
|
let (a, b) = match h {
|
|
Handle::Top => (0, 1),
|
|
Handle::Right => (1, 2),
|
|
Handle::Bottom => (2, 3),
|
|
_ => (3, 0),
|
|
};
|
|
q[a] += d;
|
|
q[b] += d;
|
|
}
|
|
}
|
|
q
|
|
}
|
|
|
|
impl FreeTransformTool {
|
|
/// What dragging handle `h` with modifiers `m` does: Cmd on a corner distorts it freely,
|
|
/// Cmd+Alt+Shift in perspective, Cmd on a side shears; otherwise the tool's mode.
|
|
fn handle_op(&self, h: Handle, m: Mods) -> Op {
|
|
let mode = if h.is_corner() && m.cmd && m.alt && m.shift {
|
|
DistortMode::Perspective
|
|
} else if h.is_corner() && m.cmd {
|
|
DistortMode::Distort
|
|
} else {
|
|
self.mode
|
|
};
|
|
match mode {
|
|
DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h),
|
|
DistortMode::Free => Op::Scale(h),
|
|
DistortMode::Perspective if !h.is_corner() => Op::Scale(h),
|
|
mode => Op::Corners(h, mode),
|
|
}
|
|
}
|
|
|
|
fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option<Op> {
|
|
let tol = cx.tol(5.0);
|
|
if let Some(h) = hit_handle(r, p, tol) {
|
|
return Some(self.handle_op(h, m));
|
|
}
|
|
if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
|
|
return Some(Op::Rotate);
|
|
}
|
|
r.contains(p).then_some(Op::Move)
|
|
}
|
|
|
|
fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action {
|
|
let shift = m.shift || self.constrain;
|
|
let delta = p - d.start;
|
|
let xf = match d.op {
|
|
Op::Corners(h, mode) => {
|
|
let q = distort_quad(d.rect, h, delta, mode);
|
|
self.quad = Some(q);
|
|
self.measure = None;
|
|
let corners: Vec<Value> = q.iter().map(|c| json!([c.x, c.y])).collect();
|
|
return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] }));
|
|
}
|
|
Op::Move => {
|
|
let v = move_delta(d.start, p, shift);
|
|
self.measure = Some((p, cx.offset_label(v.x, v.y)));
|
|
Affine::translate(v)
|
|
}
|
|
Op::Scale(h) => {
|
|
let a = scale_for_drag(d.rect, h, p, shift, m.alt);
|
|
let nr = a.transform_rect_bbox(d.rect);
|
|
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
|
a
|
|
}
|
|
Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt),
|
|
Op::Rotate => {
|
|
let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift);
|
|
self.measure = Some((p, format!("{:.1}°", -deg)));
|
|
a
|
|
}
|
|
};
|
|
self.quad = Some(rect_corners(d.rect).map(|c| xf * c));
|
|
Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(xf), "copy": false }))
|
|
}
|
|
}
|
|
|
|
impl Tool for FreeTransformTool {
|
|
fn id(&self) -> &'static str {
|
|
"freeTransform"
|
|
}
|
|
|
|
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 => {
|
|
self.drag = None;
|
|
let Some(r) = selection_bounds(cx) else { return vec![] };
|
|
if let Some(op) = self.classify(cx, r, p, ev.mods) {
|
|
self.drag = Some(Drag { op, rect: r, start: p, began: false });
|
|
}
|
|
vec![]
|
|
}
|
|
PointerKind::Drag => {
|
|
let Some(mut d) = self.drag else { return vec![] };
|
|
let mut out = vec![];
|
|
// A handle drag follows the modifiers held now: the gesture is to start dragging
|
|
// and then hold Cmd (a Cmd press on the canvas is the temporary Selection tool).
|
|
if let Some(h) = d.op.handle() {
|
|
let op = self.handle_op(h, ev.mods);
|
|
if d.began && op.label() != d.op.label() {
|
|
// Another operation: it gets an undo step of its own.
|
|
out.push(Action::Cancel);
|
|
out.push(Action::Begin(op.label().into()));
|
|
}
|
|
d.op = op;
|
|
}
|
|
if !d.began {
|
|
if p.distance(d.start) < cx.tol(2.0) {
|
|
self.drag = Some(d);
|
|
return out;
|
|
}
|
|
d.began = true;
|
|
out.push(Action::Begin(d.op.label().into()));
|
|
}
|
|
self.drag = Some(d);
|
|
out.push(self.preview(cx, d, p, ev.mods));
|
|
out
|
|
}
|
|
PointerKind::Up => {
|
|
let d = self.drag.take();
|
|
self.quad = None;
|
|
self.measure = None;
|
|
if d.is_some_and(|d| d.began) { vec![Action::Commit] } else { vec![] }
|
|
}
|
|
_ => vec![],
|
|
}
|
|
}
|
|
|
|
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
|
if key == ToolKey::Escape && self.busy() {
|
|
self.drag = None;
|
|
self.quad = None;
|
|
self.measure = None;
|
|
return vec![Action::Cancel];
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
|
let mut o = vec![];
|
|
let quad = match (self.quad, selection_bounds(cx)) {
|
|
(Some(q), _) => q,
|
|
(None, Some(r)) => rect_corners(r),
|
|
_ => return o,
|
|
};
|
|
o.push(Overlay::Path { path: polygon(&quad, true), color: BLUE, width: 1.0, dashed: false });
|
|
for i in 0..4 {
|
|
let (a, b) = (quad[i], quad[(i + 1) % 4]);
|
|
o.push(Overlay::Anchor { p: a, color: BLUE, filled: false, size: 6.0 });
|
|
if self.mode != DistortMode::Perspective {
|
|
o.push(Overlay::Anchor { p: a.midpoint(b), color: BLUE, filled: false, size: 6.0 });
|
|
}
|
|
}
|
|
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 {
|
|
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
|
|
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
|
|
Some(Op::Rotate) => Cursor::Rotate,
|
|
Some(Op::Move) => Cursor::Move,
|
|
Some(Op::Scale(h) | Op::Shear(h)) => match h {
|
|
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
|
Handle::Left | Handle::Right => Cursor::ResizeH,
|
|
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
|
|
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
|
|
},
|
|
Some(Op::Corners(..)) => Cursor::Crosshair,
|
|
None => Cursor::Arrow,
|
|
}
|
|
}
|
|
|
|
fn options(&self) -> Value {
|
|
json!({ "mode": self.mode.name(), "constrain": self.constrain })
|
|
}
|
|
|
|
fn set_option(&mut self, key: &str, value: &Value) {
|
|
match key {
|
|
"mode" => {
|
|
self.mode = match value.as_str() {
|
|
Some("perspective") => DistortMode::Perspective,
|
|
Some("distort") | Some("freeDistort") => DistortMode::Distort,
|
|
_ => DistortMode::Free,
|
|
}
|
|
}
|
|
"constrain" => self.constrain = value.as_bool().unwrap_or(false),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
|
self.quad = None;
|
|
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)
|
|
}
|
|
|
|
#[test]
|
|
fn corner_scales_and_inside_moves() {
|
|
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 = FreeTransformTool::default();
|
|
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0));
|
|
assert_eq!(a[0], Action::Begin("Scale".into()));
|
|
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][0] == 2.0));
|
|
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
|
|
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 170.0));
|
|
assert_eq!(a[0], Action::Begin("Move".into()));
|
|
assert!(matches!(&a[1], Action::Preview(_, v) if v["matrix"][4] == 10.0 && v["matrix"][5] == 20.0));
|
|
}
|
|
|
|
#[test]
|
|
fn cmd_side_handle_shears() {
|
|
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 = FreeTransformTool::default();
|
|
let cmd = Mods { cmd: true, ..Default::default() };
|
|
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0).with_mods(cmd));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
|
|
assert_eq!(a[0], Action::Begin("Shear".into()));
|
|
// Bottom edge fixed, top edge moved 20 right.
|
|
let Action::Preview(_, v) = &a[1] else { panic!() };
|
|
let c: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
|
let m = Affine::new([c[0], c[1], c[2], c[3], c[4], c[5]]);
|
|
assert!((m * Point::new(100.0, 200.0)).distance(Point::new(100.0, 200.0)) < 1e-9);
|
|
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn modifiers_pressed_during_a_handle_drag_change_what_it_does() {
|
|
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 = FreeTransformTool::default();
|
|
let cmd = Mods { cmd: true, ..Default::default() };
|
|
let perspective = Mods { cmd: true, alt: true, shift: true, ..Default::default() };
|
|
// Start dragging the bottom-right corner, then hold Cmd: Free Distort, a step of its own.
|
|
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 210.0, 210.0));
|
|
assert_eq!(a[0], Action::Begin("Scale".into()));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(cmd));
|
|
let corners = json!({"corners": [[100.0, 100.0], [200.0, 100.0], [220.0, 230.0], [100.0, 200.0]], "from": [100.0, 100.0, 200.0, 200.0]});
|
|
assert_eq!(a, vec![Action::Cancel, Action::Begin("Distort".into()), Action::Preview("object.distort".into(), corners)]);
|
|
// Cmd+Alt+Shift: Perspective Distort, still the same step.
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(perspective));
|
|
assert!(matches!(&a[..], [Action::Preview(c, _)] if c == "object.distort"), "{a:?}");
|
|
// Letting go of the keys scales again.
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0));
|
|
assert_eq!(a[..2], [Action::Cancel, Action::Begin("Scale".into())]);
|
|
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 220.0, 230.0)), vec![Action::Commit]);
|
|
// Cmd held during a side handle's drag shears.
|
|
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0));
|
|
t.pointer(&cx, &ev(PointerKind::Drag, 155.0, 100.0));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
|
|
assert_eq!(a[..2], [Action::Cancel, Action::Begin("Shear".into())]);
|
|
}
|
|
|
|
#[test]
|
|
fn distort_modes_emit_corners() {
|
|
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 = FreeTransformTool::default();
|
|
t.set_option("mode", &json!("distort"));
|
|
assert_eq!(t.options()["mode"], "distort");
|
|
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0));
|
|
assert_eq!(a[0], Action::Begin("Distort".into()));
|
|
assert_eq!(
|
|
a[1],
|
|
Action::Preview(
|
|
"object.distort".into(),
|
|
json!({"corners": [[100.0, 100.0], [200.0, 100.0], [220.0, 230.0], [100.0, 200.0]], "from": [100.0, 100.0, 200.0, 200.0]})
|
|
)
|
|
);
|
|
t.pointer(&cx, &ev(PointerKind::Up, 220.0, 230.0));
|
|
let q = distort_quad(Rect::new(0.0, 0.0, 10.0, 10.0), Handle::TopLeft, Vec2::new(2.0, 0.0), DistortMode::Perspective);
|
|
assert_eq!(q[0], Point::new(2.0, 0.0));
|
|
assert_eq!(q[1], Point::new(8.0, 0.0));
|
|
assert_eq!(q[2], Point::new(10.0, 10.0));
|
|
}
|
|
}
|