vectorcraft/crates/tools/src/bbox.rs

151 lines
5.7 KiB
Rust

//! 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> {
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> {
[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());
}
}