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//!
7//! # Domain
8//!
9//! Coordinates are `i32`, but the *domain* is [`MAX_COORD`] — see its docs
10//! for why the bound exists rather than being total over `i32`. Within it,
11//! nothing here panics and every operation is bounded in time;
12//! `tests/geometry_extremes.rs` holds that to the whole surface. Outside
13//! it, behavior is unspecified: an operation may panic under
14//! `overflow-checks`, and `Poly::click_point`'s fallback scan grows with
15//! the bounding box.
16//!
17//! Callers reading a session get this for free — `SessionFile::validate`
18//! refuses anything out of range at the load seam, so a coordinate that
19//! reaches this module has already been checked.
20
21use serde::{Deserialize, Serialize};
22
23/// The largest absolute value any coordinate or extent may carry.
24///
25/// A million pixels is more than an order of magnitude beyond the widest
26/// desktop anyone assembles, and small enough that every difference, sum,
27/// and product here stays far from an integer limit. Both halves matter:
28/// the first means no real screen is ever refused, the second means input
29/// inside the bound cannot overflow the arithmetic.
30///
31/// **Why a documented domain rather than saturating everything.** The
32/// workspace sets `overflow-checks = true` in release on the reasoning
33/// that a wrapped coordinate is silently wrong data in a file someone
34/// feeds to automation, and that a crash is the better failure. Making
35/// every operation total over all of `i32` means saturating, which
36/// produces exactly the confident-but-wrong number that decision rejects.
37/// A bound that real input never approaches, enforced once where untrusted
38/// input enters, keeps the honest failure and costs nothing.
39///
40/// The cost of the bound is bounded too: at the very edge,
41/// `Poly::click_point`'s worst-case scan takes about 25ms, and on a real
42/// screen it is under a millisecond.
43pub const MAX_COORD: i32 = 1_000_000;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Point {
47    pub x: i32,
48    pub y: i32,
49}
50
51impl Point {
52    pub const fn new(x: i32, y: i32) -> Self {
53        Self { x, y }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Size {
59    pub w: i32,
60    pub h: i32,
61}
62
63impl Size {
64    pub const fn new(w: i32, h: i32) -> Self {
65        Self { w, h }
66    }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Rect {
71    pub x: i32,
72    pub y: i32,
73    pub w: i32,
74    pub h: i32,
75}
76
77impl Rect {
78    pub const fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
79        Self { x, y, w, h }
80    }
81
82    pub const fn contains(&self, p: Point) -> bool {
83        p.x >= self.x && p.y >= self.y && p.x < self.x + self.w && p.y < self.y + self.h
84    }
85
86    /// The nearest point inside this rect.
87    ///
88    /// The upper bounds are inclusive-exclusive to match `contains`, so a
89    /// clamped point always satisfies it — a zero-sized rect is the one
90    /// exception, and it clamps to the origin corner.
91    #[must_use]
92    pub fn clamp_point(&self, p: Point) -> Point {
93        Point::new(
94            p.x.clamp(self.x, self.x + (self.w - 1).max(0)),
95            p.y.clamp(self.y, self.y + (self.h - 1).max(0)),
96        )
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum ToolKind {
103    Rect,
104    /// Legacy records only: the drawing tool is `Ellipse` now, and a
105    /// circle is an ellipse with equal radii. Old sessions still parse.
106    Circle,
107    Ellipse,
108    Triangle,
109    /// The regular-N-gon drawing tool; its records store as `poly`.
110    Polygon,
111    /// The freehand drawing tool; its records store as `poly`.
112    Freehand,
113    /// The two-point ruler. Never appears in a `SelectionRecord` — it is
114    /// only ever the *active tool*, and what it produces lands in the
115    /// session's `measures` array instead.
116    Measure,
117    /// What polygon and freehand records are tagged as: one stored kind,
118    /// one consumer code path, however the vertices were authored.
119    Poly,
120}
121
122impl ToolKind {
123    #[must_use]
124    pub const fn next(self) -> Self {
125        match self {
126            Self::Rect => Self::Ellipse,
127            Self::Circle | Self::Ellipse => Self::Triangle,
128            Self::Triangle => Self::Polygon,
129            Self::Polygon => Self::Freehand,
130            Self::Freehand => Self::Measure,
131            Self::Measure | Self::Poly => Self::Rect,
132        }
133    }
134}
135
136/// A resize grip on a shape's border.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ResizeHandle {
139    /// Dragging the rim of a circle: radius follows the cursor.
140    CircleRadius,
141    /// Dragging one or two rect edges; the others stay anchored.
142    RectEdges {
143        left: bool,
144        right: bool,
145        top: bool,
146        bottom: bool,
147    },
148}
149
150/// A committed or in-progress selection shape.
151///
152/// Serializes untagged: a rect is `{x, y, w, h}`, a circle is `{cx, cy, r}`,
153/// a triangle is its three vertices `{ax, ay, bx, by, cx, cy}` (apex, then
154/// base-left, then base-right as drawn — though any triangle is
155/// representable). The field sets are disjoint, so deserialization is
156/// unambiguous; the session schema also stores the discriminant in a
157/// sibling `shape` field.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(untagged)]
160pub enum Shape {
161    Rect(Rect),
162    Circle {
163        cx: i32,
164        cy: i32,
165        r: i32,
166    },
167    /// Axis-aligned ellipse; rotation, like a rect's, is metadata. The
168    /// field set is disjoint from every other variant, so the untagged
169    /// serde representation stays unambiguous.
170    Ellipse {
171        cx: i32,
172        cy: i32,
173        rx: i32,
174        ry: i32,
175    },
176    Triangle {
177        ax: i32,
178        ay: i32,
179        bx: i32,
180        by: i32,
181        cx: i32,
182        cy: i32,
183    },
184    /// An arbitrary closed polygon — regular N-gons and freehand paths
185    /// alike. Rotation is baked into the vertices, triangle-style, and
186    /// the winding may be either direction.
187    Poly {
188        points: Vec<Point>,
189    },
190}
191
192impl Shape {
193    /// Shape previewed while dragging from `start` to `current`, or `None`
194    /// while the drag is still degenerate. `current` is clamped into bounds
195    /// so dragging off-screen keeps the preview on-screen.
196    /// `lock` constrains the proportions (Shift held): an ellipse locks
197    /// to a perfect circle.
198    pub fn compute_preview(
199        tool: ToolKind,
200        start: Point,
201        current: Point,
202        region: Rect,
203        lock: bool,
204    ) -> Option<Self> {
205        // The drawable region is not always the whole frame. In `--target`
206        // mode it is the window's rect within the monitor, and `start` has
207        // already been rejected outside that region — so the preview only
208        // has to keep `current` from wandering out.
209        let cx = current.x.clamp(region.x, region.x + region.w - 1);
210        let cy = current.y.clamp(region.y, region.y + region.h - 1);
211        match tool {
212            ToolKind::Rect | ToolKind::Triangle | ToolKind::Ellipse => {
213                let x = start.x.min(cx);
214                let y = start.y.min(cy);
215                let w = (start.x - cx).abs();
216                let h = (start.y - cy).abs();
217                if w <= 1 || h <= 1 {
218                    return None;
219                }
220                let bbox = Rect::new(x, y, w, h);
221                Some(match tool {
222                    ToolKind::Rect => Self::Rect(bbox),
223                    ToolKind::Ellipse => ellipse_in_box(bbox, lock),
224                    _ => triangle_in_box(bbox),
225                })
226            }
227            ToolKind::Circle => {
228                let dx = f64::from(start.x - cx);
229                let dy = f64::from(start.y - cy);
230                let r = dx.hypot(dy) as i32;
231                if r <= 0 {
232                    return None;
233                }
234                Some(Self::Circle {
235                    cx: start.x,
236                    cy: start.y,
237                    r,
238                })
239            }
240            // The polygon and freehand tools build their previews in the
241            // app (they need side counts and accumulated paths this
242            // stateless helper cannot know); `Poly` is a record tag, not
243            // a drawing tool. `Measure` draws a `Line`, which is not a
244            // `Shape` at all — see `SelectionSet::add_measure`.
245            ToolKind::Polygon | ToolKind::Freehand | ToolKind::Poly | ToolKind::Measure => None,
246        }
247    }
248
249    pub const fn kind(&self) -> ToolKind {
250        match self {
251            Self::Rect(_) => ToolKind::Rect,
252            Self::Circle { .. } => ToolKind::Circle,
253            Self::Ellipse { .. } => ToolKind::Ellipse,
254            Self::Triangle { .. } => ToolKind::Triangle,
255            Self::Poly { .. } => ToolKind::Poly,
256        }
257    }
258
259    /// Axis-aligned bounding box. Saturating math so absurd deserialized
260    /// values (e.g. `r` near `i32::MAX`) misreport rather than panic.
261    pub fn bbox(&self) -> Rect {
262        match *self {
263            Self::Poly { ref points } => {
264                let mut x0 = i32::MAX;
265                let mut y0 = i32::MAX;
266                let mut x1 = i32::MIN;
267                let mut y1 = i32::MIN;
268                for p in points {
269                    x0 = x0.min(p.x);
270                    y0 = y0.min(p.y);
271                    x1 = x1.max(p.x);
272                    y1 = y1.max(p.y);
273                }
274                if points.is_empty() {
275                    return Rect::new(0, 0, 0, 0);
276                }
277                Rect::new(x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0))
278            }
279            Self::Rect(r) => r,
280            Self::Ellipse { cx, cy, rx, ry } => Rect::new(
281                cx.saturating_sub(rx),
282                cy.saturating_sub(ry),
283                rx.saturating_mul(2),
284                ry.saturating_mul(2),
285            ),
286            Self::Circle { cx, cy, r } => Rect::new(
287                cx.saturating_sub(r),
288                cy.saturating_sub(r),
289                r.saturating_mul(2),
290                r.saturating_mul(2),
291            ),
292            Self::Triangle {
293                ax,
294                ay,
295                bx,
296                by,
297                cx,
298                cy,
299            } => {
300                let x0 = min3(ax, bx, cx);
301                let y0 = min3(ay, by, cy);
302                Rect::new(
303                    x0,
304                    y0,
305                    max3(ax, bx, cx).saturating_sub(x0),
306                    max3(ay, by, cy).saturating_sub(y0),
307                )
308            }
309        }
310    }
311
312    /// Whether `p` lies inside the shape (used for cursor hit-testing).
313    /// Distance math is done in i64 so extreme coordinates cannot overflow.
314    pub fn hit_test(&self, p: Point) -> bool {
315        match *self {
316            Self::Poly { ref points } => point_in_poly(points, p),
317            Self::Rect(r) => r.contains(p),
318            Self::Ellipse { cx, cy, rx, ry } => {
319                // Normalized quadratic in i128: (dx*ry)^2 + (dy*rx)^2 <=
320                // (rx*ry)^2, boundary inclusive like the circle's test.
321                let dx = i128::from(p.x) - i128::from(cx);
322                let dy = i128::from(p.y) - i128::from(cy);
323                let rx = i128::from(rx);
324                let ry = i128::from(ry);
325                dx * dx * ry * ry + dy * dy * rx * rx <= rx * rx * ry * ry
326            }
327            Self::Circle { cx, cy, r } => {
328                let dx = i64::from(p.x - cx);
329                let dy = i64::from(p.y - cy);
330                dx * dx + dy * dy <= i64::from(r) * i64::from(r)
331            }
332            Self::Triangle {
333                ax,
334                ay,
335                bx,
336                by,
337                cx,
338                cy,
339            } => {
340                // A degenerate (zero-area) triangle covers nothing — without
341                // this, the sign test below reports the whole plane inside.
342                if cross(cx, cy, ax, ay, bx, by) == 0 {
343                    return false;
344                }
345                // Sign-of-cross-product test, edges inclusive: p is inside
346                // unless it is strictly on both sides of the edge set.
347                let d1 = cross(p.x, p.y, ax, ay, bx, by);
348                let d2 = cross(p.x, p.y, bx, by, cx, cy);
349                let d3 = cross(p.x, p.y, cx, cy, ax, ay);
350                let has_neg = d1 < 0 || d2 < 0 || d3 < 0;
351                let has_pos = d1 > 0 || d2 > 0 || d3 > 0;
352                !(has_neg && has_pos)
353            }
354        }
355    }
356
357    /// Whether the shape covers pixel (`x`, `y`) — identical to `hit_test`,
358    /// named separately because it is the crop/mask predicate.
359    pub fn covers(&self, x: i32, y: i32) -> bool {
360        self.hit_test(Point::new(x, y))
361    }
362
363    /// The point a click should aim for: the bbox center for rects (the
364    /// rotation pivot, so it holds for rotated rects unchanged), a
365    /// circle's center, and the centroid for triangles — always interior,
366    /// where a thin diagonal triangle's bbox center may fall outside.
367    /// i64 arithmetic so extreme deserialized vertices cannot overflow.
368    pub fn click_point(&self) -> Point {
369        match *self {
370            Self::Poly { ref points } => poly_interior_point(points),
371            Self::Rect(_) => self.pivot(),
372            Self::Circle { cx, cy, .. } | Self::Ellipse { cx, cy, .. } => Point::new(cx, cy),
373            Self::Triangle {
374                ax,
375                ay,
376                bx,
377                by,
378                cx,
379                cy,
380            } => Point::new(
381                ((i64::from(ax) + i64::from(bx) + i64::from(cx)) / 3) as i32,
382                ((i64::from(ay) + i64::from(by) + i64::from(cy)) / 3) as i32,
383            ),
384        }
385    }
386
387    /// The reference point a drag-move grabs: bbox origin, or a circle's
388    /// center.
389    pub fn grab_origin(&self) -> Point {
390        match *self {
391            Self::Rect(r) => Point::new(r.x, r.y),
392            Self::Circle { cx, cy, .. } | Self::Ellipse { cx, cy, .. } => Point::new(cx, cy),
393            Self::Triangle { .. } | Self::Poly { .. } => {
394                let b = self.bbox();
395                Point::new(b.x, b.y)
396            }
397        }
398    }
399
400    /// New shape position for a drag-move, clamped so the shape cannot leave
401    /// `bounds`. `grab_offset` is cursor-at-grab minus `grab_origin`.
402    #[must_use]
403    pub fn clamp_move(&self, grab_offset: Point, cursor: Point, region: Rect) -> Self {
404        // The drawable region may be a subrect of the frame (in `--target`
405        // mode it is the window's rect). Every extent that used to be
406        // `[0, bounds]` is now `[region.origin, region.origin + region.size]`.
407        let right = region.x + region.w;
408        let bottom = region.y + region.h;
409        match *self {
410            Self::Rect(rect) => {
411                let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - rect.w).max(region.x));
412                let ny =
413                    (cursor.y - grab_offset.y).clamp(region.y, (bottom - rect.h).max(region.y));
414                Self::Rect(Rect::new(nx, ny, rect.w, rect.h))
415            }
416            Self::Circle { r, .. } => {
417                let min_x = region.x + r.max(0);
418                let min_y = region.y + r.max(0);
419                let cx = (cursor.x - grab_offset.x).clamp(min_x, (right - r).max(min_x));
420                let cy = (cursor.y - grab_offset.y).clamp(min_y, (bottom - r).max(min_y));
421                Self::Circle { cx, cy, r }
422            }
423            Self::Ellipse { rx, ry, .. } => {
424                let min_x = region.x + rx.max(0);
425                let min_y = region.y + ry.max(0);
426                let cx = (cursor.x - grab_offset.x).clamp(min_x, (right - rx).max(min_x));
427                let cy = (cursor.y - grab_offset.y).clamp(min_y, (bottom - ry).max(min_y));
428                Self::Ellipse { cx, cy, rx, ry }
429            }
430            Self::Triangle { .. } | Self::Poly { .. } => {
431                let b = self.bbox();
432                let nx = (cursor.x - grab_offset.x).clamp(region.x, (right - b.w).max(region.x));
433                let ny = (cursor.y - grab_offset.y).clamp(region.y, (bottom - b.h).max(region.y));
434                self.translated(nx - b.x, ny - b.y)
435            }
436        }
437    }
438
439    /// Which resize handle, if any, `p` grabs: the rim of a circle, or an
440    /// edge/corner of a rect, within `tolerance` pixels on either side of
441    /// the border.
442    pub fn resize_grab(&self, p: Point, tolerance: i32) -> Option<ResizeHandle> {
443        let tolerance = tolerance.max(1);
444        match *self {
445            Self::Circle { cx, cy, r } => {
446                let dist = f64::from(p.x - cx).hypot(f64::from(p.y - cy));
447                let on_rim = (dist - f64::from(r)).abs() <= f64::from(tolerance);
448                on_rim.then_some(ResizeHandle::CircleRadius)
449            }
450            // Rects grab their own border; triangles grab their bounding
451            // box's border (the same frame the resize scales them in).
452            Self::Rect(rect) => box_border_grab(rect, p, tolerance),
453            Self::Ellipse { .. } | Self::Triangle { .. } | Self::Poly { .. } => {
454                box_border_grab(self.bbox(), p, tolerance)
455            }
456        }
457    }
458
459    /// The shape resized by dragging `handle` to `cursor` (clamped into
460    /// `bounds`), anchored on the parts not being dragged: a circle keeps
461    /// its center, a rect keeps its ungrabbed edges. Dimensions never drop
462    /// below 2, so a resize can't destroy a shape.
463    ///
464    /// `keep_aspect` (Shift held) preserves `self`'s width:height ratio —
465    /// the ratio at drag start, so it stays stable through the whole drag.
466    /// On a corner the opposite corner anchors and the dominant cursor axis
467    /// sets the scale; on a single edge the perpendicular axis scales with
468    /// it, centered. Circles are inherently proportional and ignore it.
469    #[must_use]
470    pub fn resize_to(
471        &self,
472        handle: ResizeHandle,
473        cursor: Point,
474        region: Rect,
475        keep_aspect: bool,
476    ) -> Self {
477        let clamped = Point::new(
478            cursor.x.clamp(region.x, region.x + region.w - 1),
479            cursor.y.clamp(region.y, region.y + region.h - 1),
480        );
481        self.resize_to_local(handle, clamped, region, keep_aspect)
482    }
483
484    /// `resize_to` without the cursor-to-bounds clamp — used by the rotated
485    /// path, where the cursor is clamped in the *visual* frame before being
486    /// inverse-rotated into this local one.
487    #[must_use]
488    fn resize_to_local(
489        &self,
490        handle: ResizeHandle,
491        clamped: Point,
492        region: Rect,
493        keep_aspect: bool,
494    ) -> Self {
495        const MIN: i32 = 2;
496        match (self.clone(), handle) {
497            (Self::Circle { cx, cy, .. }, ResizeHandle::CircleRadius) => {
498                let r = f64::from(clamped.x - cx).hypot(f64::from(clamped.y - cy)) as i32;
499                Self::Circle {
500                    cx,
501                    cy,
502                    r: r.max(MIN),
503                }
504            }
505            (
506                Self::Rect(rect),
507                ResizeHandle::RectEdges {
508                    left,
509                    right,
510                    top,
511                    bottom,
512                },
513            ) => Self::Rect(resize_box(
514                rect,
515                (left, right, top, bottom),
516                clamped,
517                region,
518                keep_aspect,
519            )),
520            (
521                ell @ Self::Ellipse { .. },
522                ResizeHandle::RectEdges {
523                    left,
524                    right,
525                    top,
526                    bottom,
527                },
528            ) => {
529                // The ellipse rides its bounding box: resize the box like a
530                // rect, then re-inscribe.
531                let bb = resize_box(
532                    ell.bbox(),
533                    (left, right, top, bottom),
534                    clamped,
535                    region,
536                    keep_aspect,
537                );
538                ellipse_in_box(bb, false)
539            }
540            (
541                poly @ Self::Poly { .. },
542                ResizeHandle::RectEdges {
543                    left,
544                    right,
545                    top,
546                    bottom,
547                },
548            ) => {
549                let old = poly.bbox();
550                let new = resize_box(
551                    old,
552                    (left, right, top, bottom),
553                    clamped,
554                    region,
555                    keep_aspect,
556                );
557                scale_into_box(&poly, old, new)
558            }
559            (
560                tri @ Self::Triangle { .. },
561                ResizeHandle::RectEdges {
562                    left,
563                    right,
564                    top,
565                    bottom,
566                },
567            ) => {
568                let old = tri.bbox();
569                let new = resize_box(
570                    old,
571                    (left, right, top, bottom),
572                    clamped,
573                    region,
574                    keep_aspect,
575                );
576                tri.mapped_between_boxes(old, new)
577            }
578            // Handle/shape mismatch cannot arise from grab-then-resize; be
579            // inert rather than panic.
580            (shape, _) => shape,
581        }
582    }
583
584    /// The shape with its vertices affinely remapped from bbox `old` to
585    /// bbox `new` — how triangles scale under a bbox resize.
586    #[must_use]
587    fn mapped_between_boxes(&self, old: Rect, new: Rect) -> Self {
588        let map_x = |v: i32| {
589            new.x
590                + (f64::from(v - old.x) * f64::from(new.w) / f64::from(old.w.max(1))).round() as i32
591        };
592        let map_y = |v: i32| {
593            new.y
594                + (f64::from(v - old.y) * f64::from(new.h) / f64::from(old.h.max(1))).round() as i32
595        };
596        match self.clone() {
597            Self::Triangle {
598                ax,
599                ay,
600                bx,
601                by,
602                cx,
603                cy,
604            } => Self::Triangle {
605                ax: map_x(ax),
606                ay: map_y(ay),
607                bx: map_x(bx),
608                by: map_y(by),
609                cx: map_x(cx),
610                cy: map_y(cy),
611            },
612            other => other,
613        }
614    }
615
616    /// The same shape translated by (`dx`, `dy`) — used to derive global
617    /// desktop coordinates from monitor-local ones.
618    #[must_use]
619    pub fn translated(&self, dx: i32, dy: i32) -> Self {
620        match *self {
621            Self::Poly { ref points } => Self::Poly {
622                points: points
623                    .iter()
624                    .map(|p| Point::new(p.x + dx, p.y + dy))
625                    .collect(),
626            },
627            Self::Rect(r) => Self::Rect(Rect::new(r.x + dx, r.y + dy, r.w, r.h)),
628            Self::Circle { cx, cy, r } => Self::Circle {
629                cx: cx + dx,
630                cy: cy + dy,
631                r,
632            },
633            Self::Ellipse { cx, cy, rx, ry } => Self::Ellipse {
634                cx: cx + dx,
635                cy: cy + dy,
636                rx,
637                ry,
638            },
639            Self::Triangle {
640                ax,
641                ay,
642                bx,
643                by,
644                cx,
645                cy,
646            } => Self::Triangle {
647                ax: ax + dx,
648                ay: ay + dy,
649                bx: bx + dx,
650                by: by + dy,
651                cx: cx + dx,
652                cy: cy + dy,
653            },
654        }
655    }
656}
657
658/// Degrees normalized into `0..360`.
659pub fn normalize_deg(deg: i32) -> i32 {
660    deg.rem_euclid(360)
661}
662
663/// Scale a vertex shape from `old` box into `new` box, vertex by vertex —
664/// the same mapping triangles use, for any point list.
665fn scale_into_box(shape: &Shape, old: Rect, new: Rect) -> Shape {
666    let map_x = |v: i32| {
667        new.x + (f64::from(v - old.x) * f64::from(new.w) / f64::from(old.w.max(1))).round() as i32
668    };
669    let map_y = |v: i32| {
670        new.y + (f64::from(v - old.y) * f64::from(new.h) / f64::from(old.h.max(1))).round() as i32
671    };
672    match shape {
673        Shape::Poly { points } => Shape::Poly {
674            points: points
675                .iter()
676                .map(|p| Point::new(map_x(p.x), map_y(p.y)))
677                .collect(),
678        },
679        other => other.clone(),
680    }
681}
682
683/// Edge-inclusive point-in-polygon: on any edge counts as inside; else
684/// even-odd ray crossing. Exact integer arithmetic throughout.
685fn point_in_poly(points: &[Point], p: Point) -> bool {
686    if points.len() < 3 {
687        return false;
688    }
689    let n = points.len();
690    let mut inside = false;
691    for i in 0..n {
692        let a = points[i];
693        let b = points[(i + 1) % n];
694        if on_segment(a, b, p) {
695            return true;
696        }
697        // Even-odd crossing of the horizontal ray to +x.
698        if (a.y > p.y) != (b.y > p.y) {
699            let cross = i64::from(b.x - a.x) * i64::from(p.y - a.y)
700                - i64::from(b.y - a.y) * i64::from(p.x - a.x);
701            let crosses = if b.y > a.y { cross > 0 } else { cross < 0 };
702            if crosses {
703                inside = !inside;
704            }
705        }
706    }
707    inside
708}
709
710/// Whether `p` lies exactly on segment `a`..`b`.
711fn on_segment(a: Point, b: Point, p: Point) -> bool {
712    let cross =
713        i64::from(b.x - a.x) * i64::from(p.y - a.y) - i64::from(b.y - a.y) * i64::from(p.x - a.x);
714    cross == 0
715        && p.x >= a.x.min(b.x)
716        && p.x <= a.x.max(b.x)
717        && p.y >= a.y.min(b.y)
718        && p.y <= a.y.max(b.y)
719}
720
721/// A point inside the polygon: the vertex average when it lands inside
722/// (cheap, and the common case), else the middle of the widest slice a
723/// horizontal cut makes. Concave freehand shapes are exactly why the
724/// fallback exists — a crescent's average sits in the hollow.
725fn poly_interior_point(points: &[Point]) -> Point {
726    if points.is_empty() {
727        return Point::new(0, 0);
728    }
729    let n = points.len() as i64;
730    let sx: i64 = points.iter().map(|p| i64::from(p.x)).sum();
731    let sy: i64 = points.iter().map(|p| i64::from(p.y)).sum();
732    let mean = Point::new((sx / n) as i32, (sy / n) as i32);
733    if point_in_poly(points, mean) {
734        return mean;
735    }
736    let bb = Shape::Poly {
737        points: points.to_vec(),
738    }
739    .bbox();
740    // Cut the shape with a few horizontal lines and take the middle of
741    // the widest piece. Any horizontal line strictly inside the bounding
742    // box of a non-degenerate polygon crosses its boundary an even number
743    // of times, so one row is normally enough; the rest are there for
744    // degenerate rows, where a line grazes a vertex or runs along an
745    // edge.
746    //
747    // Eighths rather than a sweep because this replaced a scan of the
748    // whole bounding box. That scan was correct and O(width x height x
749    // vertices), which on a large shape is millions of point-in-polygon
750    // tests to answer a question a single line answers.
751    for eighth in [4, 2, 6, 1, 3, 5, 7] {
752        let offset = i64::from(bb.h) * eighth / 8;
753        let Ok(offset) = i32::try_from(offset) else {
754            continue;
755        };
756        let row = bb.y.saturating_add(offset);
757        let crossings = scanline_spans(points, row);
758        let widest = crossings
759            .chunks_exact(2)
760            .filter_map(|pair| match pair {
761                [left, right] if right > left => Some((right - left, (left + right) / 2.0)),
762                _ => None,
763            })
764            .max_by(|(a, _), (b, _)| a.total_cmp(b));
765        let Some((_, center)) = widest else {
766            continue;
767        };
768        let candidate = Point::new(center.round() as i32, row);
769        // The spans sample mid-pixel and `point_in_poly` tests the
770        // integer point, so they can disagree at a boundary. The
771        // authoritative test gets the last word; a rejection just moves
772        // on to the next row.
773        if point_in_poly(points, candidate) {
774            return candidate;
775        }
776    }
777    mean
778}
779
780/// The sorted x positions where the polygon's edges cross the horizontal
781/// line through the center of pixel row `row`. Sampling mid-pixel
782/// sidesteps the vertex-exactly-on-scanline double-count.
783pub(crate) fn scanline_spans(points: &[Point], row: i32) -> Vec<f64> {
784    let mid = f64::from(row) + 0.5;
785    let count = points.len();
786    let mut crossings = Vec::new();
787    for i in 0..count {
788        let from = points[i];
789        let to = points[(i + 1) % count];
790        let (from_y, to_y) = (f64::from(from.y), f64::from(to.y));
791        if (from_y < mid) == (to_y < mid) {
792            continue;
793        }
794        let t = (mid - from_y) / (to_y - from_y);
795        crossings.push(f64::from(from.x) + t * f64::from(to.x - from.x));
796    }
797    crossings.sort_by(f64::total_cmp);
798    crossings
799}
800
801/// Fewer than three points is not a polygon. A geometric floor, not a
802/// preference.
803pub const MIN_POLYGON_SIDES: u32 = 3;
804
805/// The most sides a regular polygon may have.
806///
807/// Not a judgement about how many anyone should want — a thousand-sided
808/// polygon inscribed in any real screen already has several vertices per
809/// pixel, so past this the extra points are indistinguishable from the
810/// circle they approximate. The bound exists because `sides` becomes a
811/// point-per-vertex allocation, and an unbounded count is an allocation
812/// request rather than a shape.
813///
814/// The overlay reaches 3 to 9, because those are the digit keys. That is
815/// the keyboard's reach, not this limit.
816pub const MAX_POLYGON_SIDES: u32 = 1_000;
817
818/// The regular `sides`-gon centered on `center` with its first vertex at
819/// `toward` — dragging both sizes and orients it in one gesture.
820///
821/// `sides` outside [`MIN_POLYGON_SIDES`]`..=`[`MAX_POLYGON_SIDES`] is
822/// clamped into it. Documented rather than silent: the previous ceiling
823/// was 12, which nothing said and nothing could reach, so a caller asking
824/// for 100 got a dodecagon and no indication why.
825pub fn regular_polygon(center: Point, toward: Point, sides: u32) -> Shape {
826    let sides = sides.clamp(MIN_POLYGON_SIDES, MAX_POLYGON_SIDES) as usize;
827    let dx = f64::from(toward.x) - f64::from(center.x);
828    let dy = f64::from(toward.y) - f64::from(center.y);
829    let r = dx.hypot(dy);
830    let base = dy.atan2(dx);
831    let step = std::f64::consts::TAU / sides as f64;
832    let points = (0..sides)
833        .map(|i| {
834            let a = base + step * i as f64;
835            Point::new(
836                f64::from(center.x).mul_add(1.0, r * a.cos()).round() as i32,
837                f64::from(center.y).mul_add(1.0, r * a.sin()).round() as i32,
838            )
839        })
840        .collect();
841    Shape::Poly { points }
842}
843
844/// Ramer–Douglas–Peucker path simplification: keep the points that
845/// matter, drop the mouse jitter. `epsilon` is the maximum distance a
846/// dropped point may sit from the simplified path.
847pub fn simplify_path(points: &[Point], epsilon: f64) -> Vec<Point> {
848    if points.len() <= 2 {
849        return points.to_vec();
850    }
851    let mut keep = vec![false; points.len()];
852    keep[0] = true;
853    keep[points.len() - 1] = true;
854    let mut stack = vec![(0usize, points.len() - 1)];
855    while let Some((start, end)) = stack.pop() {
856        if end <= start + 1 {
857            continue;
858        }
859        let (mut worst, mut worst_dist) = (start, -1.0f64);
860        for (i, p) in points.iter().enumerate().take(end).skip(start + 1) {
861            let d = point_segment_distance(*p, points[start], points[end]);
862            if d > worst_dist {
863                worst = i;
864                worst_dist = d;
865            }
866        }
867        if worst_dist > epsilon {
868            keep[worst] = true;
869            stack.push((start, worst));
870            stack.push((worst, end));
871        }
872    }
873    points
874        .iter()
875        .zip(&keep)
876        .filter(|(_, k)| **k)
877        .map(|(p, _)| *p)
878        .collect()
879}
880
881/// Euclidean distance from `p` to segment `a`..`b`.
882fn point_segment_distance(p: Point, a: Point, b: Point) -> f64 {
883    let (px, py) = (f64::from(p.x), f64::from(p.y));
884    let (ax, ay) = (f64::from(a.x), f64::from(a.y));
885    let (bx, by) = (f64::from(b.x), f64::from(b.y));
886    let (dx, dy) = (bx - ax, by - ay);
887    let len2 = dx * dx + dy * dy;
888    if len2 <= f64::EPSILON {
889        return (px - ax).hypot(py - ay);
890    }
891    let t = ((px - ax) * dx + (py - ay) * dy) / len2;
892    let t = t.clamp(0.0, 1.0);
893    (px - (ax + t * dx)).hypot(py - (ay + t * dy))
894}
895
896/// The ellipse inscribed in `bbox`; `lock` makes it the inscribed circle
897/// centered in the box.
898fn ellipse_in_box(bbox: Rect, lock: bool) -> Shape {
899    let cx = bbox.x + bbox.w / 2;
900    let cy = bbox.y + bbox.h / 2;
901    let (rx, ry) = (bbox.w / 2, bbox.h / 2);
902    if lock {
903        let r = rx.min(ry).max(1);
904        return Shape::Ellipse {
905            cx,
906            cy,
907            rx: r,
908            ry: r,
909        };
910    }
911    Shape::Ellipse {
912        cx,
913        cy,
914        rx: rx.max(1),
915        ry: ry.max(1),
916    }
917}
918
919/// `p` rotated by `deg` degrees (clockwise, screen coordinates) about
920/// `center`, rounded to the pixel grid.
921pub fn rotate_point_about(p: Point, center: Point, deg: i32) -> Point {
922    let rad = f64::from(deg).to_radians();
923    let (sin, cos) = rad.sin_cos();
924    // All arithmetic in f64: extreme deserialized coordinates saturate at
925    // the final cast instead of overflowing i32 on the way.
926    let dx = f64::from(p.x) - f64::from(center.x);
927    let dy = f64::from(p.y) - f64::from(center.y);
928    Point::new(
929        (f64::from(center.x) + (dx * cos - dy * sin).round()) as i32,
930        (f64::from(center.y) + (dx * sin + dy * cos).round()) as i32,
931    )
932}
933
934impl Shape {
935    /// The pivot every rotation turns around: the unrotated bbox center.
936    pub fn pivot(&self) -> Point {
937        let b = self.bbox();
938        Point::new(b.x.saturating_add(b.w / 2), b.y.saturating_add(b.h / 2))
939    }
940
941    /// AABB of the shape after rotating it `deg` about its pivot.
942    pub fn rotated_bbox(&self, deg: i32) -> Rect {
943        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
944            return self.bbox();
945        }
946        let b = self.bbox();
947        let pivot = self.pivot();
948        let (bx1, by1) = (b.x.saturating_add(b.w), b.y.saturating_add(b.h));
949        let corners = [
950            Point::new(b.x, b.y),
951            Point::new(bx1, b.y),
952            Point::new(b.x, by1),
953            Point::new(bx1, by1),
954        ]
955        .map(|c| rotate_point_about(c, pivot, deg));
956        let x0 = corners.iter().map(|c| c.x).min().unwrap_or(b.x);
957        let y0 = corners.iter().map(|c| c.y).min().unwrap_or(b.y);
958        let x1 = corners.iter().map(|c| c.x).max().unwrap_or(bx1);
959        let y1 = corners.iter().map(|c| c.y).max().unwrap_or(by1);
960        Rect::new(x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0))
961    }
962
963    /// `hit_test`/`covers` for the shape rotated `deg` about its pivot:
964    /// the point is inverse-rotated into the shape's local space.
965    pub fn hit_test_rotated(&self, deg: i32, p: Point) -> bool {
966        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
967            return self.hit_test(p);
968        }
969        self.hit_test(rotate_point_about(p, self.pivot(), -deg))
970    }
971
972    /// `resize_grab` in the rotated frame: the cursor is inverse-rotated,
973    /// so grips sit on the shape as the user sees it.
974    pub fn resize_grab_rotated(&self, deg: i32, p: Point, tolerance: i32) -> Option<ResizeHandle> {
975        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
976            return self.resize_grab(p, tolerance);
977        }
978        self.resize_grab(rotate_point_about(p, self.pivot(), -deg), tolerance)
979    }
980
981    /// `resize_to` in the rotated frame; rotation itself is unchanged.
982    #[must_use]
983    pub fn resize_to_rotated(
984        &self,
985        deg: i32,
986        handle: ResizeHandle,
987        cursor: Point,
988        region: Rect,
989        keep_aspect: bool,
990    ) -> Self {
991        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
992            return self.resize_to(handle, cursor, region, keep_aspect);
993        }
994        // Clamp in the visual frame (where the cursor actually lives), THEN
995        // inverse-rotate — clamping the local-frame point instead makes the
996        // cursor stop tracking near region edges.
997        let visual = Point::new(
998            cursor.x.clamp(region.x, region.x + region.w - 1),
999            cursor.y.clamp(region.y, region.y + region.h - 1),
1000        );
1001        let local = rotate_point_about(visual, self.pivot(), -deg);
1002        self.resize_to_local(handle, local, region, keep_aspect)
1003    }
1004
1005    /// `clamp_move` keeping the *rotated* silhouette on screen.
1006    #[must_use]
1007    pub fn clamp_move_rotated(
1008        &self,
1009        deg: i32,
1010        grab_offset: Point,
1011        cursor: Point,
1012        region: Rect,
1013    ) -> Self {
1014        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
1015            return self.clamp_move(grab_offset, cursor, region);
1016        }
1017        let bb = self.rotated_bbox(deg);
1018        let right = region.x.saturating_add(region.w);
1019        let bottom = region.y.saturating_add(region.h);
1020        let nx = cursor
1021            .x
1022            .saturating_sub(grab_offset.x)
1023            .clamp(region.x, right.saturating_sub(bb.w).max(region.x));
1024        let ny = cursor
1025            .y
1026            .saturating_sub(grab_offset.y)
1027            .clamp(region.y, bottom.saturating_sub(bb.h).max(region.y));
1028        self.translated(nx.saturating_sub(bb.x), ny.saturating_sub(bb.y))
1029    }
1030
1031    /// The grab reference for a rotated move: the rotated AABB origin.
1032    pub fn grab_origin_rotated(&self, deg: i32) -> Point {
1033        if normalize_deg(deg) == 0 || matches!(self, Self::Circle { .. }) {
1034            return self.grab_origin();
1035        }
1036        let bb = self.rotated_bbox(deg);
1037        Point::new(bb.x, bb.y)
1038    }
1039
1040    /// A triangle with the rotation baked into its vertices (exact, single
1041    /// rounding); other shapes are returned unchanged — rects carry their
1042    /// rotation as metadata instead.
1043    #[must_use]
1044    pub fn with_rotation_baked(&self, deg: i32) -> Self {
1045        if let Self::Poly { points } = self {
1046            if normalize_deg(deg) == 0 {
1047                return self.clone();
1048            }
1049            let pivot = self.pivot();
1050            return Self::Poly {
1051                points: points
1052                    .iter()
1053                    .map(|p| rotate_point_about(*p, pivot, deg))
1054                    .collect(),
1055            };
1056        }
1057        match self.clone() {
1058            Self::Triangle {
1059                ax,
1060                ay,
1061                bx,
1062                by,
1063                cx,
1064                cy,
1065            } if normalize_deg(deg) != 0 => {
1066                let pivot = self.pivot();
1067                let a = rotate_point_about(Point::new(ax, ay), pivot, deg);
1068                let b = rotate_point_about(Point::new(bx, by), pivot, deg);
1069                let c = rotate_point_about(Point::new(cx, cy), pivot, deg);
1070                Self::Triangle {
1071                    ax: a.x,
1072                    ay: a.y,
1073                    bx: b.x,
1074                    by: b.y,
1075                    cx: c.x,
1076                    cy: c.y,
1077                }
1078            }
1079            other => other,
1080        }
1081    }
1082}
1083
1084/// The isoceles triangle inscribed in `bbox`: apex top-center, flat base.
1085const fn triangle_in_box(bbox: Rect) -> Shape {
1086    Shape::Triangle {
1087        ax: bbox.x + bbox.w / 2,
1088        ay: bbox.y,
1089        bx: bbox.x,
1090        by: bbox.y + bbox.h,
1091        cx: bbox.x + bbox.w,
1092        cy: bbox.y + bbox.h,
1093    }
1094}
1095
1096/// Cross product of (b - a) x (p - a) in i64: the side of segment a->b
1097/// that p lies on.
1098const fn cross(px: i32, py: i32, ax: i32, ay: i32, bx: i32, by: i32) -> i64 {
1099    let abx = bx as i64 - ax as i64;
1100    let aby = by as i64 - ay as i64;
1101    let apx = px as i64 - ax as i64;
1102    let apy = py as i64 - ay as i64;
1103    abx * apy - aby * apx
1104}
1105
1106const fn min3(a: i32, b: i32, c: i32) -> i32 {
1107    if a <= b && a <= c {
1108        return a;
1109    }
1110    if b <= c {
1111        return b;
1112    }
1113    c
1114}
1115
1116const fn max3(a: i32, b: i32, c: i32) -> i32 {
1117    if a >= b && a >= c {
1118        return a;
1119    }
1120    if b >= c {
1121        return b;
1122    }
1123    c
1124}
1125
1126/// The border-grab test for an axis-aligned box (used by rects directly and
1127/// by triangles via their bbox).
1128fn box_border_grab(rect: Rect, p: Point, tolerance: i32) -> Option<ResizeHandle> {
1129    let (x1, y1) = (rect.x + rect.w, rect.y + rect.h);
1130    let within_x = p.x >= rect.x - tolerance && p.x <= x1 + tolerance;
1131    let within_y = p.y >= rect.y - tolerance && p.y <= y1 + tolerance;
1132    let left_d = (p.x - rect.x).abs();
1133    let right_d = (p.x - x1).abs();
1134    let top_d = (p.y - rect.y).abs();
1135    let bottom_d = (p.y - y1).abs();
1136    let mut left = left_d <= tolerance && within_y;
1137    let mut right = right_d <= tolerance && within_y;
1138    let mut top = top_d <= tolerance && within_x;
1139    let mut bottom = bottom_d <= tolerance && within_x;
1140    // A box narrower than the tolerance band grabs the nearer edge, never
1141    // both.
1142    if left && right {
1143        right = right_d < left_d;
1144        left = !right;
1145    }
1146    if top && bottom {
1147        bottom = bottom_d < top_d;
1148        top = !bottom;
1149    }
1150    let grabbed = left || right || top || bottom;
1151    grabbed.then_some(ResizeHandle::RectEdges {
1152        left,
1153        right,
1154        top,
1155        bottom,
1156    })
1157}
1158
1159/// Resize an axis-aligned box by dragging the given edges to `clamped`,
1160/// optionally keeping `rect`'s original aspect ratio. Shared by rect and
1161/// triangle resizing.
1162fn resize_box(
1163    rect: Rect,
1164    (left, right, top, bottom): (bool, bool, bool, bool),
1165    clamped: Point,
1166    region: Rect,
1167    keep_aspect: bool,
1168) -> Rect {
1169    const MIN: i32 = 2;
1170    let mut x0 = rect.x;
1171    let mut x1 = rect.x + rect.w;
1172    let mut y0 = rect.y;
1173    let mut y1 = rect.y + rect.h;
1174    if left {
1175        x0 = clamped.x.min(x1 - MIN);
1176    }
1177    if right {
1178        x1 = clamped.x.max(x0 + MIN);
1179    }
1180    if top {
1181        y0 = clamped.y.min(y1 - MIN);
1182    }
1183    if bottom {
1184        y1 = clamped.y.max(y0 + MIN);
1185    }
1186    if keep_aspect && rect.w >= MIN && rect.h >= MIN {
1187        let (w0, h0) = (f64::from(rect.w), f64::from(rect.h));
1188        // Dispatch on which axes are grabbed: corner, horizontal edge, or
1189        // vertical edge.
1190        match (left || right, top || bottom) {
1191            (true, true) => {
1192                // Corner: dominant axis sets the scale, capped so the
1193                // locked box never leaves the region.
1194                let mut s = (f64::from(x1 - x0) / w0).max(f64::from(y1 - y0) / h0);
1195                let region_right = region.x + region.w;
1196                let region_bottom = region.y + region.h;
1197                let avail_w = if left {
1198                    x1 - region.x
1199                } else {
1200                    region_right - x0
1201                };
1202                let avail_h = if top {
1203                    y1 - region.y
1204                } else {
1205                    region_bottom - y0
1206                };
1207                s = s.min(f64::from(avail_w) / w0).min(f64::from(avail_h) / h0);
1208                let w = ((w0 * s).round() as i32).max(MIN);
1209                let h = ((h0 * s).round() as i32).max(MIN);
1210                (x0, x1) = if left { (x1 - w, x1) } else { (x0, x0 + w) };
1211                (y0, y1) = if top { (y1 - h, y1) } else { (y0, y0 + h) };
1212            }
1213            (true, false) => {
1214                // Horizontal edge: height follows proportionally, centered
1215                // on where the box was.
1216                let h = ((f64::from(x1 - x0) * h0 / w0).round() as i32)
1217                    .max(MIN)
1218                    .min(region.h);
1219                let center_y = rect.y + rect.h / 2;
1220                y0 = (center_y - h / 2).clamp(region.y, region.y + region.h - h);
1221                y1 = y0 + h;
1222            }
1223            (false, _) => {
1224                let w = ((f64::from(y1 - y0) * w0 / h0).round() as i32)
1225                    .max(MIN)
1226                    .min(region.w);
1227                let center_x = rect.x + rect.w / 2;
1228                x0 = (center_x - w / 2).clamp(region.x, region.x + region.w - w);
1229                x1 = x0 + w;
1230            }
1231        }
1232    }
1233    let (w, h) = (x1 - x0, y1 - y0);
1234    if rect.w >= MIN && rect.h >= MIN {
1235        // Normal case: edges land where the (clamped) cursor put them and
1236        // anchored edges never move. Under rotation the local box may
1237        // legitimately exceed the visual bounds — clamping it here would
1238        // drift the anchor.
1239        return Rect::new(x0, y0, w, h);
1240    }
1241    // Sub-MIN input box only: the MIN floor can push it past a screen
1242    // edge; shift it back inside without shrinking.
1243    let x0 = x0.clamp(region.x, (region.x + region.w - w).max(region.x));
1244    let y0 = y0.clamp(region.y, (region.y + region.h - h).max(region.y));
1245    Rect::new(x0, y0, w, h)
1246}
1247
1248/// A two-point measurement: a ruler laid on the frozen image.
1249///
1250/// Not a [`Shape`]. A measure has no interior, no crop, no cutout
1251/// contribution and no click point, so putting it through the selection
1252/// path would make `assert`, `emit`, `find`, and the crop writer each
1253/// grow a special case for a thing none of them can answer about.
1254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1255pub struct Line {
1256    pub a: Point,
1257    pub b: Point,
1258}
1259
1260impl Line {
1261    #[must_use]
1262    pub const fn new(a: Point, b: Point) -> Self {
1263        Self { a, b }
1264    }
1265
1266    /// Signed horizontal and vertical extent, `b - a`.
1267    ///
1268    /// Saturating, because the answer is an `i32` and the true difference
1269    /// between two `i32`s need not be one. Screen coordinates never come
1270    /// close, but a session is a file a human can edit, and a bounded
1271    /// number beats a panic when the input was already meaningless.
1272    /// [`Self::length`] and [`Self::angle_deg`] do not go through this —
1273    /// they widen to `f64` first and stay exact at any input.
1274    #[must_use]
1275    pub const fn delta(self) -> (i32, i32) {
1276        (
1277            self.b.x.saturating_sub(self.a.x),
1278            self.b.y.saturating_sub(self.a.y),
1279        )
1280    }
1281
1282    /// Euclidean length in pixels.
1283    #[must_use]
1284    pub fn length(self) -> f64 {
1285        let (dx, dy) = self.delta_f64();
1286        dx.hypot(dy)
1287    }
1288
1289    /// The delta in `f64`, which holds any difference of two `i32`s
1290    /// exactly. The integer [`Self::delta`] saturates; this does not.
1291    fn delta_f64(self) -> (f64, f64) {
1292        (
1293            f64::from(self.b.x) - f64::from(self.a.x),
1294            f64::from(self.b.y) - f64::from(self.a.y),
1295        )
1296    }
1297
1298    /// Direction in degrees within `[0, 360)`, `0` pointing right along
1299    /// +X and increasing **clockwise**.
1300    ///
1301    /// Clockwise because screen Y grows downward: a ruler dragged
1302    /// visually down-and-right has to read as a positive angle, which the
1303    /// mathematical convention would report as negative.
1304    ///
1305    /// A zero-length measure has no direction; it reports `0.0` rather
1306    /// than the NaN `atan2(0, 0)` would be entitled to.
1307    #[must_use]
1308    pub fn angle_deg(self) -> f64 {
1309        let (dx, dy) = self.delta_f64();
1310        if dx == 0.0 && dy == 0.0 {
1311            return 0.0;
1312        }
1313        let deg = dy.atan2(dx).to_degrees();
1314        if deg < 0.0 { deg + 360.0 } else { deg }
1315    }
1316
1317    /// The smallest rect containing both endpoints — what the caption
1318    /// placement needs, since a line has no `Shape::bbox`.
1319    #[must_use]
1320    pub fn bbox(self) -> Rect {
1321        let x = self.a.x.min(self.b.x);
1322        let y = self.a.y.min(self.b.y);
1323        Rect::new(
1324            x,
1325            y,
1326            i32::try_from(self.a.x.abs_diff(self.b.x)).unwrap_or(i32::MAX),
1327            i32::try_from(self.a.y.abs_diff(self.b.y)).unwrap_or(i32::MAX),
1328        )
1329    }
1330
1331    #[must_use]
1332    pub const fn translated(self, dx: i32, dy: i32) -> Self {
1333        Self::new(
1334            Point::new(self.a.x + dx, self.a.y + dy),
1335            Point::new(self.b.x + dx, self.b.y + dy),
1336        )
1337    }
1338
1339    /// The endpoint `p` grabs, if either is within `tolerance`.
1340    ///
1341    /// `a` wins a tie: the two coincide only on a zero-length measure,
1342    /// where the choice cannot matter, and preferring one keeps the pick
1343    /// deterministic.
1344    #[must_use]
1345    pub fn endpoint_grab(self, p: Point, tolerance: i32) -> Option<bool> {
1346        let near = |q: Point| {
1347            let (dx, dy) = (i64::from(p.x - q.x), i64::from(p.y - q.y));
1348            dx * dx + dy * dy <= i64::from(tolerance) * i64::from(tolerance)
1349        };
1350        if near(self.a) {
1351            return Some(true);
1352        }
1353        near(self.b).then_some(false)
1354    }
1355
1356    /// Whether `p` is within `tolerance` of the segment — the whole-line
1357    /// grab, used after the endpoints have had their chance.
1358    #[must_use]
1359    pub fn hit_test(self, p: Point, tolerance: i32) -> bool {
1360        self.distance_to(p) <= f64::from(tolerance)
1361    }
1362
1363    /// Shortest distance from `p` to the segment, clamped at the ends so
1364    /// a point beyond `b` measures to `b` rather than to the infinite
1365    /// line through it.
1366    #[must_use]
1367    pub fn distance_to(self, p: Point) -> f64 {
1368        let (dx, dy) = self.delta();
1369        let (dx, dy) = (f64::from(dx), f64::from(dy));
1370        let len_sq = dx.mul_add(dx, dy * dy);
1371        let (px, py) = (f64::from(p.x - self.a.x), f64::from(p.y - self.a.y));
1372        if len_sq <= f64::EPSILON {
1373            return px.hypot(py);
1374        }
1375        let t = px.mul_add(dx, py * dy) / len_sq;
1376        let t = t.clamp(0.0, 1.0);
1377        (px - t * dx).hypot(py - t * dy)
1378    }
1379
1380    /// `b` snapped to the nearest horizontal, vertical, or 45° direction
1381    /// from `a` — what Shift does while dragging, matching the constraint
1382    /// the other tools apply.
1383    ///
1384    /// The length along the chosen direction is preserved as the
1385    /// projection of the free endpoint onto it, so the ruler tracks the
1386    /// pointer instead of jumping to a fixed radius.
1387    #[must_use]
1388    pub fn constrained(self) -> Self {
1389        // Eight directions, 45° apart, as unit vectors.
1390        const AXES: [(f64, f64); 8] = [
1391            (1.0, 0.0),
1392            (-1.0, 0.0),
1393            (0.0, 1.0),
1394            (0.0, -1.0),
1395            (
1396                std::f64::consts::FRAC_1_SQRT_2,
1397                std::f64::consts::FRAC_1_SQRT_2,
1398            ),
1399            (
1400                std::f64::consts::FRAC_1_SQRT_2,
1401                -std::f64::consts::FRAC_1_SQRT_2,
1402            ),
1403            (
1404                -std::f64::consts::FRAC_1_SQRT_2,
1405                std::f64::consts::FRAC_1_SQRT_2,
1406            ),
1407            (
1408                -std::f64::consts::FRAC_1_SQRT_2,
1409                -std::f64::consts::FRAC_1_SQRT_2,
1410            ),
1411        ];
1412        let (dx, dy) = self.delta();
1413        if dx == 0 && dy == 0 {
1414            return self;
1415        }
1416        let (fx, fy) = (f64::from(dx), f64::from(dy));
1417        let mut best = (f64::NEG_INFINITY, 0.0, 0.0);
1418        for (ax, ay) in AXES {
1419            let projection = fx.mul_add(ax, fy * ay);
1420            if projection > best.0 {
1421                best = (projection, ax, ay);
1422            }
1423        }
1424        let (projection, ax, ay) = best;
1425        let projection = projection.max(0.0);
1426        Self::new(
1427            self.a,
1428            Point::new(
1429                self.a.x + (projection * ax).round() as i32,
1430                self.a.y + (projection * ay).round() as i32,
1431            ),
1432        )
1433    }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439
1440    #[test]
1441    fn length_and_delta_are_the_plain_arithmetic() {
1442        let line = Line::new(Point::new(10, 20), Point::new(40, 60));
1443        assert_eq!(line.delta(), (30, 40));
1444        assert!((line.length() - 50.0).abs() < 1e-9, "3-4-5 triangle");
1445    }
1446
1447    #[test]
1448    fn length_is_invariant_under_translation() {
1449        let line = Line::new(Point::new(-5, 7), Point::new(11, -3));
1450        let moved = line.translated(1000, -400);
1451        assert!((line.length() - moved.length()).abs() < 1e-9);
1452        assert_eq!(line.delta(), moved.delta());
1453    }
1454
1455    #[test]
1456    fn angle_is_clockwise_from_positive_x() {
1457        // Screen Y grows downward, so "down" must read as +90, not -90.
1458        let at = |dx, dy| Line::new(Point::new(0, 0), Point::new(dx, dy)).angle_deg();
1459        assert!((at(10, 0) - 0.0).abs() < 1e-9, "right");
1460        assert!((at(0, 10) - 90.0).abs() < 1e-9, "down");
1461        assert!((at(-10, 0) - 180.0).abs() < 1e-9, "left");
1462        assert!((at(0, -10) - 270.0).abs() < 1e-9, "up");
1463        assert!((at(10, 10) - 45.0).abs() < 1e-9, "down-right");
1464    }
1465
1466    #[test]
1467    fn angle_is_antisymmetric_under_endpoint_swap() {
1468        // The invariant the issue names: angle(A,B) == (angle(B,A) + 180) % 360.
1469        for (ax, ay, bx, by) in [
1470            (0, 0, 10, 0),
1471            (3, 7, -11, 2),
1472            (-5, -5, 5, 5),
1473            (100, -20, 100, 40),
1474        ] {
1475            let ab = Line::new(Point::new(ax, ay), Point::new(bx, by)).angle_deg();
1476            let ba = Line::new(Point::new(bx, by), Point::new(ax, ay)).angle_deg();
1477            let expected = (ba + 180.0) % 360.0;
1478            assert!((ab - expected).abs() < 1e-9, "{ab} vs {expected}");
1479        }
1480    }
1481
1482    #[test]
1483    fn a_zero_length_measure_has_no_direction_rather_than_nan() {
1484        let dot = Line::new(Point::new(4, 4), Point::new(4, 4));
1485        assert!((dot.length() - 0.0).abs() < f64::EPSILON);
1486        assert!(dot.angle_deg().is_finite(), "atan2(0,0) must not escape");
1487        assert!((dot.angle_deg() - 0.0).abs() < f64::EPSILON);
1488        // And it is still grabbable, so a mis-drag can be deleted.
1489        assert!(dot.hit_test(Point::new(4, 4), 6));
1490    }
1491
1492    #[test]
1493    fn distance_clamps_at_the_ends_rather_than_using_the_infinite_line() {
1494        let line = Line::new(Point::new(0, 0), Point::new(100, 0));
1495        // Beside the middle: perpendicular distance.
1496        assert!((line.distance_to(Point::new(50, 10)) - 10.0).abs() < 1e-9);
1497        // Past b: measured to b, not to the line through it, which would
1498        // report 0 for a point far off the end.
1499        assert!((line.distance_to(Point::new(200, 0)) - 100.0).abs() < 1e-9);
1500        assert!((line.distance_to(Point::new(-30, 40)) - 50.0).abs() < 1e-9);
1501    }
1502
1503    #[test]
1504    fn grabbing_prefers_an_endpoint_then_the_segment() {
1505        let line = Line::new(Point::new(0, 0), Point::new(100, 0));
1506        assert_eq!(line.endpoint_grab(Point::new(2, 2), 6), Some(true), "a");
1507        assert_eq!(line.endpoint_grab(Point::new(98, 1), 6), Some(false), "b");
1508        assert_eq!(line.endpoint_grab(Point::new(50, 0), 6), None, "middle");
1509        assert!(line.hit_test(Point::new(50, 3), 6), "still on the line");
1510        assert!(!line.hit_test(Point::new(50, 40), 6));
1511    }
1512
1513    #[test]
1514    fn shift_snaps_to_the_eight_directions_and_tracks_the_pointer() {
1515        let from = Point::new(100, 100);
1516        // Slightly off horizontal snaps flat, keeping the horizontal reach.
1517        let nearly = Line::new(from, Point::new(200, 104)).constrained();
1518        assert_eq!(nearly.b.y, 100, "snapped to horizontal");
1519        assert!((nearly.length() - 100.0).abs() < 1.0, "reach preserved");
1520
1521        // Slightly off the diagonal snaps to 45 degrees.
1522        let diag = Line::new(from, Point::new(160, 172)).constrained();
1523        assert!(
1524            ((diag.b.x - from.x) - (diag.b.y - from.y)).abs() <= 1,
1525            "equal legs: {diag:?}"
1526        );
1527        assert!((diag.angle_deg() - 45.0).abs() < 1.0);
1528
1529        // Upward-left still works: the constraint is eight-way, not four.
1530        let up_left = Line::new(from, Point::new(30, 26)).constrained();
1531        assert!((up_left.angle_deg() - 225.0).abs() < 1.0, "{up_left:?}");
1532    }
1533
1534    #[test]
1535    fn constraining_a_zero_length_measure_leaves_it_alone() {
1536        let dot = Line::new(Point::new(9, 9), Point::new(9, 9));
1537        assert_eq!(dot.constrained(), dot);
1538    }
1539
1540    const BOUNDS: Size = Size::new(1920, 1080);
1541    const BOUNDS_RECT: Rect = Rect::new(0, 0, BOUNDS.w, BOUNDS.h);
1542
1543    #[test]
1544    fn rect_preview_normalizes_inverted_drag() {
1545        let s = Shape::compute_preview(
1546            ToolKind::Rect,
1547            Point::new(100, 200),
1548            Point::new(40, 50),
1549            BOUNDS_RECT,
1550            false,
1551        );
1552        assert_eq!(s, Some(Shape::Rect(Rect::new(40, 50, 60, 150))));
1553    }
1554
1555    #[test]
1556    fn rect_preview_clamps_cursor_to_bounds() {
1557        let s = Shape::compute_preview(
1558            ToolKind::Rect,
1559            Point::new(1900, 1000),
1560            Point::new(5000, 5000),
1561            BOUNDS_RECT,
1562            false,
1563        );
1564        assert_eq!(s, Some(Shape::Rect(Rect::new(1900, 1000, 19, 79))));
1565    }
1566
1567    #[test]
1568    fn rect_preview_degenerate_is_none() {
1569        assert_eq!(
1570            Shape::compute_preview(
1571                ToolKind::Rect,
1572                Point::new(10, 10),
1573                Point::new(10, 300),
1574                BOUNDS_RECT,
1575                false
1576            ),
1577            None
1578        );
1579        assert_eq!(
1580            Shape::compute_preview(
1581                ToolKind::Rect,
1582                Point::new(10, 10),
1583                Point::new(10, 10),
1584                BOUNDS_RECT,
1585                false
1586            ),
1587            None
1588        );
1589    }
1590
1591    #[test]
1592    fn circle_preview_radius_is_distance() {
1593        let s = Shape::compute_preview(
1594            ToolKind::Circle,
1595            Point::new(100, 100),
1596            Point::new(103, 104),
1597            BOUNDS_RECT,
1598            false,
1599        );
1600        assert_eq!(
1601            s,
1602            Some(Shape::Circle {
1603                cx: 100,
1604                cy: 100,
1605                r: 5
1606            })
1607        );
1608    }
1609
1610    #[test]
1611    fn circle_preview_zero_radius_is_none() {
1612        assert_eq!(
1613            Shape::compute_preview(
1614                ToolKind::Circle,
1615                Point::new(7, 7),
1616                Point::new(7, 7),
1617                BOUNDS_RECT,
1618                false
1619            ),
1620            None
1621        );
1622    }
1623
1624    #[test]
1625    fn rect_hit_test_edges() {
1626        let s = Shape::Rect(Rect::new(10, 10, 20, 20));
1627        assert!(s.hit_test(Point::new(10, 10)));
1628        assert!(s.hit_test(Point::new(29, 29)));
1629        assert!(!s.hit_test(Point::new(30, 30)));
1630        assert!(!s.hit_test(Point::new(9, 10)));
1631    }
1632
1633    #[test]
1634    fn circle_hit_test_boundary_inclusive() {
1635        let s = Shape::Circle {
1636            cx: 0,
1637            cy: 0,
1638            r: 10,
1639        };
1640        assert!(s.hit_test(Point::new(10, 0)));
1641        assert!(s.hit_test(Point::new(6, 8)));
1642        assert!(!s.hit_test(Point::new(8, 8)));
1643    }
1644
1645    #[test]
1646    fn circle_hit_test_survives_extreme_coords() {
1647        let s = Shape::Circle { cx: 0, cy: 0, r: 5 };
1648        assert!(!s.hit_test(Point::new(i32::MAX, i32::MAX)));
1649    }
1650
1651    #[test]
1652    fn bbox_of_circle() {
1653        let s = Shape::Circle {
1654            cx: 50,
1655            cy: 60,
1656            r: 10,
1657        };
1658        assert_eq!(s.bbox(), Rect::new(40, 50, 20, 20));
1659    }
1660
1661    #[test]
1662    fn rect_clamp_move_never_escapes_bounds() {
1663        let s = Shape::Rect(Rect::new(0, 0, 300, 200));
1664        let grab = Point::new(0, 0);
1665        for cx in [-500, 0, 960, 5000] {
1666            for cy in [-500, 0, 540, 5000] {
1667                let Shape::Rect(r) = s.clamp_move(grab, Point::new(cx, cy), BOUNDS_RECT) else {
1668                    panic!("rect stayed rect");
1669                };
1670                assert!(r.x >= 0 && r.y >= 0, "({cx},{cy}) gave {r:?}");
1671                assert!(
1672                    r.x + r.w <= BOUNDS.w && r.y + r.h <= BOUNDS.h,
1673                    "({cx},{cy}) gave {r:?}"
1674                );
1675            }
1676        }
1677    }
1678
1679    #[test]
1680    fn circle_clamp_move_never_escapes_bounds() {
1681        let s = Shape::Circle {
1682            cx: 500,
1683            cy: 500,
1684            r: 40,
1685        };
1686        let grab = Point::new(0, 0);
1687        for cx in [-500, 0, 960, 5000] {
1688            for cy in [-500, 0, 540, 5000] {
1689                let Shape::Circle {
1690                    cx: ncx,
1691                    cy: ncy,
1692                    r,
1693                } = s.clamp_move(grab, Point::new(cx, cy), BOUNDS_RECT)
1694                else {
1695                    panic!("circle stayed circle");
1696                };
1697                assert!(
1698                    ncx - r >= 0 && ncy - r >= 0,
1699                    "({cx},{cy}) gave center ({ncx},{ncy})"
1700                );
1701                assert!(
1702                    ncx + r <= BOUNDS.w && ncy + r <= BOUNDS.h,
1703                    "({cx},{cy}) gave center ({ncx},{ncy})"
1704                );
1705            }
1706        }
1707    }
1708
1709    #[test]
1710    fn oversized_circle_clamp_is_stable() {
1711        // Circle larger than the window: clamps to the r-pinned position
1712        // instead of oscillating or going negative (predecessor behavior).
1713        let s = Shape::Circle {
1714            cx: 100,
1715            cy: 100,
1716            r: 2000,
1717        };
1718        let moved = s.clamp_move(Point::new(0, 0), Point::new(0, 0), BOUNDS_RECT);
1719        assert_eq!(
1720            moved,
1721            Shape::Circle {
1722                cx: 2000,
1723                cy: 2000,
1724                r: 2000
1725            }
1726        );
1727    }
1728
1729    #[test]
1730    fn translated_shifts_both_kinds() {
1731        assert_eq!(
1732            Shape::Rect(Rect::new(1, 2, 3, 4)).translated(10, 20),
1733            Shape::Rect(Rect::new(11, 22, 3, 4))
1734        );
1735        assert_eq!(
1736            Shape::Circle { cx: 1, cy: 2, r: 3 }.translated(10, 20),
1737            Shape::Circle {
1738                cx: 11,
1739                cy: 22,
1740                r: 3
1741            }
1742        );
1743    }
1744
1745    #[test]
1746    fn circle_rim_grab_within_tolerance_only() {
1747        let s = Shape::Circle {
1748            cx: 100,
1749            cy: 100,
1750            r: 50,
1751        };
1752        assert_eq!(
1753            s.resize_grab(Point::new(153, 100), 5),
1754            Some(ResizeHandle::CircleRadius)
1755        );
1756        assert_eq!(
1757            s.resize_grab(Point::new(147, 100), 5),
1758            Some(ResizeHandle::CircleRadius)
1759        );
1760        assert_eq!(s.resize_grab(Point::new(100, 100), 5), None); // center
1761        assert_eq!(s.resize_grab(Point::new(160, 100), 5), None); // far outside
1762    }
1763
1764    #[test]
1765    fn rect_edge_and_corner_grabs() {
1766        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1767        assert_eq!(
1768            s.resize_grab(Point::new(100, 150), 5),
1769            Some(ResizeHandle::RectEdges {
1770                left: true,
1771                right: false,
1772                top: false,
1773                bottom: false
1774            })
1775        );
1776        assert_eq!(
1777            s.resize_grab(Point::new(302, 150), 5), // just outside right edge
1778            Some(ResizeHandle::RectEdges {
1779                left: false,
1780                right: true,
1781                top: false,
1782                bottom: false
1783            })
1784        );
1785        assert_eq!(
1786            s.resize_grab(Point::new(298, 202), 5), // bottom-right corner
1787            Some(ResizeHandle::RectEdges {
1788                left: false,
1789                right: true,
1790                top: false,
1791                bottom: true
1792            })
1793        );
1794        assert_eq!(s.resize_grab(Point::new(200, 150), 5), None); // interior
1795        assert_eq!(s.resize_grab(Point::new(90, 150), 5), None); // outside band
1796    }
1797
1798    #[test]
1799    fn tiny_rect_grabs_nearer_edge_not_both() {
1800        let s = Shape::Rect(Rect::new(100, 100, 6, 6));
1801        let Some(ResizeHandle::RectEdges { left, right, .. }) =
1802            s.resize_grab(Point::new(101, 103), 5)
1803        else {
1804            panic!("expected an edge grab");
1805        };
1806        assert!(left && !right);
1807    }
1808
1809    #[test]
1810    fn circle_resize_follows_cursor_distance() {
1811        let s = Shape::Circle {
1812            cx: 100,
1813            cy: 100,
1814            r: 50,
1815        };
1816        let resized = s.resize_to(
1817            ResizeHandle::CircleRadius,
1818            Point::new(100, 180),
1819            BOUNDS_RECT,
1820            false,
1821        );
1822        assert_eq!(
1823            resized,
1824            Shape::Circle {
1825                cx: 100,
1826                cy: 100,
1827                r: 80
1828            }
1829        );
1830        // Collapsing onto the center clamps to the minimum, not zero.
1831        let tiny = s.resize_to(
1832            ResizeHandle::CircleRadius,
1833            Point::new(100, 100),
1834            BOUNDS_RECT,
1835            false,
1836        );
1837        assert_eq!(
1838            tiny,
1839            Shape::Circle {
1840                cx: 100,
1841                cy: 100,
1842                r: 2
1843            }
1844        );
1845    }
1846
1847    #[test]
1848    fn rect_corner_resize_anchors_opposite_corner() {
1849        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1850        let handle = ResizeHandle::RectEdges {
1851            left: false,
1852            right: true,
1853            top: false,
1854            bottom: true,
1855        };
1856        let resized = s.resize_to(handle, Point::new(400, 300), BOUNDS_RECT, false);
1857        assert_eq!(resized, Shape::Rect(Rect::new(100, 100, 300, 200)));
1858    }
1859
1860    #[test]
1861    fn rect_edge_resize_moves_one_axis_only() {
1862        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1863        let handle = ResizeHandle::RectEdges {
1864            left: true,
1865            right: false,
1866            top: false,
1867            bottom: false,
1868        };
1869        let resized = s.resize_to(handle, Point::new(50, 999), BOUNDS_RECT, false);
1870        assert_eq!(resized, Shape::Rect(Rect::new(50, 100, 250, 100)));
1871    }
1872
1873    #[test]
1874    fn rect_resize_cannot_invert_or_vanish() {
1875        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1876        let handle = ResizeHandle::RectEdges {
1877            left: true,
1878            right: false,
1879            top: false,
1880            bottom: false,
1881        };
1882        // Dragging the left edge far past the right edge stops at MIN width.
1883        let resized = s.resize_to(handle, Point::new(500, 150), BOUNDS_RECT, false);
1884        assert_eq!(resized, Shape::Rect(Rect::new(298, 100, 2, 100)));
1885    }
1886
1887    #[test]
1888    fn resize_cursor_is_clamped_to_bounds() {
1889        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1890        let handle = ResizeHandle::RectEdges {
1891            left: false,
1892            right: true,
1893            top: false,
1894            bottom: false,
1895        };
1896        let resized = s.resize_to(handle, Point::new(99_999, 150), BOUNDS_RECT, false);
1897        assert_eq!(
1898            resized,
1899            Shape::Rect(Rect::new(100, 100, BOUNDS.w - 1 - 100, 100))
1900        );
1901    }
1902
1903    #[test]
1904    fn locked_corner_resize_keeps_ratio_dominant_axis_wins() {
1905        // 2:1 rect, drag the bottom-right corner. Cursor asks for 300x200;
1906        // height is the dominant scale (2x), so the result is 400x200.
1907        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1908        let corner = ResizeHandle::RectEdges {
1909            left: false,
1910            right: true,
1911            top: false,
1912            bottom: true,
1913        };
1914        let resized = s.resize_to(corner, Point::new(400, 300), BOUNDS_RECT, true);
1915        assert_eq!(resized, Shape::Rect(Rect::new(100, 100, 400, 200)));
1916    }
1917
1918    #[test]
1919    fn locked_corner_resize_anchors_the_opposite_corner() {
1920        // Dragging the top-left corner keeps (x1, y1) fixed.
1921        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1922        let corner = ResizeHandle::RectEdges {
1923            left: true,
1924            right: false,
1925            top: true,
1926            bottom: false,
1927        };
1928        let resized = s.resize_to(corner, Point::new(0, 80), BOUNDS_RECT, true);
1929        let Shape::Rect(r) = resized else {
1930            panic!("still a rect")
1931        };
1932        assert_eq!((r.x + r.w, r.y + r.h), (300, 200), "anchor moved");
1933        assert_eq!(r.w * 100, r.h * 200, "ratio drifted: {r:?}");
1934    }
1935
1936    #[test]
1937    fn locked_corner_resize_caps_scale_at_bounds() {
1938        // Anchored at (100, 100) with a 2:1 ratio on a 1920x1080 canvas:
1939        // width hits the right edge first (1820/200 = 9.1x vs 980/100 =
1940        // 9.8x), so the scale caps there and the rect stays inside.
1941        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1942        let corner = ResizeHandle::RectEdges {
1943            left: false,
1944            right: true,
1945            top: false,
1946            bottom: true,
1947        };
1948        let resized = s.resize_to(
1949            corner,
1950            Point::new(BOUNDS_RECT.w - 1, BOUNDS_RECT.h - 1),
1951            BOUNDS_RECT,
1952            true,
1953        );
1954        let Shape::Rect(r) = resized else {
1955            panic!("still a rect")
1956        };
1957        assert!(
1958            r.x + r.w <= BOUNDS.w && r.y + r.h <= BOUNDS.h,
1959            "escaped: {r:?}"
1960        );
1961        assert_eq!(r.w, BOUNDS.w - 100);
1962        assert_eq!(r.w, 2 * r.h);
1963    }
1964
1965    #[test]
1966    fn locked_edge_resize_scales_other_axis_centered() {
1967        // Dragging the right edge to double the width also doubles the
1968        // height, centered on the original vertical middle.
1969        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
1970        let edge = ResizeHandle::RectEdges {
1971            left: false,
1972            right: true,
1973            top: false,
1974            bottom: false,
1975        };
1976        let resized = s.resize_to(edge, Point::new(500, 150), BOUNDS_RECT, true);
1977        assert_eq!(resized, Shape::Rect(Rect::new(100, 50, 400, 200)));
1978    }
1979
1980    #[test]
1981    fn locked_edge_resize_clamps_centered_axis_to_bounds() {
1982        // A rect near the top: the proportional height growth would go
1983        // negative, so it shifts down to stay on screen.
1984        let s = Shape::Rect(Rect::new(100, 10, 200, 100));
1985        let edge = ResizeHandle::RectEdges {
1986            left: false,
1987            right: true,
1988            top: false,
1989            bottom: false,
1990        };
1991        let resized = s.resize_to(edge, Point::new(500, 60), BOUNDS_RECT, true);
1992        let Shape::Rect(r) = resized else {
1993            panic!("still a rect")
1994        };
1995        assert_eq!((r.w, r.h), (400, 200));
1996        assert_eq!(r.y, 0, "clamped to the top edge");
1997    }
1998
1999    #[test]
2000    fn locked_circle_resize_is_unchanged_by_lock() {
2001        let s = Shape::Circle {
2002            cx: 100,
2003            cy: 100,
2004            r: 50,
2005        };
2006        let unlocked = s.resize_to(
2007            ResizeHandle::CircleRadius,
2008            Point::new(100, 180),
2009            BOUNDS_RECT,
2010            false,
2011        );
2012        let locked = s.resize_to(
2013            ResizeHandle::CircleRadius,
2014            Point::new(100, 180),
2015            BOUNDS_RECT,
2016            true,
2017        );
2018        assert_eq!(unlocked, locked);
2019    }
2020
2021    #[test]
2022    fn mismatched_handle_is_inert() {
2023        let s = Shape::Circle { cx: 5, cy: 5, r: 5 };
2024        let handle = ResizeHandle::RectEdges {
2025            left: true,
2026            right: false,
2027            top: false,
2028            bottom: false,
2029        };
2030        assert_eq!(
2031            s.resize_to(handle, Point::new(50, 50), BOUNDS_RECT, false),
2032            s
2033        );
2034    }
2035
2036    #[test]
2037    fn ellipse_preview_inscribes_the_drag_box_and_shift_locks_a_circle() {
2038        let free = Shape::compute_preview(
2039            ToolKind::Ellipse,
2040            Point::new(10, 10),
2041            Point::new(50, 30),
2042            BOUNDS_RECT,
2043            false,
2044        );
2045        assert_eq!(
2046            free,
2047            Some(Shape::Ellipse {
2048                cx: 30,
2049                cy: 20,
2050                rx: 20,
2051                ry: 10,
2052            })
2053        );
2054        let locked = Shape::compute_preview(
2055            ToolKind::Ellipse,
2056            Point::new(10, 10),
2057            Point::new(50, 30),
2058            BOUNDS_RECT,
2059            true,
2060        );
2061        assert_eq!(
2062            locked,
2063            Some(Shape::Ellipse {
2064                cx: 30,
2065                cy: 20,
2066                rx: 10,
2067                ry: 10,
2068            }),
2069            "Shift inscribes the circle instead"
2070        );
2071    }
2072
2073    #[test]
2074    fn ellipse_hit_test_is_boundary_inclusive_and_excludes_bbox_corners() {
2075        let e = Shape::Ellipse {
2076            cx: 50,
2077            cy: 40,
2078            rx: 30,
2079            ry: 10,
2080        };
2081        assert!(e.hit_test(Point::new(50, 40)));
2082        assert!(e.hit_test(Point::new(80, 40)), "rx vertex inclusive");
2083        assert!(e.hit_test(Point::new(50, 30)), "ry vertex inclusive");
2084        assert!(!e.hit_test(Point::new(80, 30)), "bbox corner outside");
2085        assert!(!e.hit_test(Point::new(81, 40)));
2086        assert_eq!(e.bbox(), Rect::new(20, 30, 60, 20));
2087    }
2088
2089    #[test]
2090    fn ellipse_resize_rides_its_bounding_box() {
2091        let e = Shape::Ellipse {
2092            cx: 50,
2093            cy: 40,
2094            rx: 20,
2095            ry: 10,
2096        };
2097        // Grab the right edge of the bbox (x = 70) and pull to x = 90.
2098        let handle = e.resize_grab(Point::new(70, 40), 2).expect("edge grab");
2099        let resized = e.resize_to(handle, Point::new(90, 40), BOUNDS_RECT, false);
2100        assert_eq!(
2101            resized,
2102            Shape::Ellipse {
2103                cx: 60,
2104                cy: 40,
2105                rx: 30,
2106                ry: 10,
2107            },
2108            "left edge anchored, rx grew"
2109        );
2110    }
2111
2112    #[test]
2113    fn rotated_ellipse_hit_follows_the_turn() {
2114        let e = Shape::Ellipse {
2115            cx: 50,
2116            cy: 40,
2117            rx: 30,
2118            ry: 8,
2119        };
2120        // Turned 90, the wide ellipse stands tall.
2121        assert!(e.hit_test_rotated(90, Point::new(50, 65)));
2122        assert!(!e.hit_test_rotated(90, Point::new(75, 40)));
2123        assert!(e.hit_test(Point::new(75, 40)), "unrotated it lies flat");
2124    }
2125
2126    #[test]
2127    fn a_crescents_click_point_is_inside_it_not_in_the_hollow() {
2128        // The vertex average of a C sits in the opening, which is exactly
2129        // the case the fallback exists for.
2130        let c = Shape::Poly {
2131            points: vec![
2132                Point::new(0, 0),
2133                Point::new(100, 0),
2134                Point::new(100, 20),
2135                Point::new(30, 20),
2136                Point::new(30, 80),
2137                Point::new(100, 80),
2138                Point::new(100, 100),
2139                Point::new(0, 100),
2140            ],
2141        };
2142        let p = c.click_point();
2143        assert!(c.hit_test(p), "{p:?} landed outside the crescent");
2144    }
2145
2146    #[test]
2147    fn a_click_point_is_interior_for_every_awkward_polygon() {
2148        let shapes = [
2149            // A thin diagonal band.
2150            vec![
2151                Point::new(0, 0),
2152                Point::new(10, 0),
2153                Point::new(100, 90),
2154                Point::new(100, 100),
2155                Point::new(90, 100),
2156                Point::new(0, 10),
2157            ],
2158            // A zigzag whose average is off the shape.
2159            vec![
2160                Point::new(0, 0),
2161                Point::new(20, 60),
2162                Point::new(40, 0),
2163                Point::new(60, 60),
2164                Point::new(80, 0),
2165                Point::new(80, 10),
2166                Point::new(60, 70),
2167                Point::new(40, 10),
2168                Point::new(20, 70),
2169                Point::new(0, 10),
2170            ],
2171            // A one-pixel-tall sliver: the mid row is the only row.
2172            vec![
2173                Point::new(0, 0),
2174                Point::new(500, 0),
2175                Point::new(500, 1),
2176                Point::new(0, 1),
2177            ],
2178        ];
2179        for points in shapes {
2180            let shape = Shape::Poly {
2181                points: points.clone(),
2182            };
2183            let p = shape.click_point();
2184            assert!(shape.hit_test(p), "{p:?} outside {points:?}");
2185        }
2186    }
2187
2188    #[test]
2189    fn a_click_point_does_not_scan_the_bounding_box() {
2190        // The regression this replaced was O(width x height). A shape a
2191        // million pixels wide used to take about 25ms; if a scan ever
2192        // comes back this will stop finishing promptly.
2193        let wide = Shape::Poly {
2194            points: vec![
2195                Point::new(1_000_000, 0),
2196                Point::new(500_000, 750_000),
2197                Point::new(0, 375_000),
2198                Point::new(500_000, 650_000),
2199                Point::new(940_000, 90_000),
2200            ],
2201        };
2202        let started = std::time::Instant::now();
2203        let p = wide.click_point();
2204        assert!(wide.hit_test(p), "{p:?} outside");
2205        assert!(
2206            started.elapsed() < std::time::Duration::from_millis(50),
2207            "took {:?} — the bounding-box scan is back",
2208            started.elapsed()
2209        );
2210    }
2211
2212    #[test]
2213    fn point_in_poly_handles_concave_shapes_edges_included() {
2214        // A U shape: the notch between the arms is outside.
2215        let u = vec![
2216            Point::new(0, 0),
2217            Point::new(10, 0),
2218            Point::new(10, 30),
2219            Point::new(20, 30),
2220            Point::new(20, 0),
2221            Point::new(30, 0),
2222            Point::new(30, 40),
2223            Point::new(0, 40),
2224        ];
2225        let shape = Shape::Poly { points: u };
2226        assert!(shape.hit_test(Point::new(5, 20)), "left arm");
2227        assert!(shape.hit_test(Point::new(25, 20)), "right arm");
2228        assert!(shape.hit_test(Point::new(15, 35)), "base");
2229        assert!(!shape.hit_test(Point::new(15, 10)), "the notch is outside");
2230        assert!(shape.hit_test(Point::new(0, 0)), "vertex inclusive");
2231        assert!(shape.hit_test(Point::new(5, 0)), "edge inclusive");
2232        assert!(!shape.hit_test(Point::new(-1, 20)));
2233        // The interior click point avoids the notch.
2234        assert!(shape.hit_test(shape.click_point()));
2235    }
2236
2237    #[test]
2238    fn regular_polygon_puts_the_first_vertex_at_the_cursor() {
2239        let hex = regular_polygon(Point::new(100, 100), Point::new(140, 100), 6);
2240        let Shape::Poly { ref points } = hex else {
2241            panic!("regular polygon is a poly")
2242        };
2243        assert_eq!(points.len(), 6);
2244        assert_eq!(points[0], Point::new(140, 100), "first vertex at cursor");
2245        for p in points {
2246            let d = f64::from(p.x - 100).hypot(f64::from(p.y - 100));
2247            assert!((d - 40.0).abs() < 1.5, "vertex {p:?} off the radius: {d}");
2248        }
2249        // The side count clamps to something drawable.
2250        let tri = regular_polygon(Point::new(0, 0), Point::new(10, 0), 1);
2251        let Shape::Poly { points } = tri else {
2252            panic!()
2253        };
2254        assert_eq!(points.len(), 3);
2255    }
2256
2257    #[test]
2258    fn simplify_path_drops_jitter_and_keeps_corners() {
2259        // A noisy L: collinear runs with 1px wobble collapse; the corner
2260        // survives.
2261        let path: Vec<Point> = (0..=20)
2262            .map(|x| Point::new(x * 5, i32::from(x % 2 != 0)))
2263            .chain((1..=10).map(|y| Point::new(100, y * 5)))
2264            .collect();
2265        let simplified = simplify_path(&path, 2.0);
2266        assert!(
2267            simplified.len() <= 5,
2268            "expected a handful of points, got {}",
2269            simplified.len()
2270        );
2271        assert_eq!(*simplified.first().unwrap(), Point::new(0, 0));
2272        assert_eq!(*simplified.last().unwrap(), Point::new(100, 50));
2273        assert!(
2274            simplified.contains(&Point::new(100, 1)) || simplified.contains(&Point::new(100, 0)),
2275            "the corner survives: {simplified:?}"
2276        );
2277    }
2278
2279    #[test]
2280    fn poly_moves_resizes_and_rotates_like_any_shape() {
2281        let square = Shape::Poly {
2282            points: vec![
2283                Point::new(10, 10),
2284                Point::new(30, 10),
2285                Point::new(30, 30),
2286                Point::new(10, 30),
2287            ],
2288        };
2289        assert_eq!(square.bbox(), Rect::new(10, 10, 20, 20));
2290        let moved = square.translated(5, -5);
2291        assert_eq!(moved.bbox(), Rect::new(15, 5, 20, 20));
2292        // Bbox-edge resize scales every vertex.
2293        let handle = square.resize_grab(Point::new(30, 20), 2).expect("edge");
2294        let grown = square.resize_to(handle, Point::new(50, 20), BOUNDS_RECT, false);
2295        assert_eq!(grown.bbox(), Rect::new(10, 10, 40, 20));
2296        // Rotation bakes into the vertices.
2297        let turned = square.with_rotation_baked(90);
2298        assert_eq!(turned.bbox(), square.bbox(), "square is 90-symmetric");
2299        assert!(matches!(turned, Shape::Poly { .. }));
2300    }
2301
2302    #[test]
2303    fn click_point_centers_each_kind() {
2304        assert_eq!(
2305            Shape::Rect(Rect::new(10, 20, 30, 40)).click_point(),
2306            Point::new(25, 40)
2307        );
2308        assert_eq!(
2309            Shape::Circle { cx: 5, cy: 6, r: 7 }.click_point(),
2310            Point::new(5, 6)
2311        );
2312        let tri = Shape::Triangle {
2313            ax: 30,
2314            ay: 0,
2315            bx: 0,
2316            by: 60,
2317            cx: 60,
2318            cy: 60,
2319        };
2320        assert_eq!(tri.click_point(), Point::new(30, 40));
2321        assert!(tri.hit_test(tri.click_point()));
2322        // The click point is the rotation pivot, so it stays inside the
2323        // silhouette at any angle.
2324        let rect = Shape::Rect(Rect::new(10, 10, 40, 10));
2325        assert!(rect.hit_test_rotated(90, rect.click_point()));
2326    }
2327
2328    #[test]
2329    fn clamp_point_lands_inside_and_leaves_interior_points_alone() {
2330        let r = Rect::new(10, 20, 30, 40);
2331        let inside = Point::new(15, 25);
2332        assert_eq!(r.clamp_point(inside), inside);
2333        // The far edge is exclusive, matching `contains`.
2334        assert_eq!(r.clamp_point(Point::new(100, 100)), Point::new(39, 59));
2335        assert_eq!(r.clamp_point(Point::new(-5, -5)), Point::new(10, 20));
2336        for p in [
2337            Point::new(100, 100),
2338            Point::new(-5, -5),
2339            Point::new(15, 900),
2340        ] {
2341            assert!(r.contains(r.clamp_point(p)));
2342        }
2343    }
2344
2345    #[test]
2346    fn clamp_point_on_a_zero_sized_rect_gives_the_origin_corner() {
2347        let r = Rect::new(7, 9, 0, 0);
2348        assert_eq!(r.clamp_point(Point::new(100, 100)), Point::new(7, 9));
2349    }
2350
2351    #[test]
2352    fn line_bbox_spans_both_endpoints_in_any_direction() {
2353        let down = Line::new(Point::new(10, 20), Point::new(40, 60));
2354        let up = Line::new(Point::new(40, 60), Point::new(10, 20));
2355        assert_eq!(down.bbox(), Rect::new(10, 20, 30, 40));
2356        assert_eq!(up.bbox(), down.bbox());
2357        // A zero-length ruler still has a placeable caption anchor.
2358        let dot = Line::new(Point::new(5, 5), Point::new(5, 5));
2359        assert_eq!(dot.bbox(), Rect::new(5, 5, 0, 0));
2360    }
2361
2362    #[test]
2363    fn tool_kind_cycles_through_the_drawing_tools() {
2364        assert_eq!(ToolKind::Rect.next(), ToolKind::Ellipse);
2365        assert_eq!(ToolKind::Ellipse.next(), ToolKind::Triangle);
2366        assert_eq!(ToolKind::Triangle.next(), ToolKind::Polygon);
2367        assert_eq!(ToolKind::Polygon.next(), ToolKind::Freehand);
2368        assert_eq!(ToolKind::Freehand.next(), ToolKind::Measure);
2369        assert_eq!(ToolKind::Measure.next(), ToolKind::Rect);
2370        // Record-only kinds cycle back into the modern set.
2371        assert_eq!(ToolKind::Circle.next(), ToolKind::Triangle);
2372        assert_eq!(ToolKind::Poly.next(), ToolKind::Rect);
2373    }
2374
2375    #[test]
2376    fn triangle_preview_is_apex_top_center_in_drag_box() {
2377        let s = Shape::compute_preview(
2378            ToolKind::Triangle,
2379            Point::new(100, 100),
2380            Point::new(300, 200),
2381            BOUNDS_RECT,
2382            false,
2383        );
2384        assert_eq!(
2385            s,
2386            Some(Shape::Triangle {
2387                ax: 200,
2388                ay: 100,
2389                bx: 100,
2390                by: 200,
2391                cx: 300,
2392                cy: 200,
2393            })
2394        );
2395    }
2396
2397    #[test]
2398    fn triangle_hit_test_excludes_bbox_corners() {
2399        let tri = Shape::Triangle {
2400            ax: 200,
2401            ay: 100,
2402            bx: 100,
2403            by: 200,
2404            cx: 300,
2405            cy: 200,
2406        };
2407        assert!(tri.hit_test(Point::new(200, 150))); // centroid area
2408        assert!(tri.hit_test(Point::new(200, 100))); // apex, inclusive
2409        assert!(tri.hit_test(Point::new(150, 200))); // on the base
2410        assert!(!tri.hit_test(Point::new(105, 105))); // bbox top-left, empty
2411        assert!(!tri.hit_test(Point::new(295, 105))); // bbox top-right, empty
2412    }
2413
2414    #[test]
2415    fn triangle_bbox_and_move_clamp() {
2416        let tri = Shape::Triangle {
2417            ax: 200,
2418            ay: 100,
2419            bx: 100,
2420            by: 200,
2421            cx: 300,
2422            cy: 200,
2423        };
2424        assert_eq!(tri.bbox(), Rect::new(100, 100, 200, 100));
2425        // Dragged far off-screen: the bbox pins to the corner and all three
2426        // vertices translate together.
2427        let moved = tri.clamp_move(Point::new(0, 0), Point::new(-500, -500), BOUNDS_RECT);
2428        assert_eq!(moved.bbox(), Rect::new(0, 0, 200, 100));
2429        assert_eq!(
2430            moved,
2431            Shape::Triangle {
2432                ax: 100,
2433                ay: 0,
2434                bx: 0,
2435                by: 100,
2436                cx: 200,
2437                cy: 100,
2438            }
2439        );
2440    }
2441
2442    #[test]
2443    fn triangle_resize_scales_vertices_into_new_bbox() {
2444        let tri = Shape::Triangle {
2445            ax: 200,
2446            ay: 100,
2447            bx: 100,
2448            by: 200,
2449            cx: 300,
2450            cy: 200,
2451        };
2452        // Drag the bottom-right bbox corner to double both dimensions.
2453        let handle = ResizeHandle::RectEdges {
2454            left: false,
2455            right: true,
2456            top: false,
2457            bottom: true,
2458        };
2459        let resized = tri.resize_to(handle, Point::new(500, 300), BOUNDS_RECT, false);
2460        assert_eq!(
2461            resized,
2462            Shape::Triangle {
2463                ax: 300,
2464                ay: 100,
2465                bx: 100,
2466                by: 300,
2467                cx: 500,
2468                cy: 300,
2469            }
2470        );
2471    }
2472
2473    #[test]
2474    fn triangle_resize_grab_is_on_the_bbox_border() {
2475        let tri = Shape::Triangle {
2476            ax: 200,
2477            ay: 100,
2478            bx: 100,
2479            by: 200,
2480            cx: 300,
2481            cy: 200,
2482        };
2483        // Top edge of the bbox (empty space next to the apex) still grabs.
2484        assert_eq!(
2485            tri.resize_grab(Point::new(150, 100), 5),
2486            Some(ResizeHandle::RectEdges {
2487                left: false,
2488                right: false,
2489                top: true,
2490                bottom: false
2491            })
2492        );
2493        assert_eq!(tri.resize_grab(Point::new(200, 150), 5), None); // interior
2494    }
2495
2496    #[test]
2497    fn degenerate_triangles_cover_nothing() {
2498        let point = Shape::Triangle {
2499            ax: 0,
2500            ay: 0,
2501            bx: 0,
2502            by: 0,
2503            cx: 0,
2504            cy: 0,
2505        };
2506        assert!(!point.hit_test(Point::new(500, 500)));
2507        assert!(!point.hit_test(Point::new(0, 0)));
2508        let line = Shape::Triangle {
2509            ax: 0,
2510            ay: 0,
2511            bx: 10,
2512            by: 10,
2513            cx: 20,
2514            cy: 20,
2515        };
2516        assert!(!line.hit_test(Point::new(400, 400)));
2517        assert!(!line.hit_test(Point::new(5, 5)));
2518    }
2519
2520    #[test]
2521    fn extreme_shapes_do_not_panic() {
2522        let huge = Shape::Circle {
2523            cx: 0,
2524            cy: 0,
2525            r: 2_000_000_000,
2526        };
2527        let bb = huge.bbox();
2528        assert!(bb.w > 0);
2529        let far = Shape::Rect(Rect::new(
2530            2_000_000_000,
2531            2_000_000_000,
2532            400_000_000,
2533            400_000_000,
2534        ));
2535        let _ = far.rotated_bbox(45);
2536    }
2537
2538    #[test]
2539    fn resize_of_sub_min_rect_stays_in_bounds() {
2540        // A 1px-thin rect (below the resize MIN floor): dragging its left
2541        // edge to the screen edge must not push it to x = -1.
2542        let s = Shape::Rect(Rect::new(0, 0, 1, 100));
2543        let handle = ResizeHandle::RectEdges {
2544            left: true,
2545            right: false,
2546            top: false,
2547            bottom: false,
2548        };
2549        let Shape::Rect(r) = s.resize_to(handle, Point::new(0, 50), BOUNDS_RECT, false) else {
2550            panic!("still a rect")
2551        };
2552        assert!(r.x >= 0, "escaped left: {r:?}");
2553        // Mirror case at the right edge.
2554        let s = Shape::Rect(Rect::new(BOUNDS.w - 1, 0, 1, 100));
2555        let handle = ResizeHandle::RectEdges {
2556            left: false,
2557            right: true,
2558            top: false,
2559            bottom: false,
2560        };
2561        let Shape::Rect(r) = s.resize_to(
2562            handle,
2563            Point::new(BOUNDS_RECT.w - 1, 50),
2564            BOUNDS_RECT,
2565            false,
2566        ) else {
2567            panic!("still a rect")
2568        };
2569        assert!(r.x + r.w <= BOUNDS.w, "escaped right: {r:?}");
2570    }
2571
2572    #[test]
2573    fn rotated_resize_never_moves_the_anchored_edge() {
2574        // Regression: the sub-MIN shift-clamp must not fire for normal
2575        // boxes — under rotation the local box can exceed visual bounds,
2576        // and clamping it drifted the anchor by up to the rotated diagonal.
2577        let s = Shape::Rect(Rect::new(800, 500, 200, 100));
2578        let handle = ResizeHandle::RectEdges {
2579            left: false,
2580            right: true,
2581            top: false,
2582            bottom: false,
2583        };
2584        let Shape::Rect(r) =
2585            s.resize_to_rotated(45, handle, Point::new(1900, 1000), BOUNDS_RECT, false)
2586        else {
2587            panic!("still a rect")
2588        };
2589        assert_eq!(r.x, 800, "anchored left edge moved");
2590        assert_eq!(r.y, 500, "anchored top edge moved");
2591    }
2592
2593    #[test]
2594    fn resize_of_offscreen_local_box_does_not_teleport() {
2595        // Regression: a rotated move can leave the local box partially
2596        // off-screen; a later resize must adjust one edge, not relocate
2597        // the shape to the origin.
2598        let s = Shape::Rect(Rect::new(-90, 0, 200, 20));
2599        let handle = ResizeHandle::RectEdges {
2600            left: false,
2601            right: true,
2602            top: false,
2603            bottom: false,
2604        };
2605        let Shape::Rect(r) = s.resize_to(handle, Point::new(120, 10), BOUNDS_RECT, false) else {
2606            panic!("still a rect")
2607        };
2608        assert_eq!(r.x, -90, "shape teleported");
2609        assert_eq!(r.w, 210);
2610    }
2611
2612    #[test]
2613    fn rotated_resize_tracks_cursor_at_screen_edge() {
2614        // Cursor clamps in the visual frame: resizing a rotated shape with
2615        // the cursor at the screen corner still lands on-screen local
2616        // coordinates instead of freezing early.
2617        let s = Shape::Rect(Rect::new(800, 500, 200, 100));
2618        let handle = ResizeHandle::RectEdges {
2619            left: false,
2620            right: true,
2621            top: false,
2622            bottom: false,
2623        };
2624        let r45 = s.resize_to_rotated(45, handle, Point::new(99_999, 99_999), BOUNDS_RECT, false);
2625        // The local resize saw a finite, in-bounds visual point.
2626        assert_ne!(r45, s);
2627    }
2628
2629    #[test]
2630    fn rotate_point_quarter_turn() {
2631        let center = Point::new(100, 100);
2632        // 90 deg clockwise in screen coords: (110, 100) -> (100, 110).
2633        assert_eq!(
2634            rotate_point_about(Point::new(110, 100), center, 90),
2635            Point::new(100, 110)
2636        );
2637        assert_eq!(
2638            rotate_point_about(Point::new(110, 100), center, -90),
2639            Point::new(100, 90)
2640        );
2641        assert_eq!(
2642            rotate_point_about(Point::new(110, 100), center, 360),
2643            Point::new(110, 100)
2644        );
2645    }
2646
2647    #[test]
2648    fn normalize_deg_wraps_into_range() {
2649        assert_eq!(normalize_deg(0), 0);
2650        assert_eq!(normalize_deg(-1), 359);
2651        assert_eq!(normalize_deg(360), 0);
2652        assert_eq!(normalize_deg(725), 5);
2653    }
2654
2655    #[test]
2656    fn rotated_bbox_of_quarter_turned_rect_swaps_dimensions() {
2657        let s = Shape::Rect(Rect::new(100, 100, 200, 100));
2658        let bb = s.rotated_bbox(90);
2659        assert_eq!((bb.w, bb.h), (100, 200));
2660        // Same center as the unrotated shape.
2661        assert_eq!(bb.x + bb.w / 2, 200);
2662        assert_eq!(bb.y + bb.h / 2, 150);
2663        // Rotation 0 and circles are identity.
2664        assert_eq!(s.rotated_bbox(0), s.bbox());
2665        let c = Shape::Circle {
2666            cx: 50,
2667            cy: 50,
2668            r: 20,
2669        };
2670        assert_eq!(c.rotated_bbox(45), c.bbox());
2671    }
2672
2673    #[test]
2674    fn rotated_hit_test_follows_the_turned_shape() {
2675        // Wide flat rect turned 90 deg: a point above the center (inside
2676        // the turned shape, outside the original) now hits, and a far-right
2677        // point (inside the original) no longer does.
2678        let s = Shape::Rect(Rect::new(100, 100, 200, 20));
2679        assert!(s.hit_test_rotated(90, Point::new(200, 30)));
2680        assert!(!s.hit_test_rotated(90, Point::new(290, 110)));
2681        assert!(s.hit_test_rotated(0, Point::new(290, 110)));
2682    }
2683
2684    #[test]
2685    fn rotated_resize_grab_finds_the_visual_edge() {
2686        // The turned rect's visually-left edge maps to a local edge grab.
2687        let s = Shape::Rect(Rect::new(100, 100, 200, 20));
2688        // After 90 deg the shape occupies x in [190, 210], y in [10, 210].
2689        assert!(s.resize_grab_rotated(90, Point::new(190, 110), 5).is_some());
2690        assert!(s.resize_grab_rotated(90, Point::new(150, 110), 5).is_none());
2691    }
2692
2693    #[test]
2694    fn baked_triangle_rotates_vertices_others_unchanged() {
2695        let tri = Shape::Triangle {
2696            ax: 200,
2697            ay: 100,
2698            bx: 100,
2699            by: 200,
2700            cx: 300,
2701            cy: 200,
2702        };
2703        let baked = tri.with_rotation_baked(180);
2704        // Pivot is the bbox center (200, 150): apex flips below.
2705        assert_eq!(
2706            baked,
2707            Shape::Triangle {
2708                ax: 200,
2709                ay: 200,
2710                bx: 300,
2711                by: 100,
2712                cx: 100,
2713                cy: 100,
2714            }
2715        );
2716        let rect = Shape::Rect(Rect::new(1, 2, 3, 4));
2717        assert_eq!(rect.with_rotation_baked(90), rect);
2718        assert_eq!(tri.with_rotation_baked(0), tri);
2719    }
2720
2721    #[test]
2722    fn triangle_serde_is_distinct_from_rect_and_circle() {
2723        let tri = Shape::Triangle {
2724            ax: 1,
2725            ay: 2,
2726            bx: 3,
2727            by: 4,
2728            cx: 5,
2729            cy: 6,
2730        };
2731        let json = serde_json::to_string(&tri).unwrap();
2732        let back: Shape = serde_json::from_str(&json).unwrap();
2733        assert_eq!(back, tri);
2734        // The old kinds still round-trip to themselves.
2735        let rect: Shape = serde_json::from_str(r#"{"x":1,"y":2,"w":3,"h":4}"#).unwrap();
2736        assert_eq!(rect, Shape::Rect(Rect::new(1, 2, 3, 4)));
2737        let circle: Shape = serde_json::from_str(r#"{"cx":1,"cy":2,"r":3}"#).unwrap();
2738        assert_eq!(circle, Shape::Circle { cx: 1, cy: 2, r: 3 });
2739    }
2740
2741    #[test]
2742    fn a_triangle_grabs_from_its_bbox_origin() {
2743        let tri = Shape::Triangle {
2744            ax: 50,
2745            ay: 10,
2746            bx: 20,
2747            by: 70,
2748            cx: 80,
2749            cy: 70,
2750        };
2751        assert_eq!(tri.grab_origin(), Point::new(20, 10));
2752    }
2753
2754    #[test]
2755    fn a_rotated_move_clamps_the_rotated_box_to_bounds() {
2756        let rect = Shape::Rect(Rect::new(10, 10, 40, 20));
2757        let bounds = Size::new(200, 200);
2758        // Dragged far past the corner: the rotated AABB, not the unrotated
2759        // rect, is what must stay inside.
2760        let moved = rect.clamp_move_rotated(
2761            45,
2762            Point::new(0, 0),
2763            Point::new(500, 500),
2764            Rect::new(0, 0, bounds.w, bounds.h),
2765        );
2766        let bb = moved.rotated_bbox(45);
2767        assert!(bb.x >= 0 && bb.y >= 0, "{bb:?}");
2768        assert!(bb.x + bb.w <= bounds.w, "{bb:?}");
2769        assert!(bb.y + bb.h <= bounds.h, "{bb:?}");
2770    }
2771
2772    #[test]
2773    fn a_rotated_grab_references_the_rotated_box_origin() {
2774        let rect = Shape::Rect(Rect::new(10, 10, 40, 20));
2775        assert_eq!(rect.grab_origin_rotated(0), rect.grab_origin());
2776        let rotated = rect.grab_origin_rotated(45);
2777        assert_eq!(
2778            rotated,
2779            Point::new(rect.rotated_bbox(45).x, rect.rotated_bbox(45).y)
2780        );
2781        // A circle has no orientation, so rotation cannot move its grab.
2782        let circle = Shape::Circle {
2783            cx: 40,
2784            cy: 40,
2785            r: 9,
2786        };
2787        assert_eq!(circle.grab_origin_rotated(30), circle.grab_origin());
2788    }
2789
2790    #[test]
2791    fn min3_and_max3_pick_each_position() {
2792        assert_eq!(min3(1, 2, 3), 1);
2793        assert_eq!(min3(2, 1, 3), 1);
2794        assert_eq!(min3(3, 2, 1), 1);
2795        assert_eq!(max3(3, 2, 1), 3);
2796        assert_eq!(max3(1, 3, 2), 3);
2797        assert_eq!(max3(1, 2, 3), 3);
2798    }
2799
2800    #[test]
2801    fn a_proportional_vertical_edge_resize_keeps_the_aspect() {
2802        // Grabbing only a vertical edge with Shift: width follows height.
2803        let rect = Shape::Rect(Rect::new(20, 20, 40, 20));
2804        let resized = rect.resize_to_rotated(
2805            0,
2806            ResizeHandle::RectEdges {
2807                left: false,
2808                right: false,
2809                top: true,
2810                bottom: false,
2811            },
2812            Point::new(30, 0),
2813            Rect::new(0, 0, 300, 300),
2814            true,
2815        );
2816        let bb = resized.bbox();
2817        assert!(bb.w >= 2 && bb.h >= 2, "{bb:?}");
2818        assert!(bb.x >= 0 && bb.x + bb.w <= 300, "{bb:?}");
2819    }
2820}