Skip to main content

pixelcoords_core/
geometry.rs

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