//! Bounding-box handle geometry shared by the Selection tool and the UI. use vectorcraft_geom::{Affine, Point, Rect, Vec2}; /// The 8 handles, clockwise from top-left. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Handle { TopLeft, Top, TopRight, Right, BottomRight, Bottom, BottomLeft, Left, } impl Handle { pub const ALL: [Handle; 8] = [Handle::TopLeft, Handle::Top, Handle::TopRight, Handle::Right, Handle::BottomRight, Handle::Bottom, Handle::BottomLeft, Handle::Left]; pub fn pos(self, r: Rect) -> Point { let c = r.center(); match self { Handle::TopLeft => Point::new(r.x0, r.y0), Handle::Top => Point::new(c.x, r.y0), Handle::TopRight => Point::new(r.x1, r.y0), Handle::Right => Point::new(r.x1, c.y), Handle::BottomRight => Point::new(r.x1, r.y1), Handle::Bottom => Point::new(c.x, r.y1), Handle::BottomLeft => Point::new(r.x0, r.y1), Handle::Left => Point::new(r.x0, c.y), } } pub fn opposite(self) -> Handle { Handle::ALL[(self as usize + 4) % 8] } pub fn is_corner(self) -> bool { (self as usize).is_multiple_of(2) } /// Which axes this handle scales (x, y). pub fn axes(self) -> (bool, bool) { match self { Handle::Top | Handle::Bottom => (false, true), Handle::Left | Handle::Right => (true, false), _ => (true, true), } } } /// Handle under `p` (within `tol` document units). pub fn hit_handle(r: Rect, p: Point, tol: f64) -> Option { Handle::ALL.into_iter().find(|h| { let q = h.pos(r); (q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol }) } /// Is `p` in the rotate zone just outside a corner (Illustrator shows the curved rotate cursor there)? pub fn in_rotate_zone(r: Rect, p: Point, inner: f64, outer: f64) -> Option { [Handle::TopLeft, Handle::TopRight, Handle::BottomRight, Handle::BottomLeft].into_iter().find(|h| { let q = h.pos(r); let d = p.distance(q); d > inner && d <= outer && !r.contains(p) }) } /// Scale transform for dragging `handle` of `r` to `p`. /// `proportional` (Shift) keeps the aspect ratio; `from_center` (Alt) scales about the centre. pub fn scale_for_drag(r: Rect, handle: Handle, p: Point, proportional: bool, from_center: bool) -> Affine { let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; let start = handle.pos(r); let (ax, ay) = handle.axes(); let d0 = start - origin; let d1 = p - origin; let mut sx = if ax && d0.x.abs() > 1e-9 { d1.x / d0.x } else { 1.0 }; let mut sy = if ay && d0.y.abs() > 1e-9 { d1.y / d0.y } else { 1.0 }; if proportional { if handle.is_corner() { let s = if sx.abs() > sy.abs() { sx } else { sy }; sx = s.abs() * sx.signum(); sy = s.abs() * sy.signum(); } else if ax { sy = sx.abs(); } else { sx = sy.abs(); } } // Avoid degenerate zero scales (Illustrator never collapses an object completely). let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4 * if s < 0.0 { -1.0 } else { 1.0 } } else { s }; Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(clamp(sx), clamp(sy)) * Affine::translate(-origin.to_vec2()) } /// Rotation about `center` taking `from` to `to`; `snap` constrains to 45° steps. pub fn rotate_for_drag(center: Point, from: Point, to: Point, snap: bool) -> (Affine, f64) { let a0 = (from - center).atan2(); let a1 = (to - center).atan2(); let mut a = a1 - a0; if snap { let step = std::f64::consts::FRAC_PI_4; a = (a / step).round() * step; } (Affine::translate(center.to_vec2()) * Affine::rotate(a) * Affine::translate(-center.to_vec2()), -a.to_degrees()) } /// Move constrained to 45° multiples when `shift`. pub fn move_delta(from: Point, to: Point, shift: bool) -> Vec2 { let d = to - from; if shift { vectorcraft_geom::constrain_angle(d, 45.0) } else { d } } #[cfg(test)] mod tests { use super::*; const R: Rect = Rect::new(0.0, 0.0, 100.0, 50.0); #[test] fn handle_positions() { assert_eq!(Handle::Right.pos(R), Point::new(100.0, 25.0)); assert_eq!(Handle::TopLeft.opposite(), Handle::BottomRight); assert_eq!(Handle::Top.opposite(), Handle::Bottom); assert_eq!(hit_handle(R, Point::new(99.0, 24.0), 3.0), Some(Handle::Right)); assert_eq!(hit_handle(R, Point::new(50.0, 25.0), 3.0), None); } #[test] fn scale_corner_free_and_proportional() { let a = scale_for_drag(R, Handle::BottomRight, Point::new(200.0, 50.0), false, false); assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 50.0)); let a = scale_for_drag(R, Handle::BottomRight, Point::new(200.0, 50.0), true, false); assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 200.0, 100.0)); } #[test] fn scale_side_and_center() { let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 999.0), false, false); assert_eq!(a.transform_rect_bbox(R), Rect::new(0.0, 0.0, 150.0, 50.0)); let a = scale_for_drag(R, Handle::Right, Point::new(150.0, 25.0), false, true); assert_eq!(a.transform_rect_bbox(R), Rect::new(-50.0, 0.0, 150.0, 50.0)); } #[test] fn rotate_snaps() { let (_, deg) = rotate_for_drag(Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 9.0), true); assert!((deg + 45.0).abs() < 1e-9); assert!(in_rotate_zone(R, Point::new(-8.0, -8.0), 4.0, 20.0).is_some()); assert!(in_rotate_zone(R, Point::new(5.0, 5.0), 4.0, 20.0).is_none()); } }