mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Rotating an object baked the rotation into its geometry and kept nothing
else, so the bounding box, the handles and the Transform/Properties
panels fell back to the page-aligned box (the Rotate field went back to 0).
Root cause: nodes carried no record of their own axes. Each node now
keeps `bbox_angle` (append-only, written only when non-zero), which every
transform turns with the object (mirroring transforms keep the nearer of
the two possible boxes). The geometry is still baked. The selection's box
(`Document::oriented_bounds`) stands at the angle the selected objects
share. The Selection tool hit-tests and scales its handles in that frame,
and the canvas draws it rotated. `object.setBounds` measures W/H and the
reference point along it. `object.rotate {absolute}` turns the selection
to an angle, which the panels' Rotate field now shows and sets. Object >
Transform > Reset Bounding Box (previously a no-op) clears the angle in
one undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
17 KiB
Rust
410 lines
17 KiB
Rust
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains),
|
|
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
|
//! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode.
|
|
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
|
//! along the objects' axes.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::hit::{hit_test, marquee};
|
|
use vectorcraft_doc::{NodeId, OrientedBox};
|
|
use vectorcraft_geom::{Affine, Point, Rect};
|
|
|
|
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
|
use crate::corners::{CornerDrag, over_widget};
|
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
enum State {
|
|
#[default]
|
|
Idle,
|
|
/// Pressed on an object; becomes Moving after the drag threshold.
|
|
Moving {
|
|
start: Point,
|
|
began: bool,
|
|
},
|
|
Scaling {
|
|
handle: Handle,
|
|
bx: OrientedBox,
|
|
},
|
|
Rotating {
|
|
center: Point,
|
|
start: Point,
|
|
},
|
|
Marquee {
|
|
start: Point,
|
|
cur: Point,
|
|
add: bool,
|
|
},
|
|
/// Dragging a Live Corners widget.
|
|
Corner(CornerDrag),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct SelectionTool {
|
|
state: State,
|
|
measure: Option<(Point, String)>,
|
|
guides: Vec<Overlay>,
|
|
targets: Option<crate::guides::Targets>,
|
|
start_bounds: Option<Rect>,
|
|
}
|
|
|
|
pub fn matrix_json(a: Affine) -> Value {
|
|
let c = a.as_coeffs();
|
|
json!([c[0], c[1], c[2], c[3], c[4], c[5]])
|
|
}
|
|
|
|
/// Selection bounds used for the bounding box: visual bounds with Use Preview Bounds, else
|
|
/// geometric bounds.
|
|
pub fn selection_bounds(cx: &ToolContext) -> Option<Rect> {
|
|
cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds)
|
|
}
|
|
|
|
/// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool
|
|
/// shows and drags (rotated after a rotation).
|
|
pub fn selection_box(cx: &ToolContext) -> Option<OrientedBox> {
|
|
cx.doc.oriented_bounds(&cx.selection.objects, cx.preview_bounds)
|
|
}
|
|
|
|
/// What of the bounding box is under the pointer.
|
|
enum BoxHit {
|
|
Handle(Handle),
|
|
/// Just outside a corner.
|
|
Rotate,
|
|
}
|
|
|
|
/// The bounding-box handle or rotate zone under `p`.
|
|
fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option<BoxHit> {
|
|
let (tol, lp) = (cx.tol(5.0), b.to_local(p));
|
|
if let Some(h) = hit_handle(b.rect, lp, tol) {
|
|
return Some(BoxHit::Handle(h));
|
|
}
|
|
in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate)
|
|
}
|
|
|
|
impl SelectionTool {
|
|
fn drag_threshold(cx: &ToolContext) -> f64 {
|
|
cx.tol(3.0)
|
|
}
|
|
}
|
|
|
|
impl Tool for SelectionTool {
|
|
fn id(&self) -> &'static str {
|
|
"selection"
|
|
}
|
|
|
|
fn busy(&self) -> bool {
|
|
!matches!(self.state, State::Idle)
|
|
}
|
|
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
|
let p = ev.pos;
|
|
let m = ev.mods;
|
|
match (ev.kind, self.state.clone()) {
|
|
(PointerKind::DoubleClick, _) => {
|
|
self.state = State::Idle;
|
|
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
|
let top = h.top_object(cx.isolation);
|
|
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
|
|
return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))];
|
|
}
|
|
if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) {
|
|
return vec![Action::SwitchTool("type".into())];
|
|
}
|
|
} else if cx.isolation.is_some() {
|
|
return vec![Action::Exec("object.exitIsolation".into(), json!({}))];
|
|
}
|
|
vec![]
|
|
}
|
|
(PointerKind::Down, _) => {
|
|
// 1. Live Corners widgets, then the bounding-box handles of the current selection.
|
|
if let Some(c) = CornerDrag::hit(cx, p) {
|
|
self.state = State::Corner(c);
|
|
return vec![];
|
|
}
|
|
if cx.show_bbox
|
|
&& let Some(bx) = selection_box(cx)
|
|
{
|
|
match box_hit(cx, &bx, p) {
|
|
Some(BoxHit::Handle(handle)) => {
|
|
self.state = State::Scaling { handle, bx };
|
|
return vec![Action::Begin("Scale".into())];
|
|
}
|
|
Some(BoxHit::Rotate) => {
|
|
self.state = State::Rotating { center: bx.center(), start: p };
|
|
return vec![Action::Begin("Rotate".into())];
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
// 2. Objects.
|
|
match hit_test(cx.doc, p, cx.hit_options()) {
|
|
Some(h) => {
|
|
let top = h.top_object(cx.isolation);
|
|
let mut out = vec![];
|
|
if m.shift {
|
|
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
|
if cx.selection.contains(top) {
|
|
self.state = State::Idle;
|
|
return out;
|
|
}
|
|
} else if !cx.selection.contains(top) {
|
|
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
|
|
}
|
|
self.state = State::Moving { start: p, began: false };
|
|
out
|
|
}
|
|
None => {
|
|
self.state = State::Marquee { start: p, cur: p, add: m.shift };
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
(PointerKind::Drag, State::Moving { start, began, .. }) => {
|
|
let mut out = vec![];
|
|
if !began {
|
|
if p.distance(start) < Self::drag_threshold(cx) {
|
|
return out;
|
|
}
|
|
out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() }));
|
|
}
|
|
let mut d = move_delta(start, p, m.shift);
|
|
if !began {
|
|
self.start_bounds = selection_bounds(cx);
|
|
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
|
|
}
|
|
self.guides.clear();
|
|
if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) {
|
|
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
|
|
d += adj;
|
|
self.guides = ov;
|
|
}
|
|
// Snap to Pixel: the moved selection's top-left lands on whole pixels.
|
|
if cx.snap_to_pixel
|
|
&& let Some(b) = self.start_bounds
|
|
{
|
|
d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
|
|
self.guides.clear();
|
|
}
|
|
self.state = State::Moving { start, began: true };
|
|
self.measure = Some((p, cx.offset_label(d.x, d.y)));
|
|
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
|
out
|
|
}
|
|
(PointerKind::Drag, State::Scaling { handle, bx }) => {
|
|
// Scale in the box's own frame: along the objects' axes when it is rotated.
|
|
let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
|
let nr = a.transform_rect_bbox(bx.rect);
|
|
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
|
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))]
|
|
}
|
|
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
|
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
|
self.measure = Some((p, format!("{:.1}°", -deg)));
|
|
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
|
|
}
|
|
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
|
|
self.state = State::Marquee { start, cur: p, add };
|
|
vec![]
|
|
}
|
|
(PointerKind::Drag, State::Corner(mut c)) => {
|
|
let out = c.drag(cx, p);
|
|
self.state = State::Corner(c);
|
|
out
|
|
}
|
|
(PointerKind::Up, State::Corner(c)) => {
|
|
self.state = State::Idle;
|
|
c.finish()
|
|
}
|
|
(PointerKind::Up, State::Moving { began, .. }) => {
|
|
self.state = State::Idle;
|
|
self.measure = None;
|
|
self.guides.clear();
|
|
self.targets = None;
|
|
if began { vec![Action::Commit] } else { vec![] }
|
|
}
|
|
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
|
self.state = State::Idle;
|
|
self.measure = None;
|
|
vec![Action::Commit]
|
|
}
|
|
(PointerKind::Up, State::Marquee { start, add, .. }) => {
|
|
self.state = State::Idle;
|
|
let r = Rect::from_points(start, p);
|
|
if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) {
|
|
return if add { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
|
}
|
|
let ids: Vec<NodeId> = marquee(cx.doc, r, cx.isolation, false);
|
|
if add {
|
|
vec![Action::Exec("select.add".into(), json!({ "ids": json_ids(&ids) }))]
|
|
} else {
|
|
vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))]
|
|
}
|
|
}
|
|
_ => vec![],
|
|
}
|
|
}
|
|
|
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
|
let mut o = vec![];
|
|
match &self.state {
|
|
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
|
State::Corner(c) => o.extend(c.overlays(cx)),
|
|
_ => {}
|
|
}
|
|
o.extend(self.guides.iter().cloned());
|
|
if let Some((p, t)) = &self.measure {
|
|
o.push(Overlay::Measure { p: *p, text: t.clone() });
|
|
}
|
|
o
|
|
}
|
|
|
|
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
|
match self.state {
|
|
State::Rotating { .. } => return Cursor::Rotate,
|
|
State::Scaling { handle, bx } => return handle_cursor(handle, bx.angle),
|
|
State::Moving { began: true, .. } => return Cursor::Arrow,
|
|
State::Corner(_) => return Cursor::CornerRadius,
|
|
_ => {}
|
|
}
|
|
if over_widget(cx, p) {
|
|
return Cursor::CornerRadius;
|
|
}
|
|
if cx.show_bbox
|
|
&& let Some(bx) = selection_box(cx)
|
|
{
|
|
match box_hit(cx, &bx, p) {
|
|
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle),
|
|
Some(BoxHit::Rotate) => return Cursor::Rotate,
|
|
None => {}
|
|
}
|
|
}
|
|
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
|
if cx.selection.contains(h.top_object(cx.isolation)) || m.alt {
|
|
return Cursor::Move;
|
|
}
|
|
return Cursor::Arrow;
|
|
}
|
|
Cursor::Arrow
|
|
}
|
|
}
|
|
|
|
/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the
|
|
/// handle that sits where `h` appears on screen, in 45° steps.
|
|
fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
|
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
|
|
let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(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,
|
|
}
|
|
}
|
|
|
|
#[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 click_selects_object() {
|
|
let (d, id) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let mut t = SelectionTool::default();
|
|
let a = t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
|
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
|
assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn drag_moves_with_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 = SelectionTool::default();
|
|
assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty());
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 150.0));
|
|
assert_eq!(a[0], Action::Begin("Move".into()));
|
|
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][4] == 10.0));
|
|
let a = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 150.0));
|
|
assert_eq!(a, vec![Action::Commit]);
|
|
}
|
|
|
|
#[test]
|
|
fn marquee_selects() {
|
|
let (d, id) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let mut t = SelectionTool::default();
|
|
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
|
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0));
|
|
assert_eq!(t.overlays(&cx).len(), 1);
|
|
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0));
|
|
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_drag_scales() {
|
|
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 = SelectionTool::default();
|
|
assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), Mods::default()), Cursor::ResizeNwSe);
|
|
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)), vec![Action::Begin("Scale".into())]);
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0));
|
|
assert!(matches!(&a[0], Action::Preview(_, v) if v["matrix"][0] == 2.0));
|
|
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
|
|
}
|
|
|
|
#[test]
|
|
fn turned_box_handles_turn_and_scale_along_the_object() {
|
|
let (mut d, id) = doc_with_rect();
|
|
let c = Point::new(150.0, 150.0);
|
|
let turn = Affine::translate(c.to_vec2()) * Affine::rotate(-std::f64::consts::FRAC_PI_4) * Affine::translate(-c.to_vec2());
|
|
d.node_mut(id).unwrap().transform(turn, false);
|
|
let mut s = Selection::default();
|
|
s.add(id);
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
// The Right handle sits up and to the right, where a diagonal cursor fits it.
|
|
let k = 50.0 * std::f64::consts::FRAC_1_SQRT_2;
|
|
let right = Point::new(150.0 + k, 150.0 - k);
|
|
let mut t = SelectionTool::default();
|
|
assert_eq!(t.cursor(&cx, right, Mods::default()), Cursor::ResizeNeSw);
|
|
// The page box's corner is no handle any more.
|
|
assert_ne!(t.cursor(&cx, Point::new(150.0 + 2.0 * k, 150.0 - 2.0 * k), Mods::default()), Cursor::ResizeNeSw);
|
|
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)), vec![Action::Begin("Scale".into())]);
|
|
let a = t.pointer(&cx, &ev(PointerKind::Drag, right.x + k, right.y - k));
|
|
let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") };
|
|
// Doubling the width along 45° about the left side: the left handle stays put.
|
|
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
|
let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]);
|
|
let left = Point::new(150.0 - k, 150.0 + k);
|
|
assert!((m * left).distance(left) < 1e-9);
|
|
assert!((m * right).distance(Point::new(right.x + k, right.y - k)) < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn click_empty_deselects() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let cx = cx(&d, &s, &p);
|
|
let mut t = SelectionTool::default();
|
|
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
|
|
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
|
}
|
|
}
|