Skip to main content

pixelcoords_core/
geometry.rs

1//! Shapes and their interaction math, in monitor-local physical pixels.
2//!
3//! Ported from the predecessor's rectangle/circle tools; the drag semantics
4//! (preview normalization, grab-offset moves, clamp-to-bounds) are preserved
5//! so existing muscle memory carries over.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub struct Point {
11    pub x: i32,
12    pub y: i32,
13}
14
15impl Point {
16    pub const fn new(x: i32, y: i32) -> Self {
17        Self { x, y }
18    }
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Size {
23    pub w: i32,
24    pub h: i32,
25}
26
27impl Size {
28    pub const fn new(w: i32, h: i32) -> Self {
29        Self { w, h }
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Rect {
35    pub x: i32,
36    pub y: i32,
37    pub w: i32,
38    pub h: i32,
39}
40
41impl Rect {
42    pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
43        Self { x, y, w, h }
44    }
45
46    pub const fn contains(&self, p: Point) -> bool {
47        p.x >= self.x && p.y >= self.y && p.x < self.x + self.w && p.y < self.y + self.h
48    }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ToolKind {
54    Rect,
55    /// Legacy records only: the drawing tool is `Ellipse` now, and a
56    /// circle is an ellipse with equal radii. Old sessions still parse.
57    Circle,
58    Ellipse,
59    Triangle,
60    /// The regular-N-gon drawing tool; its records store as `poly`.
61    Polygon,
62    /// The freehand drawing tool; its records store as `poly`.
63    Freehand,
64    /// What polygon and freehand records are tagged as: one stored kind,
65    /// one consumer code path, however the vertices were authored.
66    Poly,
67}
68
69impl ToolKind {
70    #[must_use]
71    pub const fn next(self) -> Self {
72        match self {
73            Self::Rect => Self::Ellipse,
74            Self::Circle | Self::Ellipse => Self::Triangle,
75            Self::Triangle => Self::Polygon,
76            Self::Polygon => Self::Freehand,
77            Self::Freehand | Self::Poly => Self::Rect,
78        }
79    }
80}
81
82/// A resize grip on a shape's border.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ResizeHandle {
85    /// Dragging the rim of a circle: radius follows the cursor.
86    CircleRadius,
87    /// Dragging one or two rect edges; the others stay anchored.
88    RectEdges {
89        left: bool,
90        right: bool,
91        top: bool,
92        bottom: bool,
93    },
94}
95
96/// A committed or in-progress selection shape.
97///
98/// Serializes untagged: a rect is `{x, y, w, h}`, a circle is `{cx, cy, r}`,
99/// a triangle is its three vertices `{ax, ay, bx, by, cx, cy}` (apex, then
100/// base-left, then base-right as drawn — though any triangle is
101/// representable). The field sets are disjoint, so deserialization is
102/// unambiguous; the session schema also stores the discriminant in a
103/// sibling `shape` field.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(untagged)]
106pub enum Shape {
107    Rect(Rect),
108    Circle {
109        cx: i32,
110        cy: i32,
111        r: i32,
112    },
113    /// Axis-aligned ellipse; rotation, like a rect's, is metadata. The
114    /// field set is disjoint from every other variant, so the untagged
115    /// serde representation stays unambiguous.
116    Ellipse {
117        cx: i32,
118        cy: i32,
119        rx: i32,
120        ry: i32,
121    },
122    Triangle {
123        ax: i32,
124        ay: i32,
125        bx: i32,
126        by: i32,
127        cx: i32,
128        cy: i32,
129    },
130    /// An arbitrary closed polygon — regular N-gons and freehand paths
131    /// alike. Rotation is baked into the vertices, triangle-style, and
132    /// the winding may be either direction.
133    Poly {
134        points: Vec<Point>,
135    },
136}
137
138impl Shape {
139    /// Shape previewed while dragging from `start` to `current`, or `None`
140    /// while the drag is still degenerate. `current` is clamped into bounds
141    /// so dragging off-screen keeps the preview on-screen.
142    /// `lock` constrains the proportions (Shift held): an ellipse locks
143    /// to a perfect circle.
144    pub fn compute_preview(
145        tool: ToolKind,
146        start: Point,
147        current: Point,
148        region: Rect,
149        lock: bool,
150    ) -> Option<Self> {
151        // The drawable region is not always the whole frame. In `--target`
152        // mode it is the window's rect within the monitor, and `start` has
153        // already been rejected outside that region — so the preview only
154        // has to keep `current` from wandering out.
155        let cx = current.x.clamp(region.x, region.x + region.w - 1);
156        let cy = current.y.clamp(region.y, region.y + region.h - 1);
157        match tool {
158            ToolKind::Rect | ToolKind::Triangle | ToolKind::Ellipse => {
159                let x = start.x.min(cx);
160                let y = start.y.min(cy);
161                let w = (start.x - cx).abs();
162                let h = (start.y - cy).abs();
163                if w <= 1 || h <= 1 {
164                    return None;
165                }
166                let bbox = Rect::new(x, y, w, h);
167                Some(match tool {
168                    ToolKind::Rect => Self::Rect(bbox),
169                    ToolKind::Ellipse => ellipse_in_box(bbox, lock),
170                    _ => triangle_in_box(bbox),
171                })
172            }
173            ToolKind::Circle => {
174                let dx = f64::from(start.x - cx);
175                let dy = f64::from(start.y - cy);
176                let r = dx.hypot(dy) as i32;
177                if r <= 0 {
178                    return None;
179                }
180                Some(Self::Circle {
181                    cx: start.x,
182                    cy: start.y,
183                    r,
184                })
185            }
186            // The polygon and freehand tools build their previews in the
187            // app (they need side counts and accumulated paths this
188            // stateless helper cannot know); `Poly` is a record tag, not
189            // a drawing tool.
190            ToolKind::Polygon | ToolKind::Freehand | ToolKind::Poly => None,
191        }
192    }
193
194    pub const fn kind(&self) -> ToolKind {
195        match self {
196            Self::Rect(_) => ToolKind::Rect,
197            Self::Circle { .. } => ToolKind::Circle,
198            Self::Ellipse { .. } => ToolKind::Ellipse,
199            Self::Triangle { .. } => ToolKind::Triangle,
200            Self::Poly { .. } => ToolKind::Poly,
201        }
202    }
203
204    /// Axis-aligned bounding box. Saturating math so absurd deserialized
205    /// values (e.g. `r` near `i32::MAX`) misreport rather than panic.
206    pub fn bbox(&self) -> Rect {
207        match *self {
208            Self::Poly { ref points } => {
209                let mut x0 = i32::MAX;
210                let mut y0 = i32::MAX;
211                let mut x1 = i32::MIN;
212                let mut y1 = i32::MIN;
213                for p in points {
214                    x0 = x0.min(p.x);
215                    y0 = y0.min(p.y);
216                    x1 = x1.max(p.x);
217                    y1 = y1.max(p.y);
218                }
219                if points.is_empty() {
220                    return Rect::new(0, 0, 0, 0);
221                }
222                Rect::new(x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0))
223            }
224            Self::Rect(r) => r,
225            Self::Ellipse { cx, cy, rx, ry } => Rect::new(
226                cx.saturating_sub(rx),
227                cy.saturating_sub(ry),
228                rx.saturating_mul(2),
229                ry.saturating_mul(2),
230            ),
231            Self::Circle { cx, cy, r } => Rect::new(
232                cx.saturating_sub(r),
233                cy.saturating_sub(r),
234                r.saturating_mul(2),
235                r.saturating_mul(2),
236            ),
237            Self::Triangle {
238                ax,
239                ay,
240                bx,
241                by,
242                cx,
243                cy,
244            } => {
245                let x0 = min3(ax, bx, cx);
246                let y0 = min3(ay, by, cy);
247                Rect::new(
248                    x0,
249                    y0,
250                    max3(ax, bx, cx).saturating_sub(x0),
251                    max3(ay, by, cy).saturating_sub(y0),
252                )
253            }
254        }
255    }
256
257    /// Whether `p` lies inside the shape (used for cursor hit-testing).
258    /// Distance math is done in i64 so extreme coordinates cannot overflow.
259    pub fn hit_test(&self, p: Point) -> bool {
260        match *self {
261            Self::Poly { ref points } => point_in_poly(points, p),
262            Self::Rect(r) => r.contains(p),
263            Self::Ellipse { cx, cy, rx, ry } => {
264                // Normalized quadratic in i128: (dx*ry)^2 + (dy*rx)^2 <=
265                // (rx*ry)^2, boundary inclusive like the circle's test.
266                let dx = i128::from(p.x - cx);
267                let dy = i128::from(p.y - cy);
268                let rx = i128::from(rx);
269                let ry = i128::from(ry);
270                dx * dx * ry * ry + dy * dy * rx * rx <= rx * rx * ry * ry
271            }
272            Self::Circle { cx, cy, r } => {
273                let dx = i64::from(p.x - cx);
274                let dy = i64::from(p.y - cy);
275                dx * dx + dy * dy <= i64::from(r) * i64::from(r)
276            }
277            Self::Triangle {
278                ax,
279                ay,
280                bx,
281                by,
282                cx,
283                cy,
284            } => {
285                // A degenerate (zero-area) triangle covers nothing — without
286                // this, the sign test below reports the whole plane inside.
287                if cross(cx, cy, ax, ay, bx, by) == 0 {
288                    return false;
289                }
290                // Sign-of-cross-product test, edges inclusive: p is inside
291                // unless it is strictly on both sides of the edge set.
292                let d1 = cross(p.x, p.y, ax, ay, bx, by);
293                let d2 = cross(p.x, p.y, bx, by, cx, cy);
294                let d3 = cross(p.x, p.y, cx, cy, ax, ay);
295                let has_neg = d1 < 0 || d2 < 0 || d3 < 0;
296                let has_pos = d1 > 0 || d2 > 0 || d3 > 0;
297                !(has_neg && has_pos)
298            }
299        }
300    }
301
302    /// Whether the shape covers pixel (`x`, `y`) — identical to `hit_test`,
303    /// named separately because it is the crop/mask predicate.
304    pub fn covers(&self, x: i32, y: i32) -> bool {
305        self.hit_test(Point::new(x, y))
306    }
307
308    /// The point a click should aim for: the bbox center for rects (the
309    /// rotation pivot, so it holds for rotated rects unchanged), a
310    /// circle's center, and the centroid for triangles — always interior,
311    /// where a thin diagonal triangle's bbox center may fall outside.
312    /// i64 arithmetic so extreme deserialized vertices cannot overflow.
313    pub fn click_point(&self) -> Point {
314        match *self {
315            Self::Poly { ref points } => poly_interior_point(points),
316            Self::Rect(_) => self.pivot(),
317            Self::Circle { cx, cy, .. } | Self::Ellipse { cx, cy, .. } => Point::new(cx, cy),
318            Self::Triangle {
319                ax,
320                ay,
321                bx,
322                by,
323                cx,
324                cy,
325            } => Point::new(
326                ((i64::from(ax) + i64::from(bx) + i64::from(cx)) / 3) as i32,
327                ((i64::from(ay) + i64::from(by) + i64::from(cy)) / 3) as i32,
328            ),
329        }
330    }
331
332    /// The reference point a drag-move grabs: bbox origin, or a circle's
333    /// center.
334    pub fn grab_origin(&self) -> Point {
335        match *self {
336            Self::Rect(r) => Point::new(r.x, r.y),
337            Self::Circle { cx, cy, .. } | Self::Ellipse { cx, cy, .. } => Point::new(cx, cy),
338            Self::Triangle { .. } | Self::Poly { .. } => {
339                let b = self.bbox();
340                Point::new(b.x, b.y)
341            }
342        }
343    }
344
345    /// New shape position for a drag-move, clamped so the shape cannot leave
346    /// `bounds`. `grab_offset` is cursor-at-grab minus `grab_origin`.
347    #[must_use]
348    pub fn clamp_move(&self, grab_offset: Point, cursor: Point, region: Rect) -> Self {
349        // The drawable region may be a subrect of the frame (in `--target`
350        // mode it is the window's rect). Every extent that used to be
351        // `[0, bounds]` is now `[region.origin, region.origin + region.size]`.
352        let right = region.x + region.w;
353        let bottom = region.y + region.h;
354        match *self {
355            Self::Rect(rect) => {
356                let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - rect.w).max(region.x));
357                let ny =
358                    (cursor.y - grab_offset.y).clamp(region.y, (bottom - rect.h).max(region.y));
359                Self::Rect(Rect::new(nx, ny, rect.w, rect.h))
360            }
361            Self::Circle { r, .. } => {
362                let min_x = region.x + r.max(0);
363                let min_y = region.y + r.max(0);
364                let cx = (cursor.x - grab_offset.x).clamp(min_x, (right - r).max(min_x));
365                let cy = (cursor.y - grab_offset.y).clamp(min_y, (bottom - r).max(min_y));
366                Self::Circle { cx, cy, r }
367            }
368            Self::Ellipse { rx, ry, .. } => {
369                let min_x = region.x + rx.max(0);
370                let min_y = region.y + ry.max(0);
371                let cx = (cursor.x - grab_offset.x).clamp(min_x, (right - rx).max(min_x));
372                let cy = (cursor.y - grab_offset.y).clamp(min_y, (bottom - ry).max(min_y));
373                Self::Ellipse { cx, cy, rx, ry }
374            }
375            Self::Triangle { .. } | Self::Poly { .. } => {
376                let b = self.bbox();
377                let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - b.w).max(region.x));
378                let ny = (cursor.y - grab_offset.y).clamp(region.y, (bottom - b.h).max(region.y));
379                self.translated(nx - b.x, ny - b.y)
380            }
381        }
382    }
383
384    /// Which resize handle, if any, `p` grabs: the rim of a circle, or an
385    /// edge/corner of a rect, within `tolerance` pixels on either side of
386    /// the border.
387    pub fn resize_grab(&self, p: Point, tolerance: i32) -> Option<ResizeHandle> {
388        let tolerance = tolerance.max(1);
389        match *self {
390            Self::Circle { cx, cy, r } => {
391                let dist = f64::from(p.x - cx).hypot(f64::from(p.y - cy));
392                let on_rim = (dist - f64::from(r)).abs() <= f64::from(tolerance);
393                on_rim.then_some(ResizeHandle::CircleRadius)
394            }
395            // Rects grab their own border; triangles grab their bounding
396            // box's border (the same frame the resize scales them in).
397            Self::Rect(rect) => box_border_grab(rect, p, tolerance),
398            Self::Ellipse { .. } | Self::Triangle { .. } | Self::Poly { .. } => {
399                box_border_grab(self.bbox(), p, tolerance)
400            }
401        }
402    }
403
404    /// The shape resized by dragging `handle` to `cursor` (clamped into
405    /// `bounds`), anchored on the parts not being dragged: a circle keeps
406    /// its center, a rect keeps its ungrabbed edges. Dimensions never drop
407    /// below 2, so a resize can't destroy a shape.
408    ///
409    /// `keep_aspect` (Shift held) preserves `self`'s width:height ratio —
410    /// the ratio at drag start, so it stays stable through the whole drag.
411    /// On a corner the opposite corner anchors and the dominant cursor axis
412    /// sets the scale; on a single edge the perpendicular axis scales with
413    /// it, centered. Circles are inherently proportional and ignore it.
414    #[must_use]
415    pub fn resize_to(
416        &self,
417        handle: ResizeHandle,
418        cursor: Point,
419        region: Rect,
420        keep_aspect: bool,
421    ) -> Self {
422        let clamped = Point::new(
423            cursor.x.clamp(region.x, region.x + region.w - 1),
424            cursor.y.clamp(region.y, region.y + region.h - 1),
425        );
426        self.resize_to_local(handle, clamped, region, keep_aspect)
427    }
428
429    /// `resize_to` without the cursor-to-bounds clamp — used by the rotated
430    /// path, where the cursor is clamped in the *visual* frame before being
431    /// inverse-rotated into this local one.
432    #[must_use]
433    fn resize_to_local(
434        &self,
435        handle: ResizeHandle,
436        clamped: Point,
437        region: Rect,
438        keep_aspect: bool,
439    ) -> Self {
440        const MIN: i32 = 2;
441        match (self.clone(), handle) {
442            (Self::Circle { cx, cy, .. }, ResizeHandle::CircleRadius) => {
443                let r = f64::from(clamped.x - cx).hypot(f64::from(clamped.y - cy)) as i32;
444                Self::Circle {
445                    cx,
446                    cy,
447                    r: r.max(MIN),
448                }
449            }
450            (
451                Self::Rect(rect),
452                ResizeHandle::RectEdges {
453                    left,
454                    right,
455                    top,
456                    bottom,
457                },
458            ) => Self::Rect(resize_box(
459                rect,
460                (left, right, top, bottom),
461                clamped,
462                region,
463                keep_aspect,
464            )),
465            (
466                ell @ Self::Ellipse { .. },
467                ResizeHandle::RectEdges {
468                    left,
469                    right,
470                    top,
471                    bottom,
472                },
473            ) => {
474                // The ellipse rides its bounding box: resize the box like a
475                // rect, then re-inscribe.
476                let bb = resize_box(
477                    ell.bbox(),
478                    (left, right, top, bottom),
479                    clamped,
480                    region,
481                    keep_aspect,
482                );
483                ellipse_in_box(bb, false)
484            }
485            (
486                poly @ Self::Poly { .. },
487                ResizeHandle::RectEdges {
488                    left,
489                    right,
490                    top,
491                    bottom,
492                },
493            ) => {
494                let old = poly.bbox();
495                let new = resize_box(
496                    old,
497                    (left, right, top, bottom),
498                    clamped,
499                    region,
500                    keep_aspect,
501                );
502                scale_into_box(&poly, old, new)
503            }
504            (
505                tri @ Self::Triangle { .. },
506                ResizeHandle::RectEdges {
507                    left,
508                    right,
509                    top,
510                    bottom,
511                },
512            ) => {
513                let old = tri.bbox();
514                let new = resize_box(
515                    old,
516                    (left, right, top, bottom),
517                    clamped,
518                    region,
519                    keep_aspect,
520                );
521                tri.mapped_between_boxes(old, new)
522            }
523            // Handle/shape mismatch cannot arise from grab-then-resize; be
524            // inert rather than panic.
525            (shape, _) => shape,
526        }
527    }
528
529    /// The shape with its vertices affinely remapped from bbox `old` to
530    /// bbox `new` — how triangles scale under a bbox resize.
531    #[must_use]
532    fn mapped_between_boxes(&self, old: Rect, new: Rect) -> Self {
533        let map_x = |v: i32| {
534            new.x
535                + (f64::from(v - old.x) * f64::from(new.w) / f64::from(old.w.max(1))).round() as i32
536        };
537        let map_y = |v: i32| {
538            new.y
539                + (f64::from(v - old.y) * f64::from(new.h) / f64::from(old.h.max(1))).round() as i32
540        };
541        match self.clone() {
542            Self::Triangle {
543                ax,
544                ay,
545                bx,
546                by,
547                cx,
548                cy,
549            } => Self::Triangle {
550                ax: map_x(ax),
551                ay: map_y(ay),
552                bx: map_x(bx),
553                by: map_y(by),
554                cx: map_x(cx),
555                cy: map_y(cy),
556            },
557            other => other,
558        }
559    }
560
561    /// The same shape translated by (`dx`, `dy`) — used to derive global
562    /// desktop coordinates from monitor-local ones.
563    #[must_use]
564    pub fn translated(&self, dx: i32, dy: i32) -> Self {
565        match *self {
566            Self::Poly { ref points } => Self::Poly {
567                points: points
568                    .iter()
569                    .map(|p| Point::new(p.x + dx, p.y + dy))
570                    .collect(),
571            },
572            Self::Rect(r) => Self::Rect(Rect::new(r.x + dx, r.y + dy, r.w, r.h)),
573            Self::Circle { cx, cy, r } => Self::Circle {
574                cx: cx + dx,
575                cy: cy + dy,
576                r,
577            },
578            Self::Ellipse { cx, cy, rx, ry } => Self::Ellipse {
579                cx: cx + dx,
580                cy: cy + dy,
581                rx,
582                ry,
583            },
584            Self::Triangle {
585                ax,
586                ay,
587                bx,
588                by,
589                cx,
590                cy,
591            } => Self::Triangle {
592                ax: ax + dx,
593                ay: ay + dy,
594                bx: bx + dx,
595                by: by + dy,
596                cx: cx + dx,
597                cy: cy + dy,
598            },
599        }
600    }
601}
602
603/// Degrees normalized into `0..360`.
604pub fn normalize_deg(deg: i32) -> i32 {
605    deg.rem_euclid(360)
606}
607
608/// Scale a vertex shape from `old` box into `new` box, vertex by vertex —
609/// the same mapping triangles use, for any point list.
610fn scale_into_box(shape: &Shape, old: Rect, new: Rect) -> Shape {
611    let map_x = |v: i32| {
612        new.x + (f64::from(v - old.x) * f64::from(new.w) / f64::from(old.w.max(1))).round() as i32
613    };
614    let map_y = |v: i32| {
615        new.y + (f64::from(v - old.y) * f64::from(new.h) / f64::from(old.h.max(1))).round() as i32
616    };
617    match shape {
618        Shape::Poly { points } => Shape::Poly {
619            points: points
620                .iter()
621                .map(|p| Point::new(map_x(p.x), map_y(p.y)))
622                .collect(),
623        },
624        other => other.clone(),
625    }
626}
627
628/// Edge-inclusive point-in-polygon: on any edge counts as inside; else
629/// even-odd ray crossing. Exact integer arithmetic throughout.
630fn point_in_poly(points: &[Point], p: Point) -> bool {
631    if points.len() < 3 {
632        return false;
633    }
634    let n = points.len();
635    let mut inside = false;
636    for i in 0..n {
637        let a = points[i];
638        let b = points[(i + 1) % n];
639        if on_segment(a, b, p) {
640            return true;
641        }
642        // Even-odd crossing of the horizontal ray to +x.
643        if (a.y > p.y) != (b.y > p.y) {
644            let cross = i64::from(b.x - a.x) * i64::from(p.y - a.y)
645                - i64::from(b.y - a.y) * i64::from(p.x - a.x);
646            let crosses = if b.y > a.y { cross > 0 } else { cross < 0 };
647            if crosses {
648                inside = !inside;
649            }
650        }
651    }
652    inside
653}
654
655/// Whether `p` lies exactly on segment `a`..`b`.
656fn on_segment(a: Point, b: Point, p: Point) -> bool {
657    let cross =
658        i64::from(b.x - a.x) * i64::from(p.y - a.y) - i64::from(b.y - a.y) * i64::from(p.x - a.x);
659    cross == 0
660        && p.x >= a.x.min(b.x)
661        && p.x <= a.x.max(b.x)
662        && p.y >= a.y.min(b.y)
663        && p.y <= a.y.max(b.y)
664}
665
666/// A point guaranteed inside the polygon when any pixel is: the vertex
667/// average when it lands inside (cheap, common), else the first covered
668/// pixel of a bbox scan. Concave freehand shapes are exactly why the
669/// fallback exists.
670fn poly_interior_point(points: &[Point]) -> Point {
671    if points.is_empty() {
672        return Point::new(0, 0);
673    }
674    let n = points.len() as i64;
675    let sx: i64 = points.iter().map(|p| i64::from(p.x)).sum();
676    let sy: i64 = points.iter().map(|p| i64::from(p.y)).sum();
677    let mean = Point::new((sx / n) as i32, (sy / n) as i32);
678    if point_in_poly(points, mean) {
679        return mean;
680    }
681    let shape = Shape::Poly {
682        points: points.to_vec(),
683    };
684    let bb = shape.bbox();
685    for y in bb.y..=bb.y.saturating_add(bb.h) {
686        for x in bb.x..=bb.x.saturating_add(bb.w) {
687            if point_in_poly(points, Point::new(x, y)) {
688                return Point::new(x, y);
689            }
690        }
691    }
692    mean
693}
694
695/// The regular `sides`-gon centered on `center` with its first vertex at
696/// `toward` — dragging both sizes and orients it in one gesture.
697pub fn regular_polygon(center: Point, toward: Point, sides: u32) -> Shape {
698    let sides = sides.clamp(3, 12) as usize;
699    let r = f64::from(toward.x - center.x).hypot(f64::from(toward.y - center.y));
700    let base = f64::from(toward.y - center.y).atan2(f64::from(toward.x - center.x));
701    let step = std::f64::consts::TAU / sides as f64;
702    let points = (0..sides)
703        .map(|i| {
704            let a = base + step * i as f64;
705            Point::new(
706                f64::from(center.x).mul_add(1.0, r * a.cos()).round() as i32,
707                f64::from(center.y).mul_add(1.0, r * a.sin()).round() as i32,
708            )
709        })
710        .collect();
711    Shape::Poly { points }
712}
713
714/// Ramer–Douglas–Peucker path simplification: keep the points that
715/// matter, drop the mouse jitter. `epsilon` is the maximum distance a
716/// dropped point may sit from the simplified path.
717pub fn simplify_path(points: &[Point], epsilon: f64) -> Vec<Point> {
718    if points.len() <= 2 {
719        return points.to_vec();
720    }
721    let mut keep = vec![false; points.len()];
722    keep[0] = true;
723    keep[points.len() - 1] = true;
724    let mut stack = vec![(0usize, points.len() - 1)];
725    while let Some((start, end)) = stack.pop() {
726        if end <= start + 1 {
727            continue;
728        }
729        let (mut worst, mut worst_dist) = (start, -1.0f64);
730        for (i, p) in points.iter().enumerate().take(end).skip(start + 1) {
731            let d = point_segment_distance(*p, points[start], points[end]);
732            if d > worst_dist {
733                worst = i;
734                worst_dist = d;
735            }
736        }
737        if worst_dist > epsilon {
738            keep[worst] = true;
739            stack.push((start, worst));
740            stack.push((worst, end));
741        }
742    }
743    points
744        .iter()
745        .zip(&keep)
746        .filter(|(_, k)| **k)
747        .map(|(p, _)| *p)
748        .collect()
749}
750
751/// Euclidean distance from `p` to segment `a`..`b`.
752fn point_segment_distance(p: Point, a: Point, b: Point) -> f64 {
753    let (px, py) = (f64::from(p.x), f64::from(p.y));
754    let (ax, ay) = (f64::from(a.x), f64::from(a.y));
755    let (bx, by) = (f64::from(b.x), f64::from(b.y));
756    let (dx, dy) = (bx - ax, by - ay);
757    let len2 = dx * dx + dy * dy;
758    if len2 <= f64::EPSILON {
759        return (px - ax).hypot(py - ay);
760    }
761    let t = ((px - ax) * dx + (py - ay) * dy) / len2;
762    let t = t.clamp(0.0, 1.0);
763    (px - (ax + t * dx)).hypot(py - (ay + t * dy))
764}
765
766/// The ellipse inscribed in `bbox`; `lock` makes it the inscribed circle
767/// centered in the box.
768fn ellipse_in_box(bbox: Rect, lock: bool) -> Shape {
769    let cx = bbox.x + bbox.w / 2;
770    let cy = bbox.y + bbox.h / 2;
771    let (rx, ry) = (bbox.w / 2, bbox.h / 2);
772    if lock {
773        let r = rx.min(ry).max(1);
774        return Shape::Ellipse {
775            cx,
776            cy,
777            rx: r,
778            ry: r,
779        };
780    }
781    Shape::Ellipse {
782        cx,
783        cy,
784        rx: rx.max(1),
785        ry: ry.max(1),
786    }
787}
788
789/// `p` rotated by `deg` degrees (clockwise, screen coordinates) about
790/// `center`, rounded to the pixel grid.
791pub fn rotate_point_about(p: Point, center: Point, deg: i32) -> Point {
792    let rad = f64::from(deg).to_radians();
793    let (sin, cos) = rad.sin_cos();
794    // All arithmetic in f64: extreme deserialized coordinates saturate at
795    // the final cast instead of overflowing i32 on the way.
796    let dx = f64::from(p.x) - f64::from(center.x);
797    let dy = f64::from(p.y) - f64::from(center.y);
798    Point::new(
799        (f64::from(center.x) + (dx * cos - dy * sin).round()) as i32,
800        (f64::from(center.y) + (dx * sin + dy * cos).round()) as i32,
801    )
802}
803
804impl Shape {
805    /// The pivot every rotation turns around: the unrotated bbox center.
806    pub fn pivot(&self) -> Point {
807        let b = self.bbox();
808        Point::new(b.x.saturating_add(b.w / 2), b.y.saturating_add(b.h / 2))
809    }
810
811    /// AABB of the shape after rotating it `deg` about its pivot.
812    pub fn rotated_bbox(&self, deg: i32) -> Rect {
813        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
814            return self.bbox();
815        }
816        let b = self.bbox();
817        let pivot = self.pivot();
818        let (bx1, by1) = (b.x.saturating_add(b.w), b.y.saturating_add(b.h));
819        let corners = [
820            Point::new(b.x, b.y),
821            Point::new(bx1, b.y),
822            Point::new(b.x, by1),
823            Point::new(bx1, by1),
824        ]
825        .map(|c| rotate_point_about(c, pivot, deg));
826        let x0 = corners.iter().map(|c| c.x).min().unwrap_or(b.x);
827        let y0 = corners.iter().map(|c| c.y).min().unwrap_or(b.y);
828        let x1 = corners.iter().map(|c| c.x).max().unwrap_or(bx1);
829        let y1 = corners.iter().map(|c| c.y).max().unwrap_or(by1);
830        Rect::new(x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0))
831    }
832
833    /// `hit_test`/`covers` for the shape rotated `deg` about its pivot:
834    /// the point is inverse-rotated into the shape's local space.
835    pub fn hit_test_rotated(&self, deg: i32, p: Point) -> bool {
836        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
837            return self.hit_test(p);
838        }
839        self.hit_test(rotate_point_about(p, self.pivot(), -deg))
840    }
841
842    /// `resize_grab` in the rotated frame: the cursor is inverse-rotated,
843    /// so grips sit on the shape as the user sees it.
844    pub fn resize_grab_rotated(&self, deg: i32, p: Point, tolerance: i32) -> Option<ResizeHandle> {
845        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
846            return self.resize_grab(p, tolerance);
847        }
848        self.resize_grab(rotate_point_about(p, self.pivot(), -deg), tolerance)
849    }
850
851    /// `resize_to` in the rotated frame; rotation itself is unchanged.
852    #[must_use]
853    pub fn resize_to_rotated(
854        &self,
855        deg: i32,
856        handle: ResizeHandle,
857        cursor: Point,
858        region: Rect,
859        keep_aspect: bool,
860    ) -> Self {
861        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
862            return self.resize_to(handle, cursor, region, keep_aspect);
863        }
864        // Clamp in the visual frame (where the cursor actually lives), THEN
865        // inverse-rotate — clamping the local-frame point instead makes the
866        // cursor stop tracking near region edges.
867        let visual = Point::new(
868            cursor.x.clamp(region.x, region.x + region.w - 1),
869            cursor.y.clamp(region.y, region.y + region.h - 1),
870        );
871        let local = rotate_point_about(visual, self.pivot(), -deg);
872        self.resize_to_local(handle, local, region, keep_aspect)
873    }
874
875    /// `clamp_move` keeping the *rotated* silhouette on screen.
876    #[must_use]
877    pub fn clamp_move_rotated(
878        &self,
879        deg: i32,
880        grab_offset: Point,
881        cursor: Point,
882        region: Rect,
883    ) -> Self {
884        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
885            return self.clamp_move(grab_offset, cursor, region);
886        }
887        let bb = self.rotated_bbox(deg);
888        let right = region.x + region.w;
889        let bottom = region.y + region.h;
890        let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - bb.w).max(region.x));
891        let ny = (cursor.y - grab_offset.y).clamp(region.y, (bottom - bb.h).max(region.y));
892        self.translated(nx - bb.x, ny - bb.y)
893    }
894
895    /// The grab reference for a rotated move: the rotated AABB origin.
896    pub fn grab_origin_rotated(&self, deg: i32) -> Point {
897        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
898            return self.grab_origin();
899        }
900        let bb = self.rotated_bbox(deg);
901        Point::new(bb.x, bb.y)
902    }
903
904    /// A triangle with the rotation baked into its vertices (exact, single
905    /// rounding); other shapes are returned unchanged — rects carry their
906    /// rotation as metadata instead.
907    #[must_use]
908    pub fn with_rotation_baked(&self, deg: i32) -> Self {
909        if let Self::Poly { points } = self {
910            if normalize_deg(deg) == 0 {
911                return self.clone();
912            }
913            let pivot = self.pivot();
914            return Self::Poly {
915                points: points
916                    .iter()
917                    .map(|p| rotate_point_about(*p, pivot, deg))
918                    .collect(),
919            };
920        }
921        match self.clone() {
922            Self::Triangle {
923                ax,
924                ay,
925                bx,
926                by,
927                cx,
928                cy,
929            } if normalize_deg(deg) != 0 => {
930                let pivot = self.pivot();
931                let a = rotate_point_about(Point::new(ax, ay), pivot, deg);
932                let b = rotate_point_about(Point::new(bx, by), pivot, deg);
933                let c = rotate_point_about(Point::new(cx, cy), pivot, deg);
934                Self::Triangle {
935                    ax: a.x,
936                    ay: a.y,
937                    bx: b.x,
938                    by: b.y,
939                    cx: c.x,
940                    cy: c.y,
941                }
942            }
943            other => other,
944        }
945    }
946}
947
948/// The isoceles triangle inscribed in `bbox`: apex top-center, flat base.
949const fn triangle_in_box(bbox: Rect) -> Shape {
950    Shape::Triangle {
951        ax: bbox.x + bbox.w / 2,
952        ay: bbox.y,
953        bx: bbox.x,
954        by: bbox.y + bbox.h,
955        cx: bbox.x + bbox.w,
956        cy: bbox.y + bbox.h,
957    }
958}
959
960/// Cross product of (b - a) x (p - a) in i64: the side of segment a->b
961/// that p lies on.
962const fn cross(px: i32, py: i32, ax: i32, ay: i32, bx: i32, by: i32) -> i64 {
963    let abx = (bx - ax) as i64;
964    let aby = (by - ay) as i64;
965    let apx = (px - ax) as i64;
966    let apy = (py - ay) as i64;
967    abx * apy - aby * apx
968}
969
970const fn min3(a: i32, b: i32, c: i32) -> i32 {
971    if a <= b && a <= c {
972        return a;
973    }
974    if b <= c {
975        return b;
976    }
977    c
978}
979
980const fn max3(a: i32, b: i32, c: i32) -> i32 {
981    if a >= b && a >= c {
982        return a;
983    }
984    if b >= c {
985        return b;
986    }
987    c
988}
989
990/// The border-grab test for an axis-aligned box (used by rects directly and
991/// by triangles via their bbox).
992fn box_border_grab(rect: Rect, p: Point, tolerance: i32) -> Option<ResizeHandle> {
993    let (x1, y1) = (rect.x + rect.w, rect.y + rect.h);
994    let within_x = p.x >= rect.x - tolerance && p.x <= x1 + tolerance;
995    let within_y = p.y >= rect.y - tolerance && p.y <= y1 + tolerance;
996    let left_d = (p.x - rect.x).abs();
997    let right_d = (p.x - x1).abs();
998    let top_d = (p.y - rect.y).abs();
999    let bottom_d = (p.y - y1).abs();
1000    let mut left = left_d <= tolerance && within_y;
1001    let mut right = right_d <= tolerance && within_y;
1002    let mut top = top_d <= tolerance && within_x;
1003    let mut bottom = bottom_d <= tolerance && within_x;
1004    // A box narrower than the tolerance band grabs the nearer edge, never
1005    // both.
1006    if left && right {
1007        right = right_d < left_d;
1008        left = !right;
1009    }
1010    if top && bottom {
1011        bottom = bottom_d < top_d;
1012        top = !bottom;
1013    }
1014    let grabbed = left || right || top || bottom;
1015    grabbed.then_some(ResizeHandle::RectEdges {
1016        left,
1017        right,
1018        top,
1019        bottom,
1020    })
1021}
1022
1023/// Resize an axis-aligned box by dragging the given edges to `clamped`,
1024/// optionally keeping `rect`'s original aspect ratio. Shared by rect and
1025/// triangle resizing.
1026fn resize_box(
1027    rect: Rect,
1028    (left, right, top, bottom): (bool, bool, bool, bool),
1029    clamped: Point,
1030    region: Rect,
1031    keep_aspect: bool,
1032) -> Rect {
1033    const MIN: i32 = 2;
1034    let mut x0 = rect.x;
1035    let mut x1 = rect.x + rect.w;
1036    let mut y0 = rect.y;
1037    let mut y1 = rect.y + rect.h;
1038    if left {
1039        x0 = clamped.x.min(x1 - MIN);
1040    }
1041    if right {
1042        x1 = clamped.x.max(x0 + MIN);
1043    }
1044    if top {
1045        y0 = clamped.y.min(y1 - MIN);
1046    }
1047    if bottom {
1048        y1 = clamped.y.max(y0 + MIN);
1049    }
1050    if keep_aspect && rect.w >= MIN && rect.h >= MIN {
1051        let (w0, h0) = (f64::from(rect.w), f64::from(rect.h));
1052        // Dispatch on which axes are grabbed: corner, horizontal edge, or
1053        // vertical edge.
1054        match (left || right, top || bottom) {
1055            (true, true) => {
1056                // Corner: dominant axis sets the scale, capped so the
1057                // locked box never leaves the region.
1058                let mut s = (f64::from(x1 - x0) / w0).max(f64::from(y1 - y0) / h0);
1059                let region_right = region.x + region.w;
1060                let region_bottom = region.y + region.h;
1061                let avail_w = if left {
1062                    x1 - region.x
1063                } else {
1064                    region_right - x0
1065                };
1066                let avail_h = if top {
1067                    y1 - region.y
1068                } else {
1069                    region_bottom - y0
1070                };
1071                s = s.min(f64::from(avail_w) / w0).min(f64::from(avail_h) / h0);
1072                let w = ((w0 * s).round() as i32).max(MIN);
1073                let h = ((h0 * s).round() as i32).max(MIN);
1074                (x0, x1) = if left { (x1 - w, x1) } else { (x0, x0 + w) };
1075                (y0, y1) = if top { (y1 - h, y1) } else { (y0, y0 + h) };
1076            }
1077            (true, false) => {
1078                // Horizontal edge: height follows proportionally, centered
1079                // on where the box was.
1080                let h = ((f64::from(x1 - x0) * h0 / w0).round() as i32)
1081                    .max(MIN)
1082                    .min(region.h);
1083                let center_y = rect.y + rect.h / 2;
1084                y0 = (center_y - h / 2).clamp(region.y, region.y + region.h - h);
1085                y1 = y0 + h;
1086            }
1087            (false, _) => {
1088                let w = ((f64::from(y1 - y0) * w0 / h0).round() as i32)
1089                    .max(MIN)
1090                    .min(region.w);
1091                let center_x = rect.x + rect.w / 2;
1092                x0 = (center_x - w / 2).clamp(region.x, region.x + region.w - w);
1093                x1 = x0 + w;
1094            }
1095        }
1096    }
1097    let (w, h) = (x1 - x0, y1 - y0);
1098    if rect.w >= MIN && rect.h >= MIN {
1099        // Normal case: edges land where the (clamped) cursor put them and
1100        // anchored edges never move. Under rotation the local box may
1101        // legitimately exceed the visual bounds — clamping it here would
1102        // drift the anchor.
1103        return Rect::new(x0, y0, w, h);
1104    }
1105    // Sub-MIN input box only: the MIN floor can push it past a screen
1106    // edge; shift it back inside without shrinking.
1107    let x0 = x0.clamp(region.x, (region.x + region.w - w).max(region.x));
1108    let y0 = y0.clamp(region.y, (region.y + region.h - h).max(region.y));
1109    Rect::new(x0, y0, w, h)
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    const BOUNDS: Size = Size::new(1920, 1080);
1117    const BOUNDS_RECT: Rect = Rect::new(0, 0, BOUNDS.w, BOUNDS.h);
1118
1119    #[test]
1120    fn rect_preview_normalizes_inverted_drag() {
1121        let s = Shape::compute_preview(
1122            ToolKind::Rect,
1123            Point::new(100, 200),
1124            Point::new(40, 50),
1125            BOUNDS_RECT,
1126            false,
1127        );
1128        assert_eq!(s, Some(Shape::Rect(Rect::new(40, 50, 60, 150))));
1129    }
1130
1131    #[test]
1132    fn rect_preview_clamps_cursor_to_bounds() {
1133        let s = Shape::compute_preview(
1134            ToolKind::Rect,
1135            Point::new(1900, 1000),
1136            Point::new(5000, 5000),
1137            BOUNDS_RECT,
1138            false,
1139        );
1140        assert_eq!(s, Some(Shape::Rect(Rect::new(1900, 1000, 19, 79))));
1141    }
1142
1143    #[test]
1144    fn rect_preview_degenerate_is_none() {
1145        assert_eq!(
1146            Shape::compute_preview(
1147                ToolKind::Rect,
1148                Point::new(10, 10),
1149                Point::new(10, 300),
1150                BOUNDS_RECT,
1151                false
1152            ),
1153            None
1154        );
1155        assert_eq!(
1156            Shape::compute_preview(
1157                ToolKind::Rect,
1158                Point::new(10, 10),
1159                Point::new(10, 10),
1160                BOUNDS_RECT,
1161                false
1162            ),
1163            None
1164        );
1165    }
1166
1167    #[test]
1168    fn circle_preview_radius_is_distance() {
1169        let s = Shape::compute_preview(
1170            ToolKind::Circle,
1171            Point::new(100, 100),
1172            Point::new(103, 104),
1173            BOUNDS_RECT,
1174            false,
1175        );
1176        assert_eq!(
1177            s,
1178            Some(Shape::Circle {
1179                cx: 100,
1180                cy: 100,
1181                r: 5
1182            })
1183        );
1184    }
1185
1186    #[test]
1187    fn circle_preview_zero_radius_is_none() {
1188        assert_eq!(
1189            Shape::compute_preview(
1190                ToolKind::Circle,
1191                Point::new(7, 7),
1192                Point::new(7, 7),
1193                BOUNDS_RECT,
1194                false
1195            ),
1196            None
1197        );
1198    }
1199
1200    #[test]
1201    fn rect_hit_test_edges() {
1202        let s = Shape::Rect(Rect::new(10, 10, 20, 20));
1203        assert!(s.hit_test(Point::new(10, 10)));
1204        assert!(s.hit_test(Point::new(29, 29)));
1205        assert!(!s.hit_test(Point::new(30, 30)));
1206        assert!(!s.hit_test(Point::new(9, 10)));
1207    }
1208
1209    #[test]
1210    fn circle_hit_test_boundary_inclusive() {
1211        let s = Shape::Circle {
1212            cx: 0,
1213            cy: 0,
1214            r: 10,
1215        };
1216        assert!(s.hit_test(Point::new(10, 0)));
1217        assert!(s.hit_test(Point::new(6, 8)));
1218        assert!(!s.hit_test(Point::new(8, 8)));
1219    }
1220
1221    #[test]
1222    fn circle_hit_test_survives_extreme_coords() {
1223        let s = Shape::Circle { cx: 0, cy: 0, r: 5 };
1224        assert!(!s.hit_test(Point::new(i32::MAX, i32::MAX)));
1225    }
1226
1227    #[test]
1228    fn bbox_of_circle() {
1229        let s = Shape::Circle {
1230            cx: 50,
1231            cy: 60,
1232            r: 10,
1233        };
1234        assert_eq!(s.bbox(), Rect::new(40, 50, 20, 20));
1235    }
1236
1237    #[test]
1238    fn rect_clamp_move_never_escapes_bounds() {
1239        let s = Shape::Rect(Rect::new(0, 0, 300, 200));
1240        let grab = Point::new(0, 0);
1241        for cx in [-500, 0, 960, 5000] {
1242            for cy in [-500, 0, 540, 5000] {
1243                let Shape::Rect(r) = s.clamp_move(grab, Point::new(cx, cy), BOUNDS_RECT) else {
1244                    panic!("rect stayed rect");
1245                };
1246                assert!(r.x >= 0 && r.y >= 0, "({cx},{cy}) gave {r:?}");
1247                assert!(
1248                    r.x + r.w <= BOUNDS.w && r.y + r.h <= BOUNDS.h,
1249                    "({cx},{cy}) gave {r:?}"
1250                );
1251            }
1252        }
1253    }
1254
1255    #[test]
1256    fn circle_clamp_move_never_escapes_bounds() {
1257        let s = Shape::Circle {
1258            cx: 500,
1259            cy: 500,
1260            r: 40,
1261        };
1262        let grab = Point::new(0, 0);
1263        for cx in [-500, 0, 960, 5000] {
1264            for cy in [-500, 0, 540, 5000] {
1265                let Shape::Circle {
1266                    cx: ncx,
1267                    cy: ncy,
1268                    r,
1269                } = s.clamp_move(grab, Point::new(cx, cy), BOUNDS_RECT)
1270                else {
1271                    panic!("circle stayed circle");
1272                };
1273                assert!(
1274                    ncx - r >= 0 && ncy - r >= 0,
1275                    "({cx},{cy}) gave center ({ncx},{ncy})"
1276                );
1277                assert!(
1278                    ncx + r <= BOUNDS.w && ncy + r <= BOUNDS.h,
1279                    "({cx},{cy}) gave center ({ncx},{ncy})"
1280                );
1281            }
1282        }
1283    }
1284
1285    #[test]
1286    fn oversized_circle_clamp_is_stable() {
1287        // Circle larger than the window: clamps to the r-pinned position
1288        // instead of oscillating or going negative (predecessor behavior).
1289        let s = Shape::Circle {
1290            cx: 100,
1291            cy: 100,
1292            r: 2000,
1293        };
1294        let moved = s.clamp_move(Point::new(0, 0), Point::new(0, 0), BOUNDS_RECT);
1295        assert_eq!(
1296            moved,
1297            Shape::Circle {
1298                cx: 2000,
1299                cy: 2000,
1300                r: 2000
1301            }
1302        );
1303    }
1304
1305    #[test]
1306    fn translated_shifts_both_kinds() {
1307        assert_eq!(
1308            Shape::Rect(Rect::new(1, 2, 3, 4)).translated(10, 20),
1309            Shape::Rect(Rect::new(11, 22, 3, 4))
1310        );
1311        assert_eq!(
1312            Shape::Circle { cx: 1, cy: 2, r: 3 }.translated(10, 20),
1313            Shape::Circle {
1314                cx: 11,
1315                cy: 22,
1316                r: 3
1317            }
1318        );
1319    }
1320
1321    #[test]
1322    fn circle_rim_grab_within_tolerance_only() {
1323        let s = Shape::Circle {
1324            cx: 100,
1325            cy: 100,
1326            r: 50,
1327        };
1328        assert_eq!(
1329            s.resize_grab(Point::new(153, 100), 5),
1330            Some(ResizeHandle::CircleRadius)
1331        );
1332        assert_eq!(
1333            s.resize_grab(Point::new(147, 100), 5),
1334            Some(ResizeHandle::CircleRadius)
1335        );
1336        assert_eq!(s.resize_grab(Point::new(100, 100), 5), None); // center
1337        assert_eq!(s.resize_grab(Point::new(160, 100), 5), None); // far outside
1338    }
1339
1340    #[test]
1341    fn rect_edge_and_corner_grabs() {
1342        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1343        assert_eq!(
1344            s.resize_grab(Point::new(100, 150), 5),
1345            Some(ResizeHandle::RectEdges {
1346                left: true,
1347                right: false,
1348                top: false,
1349                bottom: false
1350            })
1351        );
1352        assert_eq!(
1353            s.resize_grab(Point::new(302, 150), 5), // just outside right edge
1354            Some(ResizeHandle::RectEdges {
1355                left: false,
1356                right: true,
1357                top: false,
1358                bottom: false
1359            })
1360        );
1361        assert_eq!(
1362            s.resize_grab(Point::new(298, 202), 5), // bottom-right corner
1363            Some(ResizeHandle::RectEdges {
1364                left: false,
1365                right: true,
1366                top: false,
1367                bottom: true
1368            })
1369        );
1370        assert_eq!(s.resize_grab(Point::new(200, 150), 5), None); // interior
1371        assert_eq!(s.resize_grab(Point::new(90, 150), 5), None); // outside band
1372    }
1373
1374    #[test]
1375    fn tiny_rect_grabs_nearer_edge_not_both() {
1376        let s = Shape::Rect(Rect::new(100, 100, 6, 6));
1377        let Some(ResizeHandle::RectEdges { left, right, .. }) =
1378            s.resize_grab(Point::new(101, 103), 5)
1379        else {
1380            panic!("expected an edge grab");
1381        };
1382        assert!(left && !right);
1383    }
1384
1385    #[test]
1386    fn circle_resize_follows_cursor_distance() {
1387        let s = Shape::Circle {
1388            cx: 100,
1389            cy: 100,
1390            r: 50,
1391        };
1392        let resized = s.resize_to(
1393            ResizeHandle::CircleRadius,
1394            Point::new(100, 180),
1395            BOUNDS_RECT,
1396            false,
1397        );
1398        assert_eq!(
1399            resized,
1400            Shape::Circle {
1401                cx: 100,
1402                cy: 100,
1403                r: 80
1404            }
1405        );
1406        // Collapsing onto the center clamps to the minimum, not zero.
1407        let tiny = s.resize_to(
1408            ResizeHandle::CircleRadius,
1409            Point::new(100, 100),
1410            BOUNDS_RECT,
1411            false,
1412        );
1413        assert_eq!(
1414            tiny,
1415            Shape::Circle {
1416                cx: 100,
1417                cy: 100,
1418                r: 2
1419            }
1420        );
1421    }
1422
1423    #[test]
1424    fn rect_corner_resize_anchors_opposite_corner() {
1425        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1426        let handle = ResizeHandle::RectEdges {
1427            left: false,
1428            right: true,
1429            top: false,
1430            bottom: true,
1431        };
1432        let resized = s.resize_to(handle, Point::new(400, 300), BOUNDS_RECT, false);
1433        assert_eq!(resized, Shape::Rect(Rect::new(100, 100, 300, 200)));
1434    }
1435
1436    #[test]
1437    fn rect_edge_resize_moves_one_axis_only() {
1438        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1439        let handle = ResizeHandle::RectEdges {
1440            left: true,
1441            right: false,
1442            top: false,
1443            bottom: false,
1444        };
1445        let resized = s.resize_to(handle, Point::new(50, 999), BOUNDS_RECT, false);
1446        assert_eq!(resized, Shape::Rect(Rect::new(50, 100, 250, 100)));
1447    }
1448
1449    #[test]
1450    fn rect_resize_cannot_invert_or_vanish() {
1451        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1452        let handle = ResizeHandle::RectEdges {
1453            left: true,
1454            right: false,
1455            top: false,
1456            bottom: false,
1457        };
1458        // Dragging the left edge far past the right edge stops at MIN width.
1459        let resized = s.resize_to(handle, Point::new(500, 150), BOUNDS_RECT, false);
1460        assert_eq!(resized, Shape::Rect(Rect::new(298, 100, 2, 100)));
1461    }
1462
1463    #[test]
1464    fn resize_cursor_is_clamped_to_bounds() {
1465        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1466        let handle = ResizeHandle::RectEdges {
1467            left: false,
1468            right: true,
1469            top: false,
1470            bottom: false,
1471        };
1472        let resized = s.resize_to(handle, Point::new(99_999, 150), BOUNDS_RECT, false);
1473        assert_eq!(
1474            resized,
1475            Shape::Rect(Rect::new(100, 100, BOUNDS.w - 1 - 100, 100))
1476        );
1477    }
1478
1479    #[test]
1480    fn locked_corner_resize_keeps_ratio_dominant_axis_wins() {
1481        // 2:1 rect, drag the bottom-right corner. Cursor asks for 300x200;
1482        // height is the dominant scale (2x), so the result is 400x200.
1483        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1484        let corner = ResizeHandle::RectEdges {
1485            left: false,
1486            right: true,
1487            top: false,
1488            bottom: true,
1489        };
1490        let resized = s.resize_to(corner, Point::new(400, 300), BOUNDS_RECT, true);
1491        assert_eq!(resized, Shape::Rect(Rect::new(100, 100, 400, 200)));
1492    }
1493
1494    #[test]
1495    fn locked_corner_resize_anchors_the_opposite_corner() {
1496        // Dragging the top-left corner keeps (x1, y1) fixed.
1497        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1498        let corner = ResizeHandle::RectEdges {
1499            left: true,
1500            right: false,
1501            top: true,
1502            bottom: false,
1503        };
1504        let resized = s.resize_to(corner, Point::new(0, 80), BOUNDS_RECT, true);
1505        let Shape::Rect(r) = resized else {
1506            panic!("still a rect")
1507        };
1508        assert_eq!((r.x + r.w, r.y + r.h), (300, 200), "anchor moved");
1509        assert_eq!(r.w * 100, r.h * 200, "ratio drifted: {r:?}");
1510    }
1511
1512    #[test]
1513    fn locked_corner_resize_caps_scale_at_bounds() {
1514        // Anchored at (100, 100) with a 2:1 ratio on a 1920x1080 canvas:
1515        // width hits the right edge first (1820/200 = 9.1x vs 980/100 =
1516        // 9.8x), so the scale caps there and the rect stays inside.
1517        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1518        let corner = ResizeHandle::RectEdges {
1519            left: false,
1520            right: true,
1521            top: false,
1522            bottom: true,
1523        };
1524        let resized = s.resize_to(
1525            corner,
1526            Point::new(BOUNDS_RECT.w - 1, BOUNDS_RECT.h - 1),
1527            BOUNDS_RECT,
1528            true,
1529        );
1530        let Shape::Rect(r) = resized else {
1531            panic!("still a rect")
1532        };
1533        assert!(
1534            r.x + r.w <= BOUNDS.w && r.y + r.h <= BOUNDS.h,
1535            "escaped: {r:?}"
1536        );
1537        assert_eq!(r.w, BOUNDS.w - 100);
1538        assert_eq!(r.w, 2 * r.h);
1539    }
1540
1541    #[test]
1542    fn locked_edge_resize_scales_other_axis_centered() {
1543        // Dragging the right edge to double the width also doubles the
1544        // height, centered on the original vertical middle.
1545        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1546        let edge = ResizeHandle::RectEdges {
1547            left: false,
1548            right: true,
1549            top: false,
1550            bottom: false,
1551        };
1552        let resized = s.resize_to(edge, Point::new(500, 150), BOUNDS_RECT, true);
1553        assert_eq!(resized, Shape::Rect(Rect::new(100, 50, 400, 200)));
1554    }
1555
1556    #[test]
1557    fn locked_edge_resize_clamps_centered_axis_to_bounds() {
1558        // A rect near the top: the proportional height growth would go
1559        // negative, so it shifts down to stay on screen.
1560        let s = Shape::Rect(Rect::new(100, 10, 200, 100));
1561        let edge = ResizeHandle::RectEdges {
1562            left: false,
1563            right: true,
1564            top: false,
1565            bottom: false,
1566        };
1567        let resized = s.resize_to(edge, Point::new(500, 60), BOUNDS_RECT, true);
1568        let Shape::Rect(r) = resized else {
1569            panic!("still a rect")
1570        };
1571        assert_eq!((r.w, r.h), (400, 200));
1572        assert_eq!(r.y, 0, "clamped to the top edge");
1573    }
1574
1575    #[test]
1576    fn locked_circle_resize_is_unchanged_by_lock() {
1577        let s = Shape::Circle {
1578            cx: 100,
1579            cy: 100,
1580            r: 50,
1581        };
1582        let unlocked = s.resize_to(
1583            ResizeHandle::CircleRadius,
1584            Point::new(100, 180),
1585            BOUNDS_RECT,
1586            false,
1587        );
1588        let locked = s.resize_to(
1589            ResizeHandle::CircleRadius,
1590            Point::new(100, 180),
1591            BOUNDS_RECT,
1592            true,
1593        );
1594        assert_eq!(unlocked, locked);
1595    }
1596
1597    #[test]
1598    fn mismatched_handle_is_inert() {
1599        let s = Shape::Circle { cx: 5, cy: 5, r: 5 };
1600        let handle = ResizeHandle::RectEdges {
1601            left: true,
1602            right: false,
1603            top: false,
1604            bottom: false,
1605        };
1606        assert_eq!(
1607            s.resize_to(handle, Point::new(50, 50), BOUNDS_RECT, false),
1608            s
1609        );
1610    }
1611
1612    #[test]
1613    fn ellipse_preview_inscribes_the_drag_box_and_shift_locks_a_circle() {
1614        let free = Shape::compute_preview(
1615            ToolKind::Ellipse,
1616            Point::new(10, 10),
1617            Point::new(50, 30),
1618            BOUNDS_RECT,
1619            false,
1620        );
1621        assert_eq!(
1622            free,
1623            Some(Shape::Ellipse {
1624                cx: 30,
1625                cy: 20,
1626                rx: 20,
1627                ry: 10,
1628            })
1629        );
1630        let locked = Shape::compute_preview(
1631            ToolKind::Ellipse,
1632            Point::new(10, 10),
1633            Point::new(50, 30),
1634            BOUNDS_RECT,
1635            true,
1636        );
1637        assert_eq!(
1638            locked,
1639            Some(Shape::Ellipse {
1640                cx: 30,
1641                cy: 20,
1642                rx: 10,
1643                ry: 10,
1644            }),
1645            "Shift inscribes the circle instead"
1646        );
1647    }
1648
1649    #[test]
1650    fn ellipse_hit_test_is_boundary_inclusive_and_excludes_bbox_corners() {
1651        let e = Shape::Ellipse {
1652            cx: 50,
1653            cy: 40,
1654            rx: 30,
1655            ry: 10,
1656        };
1657        assert!(e.hit_test(Point::new(50, 40)));
1658        assert!(e.hit_test(Point::new(80, 40)), "rx vertex inclusive");
1659        assert!(e.hit_test(Point::new(50, 30)), "ry vertex inclusive");
1660        assert!(!e.hit_test(Point::new(80, 30)), "bbox corner outside");
1661        assert!(!e.hit_test(Point::new(81, 40)));
1662        assert_eq!(e.bbox(), Rect::new(20, 30, 60, 20));
1663    }
1664
1665    #[test]
1666    fn ellipse_resize_rides_its_bounding_box() {
1667        let e = Shape::Ellipse {
1668            cx: 50,
1669            cy: 40,
1670            rx: 20,
1671            ry: 10,
1672        };
1673        // Grab the right edge of the bbox (x = 70) and pull to x = 90.
1674        let handle = e.resize_grab(Point::new(70, 40), 2).expect("edge grab");
1675        let resized = e.resize_to(handle, Point::new(90, 40), BOUNDS_RECT, false);
1676        assert_eq!(
1677            resized,
1678            Shape::Ellipse {
1679                cx: 60,
1680                cy: 40,
1681                rx: 30,
1682                ry: 10,
1683            },
1684            "left edge anchored, rx grew"
1685        );
1686    }
1687
1688    #[test]
1689    fn rotated_ellipse_hit_follows_the_turn() {
1690        let e = Shape::Ellipse {
1691            cx: 50,
1692            cy: 40,
1693            rx: 30,
1694            ry: 8,
1695        };
1696        // Turned 90, the wide ellipse stands tall.
1697        assert!(e.hit_test_rotated(90, Point::new(50, 65)));
1698        assert!(!e.hit_test_rotated(90, Point::new(75, 40)));
1699        assert!(e.hit_test(Point::new(75, 40)), "unrotated it lies flat");
1700    }
1701
1702    #[test]
1703    fn point_in_poly_handles_concave_shapes_edges_included() {
1704        // A U shape: the notch between the arms is outside.
1705        let u = vec![
1706            Point::new(0, 0),
1707            Point::new(10, 0),
1708            Point::new(10, 30),
1709            Point::new(20, 30),
1710            Point::new(20, 0),
1711            Point::new(30, 0),
1712            Point::new(30, 40),
1713            Point::new(0, 40),
1714        ];
1715        let shape = Shape::Poly { points: u };
1716        assert!(shape.hit_test(Point::new(5, 20)), "left arm");
1717        assert!(shape.hit_test(Point::new(25, 20)), "right arm");
1718        assert!(shape.hit_test(Point::new(15, 35)), "base");
1719        assert!(!shape.hit_test(Point::new(15, 10)), "the notch is outside");
1720        assert!(shape.hit_test(Point::new(0, 0)), "vertex inclusive");
1721        assert!(shape.hit_test(Point::new(5, 0)), "edge inclusive");
1722        assert!(!shape.hit_test(Point::new(-1, 20)));
1723        // The interior click point avoids the notch.
1724        assert!(shape.hit_test(shape.click_point()));
1725    }
1726
1727    #[test]
1728    fn regular_polygon_puts_the_first_vertex_at_the_cursor() {
1729        let hex = regular_polygon(Point::new(100, 100), Point::new(140, 100), 6);
1730        let Shape::Poly { ref points } = hex else {
1731            panic!("regular polygon is a poly")
1732        };
1733        assert_eq!(points.len(), 6);
1734        assert_eq!(points[0], Point::new(140, 100), "first vertex at cursor");
1735        for p in points {
1736            let d = f64::from(p.x - 100).hypot(f64::from(p.y - 100));
1737            assert!((d - 40.0).abs() < 1.5, "vertex {p:?} off the radius: {d}");
1738        }
1739        // The side count clamps to something drawable.
1740        let tri = regular_polygon(Point::new(0, 0), Point::new(10, 0), 1);
1741        let Shape::Poly { points } = tri else {
1742            panic!()
1743        };
1744        assert_eq!(points.len(), 3);
1745    }
1746
1747    #[test]
1748    fn simplify_path_drops_jitter_and_keeps_corners() {
1749        // A noisy L: collinear runs with 1px wobble collapse; the corner
1750        // survives.
1751        let path: Vec<Point> = (0..=20)
1752            .map(|x| Point::new(x * 5, i32::from(x % 2 != 0)))
1753            .chain((1..=10).map(|y| Point::new(100, y * 5)))
1754            .collect();
1755        let simplified = simplify_path(&path, 2.0);
1756        assert!(
1757            simplified.len() <= 5,
1758            "expected a handful of points, got {}",
1759            simplified.len()
1760        );
1761        assert_eq!(*simplified.first().unwrap(), Point::new(0, 0));
1762        assert_eq!(*simplified.last().unwrap(), Point::new(100, 50));
1763        assert!(
1764            simplified.contains(&Point::new(100, 1)) || simplified.contains(&Point::new(100, 0)),
1765            "the corner survives: {simplified:?}"
1766        );
1767    }
1768
1769    #[test]
1770    fn poly_moves_resizes_and_rotates_like_any_shape() {
1771        let square = Shape::Poly {
1772            points: vec![
1773                Point::new(10, 10),
1774                Point::new(30, 10),
1775                Point::new(30, 30),
1776                Point::new(10, 30),
1777            ],
1778        };
1779        assert_eq!(square.bbox(), Rect::new(10, 10, 20, 20));
1780        let moved = square.translated(5, -5);
1781        assert_eq!(moved.bbox(), Rect::new(15, 5, 20, 20));
1782        // Bbox-edge resize scales every vertex.
1783        let handle = square.resize_grab(Point::new(30, 20), 2).expect("edge");
1784        let grown = square.resize_to(handle, Point::new(50, 20), BOUNDS_RECT, false);
1785        assert_eq!(grown.bbox(), Rect::new(10, 10, 40, 20));
1786        // Rotation bakes into the vertices.
1787        let turned = square.with_rotation_baked(90);
1788        assert_eq!(turned.bbox(), square.bbox(), "square is 90-symmetric");
1789        assert!(matches!(turned, Shape::Poly { .. }));
1790    }
1791
1792    #[test]
1793    fn click_point_centers_each_kind() {
1794        assert_eq!(
1795            Shape::Rect(Rect::new(10, 20, 30, 40)).click_point(),
1796            Point::new(25, 40)
1797        );
1798        assert_eq!(
1799            Shape::Circle { cx: 5, cy: 6, r: 7 }.click_point(),
1800            Point::new(5, 6)
1801        );
1802        let tri = Shape::Triangle {
1803            ax: 30,
1804            ay: 0,
1805            bx: 0,
1806            by: 60,
1807            cx: 60,
1808            cy: 60,
1809        };
1810        assert_eq!(tri.click_point(), Point::new(30, 40));
1811        assert!(tri.hit_test(tri.click_point()));
1812        // The click point is the rotation pivot, so it stays inside the
1813        // silhouette at any angle.
1814        let rect = Shape::Rect(Rect::new(10, 10, 40, 10));
1815        assert!(rect.hit_test_rotated(90, rect.click_point()));
1816    }
1817
1818    #[test]
1819    fn tool_kind_cycles_through_the_drawing_tools() {
1820        assert_eq!(ToolKind::Rect.next(), ToolKind::Ellipse);
1821        assert_eq!(ToolKind::Ellipse.next(), ToolKind::Triangle);
1822        assert_eq!(ToolKind::Triangle.next(), ToolKind::Polygon);
1823        assert_eq!(ToolKind::Polygon.next(), ToolKind::Freehand);
1824        assert_eq!(ToolKind::Freehand.next(), ToolKind::Rect);
1825        // Record-only kinds cycle back into the modern set.
1826        assert_eq!(ToolKind::Circle.next(), ToolKind::Triangle);
1827        assert_eq!(ToolKind::Poly.next(), ToolKind::Rect);
1828    }
1829
1830    #[test]
1831    fn triangle_preview_is_apex_top_center_in_drag_box() {
1832        let s = Shape::compute_preview(
1833            ToolKind::Triangle,
1834            Point::new(100, 100),
1835            Point::new(300, 200),
1836            BOUNDS_RECT,
1837            false,
1838        );
1839        assert_eq!(
1840            s,
1841            Some(Shape::Triangle {
1842                ax: 200,
1843                ay: 100,
1844                bx: 100,
1845                by: 200,
1846                cx: 300,
1847                cy: 200,
1848            })
1849        );
1850    }
1851
1852    #[test]
1853    fn triangle_hit_test_excludes_bbox_corners() {
1854        let tri = Shape::Triangle {
1855            ax: 200,
1856            ay: 100,
1857            bx: 100,
1858            by: 200,
1859            cx: 300,
1860            cy: 200,
1861        };
1862        assert!(tri.hit_test(Point::new(200, 150))); // centroid area
1863        assert!(tri.hit_test(Point::new(200, 100))); // apex, inclusive
1864        assert!(tri.hit_test(Point::new(150, 200))); // on the base
1865        assert!(!tri.hit_test(Point::new(105, 105))); // bbox top-left, empty
1866        assert!(!tri.hit_test(Point::new(295, 105))); // bbox top-right, empty
1867    }
1868
1869    #[test]
1870    fn triangle_bbox_and_move_clamp() {
1871        let tri = Shape::Triangle {
1872            ax: 200,
1873            ay: 100,
1874            bx: 100,
1875            by: 200,
1876            cx: 300,
1877            cy: 200,
1878        };
1879        assert_eq!(tri.bbox(), Rect::new(100, 100, 200, 100));
1880        // Dragged far off-screen: the bbox pins to the corner and all three
1881        // vertices translate together.
1882        let moved = tri.clamp_move(Point::new(0, 0), Point::new(-500, -500), BOUNDS_RECT);
1883        assert_eq!(moved.bbox(), Rect::new(0, 0, 200, 100));
1884        assert_eq!(
1885            moved,
1886            Shape::Triangle {
1887                ax: 100,
1888                ay: 0,
1889                bx: 0,
1890                by: 100,
1891                cx: 200,
1892                cy: 100,
1893            }
1894        );
1895    }
1896
1897    #[test]
1898    fn triangle_resize_scales_vertices_into_new_bbox() {
1899        let tri = Shape::Triangle {
1900            ax: 200,
1901            ay: 100,
1902            bx: 100,
1903            by: 200,
1904            cx: 300,
1905            cy: 200,
1906        };
1907        // Drag the bottom-right bbox corner to double both dimensions.
1908        let handle = ResizeHandle::RectEdges {
1909            left: false,
1910            right: true,
1911            top: false,
1912            bottom: true,
1913        };
1914        let resized = tri.resize_to(handle, Point::new(500, 300), BOUNDS_RECT, false);
1915        assert_eq!(
1916            resized,
1917            Shape::Triangle {
1918                ax: 300,
1919                ay: 100,
1920                bx: 100,
1921                by: 300,
1922                cx: 500,
1923                cy: 300,
1924            }
1925        );
1926    }
1927
1928    #[test]
1929    fn triangle_resize_grab_is_on_the_bbox_border() {
1930        let tri = Shape::Triangle {
1931            ax: 200,
1932            ay: 100,
1933            bx: 100,
1934            by: 200,
1935            cx: 300,
1936            cy: 200,
1937        };
1938        // Top edge of the bbox (empty space next to the apex) still grabs.
1939        assert_eq!(
1940            tri.resize_grab(Point::new(150, 100), 5),
1941            Some(ResizeHandle::RectEdges {
1942                left: false,
1943                right: false,
1944                top: true,
1945                bottom: false
1946            })
1947        );
1948        assert_eq!(tri.resize_grab(Point::new(200, 150), 5), None); // interior
1949    }
1950
1951    #[test]
1952    fn degenerate_triangles_cover_nothing() {
1953        let point = Shape::Triangle {
1954            ax: 0,
1955            ay: 0,
1956            bx: 0,
1957            by: 0,
1958            cx: 0,
1959            cy: 0,
1960        };
1961        assert!(!point.hit_test(Point::new(500, 500)));
1962        assert!(!point.hit_test(Point::new(0, 0)));
1963        let line = Shape::Triangle {
1964            ax: 0,
1965            ay: 0,
1966            bx: 10,
1967            by: 10,
1968            cx: 20,
1969            cy: 20,
1970        };
1971        assert!(!line.hit_test(Point::new(400, 400)));
1972        assert!(!line.hit_test(Point::new(5, 5)));
1973    }
1974
1975    #[test]
1976    fn extreme_shapes_do_not_panic() {
1977        let huge = Shape::Circle {
1978            cx: 0,
1979            cy: 0,
1980            r: 2_000_000_000,
1981        };
1982        let bb = huge.bbox();
1983        assert!(bb.w > 0);
1984        let far = Shape::Rect(Rect::new(
1985            2_000_000_000,
1986            2_000_000_000,
1987            400_000_000,
1988            400_000_000,
1989        ));
1990        let _ = far.rotated_bbox(45);
1991    }
1992
1993    #[test]
1994    fn resize_of_sub_min_rect_stays_in_bounds() {
1995        // A 1px-thin rect (below the resize MIN floor): dragging its left
1996        // edge to the screen edge must not push it to x = -1.
1997        let s = Shape::Rect(Rect::new(0, 0, 1, 100));
1998        let handle = ResizeHandle::RectEdges {
1999            left: true,
2000            right: false,
2001            top: false,
2002            bottom: false,
2003        };
2004        let Shape::Rect(r) = s.resize_to(handle, Point::new(0, 50), BOUNDS_RECT, false) else {
2005            panic!("still a rect")
2006        };
2007        assert!(r.x >= 0, "escaped left: {r:?}");
2008        // Mirror case at the right edge.
2009        let s = Shape::Rect(Rect::new(BOUNDS.w - 1, 0, 1, 100));
2010        let handle = ResizeHandle::RectEdges {
2011            left: false,
2012            right: true,
2013            top: false,
2014            bottom: false,
2015        };
2016        let Shape::Rect(r) = s.resize_to(
2017            handle,
2018            Point::new(BOUNDS_RECT.w - 1, 50),
2019            BOUNDS_RECT,
2020            false,
2021        ) else {
2022            panic!("still a rect")
2023        };
2024        assert!(r.x + r.w <= BOUNDS.w, "escaped right: {r:?}");
2025    }
2026
2027    #[test]
2028    fn rotated_resize_never_moves_the_anchored_edge() {
2029        // Regression: the sub-MIN shift-clamp must not fire for normal
2030        // boxes — under rotation the local box can exceed visual bounds,
2031        // and clamping it drifted the anchor by up to the rotated diagonal.
2032        let s = Shape::Rect(Rect::new(800, 500, 200, 100));
2033        let handle = ResizeHandle::RectEdges {
2034            left: false,
2035            right: true,
2036            top: false,
2037            bottom: false,
2038        };
2039        let Shape::Rect(r) =
2040            s.resize_to_rotated(45, handle, Point::new(1900, 1000), BOUNDS_RECT, false)
2041        else {
2042            panic!("still a rect")
2043        };
2044        assert_eq!(r.x, 800, "anchored left edge moved");
2045        assert_eq!(r.y, 500, "anchored top edge moved");
2046    }
2047
2048    #[test]
2049    fn resize_of_offscreen_local_box_does_not_teleport() {
2050        // Regression: a rotated move can leave the local box partially
2051        // off-screen; a later resize must adjust one edge, not relocate
2052        // the shape to the origin.
2053        let s = Shape::Rect(Rect::new(-90, 0, 200, 20));
2054        let handle = ResizeHandle::RectEdges {
2055            left: false,
2056            right: true,
2057            top: false,
2058            bottom: false,
2059        };
2060        let Shape::Rect(r) = s.resize_to(handle, Point::new(120, 10), BOUNDS_RECT, false) else {
2061            panic!("still a rect")
2062        };
2063        assert_eq!(r.x, -90, "shape teleported");
2064        assert_eq!(r.w, 210);
2065    }
2066
2067    #[test]
2068    fn rotated_resize_tracks_cursor_at_screen_edge() {
2069        // Cursor clamps in the visual frame: resizing a rotated shape with
2070        // the cursor at the screen corner still lands on-screen local
2071        // coordinates instead of freezing early.
2072        let s = Shape::Rect(Rect::new(800, 500, 200, 100));
2073        let handle = ResizeHandle::RectEdges {
2074            left: false,
2075            right: true,
2076            top: false,
2077            bottom: false,
2078        };
2079        let r45 = s.resize_to_rotated(45, handle, Point::new(99_999, 99_999), BOUNDS_RECT, false);
2080        // The local resize saw a finite, in-bounds visual point.
2081        assert_ne!(r45, s);
2082    }
2083
2084    #[test]
2085    fn rotate_point_quarter_turn() {
2086        let center = Point::new(100, 100);
2087        // 90 deg clockwise in screen coords: (110, 100) -> (100, 110).
2088        assert_eq!(
2089            rotate_point_about(Point::new(110, 100), center, 90),
2090            Point::new(100, 110)
2091        );
2092        assert_eq!(
2093            rotate_point_about(Point::new(110, 100), center, -90),
2094            Point::new(100, 90)
2095        );
2096        assert_eq!(
2097            rotate_point_about(Point::new(110, 100), center, 360),
2098            Point::new(110, 100)
2099        );
2100    }
2101
2102    #[test]
2103    fn normalize_deg_wraps_into_range() {
2104        assert_eq!(normalize_deg(0), 0);
2105        assert_eq!(normalize_deg(-1), 359);
2106        assert_eq!(normalize_deg(360), 0);
2107        assert_eq!(normalize_deg(725), 5);
2108    }
2109
2110    #[test]
2111    fn rotated_bbox_of_quarter_turned_rect_swaps_dimensions() {
2112        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
2113        let bb = s.rotated_bbox(90);
2114        assert_eq!((bb.w, bb.h), (100, 200));
2115        // Same center as the unrotated shape.
2116        assert_eq!(bb.x + bb.w / 2, 200);
2117        assert_eq!(bb.y + bb.h / 2, 150);
2118        // Rotation 0 and circles are identity.
2119        assert_eq!(s.rotated_bbox(0), s.bbox());
2120        let c = Shape::Circle {
2121            cx: 50,
2122            cy: 50,
2123            r: 20,
2124        };
2125        assert_eq!(c.rotated_bbox(45), c.bbox());
2126    }
2127
2128    #[test]
2129    fn rotated_hit_test_follows_the_turned_shape() {
2130        // Wide flat rect turned 90 deg: a point above the center (inside
2131        // the turned shape, outside the original) now hits, and a far-right
2132        // point (inside the original) no longer does.
2133        let s = Shape::Rect(Rect::new(100, 100, 200, 20));
2134        assert!(s.hit_test_rotated(90, Point::new(200, 30)));
2135        assert!(!s.hit_test_rotated(90, Point::new(290, 110)));
2136        assert!(s.hit_test_rotated(0, Point::new(290, 110)));
2137    }
2138
2139    #[test]
2140    fn rotated_resize_grab_finds_the_visual_edge() {
2141        // The turned rect's visually-left edge maps to a local edge grab.
2142        let s = Shape::Rect(Rect::new(100, 100, 200, 20));
2143        // After 90 deg the shape occupies x in [190, 210], y in [10, 210].
2144        assert!(s.resize_grab_rotated(90, Point::new(190, 110), 5).is_some());
2145        assert!(s.resize_grab_rotated(90, Point::new(150, 110), 5).is_none());
2146    }
2147
2148    #[test]
2149    fn baked_triangle_rotates_vertices_others_unchanged() {
2150        let tri = Shape::Triangle {
2151            ax: 200,
2152            ay: 100,
2153            bx: 100,
2154            by: 200,
2155            cx: 300,
2156            cy: 200,
2157        };
2158        let baked = tri.with_rotation_baked(180);
2159        // Pivot is the bbox center (200, 150): apex flips below.
2160        assert_eq!(
2161            baked,
2162            Shape::Triangle {
2163                ax: 200,
2164                ay: 200,
2165                bx: 300,
2166                by: 100,
2167                cx: 100,
2168                cy: 100,
2169            }
2170        );
2171        let rect = Shape::Rect(Rect::new(1, 2, 3, 4));
2172        assert_eq!(rect.with_rotation_baked(90), rect);
2173        assert_eq!(tri.with_rotation_baked(0), tri);
2174    }
2175
2176    #[test]
2177    fn triangle_serde_is_distinct_from_rect_and_circle() {
2178        let tri = Shape::Triangle {
2179            ax: 1,
2180            ay: 2,
2181            bx: 3,
2182            by: 4,
2183            cx: 5,
2184            cy: 6,
2185        };
2186        let json = serde_json::to_string(&tri).unwrap();
2187        let back: Shape = serde_json::from_str(&json).unwrap();
2188        assert_eq!(back, tri);
2189        // The old kinds still round-trip to themselves.
2190        let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
2191        assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
2192        let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
2193        assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
2194    }
2195
2196    #[test]
2197    fn a_triangle_grabs_from_its_bbox_origin() {
2198        let tri = Shape::Triangle {
2199            ax: 50,
2200            ay: 10,
2201            bx: 20,
2202            by: 70,
2203            cx: 80,
2204            cy: 70,
2205        };
2206        assert_eq!(tri.grab_origin(), Point::new(20, 10));
2207    }
2208
2209    #[test]
2210    fn a_rotated_move_clamps_the_rotated_box_to_bounds() {
2211        let rect = Shape::Rect(Rect::new(10, 10, 40, 20));
2212        let bounds = Size::new(200, 200);
2213        // Dragged far past the corner: the rotated AABB, not the unrotated
2214        // rect, is what must stay inside.
2215        let moved = rect.clamp_move_rotated(
2216            45,
2217            Point::new(0, 0),
2218            Point::new(500, 500),
2219            Rect::new(0, 0, bounds.w, bounds.h),
2220        );
2221        let bb = moved.rotated_bbox(45);
2222        assert!(bb.x >= 0 && bb.y >= 0, "{bb:?}");
2223        assert!(bb.x + bb.w <= bounds.w, "{bb:?}");
2224        assert!(bb.y + bb.h <= bounds.h, "{bb:?}");
2225    }
2226
2227    #[test]
2228    fn a_rotated_grab_references_the_rotated_box_origin() {
2229        let rect = Shape::Rect(Rect::new(10, 10, 40, 20));
2230        assert_eq!(rect.grab_origin_rotated(0), rect.grab_origin());
2231        let rotated = rect.grab_origin_rotated(45);
2232        assert_eq!(
2233            rotated,
2234            Point::new(rect.rotated_bbox(45).x, rect.rotated_bbox(45).y)
2235        );
2236        // A circle has no orientation, so rotation cannot move its grab.
2237        let circle = Shape::Circle {
2238            cx: 40,
2239            cy: 40,
2240            r: 9,
2241        };
2242        assert_eq!(circle.grab_origin_rotated(30), circle.grab_origin());
2243    }
2244
2245    #[test]
2246    fn min3_and_max3_pick_each_position() {
2247        assert_eq!(min3(1, 2, 3), 1);
2248        assert_eq!(min3(2, 1, 3), 1);
2249        assert_eq!(min3(3, 2, 1), 1);
2250        assert_eq!(max3(3, 2, 1), 3);
2251        assert_eq!(max3(1, 3, 2), 3);
2252        assert_eq!(max3(1, 2, 3), 3);
2253    }
2254
2255    #[test]
2256    fn a_proportional_vertical_edge_resize_keeps_the_aspect() {
2257        // Grabbing only a vertical edge with Shift: width follows height.
2258        let rect = Shape::Rect(Rect::new(20, 20, 40, 20));
2259        let resized = rect.resize_to_rotated(
2260            0,
2261            ResizeHandle::RectEdges {
2262                left: false,
2263                right: false,
2264                top: true,
2265                bottom: false,
2266            },
2267            Point::new(30, 0),
2268            Rect::new(0, 0, 300, 300),
2269            true,
2270        );
2271        let bb = resized.bbox();
2272        assert!(bb.w >= 2 && bb.h >= 2, "{bb:?}");
2273        assert!(bb.x >= 0 && bb.x + bb.w <= 300, "{bb:?}");
2274    }
2275}