use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Point {
pub x: i32,
pub y: i32,
}
impl Point {
pub const fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Size {
pub w: i32,
pub h: i32,
}
impl Size {
pub const fn new(w: i32, h: i32) -> Self {
Self { w, h }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
impl Rect {
pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
Self { x, y, w, h }
}
pub const fn contains(&self, p: Point) -> bool {
p.x >= self.x && p.y >= self.y && p.x < self.x + self.w && p.y < self.y + self.h
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolKind {
Rect,
Circle,
Ellipse,
Triangle,
Polygon,
Freehand,
Poly,
}
impl ToolKind {
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Rect => Self::Ellipse,
Self::Circle | Self::Ellipse => Self::Triangle,
Self::Triangle => Self::Polygon,
Self::Polygon => Self::Freehand,
Self::Freehand | Self::Poly => Self::Rect,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResizeHandle {
CircleRadius,
RectEdges {
left: bool,
right: bool,
top: bool,
bottom: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Shape {
Rect(Rect),
Circle {
cx: i32,
cy: i32,
r: i32,
},
Ellipse {
cx: i32,
cy: i32,
rx: i32,
ry: i32,
},
Triangle {
ax: i32,
ay: i32,
bx: i32,
by: i32,
cx: i32,
cy: i32,
},
Poly {
points: Vec<Point>,
},
}
impl Shape {
pub fn compute_preview(
tool: ToolKind,
start: Point,
current: Point,
region: Rect,
lock: bool,
) -> Option<Self> {
let cx = current.x.clamp(region.x, region.x + region.w - 1);
let cy = current.y.clamp(region.y, region.y + region.h - 1);
match tool {
ToolKind::Rect | ToolKind::Triangle | ToolKind::Ellipse => {
let x = start.x.min(cx);
let y = start.y.min(cy);
let w = (start.x - cx).abs();
let h = (start.y - cy).abs();
if w <= 1 || h <= 1 {
return None;
}
let bbox = Rect::new(x, y, w, h);
Some(match tool {
ToolKind::Rect => Self::Rect(bbox),
ToolKind::Ellipse => ellipse_in_box(bbox, lock),
_ => triangle_in_box(bbox),
})
}
ToolKind::Circle => {
let dx = f64::from(start.x - cx);
let dy = f64::from(start.y - cy);
let r = dx.hypot(dy) as i32;
if r <= 0 {
return None;
}
Some(Self::Circle {
cx: start.x,
cy: start.y,
r,
})
}
ToolKind::Polygon | ToolKind::Freehand | ToolKind::Poly => None,
}
}
pub const fn kind(&self) -> ToolKind {
match self {
Self::Rect(_) => ToolKind::Rect,
Self::Circle { .. } => ToolKind::Circle,
Self::Ellipse { .. } => ToolKind::Ellipse,
Self::Triangle { .. } => ToolKind::Triangle,
Self::Poly { .. } => ToolKind::Poly,
}
}
pub fn bbox(&self) -> Rect {
match *self {
Self::Poly { ref points } => {
let mut x0 = i32::MAX;
let mut y0 = i32::MAX;
let mut x1 = i32::MIN;
let mut y1 = i32::MIN;
for p in points {
x0 = x0.min(p.x);
y0 = y0.min(p.y);
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
if points.is_empty() {
return Rect::new(0, 0, 0, 0);
}
Rect::new(x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0))
}
Self::Rect(r) => r,
Self::Ellipse { cx, cy, rx, ry } => Rect::new(
cx.saturating_sub(rx),
cy.saturating_sub(ry),
rx.saturating_mul(2),
ry.saturating_mul(2),
),
Self::Circle { cx, cy, r } => Rect::new(
cx.saturating_sub(r),
cy.saturating_sub(r),
r.saturating_mul(2),
r.saturating_mul(2),
),
Self::Triangle {
ax,
ay,
bx,
by,
cx,
cy,
} => {
let x0 = min3(ax, bx, cx);
let y0 = min3(ay, by, cy);
Rect::new(
x0,
y0,
max3(ax, bx, cx).saturating_sub(x0),
max3(ay, by, cy).saturating_sub(y0),
)
}
}
}
pub fn hit_test(&self, p: Point) -> bool {
match *self {
Self::Poly { ref points } => point_in_poly(points, p),
Self::Rect(r) => r.contains(p),
Self::Ellipse { cx, cy, rx, ry } => {
let dx = i128::from(p.x - cx);
let dy = i128::from(p.y - cy);
let rx = i128::from(rx);
let ry = i128::from(ry);
dx * dx * ry * ry + dy * dy * rx * rx <= rx * rx * ry * ry
}
Self::Circle { cx, cy, r } => {
let dx = i64::from(p.x - cx);
let dy = i64::from(p.y - cy);
dx * dx + dy * dy <= i64::from(r) * i64::from(r)
}
Self::Triangle {
ax,
ay,
bx,
by,
cx,
cy,
} => {
if cross(cx, cy, ax, ay, bx, by) == 0 {
return false;
}
let d1 = cross(p.x, p.y, ax, ay, bx, by);
let d2 = cross(p.x, p.y, bx, by, cx, cy);
let d3 = cross(p.x, p.y, cx, cy, ax, ay);
let has_neg = d1 < 0 || d2 < 0 || d3 < 0;
let has_pos = d1 > 0 || d2 > 0 || d3 > 0;
!(has_neg && has_pos)
}
}
}
pub fn covers(&self, x: i32, y: i32) -> bool {
self.hit_test(Point::new(x, y))
}
pub fn click_point(&self) -> Point {
match *self {
Self::Poly { ref points } => poly_interior_point(points),
Self::Rect(_) => self.pivot(),
Self::Circle { cx, cy, .. } | Self::Ellipse { cx, cy, .. } => Point::new(cx, cy),
Self::Triangle {
ax,
ay,
bx,
by,
cx,
cy,
} => Point::new(
((i64::from(ax) + i64::from(bx) + i64::from(cx)) / 3) as i32,
((i64::from(ay) + i64::from(by) + i64::from(cy)) / 3) as i32,
),
}
}
pub fn grab_origin(&self) -> Point {
match *self {
Self::Rect(r) => Point::new(r.x, r.y),
Self::Circle { cx, cy, .. } | Self::Ellipse { cx, cy, .. } => Point::new(cx, cy),
Self::Triangle { .. } | Self::Poly { .. } => {
let b = self.bbox();
Point::new(b.x, b.y)
}
}
}
#[must_use]
pub fn clamp_move(&self, grab_offset: Point, cursor: Point, region: Rect) -> Self {
let right = region.x + region.w;
let bottom = region.y + region.h;
match *self {
Self::Rect(rect) => {
let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - rect.w).max(region.x));
let ny =
(cursor.y - grab_offset.y).clamp(region.y, (bottom - rect.h).max(region.y));
Self::Rect(Rect::new(nx, ny, rect.w, rect.h))
}
Self::Circle { r, .. } => {
let min_x = region.x + r.max(0);
let min_y = region.y + r.max(0);
let cx = (cursor.x - grab_offset.x).clamp(min_x, (right - r).max(min_x));
let cy = (cursor.y - grab_offset.y).clamp(min_y, (bottom - r).max(min_y));
Self::Circle { cx, cy, r }
}
Self::Ellipse { rx, ry, .. } => {
let min_x = region.x + rx.max(0);
let min_y = region.y + ry.max(0);
let cx = (cursor.x - grab_offset.x).clamp(min_x, (right - rx).max(min_x));
let cy = (cursor.y - grab_offset.y).clamp(min_y, (bottom - ry).max(min_y));
Self::Ellipse { cx, cy, rx, ry }
}
Self::Triangle { .. } | Self::Poly { .. } => {
let b = self.bbox();
let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - b.w).max(region.x));
let ny = (cursor.y - grab_offset.y).clamp(region.y, (bottom - b.h).max(region.y));
self.translated(nx - b.x, ny - b.y)
}
}
}
pub fn resize_grab(&self, p: Point, tolerance: i32) -> Option<ResizeHandle> {
let tolerance = tolerance.max(1);
match *self {
Self::Circle { cx, cy, r } => {
let dist = f64::from(p.x - cx).hypot(f64::from(p.y - cy));
let on_rim = (dist - f64::from(r)).abs() <= f64::from(tolerance);
on_rim.then_some(ResizeHandle::CircleRadius)
}
Self::Rect(rect) => box_border_grab(rect, p, tolerance),
Self::Ellipse { .. } | Self::Triangle { .. } | Self::Poly { .. } => {
box_border_grab(self.bbox(), p, tolerance)
}
}
}
#[must_use]
pub fn resize_to(
&self,
handle: ResizeHandle,
cursor: Point,
region: Rect,
keep_aspect: bool,
) -> Self {
let clamped = Point::new(
cursor.x.clamp(region.x, region.x + region.w - 1),
cursor.y.clamp(region.y, region.y + region.h - 1),
);
self.resize_to_local(handle, clamped, region, keep_aspect)
}
#[must_use]
fn resize_to_local(
&self,
handle: ResizeHandle,
clamped: Point,
region: Rect,
keep_aspect: bool,
) -> Self {
const MIN: i32 = 2;
match (self.clone(), handle) {
(Self::Circle { cx, cy, .. }, ResizeHandle::CircleRadius) => {
let r = f64::from(clamped.x - cx).hypot(f64::from(clamped.y - cy)) as i32;
Self::Circle {
cx,
cy,
r: r.max(MIN),
}
}
(
Self::Rect(rect),
ResizeHandle::RectEdges {
left,
right,
top,
bottom,
},
) => Self::Rect(resize_box(
rect,
(left, right, top, bottom),
clamped,
region,
keep_aspect,
)),
(
ell @ Self::Ellipse { .. },
ResizeHandle::RectEdges {
left,
right,
top,
bottom,
},
) => {
let bb = resize_box(
ell.bbox(),
(left, right, top, bottom),
clamped,
region,
keep_aspect,
);
ellipse_in_box(bb, false)
}
(
poly @ Self::Poly { .. },
ResizeHandle::RectEdges {
left,
right,
top,
bottom,
},
) => {
let old = poly.bbox();
let new = resize_box(
old,
(left, right, top, bottom),
clamped,
region,
keep_aspect,
);
scale_into_box(&poly, old, new)
}
(
tri @ Self::Triangle { .. },
ResizeHandle::RectEdges {
left,
right,
top,
bottom,
},
) => {
let old = tri.bbox();
let new = resize_box(
old,
(left, right, top, bottom),
clamped,
region,
keep_aspect,
);
tri.mapped_between_boxes(old, new)
}
(shape, _) => shape,
}
}
#[must_use]
fn mapped_between_boxes(&self, old: Rect, new: Rect) -> Self {
let map_x = |v: i32| {
new.x
+ (f64::from(v - old.x) * f64::from(new.w) / f64::from(old.w.max(1))).round() as i32
};
let map_y = |v: i32| {
new.y
+ (f64::from(v - old.y) * f64::from(new.h) / f64::from(old.h.max(1))).round() as i32
};
match self.clone() {
Self::Triangle {
ax,
ay,
bx,
by,
cx,
cy,
} => Self::Triangle {
ax: map_x(ax),
ay: map_y(ay),
bx: map_x(bx),
by: map_y(by),
cx: map_x(cx),
cy: map_y(cy),
},
other => other,
}
}
#[must_use]
pub fn translated(&self, dx: i32, dy: i32) -> Self {
match *self {
Self::Poly { ref points } => Self::Poly {
points: points
.iter()
.map(|p| Point::new(p.x + dx, p.y + dy))
.collect(),
},
Self::Rect(r) => Self::Rect(Rect::new(r.x + dx, r.y + dy, r.w, r.h)),
Self::Circle { cx, cy, r } => Self::Circle {
cx: cx + dx,
cy: cy + dy,
r,
},
Self::Ellipse { cx, cy, rx, ry } => Self::Ellipse {
cx: cx + dx,
cy: cy + dy,
rx,
ry,
},
Self::Triangle {
ax,
ay,
bx,
by,
cx,
cy,
} => Self::Triangle {
ax: ax + dx,
ay: ay + dy,
bx: bx + dx,
by: by + dy,
cx: cx + dx,
cy: cy + dy,
},
}
}
}
pub fn normalize_deg(deg: i32) -> i32 {
deg.rem_euclid(360)
}
fn scale_into_box(shape: &Shape, old: Rect, new: Rect) -> Shape {
let map_x = |v: i32| {
new.x + (f64::from(v - old.x) * f64::from(new.w) / f64::from(old.w.max(1))).round() as i32
};
let map_y = |v: i32| {
new.y + (f64::from(v - old.y) * f64::from(new.h) / f64::from(old.h.max(1))).round() as i32
};
match shape {
Shape::Poly { points } => Shape::Poly {
points: points
.iter()
.map(|p| Point::new(map_x(p.x), map_y(p.y)))
.collect(),
},
other => other.clone(),
}
}
fn point_in_poly(points: &[Point], p: Point) -> bool {
if points.len() < 3 {
return false;
}
let n = points.len();
let mut inside = false;
for i in 0..n {
let a = points[i];
let b = points[(i + 1) % n];
if on_segment(a, b, p) {
return true;
}
if (a.y > p.y) != (b.y > p.y) {
let cross = i64::from(b.x - a.x) * i64::from(p.y - a.y)
- i64::from(b.y - a.y) * i64::from(p.x - a.x);
let crosses = if b.y > a.y { cross > 0 } else { cross < 0 };
if crosses {
inside = !inside;
}
}
}
inside
}
fn on_segment(a: Point, b: Point, p: Point) -> bool {
let cross =
i64::from(b.x - a.x) * i64::from(p.y - a.y) - i64::from(b.y - a.y) * i64::from(p.x - a.x);
cross == 0
&& p.x >= a.x.min(b.x)
&& p.x <= a.x.max(b.x)
&& p.y >= a.y.min(b.y)
&& p.y <= a.y.max(b.y)
}
fn poly_interior_point(points: &[Point]) -> Point {
if points.is_empty() {
return Point::new(0, 0);
}
let n = points.len() as i64;
let sx: i64 = points.iter().map(|p| i64::from(p.x)).sum();
let sy: i64 = points.iter().map(|p| i64::from(p.y)).sum();
let mean = Point::new((sx / n) as i32, (sy / n) as i32);
if point_in_poly(points, mean) {
return mean;
}
let shape = Shape::Poly {
points: points.to_vec(),
};
let bb = shape.bbox();
for y in bb.y..=bb.y.saturating_add(bb.h) {
for x in bb.x..=bb.x.saturating_add(bb.w) {
if point_in_poly(points, Point::new(x, y)) {
return Point::new(x, y);
}
}
}
mean
}
pub fn regular_polygon(center: Point, toward: Point, sides: u32) -> Shape {
let sides = sides.clamp(3, 12) as usize;
let r = f64::from(toward.x - center.x).hypot(f64::from(toward.y - center.y));
let base = f64::from(toward.y - center.y).atan2(f64::from(toward.x - center.x));
let step = std::f64::consts::TAU / sides as f64;
let points = (0..sides)
.map(|i| {
let a = base + step * i as f64;
Point::new(
f64::from(center.x).mul_add(1.0, r * a.cos()).round() as i32,
f64::from(center.y).mul_add(1.0, r * a.sin()).round() as i32,
)
})
.collect();
Shape::Poly { points }
}
pub fn simplify_path(points: &[Point], epsilon: f64) -> Vec<Point> {
if points.len() <= 2 {
return points.to_vec();
}
let mut keep = vec![false; points.len()];
keep[0] = true;
keep[points.len() - 1] = true;
let mut stack = vec![(0usize, points.len() - 1)];
while let Some((start, end)) = stack.pop() {
if end <= start + 1 {
continue;
}
let (mut worst, mut worst_dist) = (start, -1.0f64);
for (i, p) in points.iter().enumerate().take(end).skip(start + 1) {
let d = point_segment_distance(*p, points[start], points[end]);
if d > worst_dist {
worst = i;
worst_dist = d;
}
}
if worst_dist > epsilon {
keep[worst] = true;
stack.push((start, worst));
stack.push((worst, end));
}
}
points
.iter()
.zip(&keep)
.filter(|(_, k)| **k)
.map(|(p, _)| *p)
.collect()
}
fn point_segment_distance(p: Point, a: Point, b: Point) -> f64 {
let (px, py) = (f64::from(p.x), f64::from(p.y));
let (ax, ay) = (f64::from(a.x), f64::from(a.y));
let (bx, by) = (f64::from(b.x), f64::from(b.y));
let (dx, dy) = (bx - ax, by - ay);
let len2 = dx * dx + dy * dy;
if len2 <= f64::EPSILON {
return (px - ax).hypot(py - ay);
}
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
let t = t.clamp(0.0, 1.0);
(px - (ax + t * dx)).hypot(py - (ay + t * dy))
}
fn ellipse_in_box(bbox: Rect, lock: bool) -> Shape {
let cx = bbox.x + bbox.w / 2;
let cy = bbox.y + bbox.h / 2;
let (rx, ry) = (bbox.w / 2, bbox.h / 2);
if lock {
let r = rx.min(ry).max(1);
return Shape::Ellipse {
cx,
cy,
rx: r,
ry: r,
};
}
Shape::Ellipse {
cx,
cy,
rx: rx.max(1),
ry: ry.max(1),
}
}
pub fn rotate_point_about(p: Point, center: Point, deg: i32) -> Point {
let rad = f64::from(deg).to_radians();
let (sin, cos) = rad.sin_cos();
let dx = f64::from(p.x) - f64::from(center.x);
let dy = f64::from(p.y) - f64::from(center.y);
Point::new(
(f64::from(center.x) + (dx * cos - dy * sin).round()) as i32,
(f64::from(center.y) + (dx * sin + dy * cos).round()) as i32,
)
}
impl Shape {
pub fn pivot(&self) -> Point {
let b = self.bbox();
Point::new(b.x.saturating_add(b.w / 2), b.y.saturating_add(b.h / 2))
}
pub fn rotated_bbox(&self, deg: i32) -> Rect {
if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
return self.bbox();
}
let b = self.bbox();
let pivot = self.pivot();
let (bx1, by1) = (b.x.saturating_add(b.w), b.y.saturating_add(b.h));
let corners = [
Point::new(b.x, b.y),
Point::new(bx1, b.y),
Point::new(b.x, by1),
Point::new(bx1, by1),
]
.map(|c| rotate_point_about(c, pivot, deg));
let x0 = corners.iter().map(|c| c.x).min().unwrap_or(b.x);
let y0 = corners.iter().map(|c| c.y).min().unwrap_or(b.y);
let x1 = corners.iter().map(|c| c.x).max().unwrap_or(bx1);
let y1 = corners.iter().map(|c| c.y).max().unwrap_or(by1);
Rect::new(x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0))
}
pub fn hit_test_rotated(&self, deg: i32, p: Point) -> bool {
if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
return self.hit_test(p);
}
self.hit_test(rotate_point_about(p, self.pivot(), -deg))
}
pub fn resize_grab_rotated(&self, deg: i32, p: Point, tolerance: i32) -> Option<ResizeHandle> {
if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
return self.resize_grab(p, tolerance);
}
self.resize_grab(rotate_point_about(p, self.pivot(), -deg), tolerance)
}
#[must_use]
pub fn resize_to_rotated(
&self,
deg: i32,
handle: ResizeHandle,
cursor: Point,
region: Rect,
keep_aspect: bool,
) -> Self {
if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
return self.resize_to(handle, cursor, region, keep_aspect);
}
let visual = Point::new(
cursor.x.clamp(region.x, region.x + region.w - 1),
cursor.y.clamp(region.y, region.y + region.h - 1),
);
let local = rotate_point_about(visual, self.pivot(), -deg);
self.resize_to_local(handle, local, region, keep_aspect)
}
#[must_use]
pub fn clamp_move_rotated(
&self,
deg: i32,
grab_offset: Point,
cursor: Point,
region: Rect,
) -> Self {
if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
return self.clamp_move(grab_offset, cursor, region);
}
let bb = self.rotated_bbox(deg);
let right = region.x + region.w;
let bottom = region.y + region.h;
let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - bb.w).max(region.x));
let ny = (cursor.y - grab_offset.y).clamp(region.y, (bottom - bb.h).max(region.y));
self.translated(nx - bb.x, ny - bb.y)
}
pub fn grab_origin_rotated(&self, deg: i32) -> Point {
if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
return self.grab_origin();
}
let bb = self.rotated_bbox(deg);
Point::new(bb.x, bb.y)
}
#[must_use]
pub fn with_rotation_baked(&self, deg: i32) -> Self {
if let Self::Poly { points } = self {
if normalize_deg(deg) == 0 {
return self.clone();
}
let pivot = self.pivot();
return Self::Poly {
points: points
.iter()
.map(|p| rotate_point_about(*p, pivot, deg))
.collect(),
};
}
match self.clone() {
Self::Triangle {
ax,
ay,
bx,
by,
cx,
cy,
} if normalize_deg(deg) != 0 => {
let pivot = self.pivot();
let a = rotate_point_about(Point::new(ax, ay), pivot, deg);
let b = rotate_point_about(Point::new(bx, by), pivot, deg);
let c = rotate_point_about(Point::new(cx, cy), pivot, deg);
Self::Triangle {
ax: a.x,
ay: a.y,
bx: b.x,
by: b.y,
cx: c.x,
cy: c.y,
}
}
other => other,
}
}
}
const fn triangle_in_box(bbox: Rect) -> Shape {
Shape::Triangle {
ax: bbox.x + bbox.w / 2,
ay: bbox.y,
bx: bbox.x,
by: bbox.y + bbox.h,
cx: bbox.x + bbox.w,
cy: bbox.y + bbox.h,
}
}
const fn cross(px: i32, py: i32, ax: i32, ay: i32, bx: i32, by: i32) -> i64 {
let abx = (bx - ax) as i64;
let aby = (by - ay) as i64;
let apx = (px - ax) as i64;
let apy = (py - ay) as i64;
abx * apy - aby * apx
}
const fn min3(a: i32, b: i32, c: i32) -> i32 {
if a <= b && a <= c {
return a;
}
if b <= c {
return b;
}
c
}
const fn max3(a: i32, b: i32, c: i32) -> i32 {
if a >= b && a >= c {
return a;
}
if b >= c {
return b;
}
c
}
fn box_border_grab(rect: Rect, p: Point, tolerance: i32) -> Option<ResizeHandle> {
let (x1, y1) = (rect.x + rect.w, rect.y + rect.h);
let within_x = p.x >= rect.x - tolerance && p.x <= x1 + tolerance;
let within_y = p.y >= rect.y - tolerance && p.y <= y1 + tolerance;
let left_d = (p.x - rect.x).abs();
let right_d = (p.x - x1).abs();
let top_d = (p.y - rect.y).abs();
let bottom_d = (p.y - y1).abs();
let mut left = left_d <= tolerance && within_y;
let mut right = right_d <= tolerance && within_y;
let mut top = top_d <= tolerance && within_x;
let mut bottom = bottom_d <= tolerance && within_x;
if left && right {
right = right_d < left_d;
left = !right;
}
if top && bottom {
bottom = bottom_d < top_d;
top = !bottom;
}
let grabbed = left || right || top || bottom;
grabbed.then_some(ResizeHandle::RectEdges {
left,
right,
top,
bottom,
})
}
fn resize_box(
rect: Rect,
(left, right, top, bottom): (bool, bool, bool, bool),
clamped: Point,
region: Rect,
keep_aspect: bool,
) -> Rect {
const MIN: i32 = 2;
let mut x0 = rect.x;
let mut x1 = rect.x + rect.w;
let mut y0 = rect.y;
let mut y1 = rect.y + rect.h;
if left {
x0 = clamped.x.min(x1 - MIN);
}
if right {
x1 = clamped.x.max(x0 + MIN);
}
if top {
y0 = clamped.y.min(y1 - MIN);
}
if bottom {
y1 = clamped.y.max(y0 + MIN);
}
if keep_aspect && rect.w >= MIN && rect.h >= MIN {
let (w0, h0) = (f64::from(rect.w), f64::from(rect.h));
match (left || right, top || bottom) {
(true, true) => {
let mut s = (f64::from(x1 - x0) / w0).max(f64::from(y1 - y0) / h0);
let region_right = region.x + region.w;
let region_bottom = region.y + region.h;
let avail_w = if left {
x1 - region.x
} else {
region_right - x0
};
let avail_h = if top {
y1 - region.y
} else {
region_bottom - y0
};
s = s.min(f64::from(avail_w) / w0).min(f64::from(avail_h) / h0);
let w = ((w0 * s).round() as i32).max(MIN);
let h = ((h0 * s).round() as i32).max(MIN);
(x0, x1) = if left { (x1 - w, x1) } else { (x0, x0 + w) };
(y0, y1) = if top { (y1 - h, y1) } else { (y0, y0 + h) };
}
(true, false) => {
let h = ((f64::from(x1 - x0) * h0 / w0).round() as i32)
.max(MIN)
.min(region.h);
let center_y = rect.y + rect.h / 2;
y0 = (center_y - h / 2).clamp(region.y, region.y + region.h - h);
y1 = y0 + h;
}
(false, _) => {
let w = ((f64::from(y1 - y0) * w0 / h0).round() as i32)
.max(MIN)
.min(region.w);
let center_x = rect.x + rect.w / 2;
x0 = (center_x - w / 2).clamp(region.x, region.x + region.w - w);
x1 = x0 + w;
}
}
}
let (w, h) = (x1 - x0, y1 - y0);
if rect.w >= MIN && rect.h >= MIN {
return Rect::new(x0, y0, w, h);
}
let x0 = x0.clamp(region.x, (region.x + region.w - w).max(region.x));
let y0 = y0.clamp(region.y, (region.y + region.h - h).max(region.y));
Rect::new(x0, y0, w, h)
}
#[cfg(test)]
mod tests {
use super::*;
const BOUNDS: Size = Size::new(1920, 1080);
const BOUNDS_RECT: Rect = Rect::new(0, 0, BOUNDS.w, BOUNDS.h);
#[test]
fn rect_preview_normalizes_inverted_drag() {
let s = Shape::compute_preview(
ToolKind::Rect,
Point::new(100, 200),
Point::new(40, 50),
BOUNDS_RECT,
false,
);
assert_eq!(s, Some(Shape::Rect(Rect::new(40, 50, 60, 150))));
}
#[test]
fn rect_preview_clamps_cursor_to_bounds() {
let s = Shape::compute_preview(
ToolKind::Rect,
Point::new(1900, 1000),
Point::new(5000, 5000),
BOUNDS_RECT,
false,
);
assert_eq!(s, Some(Shape::Rect(Rect::new(1900, 1000, 19, 79))));
}
#[test]
fn rect_preview_degenerate_is_none() {
assert_eq!(
Shape::compute_preview(
ToolKind::Rect,
Point::new(10, 10),
Point::new(10, 300),
BOUNDS_RECT,
false
),
None
);
assert_eq!(
Shape::compute_preview(
ToolKind::Rect,
Point::new(10, 10),
Point::new(10, 10),
BOUNDS_RECT,
false
),
None
);
}
#[test]
fn circle_preview_radius_is_distance() {
let s = Shape::compute_preview(
ToolKind::Circle,
Point::new(100, 100),
Point::new(103, 104),
BOUNDS_RECT,
false,
);
assert_eq!(
s,
Some(Shape::Circle {
cx: 100,
cy: 100,
r: 5
})
);
}
#[test]
fn circle_preview_zero_radius_is_none() {
assert_eq!(
Shape::compute_preview(
ToolKind::Circle,
Point::new(7, 7),
Point::new(7, 7),
BOUNDS_RECT,
false
),
None
);
}
#[test]
fn rect_hit_test_edges() {
let s = Shape::Rect(Rect::new(10, 10, 20, 20));
assert!(s.hit_test(Point::new(10, 10)));
assert!(s.hit_test(Point::new(29, 29)));
assert!(!s.hit_test(Point::new(30, 30)));
assert!(!s.hit_test(Point::new(9, 10)));
}
#[test]
fn circle_hit_test_boundary_inclusive() {
let s = Shape::Circle {
cx: 0,
cy: 0,
r: 10,
};
assert!(s.hit_test(Point::new(10, 0)));
assert!(s.hit_test(Point::new(6, 8)));
assert!(!s.hit_test(Point::new(8, 8)));
}
#[test]
fn circle_hit_test_survives_extreme_coords() {
let s = Shape::Circle { cx: 0, cy: 0, r: 5 };
assert!(!s.hit_test(Point::new(i32::MAX, i32::MAX)));
}
#[test]
fn bbox_of_circle() {
let s = Shape::Circle {
cx: 50,
cy: 60,
r: 10,
};
assert_eq!(s.bbox(), Rect::new(40, 50, 20, 20));
}
#[test]
fn rect_clamp_move_never_escapes_bounds() {
let s = Shape::Rect(Rect::new(0, 0, 300, 200));
let grab = Point::new(0, 0);
for cx in [-500, 0, 960, 5000] {
for cy in [-500, 0, 540, 5000] {
let Shape::Rect(r) = s.clamp_move(grab, Point::new(cx, cy), BOUNDS_RECT) else {
panic!("rect stayed rect");
};
assert!(r.x >= 0 && r.y >= 0, "({cx},{cy}) gave {r:?}");
assert!(
r.x + r.w <= BOUNDS.w && r.y + r.h <= BOUNDS.h,
"({cx},{cy}) gave {r:?}"
);
}
}
}
#[test]
fn circle_clamp_move_never_escapes_bounds() {
let s = Shape::Circle {
cx: 500,
cy: 500,
r: 40,
};
let grab = Point::new(0, 0);
for cx in [-500, 0, 960, 5000] {
for cy in [-500, 0, 540, 5000] {
let Shape::Circle {
cx: ncx,
cy: ncy,
r,
} = s.clamp_move(grab, Point::new(cx, cy), BOUNDS_RECT)
else {
panic!("circle stayed circle");
};
assert!(
ncx - r >= 0 && ncy - r >= 0,
"({cx},{cy}) gave center ({ncx},{ncy})"
);
assert!(
ncx + r <= BOUNDS.w && ncy + r <= BOUNDS.h,
"({cx},{cy}) gave center ({ncx},{ncy})"
);
}
}
}
#[test]
fn oversized_circle_clamp_is_stable() {
let s = Shape::Circle {
cx: 100,
cy: 100,
r: 2000,
};
let moved = s.clamp_move(Point::new(0, 0), Point::new(0, 0), BOUNDS_RECT);
assert_eq!(
moved,
Shape::Circle {
cx: 2000,
cy: 2000,
r: 2000
}
);
}
#[test]
fn translated_shifts_both_kinds() {
assert_eq!(
Shape::Rect(Rect::new(1, 2, 3, 4)).translated(10, 20),
Shape::Rect(Rect::new(11, 22, 3, 4))
);
assert_eq!(
Shape::Circle { cx: 1, cy: 2, r: 3 }.translated(10, 20),
Shape::Circle {
cx: 11,
cy: 22,
r: 3
}
);
}
#[test]
fn circle_rim_grab_within_tolerance_only() {
let s = Shape::Circle {
cx: 100,
cy: 100,
r: 50,
};
assert_eq!(
s.resize_grab(Point::new(153, 100), 5),
Some(ResizeHandle::CircleRadius)
);
assert_eq!(
s.resize_grab(Point::new(147, 100), 5),
Some(ResizeHandle::CircleRadius)
);
assert_eq!(s.resize_grab(Point::new(100, 100), 5), None); assert_eq!(s.resize_grab(Point::new(160, 100), 5), None); }
#[test]
fn rect_edge_and_corner_grabs() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
assert_eq!(
s.resize_grab(Point::new(100, 150), 5),
Some(ResizeHandle::RectEdges {
left: true,
right: false,
top: false,
bottom: false
})
);
assert_eq!(
s.resize_grab(Point::new(302, 150), 5), Some(ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false
})
);
assert_eq!(
s.resize_grab(Point::new(298, 202), 5), Some(ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: true
})
);
assert_eq!(s.resize_grab(Point::new(200, 150), 5), None); assert_eq!(s.resize_grab(Point::new(90, 150), 5), None); }
#[test]
fn tiny_rect_grabs_nearer_edge_not_both() {
let s = Shape::Rect(Rect::new(100, 100, 6, 6));
let Some(ResizeHandle::RectEdges { left, right, .. }) =
s.resize_grab(Point::new(101, 103), 5)
else {
panic!("expected an edge grab");
};
assert!(left && !right);
}
#[test]
fn circle_resize_follows_cursor_distance() {
let s = Shape::Circle {
cx: 100,
cy: 100,
r: 50,
};
let resized = s.resize_to(
ResizeHandle::CircleRadius,
Point::new(100, 180),
BOUNDS_RECT,
false,
);
assert_eq!(
resized,
Shape::Circle {
cx: 100,
cy: 100,
r: 80
}
);
let tiny = s.resize_to(
ResizeHandle::CircleRadius,
Point::new(100, 100),
BOUNDS_RECT,
false,
);
assert_eq!(
tiny,
Shape::Circle {
cx: 100,
cy: 100,
r: 2
}
);
}
#[test]
fn rect_corner_resize_anchors_opposite_corner() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let handle = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: true,
};
let resized = s.resize_to(handle, Point::new(400, 300), BOUNDS_RECT, false);
assert_eq!(resized, Shape::Rect(Rect::new(100, 100, 300, 200)));
}
#[test]
fn rect_edge_resize_moves_one_axis_only() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let handle = ResizeHandle::RectEdges {
left: true,
right: false,
top: false,
bottom: false,
};
let resized = s.resize_to(handle, Point::new(50, 999), BOUNDS_RECT, false);
assert_eq!(resized, Shape::Rect(Rect::new(50, 100, 250, 100)));
}
#[test]
fn rect_resize_cannot_invert_or_vanish() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let handle = ResizeHandle::RectEdges {
left: true,
right: false,
top: false,
bottom: false,
};
let resized = s.resize_to(handle, Point::new(500, 150), BOUNDS_RECT, false);
assert_eq!(resized, Shape::Rect(Rect::new(298, 100, 2, 100)));
}
#[test]
fn resize_cursor_is_clamped_to_bounds() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let handle = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false,
};
let resized = s.resize_to(handle, Point::new(99_999, 150), BOUNDS_RECT, false);
assert_eq!(
resized,
Shape::Rect(Rect::new(100, 100, BOUNDS.w - 1 - 100, 100))
);
}
#[test]
fn locked_corner_resize_keeps_ratio_dominant_axis_wins() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let corner = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: true,
};
let resized = s.resize_to(corner, Point::new(400, 300), BOUNDS_RECT, true);
assert_eq!(resized, Shape::Rect(Rect::new(100, 100, 400, 200)));
}
#[test]
fn locked_corner_resize_anchors_the_opposite_corner() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let corner = ResizeHandle::RectEdges {
left: true,
right: false,
top: true,
bottom: false,
};
let resized = s.resize_to(corner, Point::new(0, 80), BOUNDS_RECT, true);
let Shape::Rect(r) = resized else {
panic!("still a rect")
};
assert_eq!((r.x + r.w, r.y + r.h), (300, 200), "anchor moved");
assert_eq!(r.w * 100, r.h * 200, "ratio drifted: {r:?}");
}
#[test]
fn locked_corner_resize_caps_scale_at_bounds() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let corner = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: true,
};
let resized = s.resize_to(
corner,
Point::new(BOUNDS_RECT.w - 1, BOUNDS_RECT.h - 1),
BOUNDS_RECT,
true,
);
let Shape::Rect(r) = resized else {
panic!("still a rect")
};
assert!(
r.x + r.w <= BOUNDS.w && r.y + r.h <= BOUNDS.h,
"escaped: {r:?}"
);
assert_eq!(r.w, BOUNDS.w - 100);
assert_eq!(r.w, 2 * r.h);
}
#[test]
fn locked_edge_resize_scales_other_axis_centered() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let edge = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false,
};
let resized = s.resize_to(edge, Point::new(500, 150), BOUNDS_RECT, true);
assert_eq!(resized, Shape::Rect(Rect::new(100, 50, 400, 200)));
}
#[test]
fn locked_edge_resize_clamps_centered_axis_to_bounds() {
let s = Shape::Rect(Rect::new(100, 10, 200, 100));
let edge = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false,
};
let resized = s.resize_to(edge, Point::new(500, 60), BOUNDS_RECT, true);
let Shape::Rect(r) = resized else {
panic!("still a rect")
};
assert_eq!((r.w, r.h), (400, 200));
assert_eq!(r.y, 0, "clamped to the top edge");
}
#[test]
fn locked_circle_resize_is_unchanged_by_lock() {
let s = Shape::Circle {
cx: 100,
cy: 100,
r: 50,
};
let unlocked = s.resize_to(
ResizeHandle::CircleRadius,
Point::new(100, 180),
BOUNDS_RECT,
false,
);
let locked = s.resize_to(
ResizeHandle::CircleRadius,
Point::new(100, 180),
BOUNDS_RECT,
true,
);
assert_eq!(unlocked, locked);
}
#[test]
fn mismatched_handle_is_inert() {
let s = Shape::Circle { cx: 5, cy: 5, r: 5 };
let handle = ResizeHandle::RectEdges {
left: true,
right: false,
top: false,
bottom: false,
};
assert_eq!(
s.resize_to(handle, Point::new(50, 50), BOUNDS_RECT, false),
s
);
}
#[test]
fn ellipse_preview_inscribes_the_drag_box_and_shift_locks_a_circle() {
let free = Shape::compute_preview(
ToolKind::Ellipse,
Point::new(10, 10),
Point::new(50, 30),
BOUNDS_RECT,
false,
);
assert_eq!(
free,
Some(Shape::Ellipse {
cx: 30,
cy: 20,
rx: 20,
ry: 10,
})
);
let locked = Shape::compute_preview(
ToolKind::Ellipse,
Point::new(10, 10),
Point::new(50, 30),
BOUNDS_RECT,
true,
);
assert_eq!(
locked,
Some(Shape::Ellipse {
cx: 30,
cy: 20,
rx: 10,
ry: 10,
}),
"Shift inscribes the circle instead"
);
}
#[test]
fn ellipse_hit_test_is_boundary_inclusive_and_excludes_bbox_corners() {
let e = Shape::Ellipse {
cx: 50,
cy: 40,
rx: 30,
ry: 10,
};
assert!(e.hit_test(Point::new(50, 40)));
assert!(e.hit_test(Point::new(80, 40)), "rx vertex inclusive");
assert!(e.hit_test(Point::new(50, 30)), "ry vertex inclusive");
assert!(!e.hit_test(Point::new(80, 30)), "bbox corner outside");
assert!(!e.hit_test(Point::new(81, 40)));
assert_eq!(e.bbox(), Rect::new(20, 30, 60, 20));
}
#[test]
fn ellipse_resize_rides_its_bounding_box() {
let e = Shape::Ellipse {
cx: 50,
cy: 40,
rx: 20,
ry: 10,
};
let handle = e.resize_grab(Point::new(70, 40), 2).expect("edge grab");
let resized = e.resize_to(handle, Point::new(90, 40), BOUNDS_RECT, false);
assert_eq!(
resized,
Shape::Ellipse {
cx: 60,
cy: 40,
rx: 30,
ry: 10,
},
"left edge anchored, rx grew"
);
}
#[test]
fn rotated_ellipse_hit_follows_the_turn() {
let e = Shape::Ellipse {
cx: 50,
cy: 40,
rx: 30,
ry: 8,
};
assert!(e.hit_test_rotated(90, Point::new(50, 65)));
assert!(!e.hit_test_rotated(90, Point::new(75, 40)));
assert!(e.hit_test(Point::new(75, 40)), "unrotated it lies flat");
}
#[test]
fn point_in_poly_handles_concave_shapes_edges_included() {
let u = vec![
Point::new(0, 0),
Point::new(10, 0),
Point::new(10, 30),
Point::new(20, 30),
Point::new(20, 0),
Point::new(30, 0),
Point::new(30, 40),
Point::new(0, 40),
];
let shape = Shape::Poly { points: u };
assert!(shape.hit_test(Point::new(5, 20)), "left arm");
assert!(shape.hit_test(Point::new(25, 20)), "right arm");
assert!(shape.hit_test(Point::new(15, 35)), "base");
assert!(!shape.hit_test(Point::new(15, 10)), "the notch is outside");
assert!(shape.hit_test(Point::new(0, 0)), "vertex inclusive");
assert!(shape.hit_test(Point::new(5, 0)), "edge inclusive");
assert!(!shape.hit_test(Point::new(-1, 20)));
assert!(shape.hit_test(shape.click_point()));
}
#[test]
fn regular_polygon_puts_the_first_vertex_at_the_cursor() {
let hex = regular_polygon(Point::new(100, 100), Point::new(140, 100), 6);
let Shape::Poly { ref points } = hex else {
panic!("regular polygon is a poly")
};
assert_eq!(points.len(), 6);
assert_eq!(points[0], Point::new(140, 100), "first vertex at cursor");
for p in points {
let d = f64::from(p.x - 100).hypot(f64::from(p.y - 100));
assert!((d - 40.0).abs() < 1.5, "vertex {p:?} off the radius: {d}");
}
let tri = regular_polygon(Point::new(0, 0), Point::new(10, 0), 1);
let Shape::Poly { points } = tri else {
panic!()
};
assert_eq!(points.len(), 3);
}
#[test]
fn simplify_path_drops_jitter_and_keeps_corners() {
let path: Vec<Point> = (0..=20)
.map(|x| Point::new(x * 5, i32::from(x % 2 != 0)))
.chain((1..=10).map(|y| Point::new(100, y * 5)))
.collect();
let simplified = simplify_path(&path, 2.0);
assert!(
simplified.len() <= 5,
"expected a handful of points, got {}",
simplified.len()
);
assert_eq!(*simplified.first().unwrap(), Point::new(0, 0));
assert_eq!(*simplified.last().unwrap(), Point::new(100, 50));
assert!(
simplified.contains(&Point::new(100, 1)) || simplified.contains(&Point::new(100, 0)),
"the corner survives: {simplified:?}"
);
}
#[test]
fn poly_moves_resizes_and_rotates_like_any_shape() {
let square = Shape::Poly {
points: vec![
Point::new(10, 10),
Point::new(30, 10),
Point::new(30, 30),
Point::new(10, 30),
],
};
assert_eq!(square.bbox(), Rect::new(10, 10, 20, 20));
let moved = square.translated(5, -5);
assert_eq!(moved.bbox(), Rect::new(15, 5, 20, 20));
let handle = square.resize_grab(Point::new(30, 20), 2).expect("edge");
let grown = square.resize_to(handle, Point::new(50, 20), BOUNDS_RECT, false);
assert_eq!(grown.bbox(), Rect::new(10, 10, 40, 20));
let turned = square.with_rotation_baked(90);
assert_eq!(turned.bbox(), square.bbox(), "square is 90-symmetric");
assert!(matches!(turned, Shape::Poly { .. }));
}
#[test]
fn click_point_centers_each_kind() {
assert_eq!(
Shape::Rect(Rect::new(10, 20, 30, 40)).click_point(),
Point::new(25, 40)
);
assert_eq!(
Shape::Circle { cx: 5, cy: 6, r: 7 }.click_point(),
Point::new(5, 6)
);
let tri = Shape::Triangle {
ax: 30,
ay: 0,
bx: 0,
by: 60,
cx: 60,
cy: 60,
};
assert_eq!(tri.click_point(), Point::new(30, 40));
assert!(tri.hit_test(tri.click_point()));
let rect = Shape::Rect(Rect::new(10, 10, 40, 10));
assert!(rect.hit_test_rotated(90, rect.click_point()));
}
#[test]
fn tool_kind_cycles_through_the_drawing_tools() {
assert_eq!(ToolKind::Rect.next(), ToolKind::Ellipse);
assert_eq!(ToolKind::Ellipse.next(), ToolKind::Triangle);
assert_eq!(ToolKind::Triangle.next(), ToolKind::Polygon);
assert_eq!(ToolKind::Polygon.next(), ToolKind::Freehand);
assert_eq!(ToolKind::Freehand.next(), ToolKind::Rect);
assert_eq!(ToolKind::Circle.next(), ToolKind::Triangle);
assert_eq!(ToolKind::Poly.next(), ToolKind::Rect);
}
#[test]
fn triangle_preview_is_apex_top_center_in_drag_box() {
let s = Shape::compute_preview(
ToolKind::Triangle,
Point::new(100, 100),
Point::new(300, 200),
BOUNDS_RECT,
false,
);
assert_eq!(
s,
Some(Shape::Triangle {
ax: 200,
ay: 100,
bx: 100,
by: 200,
cx: 300,
cy: 200,
})
);
}
#[test]
fn triangle_hit_test_excludes_bbox_corners() {
let tri = Shape::Triangle {
ax: 200,
ay: 100,
bx: 100,
by: 200,
cx: 300,
cy: 200,
};
assert!(tri.hit_test(Point::new(200, 150))); assert!(tri.hit_test(Point::new(200, 100))); assert!(tri.hit_test(Point::new(150, 200))); assert!(!tri.hit_test(Point::new(105, 105))); assert!(!tri.hit_test(Point::new(295, 105))); }
#[test]
fn triangle_bbox_and_move_clamp() {
let tri = Shape::Triangle {
ax: 200,
ay: 100,
bx: 100,
by: 200,
cx: 300,
cy: 200,
};
assert_eq!(tri.bbox(), Rect::new(100, 100, 200, 100));
let moved = tri.clamp_move(Point::new(0, 0), Point::new(-500, -500), BOUNDS_RECT);
assert_eq!(moved.bbox(), Rect::new(0, 0, 200, 100));
assert_eq!(
moved,
Shape::Triangle {
ax: 100,
ay: 0,
bx: 0,
by: 100,
cx: 200,
cy: 100,
}
);
}
#[test]
fn triangle_resize_scales_vertices_into_new_bbox() {
let tri = Shape::Triangle {
ax: 200,
ay: 100,
bx: 100,
by: 200,
cx: 300,
cy: 200,
};
let handle = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: true,
};
let resized = tri.resize_to(handle, Point::new(500, 300), BOUNDS_RECT, false);
assert_eq!(
resized,
Shape::Triangle {
ax: 300,
ay: 100,
bx: 100,
by: 300,
cx: 500,
cy: 300,
}
);
}
#[test]
fn triangle_resize_grab_is_on_the_bbox_border() {
let tri = Shape::Triangle {
ax: 200,
ay: 100,
bx: 100,
by: 200,
cx: 300,
cy: 200,
};
assert_eq!(
tri.resize_grab(Point::new(150, 100), 5),
Some(ResizeHandle::RectEdges {
left: false,
right: false,
top: true,
bottom: false
})
);
assert_eq!(tri.resize_grab(Point::new(200, 150), 5), None); }
#[test]
fn degenerate_triangles_cover_nothing() {
let point = Shape::Triangle {
ax: 0,
ay: 0,
bx: 0,
by: 0,
cx: 0,
cy: 0,
};
assert!(!point.hit_test(Point::new(500, 500)));
assert!(!point.hit_test(Point::new(0, 0)));
let line = Shape::Triangle {
ax: 0,
ay: 0,
bx: 10,
by: 10,
cx: 20,
cy: 20,
};
assert!(!line.hit_test(Point::new(400, 400)));
assert!(!line.hit_test(Point::new(5, 5)));
}
#[test]
fn extreme_shapes_do_not_panic() {
let huge = Shape::Circle {
cx: 0,
cy: 0,
r: 2_000_000_000,
};
let bb = huge.bbox();
assert!(bb.w > 0);
let far = Shape::Rect(Rect::new(
2_000_000_000,
2_000_000_000,
400_000_000,
400_000_000,
));
let _ = far.rotated_bbox(45);
}
#[test]
fn resize_of_sub_min_rect_stays_in_bounds() {
let s = Shape::Rect(Rect::new(0, 0, 1, 100));
let handle = ResizeHandle::RectEdges {
left: true,
right: false,
top: false,
bottom: false,
};
let Shape::Rect(r) = s.resize_to(handle, Point::new(0, 50), BOUNDS_RECT, false) else {
panic!("still a rect")
};
assert!(r.x >= 0, "escaped left: {r:?}");
let s = Shape::Rect(Rect::new(BOUNDS.w - 1, 0, 1, 100));
let handle = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false,
};
let Shape::Rect(r) = s.resize_to(
handle,
Point::new(BOUNDS_RECT.w - 1, 50),
BOUNDS_RECT,
false,
) else {
panic!("still a rect")
};
assert!(r.x + r.w <= BOUNDS.w, "escaped right: {r:?}");
}
#[test]
fn rotated_resize_never_moves_the_anchored_edge() {
let s = Shape::Rect(Rect::new(800, 500, 200, 100));
let handle = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false,
};
let Shape::Rect(r) =
s.resize_to_rotated(45, handle, Point::new(1900, 1000), BOUNDS_RECT, false)
else {
panic!("still a rect")
};
assert_eq!(r.x, 800, "anchored left edge moved");
assert_eq!(r.y, 500, "anchored top edge moved");
}
#[test]
fn resize_of_offscreen_local_box_does_not_teleport() {
let s = Shape::Rect(Rect::new(-90, 0, 200, 20));
let handle = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false,
};
let Shape::Rect(r) = s.resize_to(handle, Point::new(120, 10), BOUNDS_RECT, false) else {
panic!("still a rect")
};
assert_eq!(r.x, -90, "shape teleported");
assert_eq!(r.w, 210);
}
#[test]
fn rotated_resize_tracks_cursor_at_screen_edge() {
let s = Shape::Rect(Rect::new(800, 500, 200, 100));
let handle = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: false,
};
let r45 = s.resize_to_rotated(45, handle, Point::new(99_999, 99_999), BOUNDS_RECT, false);
assert_ne!(r45, s);
}
#[test]
fn rotate_point_quarter_turn() {
let center = Point::new(100, 100);
assert_eq!(
rotate_point_about(Point::new(110, 100), center, 90),
Point::new(100, 110)
);
assert_eq!(
rotate_point_about(Point::new(110, 100), center, -90),
Point::new(100, 90)
);
assert_eq!(
rotate_point_about(Point::new(110, 100), center, 360),
Point::new(110, 100)
);
}
#[test]
fn normalize_deg_wraps_into_range() {
assert_eq!(normalize_deg(0), 0);
assert_eq!(normalize_deg(-1), 359);
assert_eq!(normalize_deg(360), 0);
assert_eq!(normalize_deg(725), 5);
}
#[test]
fn rotated_bbox_of_quarter_turned_rect_swaps_dimensions() {
let s = Shape::Rect(Rect::new(100, 100, 200, 100));
let bb = s.rotated_bbox(90);
assert_eq!((bb.w, bb.h), (100, 200));
assert_eq!(bb.x + bb.w / 2, 200);
assert_eq!(bb.y + bb.h / 2, 150);
assert_eq!(s.rotated_bbox(0), s.bbox());
let c = Shape::Circle {
cx: 50,
cy: 50,
r: 20,
};
assert_eq!(c.rotated_bbox(45), c.bbox());
}
#[test]
fn rotated_hit_test_follows_the_turned_shape() {
let s = Shape::Rect(Rect::new(100, 100, 200, 20));
assert!(s.hit_test_rotated(90, Point::new(200, 30)));
assert!(!s.hit_test_rotated(90, Point::new(290, 110)));
assert!(s.hit_test_rotated(0, Point::new(290, 110)));
}
#[test]
fn rotated_resize_grab_finds_the_visual_edge() {
let s = Shape::Rect(Rect::new(100, 100, 200, 20));
assert!(s.resize_grab_rotated(90, Point::new(190, 110), 5).is_some());
assert!(s.resize_grab_rotated(90, Point::new(150, 110), 5).is_none());
}
#[test]
fn baked_triangle_rotates_vertices_others_unchanged() {
let tri = Shape::Triangle {
ax: 200,
ay: 100,
bx: 100,
by: 200,
cx: 300,
cy: 200,
};
let baked = tri.with_rotation_baked(180);
assert_eq!(
baked,
Shape::Triangle {
ax: 200,
ay: 200,
bx: 300,
by: 100,
cx: 100,
cy: 100,
}
);
let rect = Shape::Rect(Rect::new(1, 2, 3, 4));
assert_eq!(rect.with_rotation_baked(90), rect);
assert_eq!(tri.with_rotation_baked(0), tri);
}
#[test]
fn triangle_serde_is_distinct_from_rect_and_circle() {
let tri = Shape::Triangle {
ax: 1,
ay: 2,
bx: 3,
by: 4,
cx: 5,
cy: 6,
};
let json = serde_json::to_string(&tri).unwrap();
let back: Shape = serde_json::from_str(&json).unwrap();
assert_eq!(back, tri);
let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
}
#[test]
fn a_triangle_grabs_from_its_bbox_origin() {
let tri = Shape::Triangle {
ax: 50,
ay: 10,
bx: 20,
by: 70,
cx: 80,
cy: 70,
};
assert_eq!(tri.grab_origin(), Point::new(20, 10));
}
#[test]
fn a_rotated_move_clamps_the_rotated_box_to_bounds() {
let rect = Shape::Rect(Rect::new(10, 10, 40, 20));
let bounds = Size::new(200, 200);
let moved = rect.clamp_move_rotated(
45,
Point::new(0, 0),
Point::new(500, 500),
Rect::new(0, 0, bounds.w, bounds.h),
);
let bb = moved.rotated_bbox(45);
assert!(bb.x >= 0 && bb.y >= 0, "{bb:?}");
assert!(bb.x + bb.w <= bounds.w, "{bb:?}");
assert!(bb.y + bb.h <= bounds.h, "{bb:?}");
}
#[test]
fn a_rotated_grab_references_the_rotated_box_origin() {
let rect = Shape::Rect(Rect::new(10, 10, 40, 20));
assert_eq!(rect.grab_origin_rotated(0), rect.grab_origin());
let rotated = rect.grab_origin_rotated(45);
assert_eq!(
rotated,
Point::new(rect.rotated_bbox(45).x, rect.rotated_bbox(45).y)
);
let circle = Shape::Circle {
cx: 40,
cy: 40,
r: 9,
};
assert_eq!(circle.grab_origin_rotated(30), circle.grab_origin());
}
#[test]
fn min3_and_max3_pick_each_position() {
assert_eq!(min3(1, 2, 3), 1);
assert_eq!(min3(2, 1, 3), 1);
assert_eq!(min3(3, 2, 1), 1);
assert_eq!(max3(3, 2, 1), 3);
assert_eq!(max3(1, 3, 2), 3);
assert_eq!(max3(1, 2, 3), 3);
}
#[test]
fn a_proportional_vertical_edge_resize_keeps_the_aspect() {
let rect = Shape::Rect(Rect::new(20, 20, 40, 20));
let resized = rect.resize_to_rotated(
0,
ResizeHandle::RectEdges {
left: false,
right: false,
top: true,
bottom: false,
},
Point::new(30, 0),
Rect::new(0, 0, 300, 300),
true,
);
let bb = resized.bbox();
assert!(bb.w >= 2 && bb.h >= 2, "{bb:?}");
assert!(bb.x >= 0 && bb.x + bb.w <= 300, "{bb:?}");
}
}