Skip to main content

teksilo_scene/
shape.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`ItemShape`] — the one geometry source of truth for the lightweight tier.
5//!
6//! A [`SceneItem`](crate::SceneItem) declares *what it is*, once, as a value:
7//! [`SceneItem::shape`](crate::SceneItem::shape) returns an `ItemShape` in the
8//! item's **local** coordinates. Every geometric question the scene can ask —
9//! point hit-test, marquee selection, collision, "what lies along this path?"
10//! — is then answered by deriving from that one value. There is no second
11//! predicate to keep in sync, which is the whole point: the pair of methods
12//! this replaced (`shape_contains` plus a snapshot-cloneable `clone_shape_test`
13//! closure) had to agree by discipline, and the trait's own documentation
14//! called the divergence "a silent dispatch bug".
15//!
16//! # What a shape is
17//!
18//! A shape is the **union** of at most two parts:
19//!
20//! * an **interior** — the filled region. Always present for
21//!   [`ItemShape::bounds`], [`ItemShape::rounded_rect`] and
22//!   [`ItemShape::ellipse`]; present for [`ItemShape::path`] under whichever
23//!   [`FillRule`] it carries, and removable with [`ItemShape::unfilled`].
24//! * a **stroke band** — a ribbon of a given width centred on the outline,
25//!   added by [`ItemShape::stroked`]. A [`StrokeSpace::Device`] (cosmetic)
26//!   band is specified in device pixels, so its width in local coordinates is
27//!   divided by the live view scale at test time — that is what keeps a
28//!   hairline connector's clickable band tracking the *rendered* line at any
29//!   zoom.
30//!
31//! [`ItemShape::none`] is neither: it is never hit, never selected, never
32//! collides. That is the logical-only [`GroupItem`](crate::GroupItem) — a
33//! container that must let clicks fall through to what it contains.
34//!
35//! Every band carries a fixed [`HIT_BAND_SLACK`] of grab tolerance on top of
36//! its half-width, so a one-pixel connector is clickable without pixel-perfect
37//! aim. A coarse pointer gets more on top of that, from the view's miss-only
38//! slop pass, which widens an item's *box* rather than its shape.
39//!
40//! # Cost
41//!
42//! Cloning an `ItemShape` is O(1): the path variant is one
43//! [`Rc<ShapeGeometry>`] refcount bump and every other variant is small and
44//! `Copy`-sized. The view clones one per item each time it rebuilds a
45//! hit-test snapshot row, so that matters.
46//! Curve flattening is memoised **inside** the `Rc` — built once per
47//! `ShapeGeometry`, never once per pass — which is why
48//! [`PathItem`](crate::PathItem) builds its `ShapeGeometry` in its constructor
49//! and hands out clones of the handle.
50//!
51//! Flattening tolerance is fixed at [`SHAPE_FLATTEN_TOLERANCE`] in **local**
52//! coordinates, so the memo needs no cache key. At 20× zoom a curve therefore
53//! hit-tests about 5 device pixels coarser than it looks — well inside the
54//! grab band of any real stroke, but an approximation rather than exactness.
55//!
56//! # Spaces
57//!
58//! `ItemShape` is **local**. [`SceneRegion`] — the thing a marquee, a lasso or
59//! a collision query asks about — is **scene**-space. The two
60//! `…ItemBoundingRect` selection modes deliberately stay there and compare the
61//! item's scene AABB; the two `…ItemShape` modes compare the shape itself. For
62//! an item with a non-identity transform those are genuinely different tests —
63//! see [`ItemSelectionMode`].
64//!
65//! Which frame a `…ItemShape` query meets the region in is a **decision**, not
66//! a detail, because a stroke band is a *distance* and only a similarity
67//! carries one. Usually the region comes down into the item's frame via
68//! [`SceneRegion::to_local`]; when it cannot, the item goes up instead. The
69//! rule, and why no approximation substitutes for it, is on
70//! [`ItemShape::contained_by_scene_region`].
71
72use std::cell::OnceCell;
73use std::rc::Rc;
74
75use teksilo_canvas::{
76    FillRule, Path, Point, Rect, StrokeSpace, Subpath, Transform2D, subpaths_contain_point,
77};
78
79/// Curve-flattening tolerance, in **local item coordinates**.
80///
81/// Fixed rather than derived from the live zoom so a [`ShapeGeometry`] can
82/// memoise its polyline behind one cell with no cache key. `0.25` keeps the
83/// approximation under one device pixel out to 4× zoom and under ~5 px at 20×.
84pub const SHAPE_FLATTEN_TOLERANCE: f32 = 0.25;
85
86/// Grab tolerance added to every stroke band's half-width, in local
87/// coordinates.
88///
89/// A rendered one-pixel connector is not a one-pixel target: this is what
90/// makes a thin stroke clickable without pixel-perfect aim, and it is the same
91/// `2.0` the per-segment hit-test carried before `ItemShape` existed. It is
92/// **not** the pointer-kind allowance — a coarse pointer earns more, from the
93/// view's separate miss-only slop pass.
94pub const HIT_BAND_SLACK: f32 = 2.0;
95
96// ---------------------------------------------------------------------------
97// ShapeGeometry — a path plus its memoised flattening
98// ---------------------------------------------------------------------------
99
100/// How many consecutive segments share one rejection box. Small enough that a
101/// long stroke gets real pruning, large enough that the index costs a fraction
102/// of the vertices it covers.
103const SEGMENTS_PER_CHUNK: usize = 16;
104
105#[derive(Debug)]
106struct Flattened {
107    subpaths: Vec<Subpath>,
108    /// One AABB per subpath, in the same order. First-level rejection for a
109    /// shape made of several subpaths.
110    boxes: Vec<Rect>,
111    /// Per subpath, one AABB per run of [`SEGMENTS_PER_CHUNK`] consecutive
112    /// segments. Second-level rejection, and the one that matters for a single
113    /// long stroke: without it a 500-segment ink line answers "no" only after
114    /// 500 point-to-segment distances, **per pointer sample, per item**. The
115    /// old per-segment walk had an accidental O(1) guard here — it bailed to
116    /// the AABB on the first curve command — and flattening removed it.
117    chunks: Vec<Vec<Rect>>,
118    /// AABB of the whole flattened outline (curve-accurate, unlike
119    /// [`Path::bounds`]).
120    bounds: Rect,
121}
122
123/// AABBs for each run of [`SEGMENTS_PER_CHUNK`] segments of each subpath, in
124/// the order [`Subpath::segments`] yields them (closing edge included when the
125/// subpath is closed).
126fn chunk_boxes(subpaths: &[Subpath]) -> Vec<Vec<Rect>> {
127    subpaths
128        .iter()
129        .map(|sp| {
130            let mut out: Vec<Rect> = Vec::new();
131            let mut acc: Option<Rect> = None;
132            for (i, (a, b)) in sp.segments(false).enumerate() {
133                let seg = Rect::new(
134                    a.x.min(b.x),
135                    a.y.min(b.y),
136                    (a.x - b.x).abs(),
137                    (a.y - b.y).abs(),
138                );
139                acc = Some(match acc {
140                    None => seg,
141                    Some(r) => union_two(r, seg),
142                });
143                if (i + 1) % SEGMENTS_PER_CHUNK == 0 {
144                    out.push(acc.take().expect("just set"));
145                }
146            }
147            if let Some(r) = acc {
148                out.push(r);
149            }
150            out
151        })
152        .collect()
153}
154
155fn union_two(a: Rect, b: Rect) -> Rect {
156    let x = a.x.min(b.x);
157    let y = a.y.min(b.y);
158    Rect::new(
159        x,
160        y,
161        a.right().max(b.right()) - x,
162        a.bottom().max(b.bottom()) - y,
163    )
164}
165
166/// A [`Path`] plus its flattening, computed once and shared by handle.
167///
168/// Build one in your item's **constructor** and hold it as
169/// `Rc<ShapeGeometry>`; [`SceneItem::shape`](crate::SceneItem::shape) then
170/// hands out clones of the handle, so the per-layout-pass cost of publishing a
171/// shape is a refcount bump rather than a flatten (or, as it used to be, a
172/// full `Vec<PathCommand>` copy).
173///
174/// Neither `Send` nor `Sync` — the memo is a [`OnceCell`] behind an [`Rc`],
175/// like the rest of the scene.
176#[derive(Debug)]
177pub struct ShapeGeometry {
178    path: Path,
179    flat: OnceCell<Flattened>,
180}
181
182impl ShapeGeometry {
183    /// Wrap a path. Nothing is flattened until something asks.
184    pub fn new(path: Path) -> Self {
185        Self {
186            path,
187            flat: OnceCell::new(),
188        }
189    }
190
191    /// Wrap a path in a fresh shared handle — the form
192    /// [`ItemShape::path`] takes.
193    pub fn shared(path: Path) -> Rc<Self> {
194        Rc::new(Self::new(path))
195    }
196
197    /// The path this geometry was built from.
198    pub fn path(&self) -> &Path {
199        &self.path
200    }
201
202    /// The flattened outline, one [`Subpath`] per `MoveTo` run. Memoised.
203    pub fn outline(&self) -> &[Subpath] {
204        &self.flattened().subpaths
205    }
206
207    /// Curve-accurate AABB of the outline. Memoised. [`Rect::ZERO`] for an
208    /// empty path.
209    pub fn bounds(&self) -> Rect {
210        self.flattened().bounds
211    }
212
213    fn flattened(&self) -> &Flattened {
214        self.flat.get_or_init(|| {
215            let subpaths = self.path.flatten(SHAPE_FLATTEN_TOLERANCE);
216            let boxes: Vec<Rect> = subpaths.iter().map(|sp| sp.bounds()).collect();
217            let bounds = union_all(&boxes);
218            let chunks = chunk_boxes(&subpaths);
219            Flattened {
220                subpaths,
221                boxes,
222                chunks,
223                bounds,
224            }
225        })
226    }
227}
228
229// ---------------------------------------------------------------------------
230// ItemShape
231// ---------------------------------------------------------------------------
232
233#[derive(Debug, Clone)]
234enum ShapeKind {
235    /// Never hit, never selected, never collides.
236    None,
237    /// The plain local AABB — the trait default, and the one kind that costs
238    /// nothing to build or test.
239    Bounds(Rect),
240    RoundedRect {
241        rect: Rect,
242        radius: f32,
243    },
244    Ellipse(Rect),
245    Path(Rc<ShapeGeometry>),
246}
247
248#[derive(Debug, Clone, Copy)]
249struct HitBand {
250    width: f32,
251    space: StrokeSpace,
252}
253
254/// What an item's geometry **is**, in local item coordinates — the single
255/// source of truth for hit-test, marquee, collision and path queries.
256///
257/// The interior-plus-band model, the cost story and the local-vs-scene
258/// split are spelled out on the module this type lives in.
259#[derive(Debug, Clone)]
260pub struct ItemShape {
261    kind: ShapeKind,
262    /// Present when the shape has an interior *and* that interior needs a
263    /// rule — i.e. for the path kind. The closed forms are always filled.
264    fill: Option<FillRule>,
265    band: Option<HitBand>,
266}
267
268impl ItemShape {
269    // -- construction -------------------------------------------------
270
271    /// The item's local AABB. This is the trait default and it allocates
272    /// nothing.
273    pub fn bounds(rect: Rect) -> Self {
274        Self {
275            kind: ShapeKind::Bounds(rect),
276            fill: None,
277            band: None,
278        }
279    }
280
281    /// A rectangle with rounded corners — the corners are **outside** the
282    /// shape, so a click in the transparent corner of a rounded card misses
283    /// it, exactly as the pixels suggest. Closed form; never flattened for a
284    /// point test.
285    pub fn rounded_rect(rect: Rect, radius: f32) -> Self {
286        if radius <= 0.0 {
287            return Self::bounds(rect);
288        }
289        Self {
290            kind: ShapeKind::RoundedRect {
291                rect,
292                radius: radius.min(rect.width * 0.5).min(rect.height * 0.5),
293            },
294            fill: None,
295            band: None,
296        }
297    }
298
299    /// The ellipse inscribed in `rect`. Closed form; never flattened for a
300    /// point test.
301    pub fn ellipse(rect: Rect) -> Self {
302        Self {
303            kind: ShapeKind::Ellipse(rect),
304            fill: None,
305            band: None,
306        }
307    }
308
309    /// An arbitrary local path, filled under [`FillRule::Winding`].
310    ///
311    /// Takes the **shared** handle so the flattening is paid once per item
312    /// lifetime; build the [`ShapeGeometry`] in your constructor.
313    pub fn path(geometry: Rc<ShapeGeometry>) -> Self {
314        Self {
315            kind: ShapeKind::Path(geometry),
316            fill: Some(FillRule::Winding),
317            band: None,
318        }
319    }
320
321    /// [`ItemShape::path`] for a one-off path, allocating a fresh
322    /// [`ShapeGeometry`] on the spot.
323    ///
324    /// Convenient for a test or a shape built once and thrown away. An item
325    /// that publishes its shape every layout pass should own an
326    /// `Rc<ShapeGeometry>` instead — this one re-flattens every time it is
327    /// asked.
328    pub fn from_path(path: Path) -> Self {
329        Self::path(ShapeGeometry::shared(path))
330    }
331
332    /// Never hit, never selected, never collides.
333    ///
334    /// The logical-only [`GroupItem`](crate::GroupItem) case, and any pure
335    /// accessibility container: clicks fall straight through to whatever is
336    /// beneath.
337    pub fn none() -> Self {
338        Self {
339            kind: ShapeKind::None,
340            fill: None,
341            band: None,
342        }
343    }
344
345    // -- how the outline is interpreted -------------------------------
346
347    /// Fill the interior under `rule`.
348    ///
349    /// Pass [`FillRule::EvenOdd`] so a ring authored as two subpaths has a
350    /// real hole. A [`PathItem`](crate::PathItem) takes its rule from its own
351    /// `fill_rule`, which is the same rule it **paints** with — a hit-only
352    /// fill rule would be a second source of truth, which is precisely what
353    /// this type exists to abolish.
354    pub fn filled(mut self, rule: FillRule) -> Self {
355        self.fill = Some(rule);
356        self
357    }
358
359    /// Drop the interior, leaving only the stroke band (if any).
360    ///
361    /// A stroke-only connector: clicking the empty middle of its bounding box
362    /// must miss, and clicking anywhere along the drawn line must hit. Has no
363    /// effect on the closed forms, which are regions by construction.
364    pub fn unfilled(mut self) -> Self {
365        if matches!(self.kind, ShapeKind::Path(_)) {
366            self.fill = None;
367        }
368        self
369    }
370
371    /// Add a stroke band `width` wide, centred on the outline.
372    ///
373    /// [`StrokeSpace::Device`] is a **cosmetic** stroke: its width is in
374    /// device pixels, so the local-coordinate band is divided by the live view
375    /// scale at test time and tracks the rendered line at any zoom.
376    /// [`StrokeSpace::Logical`] is already in local units and is unaffected by
377    /// zoom.
378    pub fn stroked(mut self, width: f32, space: StrokeSpace) -> Self {
379        self.band = Some(HitBand {
380            width: width.max(0.0),
381            space,
382        });
383        self
384    }
385
386    /// Replace the band's width for **hit** purposes only, independent of what
387    /// is painted (Konva's `hitStrokeWidth`): a 1 dp connector wire made 12 dp
388    /// grabbable. Always in local units — a hit band is a target size, not a
389    /// rendered thickness, so it does not shrink as you zoom in.
390    pub fn hit_stroke_width(mut self, width: f32) -> Self {
391        self.band = Some(HitBand {
392            width: width.max(0.0),
393            space: StrokeSpace::Logical,
394        });
395        self
396    }
397
398    // -- queries ------------------------------------------------------
399
400    /// Whether `local_pt` is inside the shape.
401    ///
402    /// `view_scale` is the live view zoom — the uniform scale of the view
403    /// transform's linear part. It is consulted only by a
404    /// [`StrokeSpace::Device`] band; pass `1.0` for an item pinned in screen
405    /// space (see [`ItemFlags::IGNORES_TRANSFORMATIONS`](crate::ItemFlags)),
406    /// whose local coordinates *are* screen coordinates and which therefore
407    /// has no zoom to convert.
408    pub fn contains(&self, local_pt: Point, view_scale: f32) -> bool {
409        if matches!(self.kind, ShapeKind::None) {
410            return false;
411        }
412        let half = self.band_half_width(view_scale);
413        // One rect compare stands between a miss and any segment walk. This is
414        // what keeps a long ink stroke's per-pointer-sample cost O(1) for the
415        // overwhelmingly common answer.
416        if !self
417            .geometry_bounds()
418            .expand(half.unwrap_or(0.0))
419            .contains(local_pt)
420        {
421            return false;
422        }
423        if self.interior_contains(local_pt) {
424            return true;
425        }
426        match half {
427            Some(h) => self.outline_within(local_pt, h),
428            None => false,
429        }
430    }
431
432    /// The shape's own local AABB at **unit** view scale, inflated by any
433    /// stroke band at its logical width.
434    ///
435    /// This is the shape's extent, *not* the rectangle the spatial index
436    /// buckets on — that stays the entry's `local_bounds`, composed into a
437    /// scene AABB. Every geometry-bearing built-in derives its `local_bounds`
438    /// from exactly this value, and [`SceneItem::shape`](crate::SceneItem::shape)
439    /// asks an app item for the same, so at unit scale the box encloses the
440    /// shape and a broad phase on the box can never reject a point the narrow
441    /// phase would have accepted.
442    ///
443    /// **At other zooms that holds only for a [`StrokeSpace::Logical`] band
444    /// and the closed forms.** A [`StrokeSpace::Device`] (cosmetic) band is
445    /// specified in device pixels, so its width in local coordinates is
446    /// `w / view_scale` and it is *wider* than this rectangle at any zoom
447    /// below 1×. The narrow phase is right there — the band tracks what is
448    /// painted — but the broad phase bucketed on the unit-scale box, so a
449    /// point on a cosmetic stroke zoomed far out can be rejected before the
450    /// narrow phase sees it. Use
451    /// [`bounding_rect_at`](ItemShape::bounding_rect_at) to measure the real
452    /// extent at a zoom. This is not closed by clamping: the divergence grows
453    /// without bound as the zoom approaches zero, so the only rectangle that
454    /// would always cover it is an infinite one, and paying for the *largest*
455    /// band any item could ever need would bloat every cosmetic-stroke item's
456    /// index bucket and its advertised accessibility box at every zoom. The
457    /// mitigation is that zooming out that far turns a hairline into a hair:
458    /// at 0.25× a 40 px cosmetic stroke covers 160 local units, which is a
459    /// blot on screen rather than a line anyone is aiming at.
460    pub fn bounding_rect(&self) -> Rect {
461        self.bounding_rect_at(1.0)
462    }
463
464    /// [`ItemShape::bounding_rect`] at an explicit view zoom — the rectangle
465    /// that really does enclose everything [`ItemShape::contains`] accepts at
466    /// that zoom.
467    ///
468    /// Differs from `bounding_rect()` only for a [`StrokeSpace::Device`] band,
469    /// and only away from 1×.
470    pub fn bounding_rect_at(&self, view_scale: f32) -> Rect {
471        self.geometry_bounds()
472            .expand(self.band_half_width(view_scale).unwrap_or(0.0))
473    }
474
475    /// Whether this shape can never be hit ([`ItemShape::none`]).
476    pub fn is_none(&self) -> bool {
477        matches!(self.kind, ShapeKind::None)
478    }
479
480    /// Whether the shape and `region` share **area** — the `Intersects…`
481    /// half of [`ItemSelectionMode`].
482    ///
483    /// `region` must already be in **this shape's** coordinate space — call
484    /// [`SceneRegion::to_local`] first. `view_scale` is as for
485    /// [`ItemShape::contains`].
486    ///
487    /// # Edges belong to no one
488    ///
489    /// Sharing *area* is the rule, so two sets that meet only along their
490    /// boundaries do **not** intersect: a marquee laid exactly on an item's
491    /// edge picks it up under no mode. That is what `Scene::items_in_rect`
492    /// has always answered (its rectangle compare is strict on all four
493    /// sides), and both paths below answer it — the one-compare fast path for
494    /// a plain box against an axis-aligned band, and the general outline
495    /// algebra for everything else. The two disagreeing is what let a rounded
496    /// tile be picked by a band its square twin was not, and made
497    /// `IntersectsItemShape` report a hit where `IntersectsItemBoundingRect`
498    /// reported none — impossible for a shape that lies inside its own box.
499    ///
500    /// [`ItemShape::contains`], by contrast, is **closed**: a point on the
501    /// edge of a rectangle is on the rectangle. A point has no area to share,
502    /// so the two rules are about different questions and neither follows
503    /// from the other.
504    ///
505    /// The one exception is a region — or a shape — that *is* all boundary: a
506    /// zero-width path, which is exactly what
507    /// [`Scene::items_along_path`](crate::Scene::items_along_path) builds, or
508    /// a degenerate box. An area rule would answer "no" to every such query,
509    /// so when either side has no area of its own the question becomes "does
510    /// this line touch the item?" and contact is the answer — the behaviour
511    /// that query has always had.
512    ///
513    /// # How the general path witnesses an overlap
514    ///
515    /// Four witnesses, any one of which **proves** shared area, checked in
516    /// increasing cost order: a probe point of the shape's outline strictly
517    /// inside the region; a probe point of the region strictly inside the
518    /// shape; a pair of boundary segments that properly cross (or, with a
519    /// band, come closer than the two half-widths together); and — last —
520    /// every vertex of the shape inside the *closed* region, which is the only
521    /// witness that survives two outlines lying exactly on top of each other.
522    /// That last case is not exotic: it is a duplicate of a rotated item laid
523    /// over the original, which `colliding_items` really does get asked about,
524    /// and without it two identical rotated cards would report no collision.
525    ///
526    /// A **probe point** is a vertex *or a segment midpoint*. The midpoints
527    /// are load-bearing rather than belt-and-braces: two rectangles with the
528    /// same top and bottom edges, overlapping in x — a marquee dragged to
529    /// exactly an item's height, which is a gesture, not a curiosity — put
530    /// every vertex of each on the other's boundary and cross nothing
531    /// transversally, so the vertices alone witness nothing.
532    ///
533    /// Every witness is **sound** (each really does imply shared area), and
534    /// together they are not quite **complete**: a sliver of overlap that no
535    /// probe point lands in and no segment pair crosses transversally reports
536    /// no intersection. Erring that way is deliberate — a false positive here
537    /// would let a `…ItemShape` query pick an item its own bounding rect does
538    /// not, which is impossible for a shape that lies inside its box, whereas
539    /// a false negative only ever agrees with the cheaper mode.
540    ///
541    /// Where a probe lands exactly on a freeform outline, "strictly inside"
542    /// is decided within [`SHAPE_FLATTEN_TOLERANCE`]: a ray cast against a
543    /// polyline has no meaningful answer on the polyline itself. Against a
544    /// rectangle, the closed form every default shape and every marquee band
545    /// uses, it is exact.
546    pub fn intersects_region(&self, region: &SceneRegion, view_scale: f32) -> bool {
547        if self.is_none() || region.is_empty() {
548            return false;
549        }
550        // Area on both sides, or the all-boundary exception above.
551        let strict = self.has_area(view_scale) && region.has_area();
552        // The common case: an untransformed default-shape item against an
553        // axis-aligned band. One compare, and the same answer the general
554        // path below would have reached.
555        if let (Some(r), Some(reg)) = (self.plain_rect(), region.as_rect()) {
556            return if strict {
557                rects_intersect_exclusive(r, reg)
558            } else {
559                rects_overlap(r, reg)
560            };
561        }
562        let item_half = self.band_half_width(view_scale).unwrap_or(0.0);
563        // Inclusive, because this only ever rejects: a conservative reject
564        // cannot drop a pair the exact test would have kept.
565        if !rects_overlap(
566            self.geometry_bounds().expand(item_half),
567            region.bounding_rect(),
568        ) {
569            return false;
570        }
571
572        let item = self.outline_ref();
573        let item_out = item.as_slice();
574        for p in probe_points(item_out) {
575            if region.contains_point_at(p, strict) {
576                return true;
577            }
578        }
579        let reg = region.outline_ref();
580        let reg_out = reg.as_slice();
581        for p in probe_points(reg_out) {
582            if self.contains_at(p, view_scale, strict) {
583                return true;
584            }
585        }
586        let slack = item_half + region.band_half_width();
587        if outlines_meet(item_out, reg_out, slack, strict) {
588            return true;
589        }
590        // Coincident boundaries: no vertex is strictly inside anything and
591        // nothing crosses, yet the two cover the same ground. (Without
592        // `strict` the inclusive tests above have already caught this.)
593        strict
594            && item_out.iter().any(|sp| !sp.points.is_empty())
595            && item_out
596                .iter()
597                .all(|sp| sp.points.iter().all(|p| region.clearance(*p) >= 0.0))
598    }
599
600    /// Whether the shape lies entirely inside `region` — the `Contains…` half
601    /// of [`ItemSelectionMode`].
602    ///
603    /// `region` must already be in this shape's coordinate space. A shape with
604    /// a stroke band is contained only when the **band** is — the whole
605    /// clickable ribbon, not merely the centreline.
606    ///
607    /// # Edges are inside
608    ///
609    /// Containment is of **closed** sets, so a shape resting exactly on the
610    /// region's boundary is contained: an item and a band drawn at precisely
611    /// the same rectangle select under `Contains…`. That is what the
612    /// rectangle fast path has always answered, and the general path answers
613    /// it too — an outline lying *along* the region's edge is not a crossing,
614    /// only an outline passing *through* it is. (Before, a collinear edge
615    /// counted as a crossing, so a rounded tile an exact-fit band contained
616    /// was rejected while its square twin was accepted.)
617    ///
618    /// Note the asymmetry with [`ItemShape::intersects_region`], and that it
619    /// is the only consistent pair: an exact-fit band both contains the item
620    /// and shares its whole area, while a band merely *abutting* the item
621    /// does neither.
622    ///
623    /// # Two kinds of boundary
624    ///
625    /// For a region with an **interior**, "every vertex is inside and no edge
626    /// crosses the outline" settles it: an outline that never crosses the
627    /// region's boundary cannot have its inside on the other side of it.
628    ///
629    /// A **stroke band** has no such outline. Its boundary is an offset curve
630    /// the region never stores, and the outline it does store is the band's
631    /// *centreline* — which runs down the middle of the region rather than
632    /// around it. Testing crossings against that gets both answers wrong: an
633    /// item sitting squarely on a wire crosses the centreline and is reported
634    /// as escaping, while a box whose four corners are all within a bent
635    /// wire's band bulges out across the inside of the bend, crosses nothing,
636    /// and is reported as contained. Each item edge is therefore checked
637    /// against the band itself, by an exact segment-in-band test.
638    ///
639    /// A region with both is inside-the-fill **or** inside-the-band; an item
640    /// weaving between the two, inside their union but inside neither part, is
641    /// reported as not contained. That is the one deliberately conservative
642    /// answer here, and it is one-sided: never a false containment.
643    pub fn contained_by_region(&self, region: &SceneRegion, view_scale: f32) -> bool {
644        if self.is_none() || region.is_empty() {
645            return false;
646        }
647        if let (Some(r), Some(reg)) = (self.plain_rect(), region.as_rect()) {
648            return rect_contains_rect(reg, r);
649        }
650        let item_half = self.band_half_width(view_scale).unwrap_or(0.0);
651        // Containment in the region implies containment in its AABB, so this
652        // only ever rejects.
653        if !rect_contains_rect(
654            region.bounding_rect(),
655            self.geometry_bounds().expand(item_half),
656        ) {
657            return false;
658        }
659        let item = self.outline_ref();
660        let item_out = item.as_slice();
661        if item_out.iter().all(|sp| sp.points.is_empty()) {
662            return false;
663        }
664        for sp in item_out {
665            for p in &sp.points {
666                if region.clearance(*p) < item_half {
667                    return false;
668                }
669            }
670        }
671        let reg = region.outline_ref();
672        // Inside the ribbon: every edge, whole, within what the band has left
673        // over after the item's own band is taken out of it.
674        let band_half = region.band_half_width();
675        if band_half > 0.0 {
676            let allowance = band_half - item_half;
677            if allowance > 0.0
678                && item_out.iter().all(|sp| {
679                    sp.segments(true)
680                        .all(|(a, b)| segment_within_band(a, b, reg.as_slice(), allowance))
681                })
682            {
683                return true;
684            }
685        }
686        // Inside the interior: a region with a hole can contain every vertex
687        // and still be crossed, and a band on the item can spill out between
688        // two vertices that are both clear.
689        region.has_fill() && !outlines_meet(item_out, reg.as_slice(), item_half, true)
690    }
691
692    /// Whether this shape, placed by `local_to_scene`, shares area with a
693    /// **scene**-space `region` — the `Intersects…` half of
694    /// [`ItemSelectionMode`], with the frame chosen rather than assumed.
695    ///
696    /// This is the door a region query comes in by, and the reason it exists
697    /// is spelled out on [`ItemShape::contained_by_scene_region`].
698    pub fn intersects_scene_region(
699        &self,
700        region: &SceneRegion,
701        local_to_scene: &Transform2D,
702        view_scale: f32,
703    ) -> bool {
704        if self.is_none() || region.is_empty() {
705            return false;
706        }
707        if !needs_scene_frame(region, local_to_scene) {
708            let Some(inv) = local_to_scene.inverse() else {
709                return false;
710            };
711            return self.intersects_region(&region.to_local(&inv), view_scale);
712        }
713        // Union of parts: any one of them overlapping is the shape overlapping.
714        self.in_scene_frame(local_to_scene, view_scale)
715            .iter()
716            .any(|part| part.intersects_region(region, 1.0))
717    }
718
719    /// Whether this shape, placed by `local_to_scene`, lies entirely inside a
720    /// **scene**-space `region` — the `Contains…` half of
721    /// [`ItemSelectionMode`], with the frame chosen rather than assumed.
722    ///
723    /// # Why the frame is a decision
724    ///
725    /// A query has two frames to choose from and they are not
726    /// interchangeable, because a **stroke band is a distance** and only a
727    /// similarity — a rotation, a uniform scale, a translation, a reflection —
728    /// carries one. Under any other affine the exact image of a round band is
729    /// an *elliptical* one, which neither [`ItemShape`] nor [`SceneRegion`]
730    /// can hold: both store a single width.
731    ///
732    /// Each side's band is round in its own frame. The region's is round in
733    /// **scene** space. The item's is round in **local** space — that is not a
734    /// convention but what the renderer does, since
735    /// [`PathItem`](crate::PathItem) strokes its path in local coordinates and
736    /// the item's transform is pushed on the canvas around it, so an
737    /// anisotropically scaled wire really is painted with an elliptical pen.
738    ///
739    /// So: when the region carries no band, or the transform is a similarity,
740    /// the local frame is exact and the query takes it — one map, the same
741    /// cost it has always had. Otherwise the region cannot come down, so the
742    /// **item goes up**: its outline is a polyline and
743    /// [`Path::transformed`] is exact for every affine, so lifting it costs
744    /// nothing in accuracy.
745    ///
746    /// A *mapped* band is then the only thing left, and it is dealt with by
747    /// not mapping one: `in_scene_frame` writes the item's band
748    /// out as an explicit outline **in local space, where it is round**, and
749    /// lifts that. What comes back is a union of band-free parts, and a union
750    /// is contained iff every part is.
751    ///
752    /// Approximating instead — scaling the band by one of the two stretches —
753    /// cannot work here, and the reason is worth stating because it looks like
754    /// it should. The two selection-mode families owe each other implications
755    /// in *opposite* directions: `IntersectsItemShape` must never exceed
756    /// `IntersectsItemBoundingRect`, which wants a band that under-reaches,
757    /// while `ContainsItemBoundingRect` must never exceed
758    /// `ContainsItemShape`, which wants one that over-reaches. No single
759    /// scalar satisfies both, so the only answer that keeps both is the exact
760    /// one.
761    pub fn contained_by_scene_region(
762        &self,
763        region: &SceneRegion,
764        local_to_scene: &Transform2D,
765        view_scale: f32,
766    ) -> bool {
767        if self.is_none() || region.is_empty() {
768            return false;
769        }
770        if !needs_scene_frame(region, local_to_scene) {
771            let Some(inv) = local_to_scene.inverse() else {
772                return false;
773            };
774            return self.contained_by_region(&region.to_local(&inv), view_scale);
775        }
776        // Union of parts: contained iff every part is. An empty union is not
777        // "contained" — it is a shape that covers nothing, like `none()`.
778        let parts = self.in_scene_frame(local_to_scene, view_scale);
779        !parts.is_empty()
780            && parts
781                .iter()
782                .all(|part| part.contained_by_region(region, 1.0))
783    }
784
785    /// This shape in another frame, as a **union of band-free parts**.
786    ///
787    /// For a transform that is not a similarity — the only case the callers
788    /// above use this for. The interior maps exactly; the band is written out
789    /// as a filled outline first, in this shape's own frame where it is still
790    /// round, and that outline maps exactly too.
791    ///
792    /// Up to two parts, and they are a union rather than one shape because the
793    /// band's outline is a union of overlapping capsules and can only be read
794    /// under [`FillRule::Winding`], while the interior keeps whatever rule its
795    /// author chose. An [`FillRule::EvenOdd`] ring merged into the same path
796    /// would read the capsules' overlaps as holes and its own hole as filled;
797    /// keeping the two apart costs one extra query and owes the fill rules
798    /// nothing.
799    ///
800    /// # Cost
801    ///
802    /// The band costs one convex polygon of about `2 * cap_steps` points per
803    /// outline segment — paid only by an item that is both stroked and
804    /// anisotropically transformed, and only against a banded region. Nothing
805    /// on the pointer-sample path reaches it: a point hit-test never leaves
806    /// the item's own frame.
807    fn in_scene_frame(&self, local_to_scene: &Transform2D, view_scale: f32) -> Vec<ItemShape> {
808        if self.is_none() {
809            return Vec::new();
810        }
811        let mut parts = Vec::with_capacity(2);
812        if let Some(interior) = self.mapped_interior(local_to_scene) {
813            parts.push(interior);
814        }
815        if let Some(half) = self.band_half_width(view_scale) {
816            let band =
817                band_as_path(self.outline_ref().as_slice(), half).transformed(local_to_scene);
818            if !band.is_empty() {
819                parts.push(ItemShape::path(ShapeGeometry::shared(band)));
820            }
821        }
822        parts
823    }
824
825    /// The filled part of this shape, mapped exactly into another frame.
826    /// `None` for a shape with no interior — an unfilled path, or
827    /// [`ItemShape::none`].
828    fn mapped_interior(&self, t: &Transform2D) -> Option<ItemShape> {
829        match &self.kind {
830            ShapeKind::None => None,
831            // A plain box that survives the map as a box stays the one kind
832            // with a one-compare fast path on the far side.
833            ShapeKind::Bounds(r) if axis_preserving(t) => Some(ItemShape::bounds(t.apply_rect(*r))),
834            ShapeKind::Bounds(r) => Some(ItemShape::path(ShapeGeometry::shared(
835                transformed_rect_path(*r, t),
836            ))),
837            ShapeKind::RoundedRect { rect, radius } => {
838                Some(ItemShape::path(ShapeGeometry::shared(
839                    Path::rounded_rect(*rect, teksilo_tokens::CornerRadius::uniform(*radius))
840                        .transformed(t),
841                )))
842            }
843            ShapeKind::Ellipse(r) => Some(ItemShape::path(ShapeGeometry::shared(
844                Path::ellipse(*r).transformed(t),
845            ))),
846            ShapeKind::Path(g) => {
847                let rule = self.fill?;
848                Some(ItemShape::path(ShapeGeometry::shared(g.path().transformed(t))).filled(rule))
849            }
850        }
851    }
852
853    /// Re-publish this shape as a [`SceneRegion`] in another frame — its own
854    /// scene transform, so a collision query can ask the rest of the scene
855    /// about it.
856    ///
857    /// The outline goes through [`Path::transformed`], which is exact for
858    /// every affine (an arc a transform cannot carry as an arc is expanded
859    /// into the cubics the renderer paints it as), so the republished region
860    /// keeps its curves and is re-flattened at
861    /// [`SHAPE_FLATTEN_TOLERANCE`] in the *target* frame rather than
862    /// inheriting a polyline sampled in this one.
863    ///
864    /// # A band's width is still scaled here
865    ///
866    /// A region query picks the frame that keeps each band round
867    /// ([`ItemShape::contained_by_scene_region`]); this cannot, because it
868    /// returns one [`SceneRegion`] and one region holds one band width. So
869    /// under a transform that is not a similarity — where the exact image of a
870    /// round band is elliptical — the width is scaled by the transform's
871    /// **smallest** stretch: the widest uniform band that fits inside the true
872    /// image, under-reaching along the stretched axis and never claiming
873    /// ground the true band does not cover.
874    ///
875    /// Writing the band out as an outline, which is what the query path does
876    /// and which *is* exact, does not work here: a band is a union of
877    /// overlapping capsules and reads only under [`FillRule::Winding`], while
878    /// the interior it would have to be unioned with keeps its author's rule
879    /// and its author's subpath orientations. The query path escapes that by
880    /// keeping the two apart as separate parts, and a single region has
881    /// nowhere to put a second part. The consequence is confined to
882    /// [`Scene::item_region`](crate::Scene::item_region) and the collision
883    /// query built on it: an anisotropically scaled item carrying a stroke
884    /// collides as though its band were the narrowest the map allows.
885    pub fn to_scene_region(&self, local_to_scene: &Transform2D) -> SceneRegion {
886        if self.is_none() {
887            return SceneRegion::empty();
888        }
889        if let Some(r) = self.plain_rect() {
890            return if axis_preserving(local_to_scene) {
891                SceneRegion::rect(local_to_scene.apply_rect(r))
892            } else {
893                SceneRegion::lasso(transformed_rect_path(r, local_to_scene))
894            };
895        }
896        let path = match &self.kind {
897            // Handled above.
898            ShapeKind::None => Path::new(),
899            ShapeKind::Bounds(r) => Path::rect(*r).transformed(local_to_scene),
900            ShapeKind::RoundedRect { rect, radius } => {
901                Path::rounded_rect(*rect, teksilo_tokens::CornerRadius::uniform(*radius))
902                    .transformed(local_to_scene)
903            }
904            ShapeKind::Ellipse(r) => Path::ellipse(*r).transformed(local_to_scene),
905            ShapeKind::Path(g) => g.path().transformed(local_to_scene),
906        };
907        let (stretch, _) = linear_scale_bounds(local_to_scene);
908        let band = self.band_half_width(1.0).map(|h| h * 2.0 * stretch);
909        // The closed forms are regions by construction; only the path kind
910        // carries an optional fill.
911        let fill = match &self.kind {
912            ShapeKind::Path(_) => self.fill,
913            _ => Some(FillRule::Winding),
914        };
915        SceneRegion::from_geometry(ShapeGeometry::shared(path), fill, band)
916    }
917
918    // -- internals ----------------------------------------------------
919
920    /// `Some(rect)` iff this is the plain-AABB default with no band — the one
921    /// case where a shape query and a bounding-rect query are the same test.
922    fn plain_rect(&self) -> Option<Rect> {
923        match (&self.kind, self.band) {
924            (ShapeKind::Bounds(r), None) => Some(*r),
925            _ => None,
926        }
927    }
928
929    /// The shape's extent *before* any band inflation.
930    fn geometry_bounds(&self) -> Rect {
931        match &self.kind {
932            ShapeKind::None => Rect::ZERO,
933            ShapeKind::Bounds(r) => *r,
934            ShapeKind::RoundedRect { rect, .. } => *rect,
935            ShapeKind::Ellipse(r) => *r,
936            ShapeKind::Path(g) => g.bounds(),
937        }
938    }
939
940    /// Half the band's width in **local** coordinates at `view_scale`, grab
941    /// slack included. `None` when the shape has no band.
942    fn band_half_width(&self, view_scale: f32) -> Option<f32> {
943        let band = self.band?;
944        let local = if band.space == StrokeSpace::Device && view_scale > 1e-3 {
945            band.width / view_scale
946        } else {
947            band.width
948        };
949        Some(local * 0.5 + HIT_BAND_SLACK)
950    }
951
952    /// [`ItemShape::contains`] with the boundary excluded — inside with room
953    /// to spare.
954    ///
955    /// The `Intersects…` modes ask this rather than `contains`, because they
956    /// are about shared *area*: a point resting on the outline witnesses no
957    /// overlap. For a freeform outline "on the outline" is resolved within
958    /// the flattening tolerance (a ray cast has no meaningful answer on the
959    /// polyline it is cast against); for the closed forms it is exact.
960    fn contains_at(&self, p: Point, view_scale: f32, strict: bool) -> bool {
961        if strict {
962            self.contains_strictly(p, view_scale)
963        } else {
964            self.contains(p, view_scale)
965        }
966    }
967
968    fn contains_strictly(&self, p: Point, view_scale: f32) -> bool {
969        if matches!(self.kind, ShapeKind::None) {
970            return false;
971        }
972        let half = self.band_half_width(view_scale);
973        if !self
974            .geometry_bounds()
975            .expand(half.unwrap_or(0.0))
976            .contains(p)
977        {
978            return false;
979        }
980        let interior = match &self.kind {
981            ShapeKind::None => false,
982            ShapeKind::Bounds(r) => rect_contains_strictly(*r, p),
983            ShapeKind::RoundedRect { rect, radius } => {
984                rounded_rect_contains_strictly(*rect, *radius, p)
985            }
986            ShapeKind::Ellipse(r) => ellipse_contains_strictly(*r, p),
987            ShapeKind::Path(g) => match self.fill {
988                Some(rule) => {
989                    subpaths_contain_point(g.outline(), p, rule)
990                        && !outline_within(&self.outline_ref(), p, 0.0)
991                }
992                None => false,
993            },
994        };
995        if interior {
996            return true;
997        }
998        match half {
999            Some(h) => h > 0.0 && outline_within_strict(&self.outline_ref(), p, h),
1000            None => false,
1001        }
1002    }
1003
1004    /// Whether the shape covers any area at all — the guard on the
1005    /// coincident-boundary witness in [`ItemShape::intersects_region`]. A
1006    /// zero-extent box, or a path with neither a fill nor a band, covers
1007    /// nothing and cannot overlap anything.
1008    fn has_area(&self, view_scale: f32) -> bool {
1009        if self.band_half_width(view_scale).is_some_and(|h| h > 0.0) {
1010            return true;
1011        }
1012        match &self.kind {
1013            ShapeKind::None => false,
1014            ShapeKind::Bounds(r) | ShapeKind::Ellipse(r) => r.width > 0.0 && r.height > 0.0,
1015            ShapeKind::RoundedRect { rect, .. } => rect.width > 0.0 && rect.height > 0.0,
1016            ShapeKind::Path(_) => self.fill.is_some(),
1017        }
1018    }
1019
1020    fn interior_contains(&self, p: Point) -> bool {
1021        match &self.kind {
1022            ShapeKind::None => false,
1023            ShapeKind::Bounds(r) => r.contains(p),
1024            ShapeKind::RoundedRect { rect, radius } => rounded_rect_contains(*rect, *radius, p),
1025            ShapeKind::Ellipse(r) => ellipse_contains(*r, p),
1026            ShapeKind::Path(g) => match self.fill {
1027                Some(rule) => subpaths_contain_point(g.outline(), p, rule),
1028                None => false,
1029            },
1030        }
1031    }
1032
1033    fn outline_within(&self, p: Point, half: f32) -> bool {
1034        outline_within(&self.outline_ref(), p, half)
1035    }
1036
1037    fn outline_ref(&self) -> Outline<'_> {
1038        match &self.kind {
1039            ShapeKind::None => Outline::Empty,
1040            ShapeKind::Bounds(r) => Outline::owned(Path::rect(*r)),
1041            ShapeKind::RoundedRect { rect, radius } => Outline::owned(Path::rounded_rect(
1042                *rect,
1043                teksilo_tokens::CornerRadius::uniform(*radius),
1044            )),
1045            ShapeKind::Ellipse(r) => Outline::owned(Path::ellipse(*r)),
1046            ShapeKind::Path(g) => Outline::Borrowed {
1047                subpaths: g.outline(),
1048                boxes: &g.flattened().boxes,
1049                chunks: &g.flattened().chunks,
1050            },
1051        }
1052    }
1053}
1054
1055impl Default for ItemShape {
1056    /// [`ItemShape::none`] — a shape that has not been declared cannot be
1057    /// claimed to cover anything.
1058    fn default() -> Self {
1059        Self::none()
1060    }
1061}
1062
1063/// A flattened outline plus its per-subpath boxes, borrowed from a memo when
1064/// there is one and built on the spot for the closed forms (which only need it
1065/// for a region query — never for a point test).
1066enum Outline<'a> {
1067    Empty,
1068    Borrowed {
1069        subpaths: &'a [Subpath],
1070        boxes: &'a [Rect],
1071        chunks: &'a [Vec<Rect>],
1072    },
1073    Owned {
1074        subpaths: Vec<Subpath>,
1075        boxes: Vec<Rect>,
1076        chunks: Vec<Vec<Rect>>,
1077    },
1078}
1079
1080impl Outline<'_> {
1081    fn owned(path: Path) -> Self {
1082        let subpaths = path.flatten(SHAPE_FLATTEN_TOLERANCE);
1083        let boxes = subpaths.iter().map(|sp| sp.bounds()).collect();
1084        let chunks = chunk_boxes(&subpaths);
1085        Outline::Owned {
1086            subpaths,
1087            boxes,
1088            chunks,
1089        }
1090    }
1091
1092    fn as_slice(&self) -> &[Subpath] {
1093        match self {
1094            Outline::Empty => &[],
1095            Outline::Borrowed { subpaths, .. } => subpaths,
1096            Outline::Owned { subpaths, .. } => subpaths,
1097        }
1098    }
1099
1100    fn boxes(&self) -> &[Rect] {
1101        match self {
1102            Outline::Empty => &[],
1103            Outline::Borrowed { boxes, .. } => boxes,
1104            Outline::Owned { boxes, .. } => boxes,
1105        }
1106    }
1107
1108    fn chunks(&self, subpath: usize) -> &[Rect] {
1109        match self {
1110            Outline::Empty => &[],
1111            Outline::Borrowed { chunks, .. } => chunks.get(subpath).map_or(&[], |c| c.as_slice()),
1112            Outline::Owned { chunks, .. } => chunks.get(subpath).map_or(&[], |c| c.as_slice()),
1113        }
1114    }
1115}
1116
1117// ---------------------------------------------------------------------------
1118// ItemSelectionMode
1119// ---------------------------------------------------------------------------
1120
1121/// How a region query decides whether an item is picked — Qt's
1122/// `Qt::ItemSelectionMode`, one variant for one.
1123///
1124/// The `…ItemShape` modes consult [`SceneItem::shape`](crate::SceneItem::shape),
1125/// in whichever frame keeps both sides' bands round — usually the item's
1126/// **local** one, with the region mapped down into it, and scene space when
1127/// the region's band cannot make that trip
1128/// ([`ItemShape::contained_by_scene_region`]). The `…ItemBoundingRect` modes
1129/// compare the item's **scene** AABB, in scene space.
1130///
1131/// For an item whose transform is identity and whose shape is the default AABB
1132/// the two families coincide exactly. For an item carrying a rotation or a
1133/// scale they do **not**: the scene AABB of a rotated box is the enlarged
1134/// axis-aligned hull of it, so a bounding-rect query is strictly the looser
1135/// test. That is a real behaviour difference, not a rounding one — and it is
1136/// an *implication*, in both directions. A shape lies inside its own box, so
1137/// a shape the region meets its box meets too, and a box the region contains
1138/// drags its shape in with it. Both are pinned by
1139/// `tests/prop_selection_modes.rs`, and holding them is what decides how a
1140/// band may be mapped.
1141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1142pub enum ItemSelectionMode {
1143    /// Picked when its **shape** intersects the region. Qt's default and
1144    /// Teksilo's: it is the rule that makes a rubber band agree with a click
1145    /// about what an item is.
1146    #[default]
1147    IntersectsItemShape,
1148    /// Picked when its shape lies entirely inside the region — "select what
1149    /// the lasso fully encircles".
1150    ContainsItemShape,
1151    /// Picked when its scene AABB intersects the region. The cheap mode, and
1152    /// the one that reproduces the pre-`ItemShape` marquee.
1153    IntersectsItemBoundingRect,
1154    /// Picked when its scene AABB lies entirely inside the region.
1155    ContainsItemBoundingRect,
1156}
1157
1158impl ItemSelectionMode {
1159    /// Whether this mode consults the item's shape (rather than its AABB).
1160    pub fn uses_shape(self) -> bool {
1161        matches!(self, Self::IntersectsItemShape | Self::ContainsItemShape)
1162    }
1163
1164    /// Whether this mode requires full containment (rather than any overlap).
1165    pub fn requires_containment(self) -> bool {
1166        matches!(
1167            self,
1168            Self::ContainsItemShape | Self::ContainsItemBoundingRect
1169        )
1170    }
1171}
1172
1173// ---------------------------------------------------------------------------
1174// SceneRegion
1175// ---------------------------------------------------------------------------
1176
1177#[derive(Debug, Clone)]
1178enum RegionKind {
1179    Rect(Rect),
1180    Path {
1181        geometry: Rc<ShapeGeometry>,
1182        fill: Option<FillRule>,
1183        band: Option<f32>,
1184    },
1185}
1186
1187/// The region a geometry query asks about.
1188///
1189/// Two forms, and the difference matters:
1190///
1191/// * a **rectangle** — the rubber band under an unrotated view, and the one
1192///   form that keeps a query on the same one-rect-compare fast path the
1193///   pre-`ItemShape` marquee took;
1194/// * a **path** — the exact quadrilateral of a rubber band under a *rotated*
1195///   view (rather than its enlarged axis-aligned hull, which over-selects), a
1196///   freehand lasso, or a stroked path for "what lies along this connector?".
1197///
1198/// A region is in **scene** coordinates until [`SceneRegion::to_local`] maps
1199/// it into an item's frame; a rectangle that survives that map as a rectangle
1200/// stays one, and one that does not becomes the exact transformed
1201/// quadrilateral rather than being rounded up to an AABB.
1202#[derive(Debug, Clone)]
1203pub struct SceneRegion {
1204    kind: RegionKind,
1205}
1206
1207impl SceneRegion {
1208    /// An axis-aligned box.
1209    pub fn rect(rect: Rect) -> Self {
1210        Self {
1211            kind: RegionKind::Rect(rect),
1212        }
1213    }
1214
1215    /// A region that covers nothing. Every query against it is `false`.
1216    pub fn empty() -> Self {
1217        Self {
1218            kind: RegionKind::Path {
1219                geometry: ShapeGeometry::shared(Path::new()),
1220                fill: None,
1221                band: None,
1222            },
1223        }
1224    }
1225
1226    /// A closed freehand region, filled under [`FillRule::Winding`].
1227    pub fn lasso(path: Path) -> Self {
1228        Self::lasso_with_rule(path, FillRule::Winding)
1229    }
1230
1231    /// A closed freehand region under an explicit fill rule.
1232    pub fn lasso_with_rule(path: Path, rule: FillRule) -> Self {
1233        Self {
1234            kind: RegionKind::Path {
1235                geometry: ShapeGeometry::shared(path),
1236                fill: Some(rule),
1237                band: None,
1238            },
1239        }
1240    }
1241
1242    /// The **stroke band** of a path, `width` wide — "what lies along this
1243    /// line?" rather than "what lies inside this loop?".
1244    pub fn stroke(path: Path, width: f32) -> Self {
1245        Self {
1246            kind: RegionKind::Path {
1247                geometry: ShapeGeometry::shared(path),
1248                fill: None,
1249                band: Some(width.max(0.0)),
1250            },
1251        }
1252    }
1253
1254    /// A region from a shared geometry handle, with an explicit interior
1255    /// and/or band. The general constructor the others are sugar for; used to
1256    /// re-publish an item's own shape as the region a collision query asks
1257    /// about.
1258    pub fn from_geometry(
1259        geometry: Rc<ShapeGeometry>,
1260        fill: Option<FillRule>,
1261        band: Option<f32>,
1262    ) -> Self {
1263        Self {
1264            kind: RegionKind::Path {
1265                geometry,
1266                fill,
1267                band: band.map(|w| w.max(0.0)),
1268            },
1269        }
1270    }
1271
1272    /// A screen-space rectangle mapped through `screen_to_scene`.
1273    ///
1274    /// Stays a rectangle when the transform preserves axes, and becomes the
1275    /// exact transformed quadrilateral when it does not — which is what stops
1276    /// a rubber band under a rotated view from selecting everything in the
1277    /// enlarged hull of itself.
1278    pub fn from_screen_rect(screen_rect: Rect, screen_to_scene: &Transform2D) -> Self {
1279        if axis_preserving(screen_to_scene) {
1280            Self::rect(screen_to_scene.apply_rect(screen_rect))
1281        } else {
1282            Self::lasso(transformed_rect_path(screen_rect, screen_to_scene))
1283        }
1284    }
1285
1286    /// Broad-phase AABB — what goes to the spatial index. Band-inflated.
1287    pub fn bounding_rect(&self) -> Rect {
1288        match &self.kind {
1289            RegionKind::Rect(r) => *r,
1290            RegionKind::Path { geometry, .. } => geometry.bounds().expand(self.band_half_width()),
1291        }
1292    }
1293
1294    /// `Some(rect)` iff this region is an axis-aligned box with no band.
1295    pub fn as_rect(&self) -> Option<Rect> {
1296        match &self.kind {
1297            RegionKind::Rect(r) => Some(*r),
1298            _ => None,
1299        }
1300    }
1301
1302    /// Whether the region covers nothing at all.
1303    pub fn is_empty(&self) -> bool {
1304        match &self.kind {
1305            RegionKind::Rect(_) => false,
1306            RegionKind::Path {
1307                geometry,
1308                fill,
1309                band,
1310            } => geometry.outline().is_empty() || (fill.is_none() && band.is_none()),
1311        }
1312    }
1313
1314    /// Map into another coordinate frame — an item's local space, via the
1315    /// inverse of its scene transform.
1316    ///
1317    /// A rectangle survives as a rectangle only when the transform preserves
1318    /// axes; otherwise it becomes the exact transformed quadrilateral. A path
1319    /// goes through [`Path::transformed`], which is exact for every affine —
1320    /// including an arc, which it expands into cubics rather than moving the
1321    /// rectangle an arc is stored as and keeping its angles.
1322    ///
1323    /// # A band under an anisotropic map
1324    ///
1325    /// A band is a *distance*, and an affine that stretches one axis more than
1326    /// another does not carry a distance: the exact image of a round band is
1327    /// an elliptical one, which no single width can describe. A rotation, a
1328    /// uniform scale and a translation are all isotropic, so for every one of
1329    /// them the mapped width is exact and none of this applies.
1330    ///
1331    /// Under a genuinely anisotropic map the width is scaled by the
1332    /// transform's **smallest** stretch — its least singular value — which is
1333    /// the widest uniform band that fits *inside* the true image. Under
1334    /// `scale(1, 10)` the true band is between one and ten times as wide
1335    /// depending on the direction, and the mapped one claims one: it can
1336    /// under-reach along the stretched axis, and it never claims ground the
1337    /// true band does not cover. `Transform2D::geometric_scale` — the
1338    /// geometric *mean* of the two stretches — would err in both directions at
1339    /// once, over-reaching along one axis while under-reaching along the
1340    /// other, which is why it is not used here.
1341    ///
1342    /// **This is a one-sided approximation, and the framework's own region
1343    /// queries do not rely on it.** Under-reaching keeps
1344    /// `IntersectsItemShape` inside `IntersectsItemBoundingRect`, but it puts
1345    /// `ContainsItemShape` *outside* `ContainsItemBoundingRect` — the two
1346    /// families want opposite errors, so no scalar serves both. A query
1347    /// therefore never maps a banded region down into an anisotropic item's
1348    /// frame: it lifts the item into scene space instead, where the band is
1349    /// still round. See [`ItemShape::contained_by_scene_region`], which is the
1350    /// door `Scene::items_in_region` comes in by. What is left here is the
1351    /// honest answer for a caller who has asked for a mapped region and will
1352    /// measure distances in the target frame.
1353    pub fn to_local(&self, scene_to_local: &Transform2D) -> SceneRegion {
1354        match &self.kind {
1355            RegionKind::Rect(r) => {
1356                if axis_preserving(scene_to_local) {
1357                    SceneRegion::rect(scene_to_local.apply_rect(*r))
1358                } else {
1359                    SceneRegion::lasso(transformed_rect_path(*r, scene_to_local))
1360                }
1361            }
1362            RegionKind::Path {
1363                geometry,
1364                fill,
1365                band,
1366            } => {
1367                let (stretch, _) = linear_scale_bounds(scene_to_local);
1368                SceneRegion {
1369                    kind: RegionKind::Path {
1370                        geometry: ShapeGeometry::shared(
1371                            geometry.path().transformed(scene_to_local),
1372                        ),
1373                        fill: *fill,
1374                        band: band.map(|w| w * stretch),
1375                    },
1376                }
1377            }
1378        }
1379    }
1380
1381    /// Whether `p` — in the region's own coordinate space — is inside it.
1382    pub fn contains_point(&self, p: Point) -> bool {
1383        match &self.kind {
1384            RegionKind::Rect(r) => r.contains(p),
1385            RegionKind::Path {
1386                geometry,
1387                fill,
1388                band,
1389            } => {
1390                if let Some(rule) = fill
1391                    && subpaths_contain_point(geometry.outline(), p, *rule)
1392                {
1393                    return true;
1394                }
1395                match band {
1396                    Some(_) => {
1397                        let _ = geometry;
1398                        outline_within(&self.outline_ref(), p, self.band_half_width())
1399                    }
1400                    None => false,
1401                }
1402            }
1403        }
1404    }
1405
1406    /// Whether the region covers any **area** — `false` for a zero-width
1407    /// path (a bare line: the `items_along_path` query) and for a degenerate
1408    /// box. See [`ItemShape::intersects_region`], which relaxes its rule for
1409    /// exactly this case.
1410    ///
1411    /// A filled outline needs at least three points on some subpath to
1412    /// enclose anything; two points are a line, which
1413    /// [`subpaths_contain_point`] already declines to fill.
1414    pub fn has_area(&self) -> bool {
1415        match &self.kind {
1416            RegionKind::Rect(r) => r.width > 0.0 && r.height > 0.0,
1417            RegionKind::Path {
1418                geometry,
1419                fill,
1420                band,
1421            } => {
1422                if band.is_some_and(|w| w > 0.0) {
1423                    return true;
1424                }
1425                fill.is_some() && geometry.outline().iter().any(|sp| sp.points.len() >= 3)
1426            }
1427        }
1428    }
1429
1430    fn contains_point_at(&self, p: Point, strict: bool) -> bool {
1431        if strict {
1432            self.contains_point_strictly(p)
1433        } else {
1434            self.contains_point(p)
1435        }
1436    }
1437
1438    /// [`SceneRegion::contains_point`] with the boundary excluded — see
1439    /// [`ItemShape::intersects_region`] for why an `Intersects…` query asks
1440    /// this instead.
1441    fn contains_point_strictly(&self, p: Point) -> bool {
1442        match &self.kind {
1443            RegionKind::Rect(r) => rect_contains_strictly(*r, p),
1444            RegionKind::Path {
1445                geometry,
1446                fill,
1447                band,
1448            } => {
1449                if let Some(rule) = fill
1450                    && subpaths_contain_point(geometry.outline(), p, *rule)
1451                    && !outline_within(&self.outline_ref(), p, 0.0)
1452                {
1453                    return true;
1454                }
1455                match band {
1456                    Some(_) => {
1457                        let _ = geometry;
1458                        let half = self.band_half_width();
1459                        half > 0.0 && outline_within_strict(&self.outline_ref(), p, half)
1460                    }
1461                    None => false,
1462                }
1463            }
1464        }
1465    }
1466
1467    /// How far `p` is from leaving the region: positive inside, negative
1468    /// outside, in the region's own units. Used by the containment modes to
1469    /// ask whether a *band* — not merely a centreline — fits inside.
1470    pub fn clearance(&self, p: Point) -> f32 {
1471        match &self.kind {
1472            RegionKind::Rect(r) => (p.x - r.x)
1473                .min(r.right() - p.x)
1474                .min(p.y - r.y)
1475                .min(r.bottom() - p.y),
1476            RegionKind::Path {
1477                geometry,
1478                fill,
1479                band,
1480            } => {
1481                let mut best = f32::NEG_INFINITY;
1482                if let Some(rule) = fill {
1483                    // The fill's boundary — every subpath closed, SVG-style.
1484                    let dist = min_distance_to_outline(geometry.outline(), p);
1485                    let inside = subpaths_contain_point(geometry.outline(), p, *rule);
1486                    best = best.max(if inside { dist } else { -dist });
1487                }
1488                if band.is_some() {
1489                    // The band's centreline — only the edges a stroke walks.
1490                    let dist = min_distance_to_centreline(geometry.outline(), p);
1491                    best = best.max(self.band_half_width() - dist);
1492                }
1493                best
1494            }
1495        }
1496    }
1497
1498    fn band_half_width(&self) -> f32 {
1499        match &self.kind {
1500            RegionKind::Rect(_) => 0.0,
1501            RegionKind::Path { band, .. } => band.map(|w| w * 0.5).unwrap_or(0.0),
1502        }
1503    }
1504
1505    /// Whether the region has an interior at all — a rectangle always does,
1506    /// and a path does when it carries a [`FillRule`]. A region without one is
1507    /// a bare ribbon, whose outline is a centreline rather than a boundary.
1508    fn has_fill(&self) -> bool {
1509        match &self.kind {
1510            RegionKind::Rect(_) => true,
1511            RegionKind::Path { fill, .. } => fill.is_some(),
1512        }
1513    }
1514
1515    fn outline_ref(&self) -> Outline<'_> {
1516        match &self.kind {
1517            RegionKind::Rect(r) => Outline::owned(Path::rect(*r)),
1518            RegionKind::Path { geometry, .. } => Outline::Borrowed {
1519                subpaths: geometry.outline(),
1520                boxes: &geometry.flattened().boxes,
1521                chunks: &geometry.flattened().chunks,
1522            },
1523        }
1524    }
1525}
1526
1527// ---------------------------------------------------------------------------
1528// Geometry helpers
1529// ---------------------------------------------------------------------------
1530
1531/// Whether `p` is within `half` of any segment of `out`.
1532///
1533/// Two levels of rejection stand in front of the distance maths: the subpath's
1534/// own box, then a box per run of [`SEGMENTS_PER_CHUNK`] segments inside it.
1535/// The second is what matters for a single long stroke — one subpath, hundreds
1536/// of segments — which is the shape a freehand ink line takes and the one a
1537/// hover sample has to answer "no" for on every frame.
1538fn outline_within(out: &Outline<'_>, p: Point, half: f32) -> bool {
1539    outline_within_cmp(out, p, half, false)
1540}
1541
1542/// [`outline_within`] with the band's own edge excluded — `distance < half`
1543/// rather than `<=`. The strict twin the `Intersects…` rule needs: a point at
1544/// exactly the band's edge touches it and shares no area with it.
1545fn outline_within_strict(out: &Outline<'_>, p: Point, half: f32) -> bool {
1546    outline_within_cmp(out, p, half, true)
1547}
1548
1549fn outline_within_cmp(out: &Outline<'_>, p: Point, half: f32, strict: bool) -> bool {
1550    let boxes = out.boxes();
1551    let half_sq = half * half;
1552    for (i, sp) in out.as_slice().iter().enumerate() {
1553        if !boxes[i].expand(half).contains(p) {
1554            continue;
1555        }
1556        let chunks = out.chunks(i);
1557        let mut live = chunks
1558            .first()
1559            .map(|r| r.expand(half).contains(p))
1560            .unwrap_or(true);
1561        for (seg, (a, b)) in sp.segments(false).enumerate() {
1562            if seg > 0 && seg.is_multiple_of(SEGMENTS_PER_CHUNK) {
1563                live = chunks
1564                    .get(seg / SEGMENTS_PER_CHUNK)
1565                    .map(|r| r.expand(half).contains(p))
1566                    .unwrap_or(true);
1567            }
1568            if !live {
1569                continue;
1570            }
1571            let d_sq = point_to_segment_distance_sq(p, a, b);
1572            if if strict {
1573                d_sq < half_sq
1574            } else {
1575                d_sq <= half_sq
1576            } {
1577                return true;
1578            }
1579        }
1580    }
1581    false
1582}
1583
1584/// Every vertex of `out`, plus the midpoint of every segment.
1585///
1586/// Both are points of the outline, so a probe strictly inside the other set
1587/// proves the two share area. The midpoints are what catch an overlap whose
1588/// whole boundary contribution from one side lies *between* two vertices of
1589/// the other — see [`ItemShape::intersects_region`]. Only real segments are
1590/// walked (`segments(false)`), never a subpath's implied closing chord, which
1591/// is not part of a stroke band.
1592fn probe_points(out: &[Subpath]) -> impl Iterator<Item = Point> + '_ {
1593    out.iter().flat_map(|sp| {
1594        sp.points.iter().copied().chain(
1595            sp.segments(false)
1596                .map(|(a, b)| Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5)),
1597        )
1598    })
1599}
1600
1601/// Whether **every** point of the segment `a → b` lies within `half` of
1602/// `outline` — whether the segment stays inside the band, rather than merely
1603/// starting and ending inside it.
1604///
1605/// A band is a union of capsules, one per outline segment. A capsule is
1606/// **convex**, so it covers a single closed interval of the segment's
1607/// parameter; collect one interval per capsule and ask whether they cover
1608/// `[0, 1]`. Exact, and O(segments) — no sampling, so an edge that dips out of
1609/// the band between two points that are both inside it cannot slip through.
1610///
1611/// `false` for a non-positive `half`: a ribbon of no width contains nothing.
1612fn segment_within_band(a: Point, b: Point, outline: &[Subpath], half: f32) -> bool {
1613    if !half.is_finite() || half <= 0.0 {
1614        return false;
1615    }
1616    // A zero-length segment has no parameter to cover: every span below is
1617    // computed by projecting onto `b - a`, which is the zero vector here, so
1618    // the spans come back empty and the whole segment reads as outside however
1619    // deep in the band its one point sits. Degenerate segments are not exotic —
1620    // a zero-size item's outline is four coincident points, and every closed
1621    // outline produces one when its last point equals its first — so answer the
1622    // question that is actually being asked: is that point inside?
1623    if a == b {
1624        return min_distance_to_centreline(outline, a) <= half;
1625    }
1626    let mut spans: Vec<(f32, f32)> = Vec::new();
1627    for sp in outline {
1628        for (c, d) in sp.segments(false) {
1629            if let Some(span) = capsule_span(a, b, c, d, half) {
1630                spans.push(span);
1631            }
1632        }
1633    }
1634    covers_unit_interval(&mut spans)
1635}
1636
1637/// The interval of `a → b`'s parameter that lies inside the capsule of radius
1638/// `half` around `c → d`.
1639///
1640/// The capsule is a rectangle plus a disc at each end; each piece contributes
1641/// an interval, and because the capsule is convex their union is the single
1642/// interval from the earliest start to the latest end.
1643fn capsule_span(a: Point, b: Point, c: Point, d: Point, half: f32) -> Option<(f32, f32)> {
1644    let mut lo = f32::INFINITY;
1645    let mut hi = f32::NEG_INFINITY;
1646    for piece in [
1647        disc_span(a, b, c, half),
1648        disc_span(a, b, d, half),
1649        slab_span(a, b, c, d, half),
1650    ]
1651    .into_iter()
1652    .flatten()
1653    {
1654        lo = lo.min(piece.0);
1655        hi = hi.max(piece.1);
1656    }
1657    (lo <= hi).then_some((lo, hi))
1658}
1659
1660/// The part of `a → b` inside the disc of radius `half` about `centre`, as a
1661/// parameter interval clipped to `[0, 1]`.
1662fn disc_span(a: Point, b: Point, centre: Point, half: f32) -> Option<(f32, f32)> {
1663    let ex = b.x - a.x;
1664    let ey = b.y - a.y;
1665    let fx = a.x - centre.x;
1666    let fy = a.y - centre.y;
1667    let qa = ex * ex + ey * ey;
1668    let qc = fx * fx + fy * fy - half * half;
1669    if qa < 1e-12 {
1670        // A degenerate edge is one point: in or out, whole.
1671        return (qc <= 0.0).then_some((0.0, 1.0));
1672    }
1673    let qb = 2.0 * (fx * ex + fy * ey);
1674    let disc = qb * qb - 4.0 * qa * qc;
1675    if disc < 0.0 {
1676        return None;
1677    }
1678    let root = disc.sqrt();
1679    let t0 = ((-qb - root) / (2.0 * qa)).max(0.0);
1680    let t1 = ((-qb + root) / (2.0 * qa)).min(1.0);
1681    (t0 <= t1).then_some((t0, t1))
1682}
1683
1684/// The part of `a → b` inside the capsule's rectangular body — between the two
1685/// end caps and within `half` of the line through `c → d`.
1686fn slab_span(a: Point, b: Point, c: Point, d: Point, half: f32) -> Option<(f32, f32)> {
1687    let ux = d.x - c.x;
1688    let uy = d.y - c.y;
1689    let len_sq = ux * ux + uy * uy;
1690    if len_sq < 1e-12 {
1691        // No body: the end caps are the whole capsule.
1692        return None;
1693    }
1694    let ex = b.x - a.x;
1695    let ey = b.y - a.y;
1696    let wx = a.x - c.x;
1697    let wy = a.y - c.y;
1698    // Along the body: 0 <= (w + t e) . u <= |u|^2.
1699    let along = wx * ux + wy * uy;
1700    let along_rate = ex * ux + ey * uy;
1701    // Across it: |(w + t e) x u| <= half * |u|.
1702    let across = wx * uy - wy * ux;
1703    let across_rate = ex * uy - ey * ux;
1704    let reach = half * len_sq.sqrt();
1705
1706    let mut lo = 0.0_f32;
1707    let mut hi = 1.0_f32;
1708    for (rate, offset) in [
1709        (along_rate, along),
1710        (-along_rate, len_sq - along),
1711        (across_rate, across + reach),
1712        (-across_rate, reach - across),
1713    ] {
1714        if !clip_half_plane(&mut lo, &mut hi, rate, offset) {
1715            return None;
1716        }
1717    }
1718    (lo <= hi).then_some((lo, hi))
1719}
1720
1721/// Narrow `[lo, hi]` to where `rate * t + offset >= 0`. `false` when nothing
1722/// is left.
1723fn clip_half_plane(lo: &mut f32, hi: &mut f32, rate: f32, offset: f32) -> bool {
1724    if rate.abs() < 1e-12 {
1725        return offset >= 0.0;
1726    }
1727    let t = -offset / rate;
1728    if rate > 0.0 {
1729        *lo = lo.max(t);
1730    } else {
1731        *hi = hi.min(t);
1732    }
1733    *lo <= *hi
1734}
1735
1736/// Whether the spans, in any order, cover `[0, 1]` with no gap.
1737fn covers_unit_interval(spans: &mut [(f32, f32)]) -> bool {
1738    const EPS: f32 = 1e-5;
1739    spans.sort_by(|a, b| a.0.total_cmp(&b.0));
1740    let mut reach = 0.0_f32;
1741    for (start, end) in spans.iter() {
1742        if *start > reach + EPS {
1743            return false;
1744        }
1745        reach = reach.max(*end);
1746        if reach >= 1.0 - EPS {
1747            return true;
1748        }
1749    }
1750    reach >= 1.0 - EPS
1751}
1752
1753/// Shortest distance from a point to a line segment.
1754pub(crate) fn point_to_segment_distance(p: Point, a: Point, b: Point) -> f32 {
1755    point_to_segment_distance_sq(p, a, b).sqrt()
1756}
1757
1758/// [`point_to_segment_distance`] without the square root — compare against a
1759/// squared threshold. The hot path takes this one: a band test runs once per
1760/// surviving segment per pointer sample.
1761fn point_to_segment_distance_sq(p: Point, a: Point, b: Point) -> f32 {
1762    let abx = b.x - a.x;
1763    let aby = b.y - a.y;
1764    let len2 = abx * abx + aby * aby;
1765    if len2 < 1e-6 {
1766        let dx = p.x - a.x;
1767        let dy = p.y - a.y;
1768        return dx * dx + dy * dy;
1769    }
1770    let apx = p.x - a.x;
1771    let apy = p.y - a.y;
1772    let t = ((apx * abx + apy * aby) / len2).clamp(0.0, 1.0);
1773    let cx = a.x + t * abx;
1774    let cy = a.y + t * aby;
1775    let dx = p.x - cx;
1776    let dy = p.y - cy;
1777    dx * dx + dy * dy
1778}
1779
1780/// Shortest distance from `p` to the outline under **fill** semantics: every
1781/// subpath is closed, because that is the boundary a fill rule draws.
1782fn min_distance_to_outline(subpaths: &[Subpath], p: Point) -> f32 {
1783    min_distance_to_edges(subpaths, p, true)
1784}
1785
1786/// Shortest distance from `p` to the outline under **stroke** semantics: a
1787/// subpath's implied closing chord is walked only when its author closed it.
1788///
1789/// A band is the set of points within a half-width of *this* distance — the
1790/// same segments [`outline_within`] walks and [`segment_within_band`] builds
1791/// its capsules from. Measured against the fill form instead, a three-point
1792/// open polyline gains a fourth edge it is never drawn with, and its band
1793/// claims ground the point test says it does not cover.
1794fn min_distance_to_centreline(subpaths: &[Subpath], p: Point) -> f32 {
1795    min_distance_to_edges(subpaths, p, false)
1796}
1797
1798fn min_distance_to_edges(subpaths: &[Subpath], p: Point, close_implicitly: bool) -> f32 {
1799    let mut best = f32::INFINITY;
1800    for sp in subpaths {
1801        for (a, b) in sp.segments(close_implicitly) {
1802            best = best.min(point_to_segment_distance(p, a, b));
1803        }
1804    }
1805    if best.is_finite() { best } else { 0.0 }
1806}
1807
1808/// Whether the two outlines meet: a segment pair that crosses, or — when
1809/// `slack` is positive — a pair closer together than `slack`.
1810///
1811/// Under `strict` both tests exclude contact: only a *transversal* crossing
1812/// counts, and two bands whose edges exactly touch do not. Two boundaries
1813/// lying along each other share no area, and reading a collinear overlap as a
1814/// crossing is what made a band exactly fitting a rounded item report the item
1815/// as not contained while its square twin was. Without `strict` — the
1816/// all-boundary case, where there is no area to share — contact is the whole
1817/// question and a touch counts.
1818fn outlines_meet(a: &[Subpath], b: &[Subpath], slack: f32, strict: bool) -> bool {
1819    for sa in a {
1820        let abox = sa.bounds().expand(slack);
1821        for sb in b {
1822            if !rects_overlap(abox, sb.bounds()) {
1823                continue;
1824            }
1825            for (a1, a2) in sa.segments(true) {
1826                for (b1, b2) in sb.segments(true) {
1827                    if segments_cross_properly(a1, a2, b1, b2) {
1828                        return true;
1829                    }
1830                    if !strict && segments_touch_collinearly(a1, a2, b1, b2) {
1831                        return true;
1832                    }
1833                    if slack > 0.0 {
1834                        let d = segment_distance(a1, a2, b1, b2);
1835                        if if strict { d < slack } else { d <= slack } {
1836                            return true;
1837                        }
1838                    }
1839                }
1840            }
1841        }
1842    }
1843    false
1844}
1845
1846fn segment_distance(a1: Point, a2: Point, b1: Point, b2: Point) -> f32 {
1847    point_to_segment_distance(a1, b1, b2)
1848        .min(point_to_segment_distance(a2, b1, b2))
1849        .min(point_to_segment_distance(b1, a1, a2))
1850        .min(point_to_segment_distance(b2, a1, a2))
1851}
1852
1853fn cross(o: Point, a: Point, b: Point) -> f32 {
1854    (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x)
1855}
1856
1857/// Whether the two segments cross **transversally** — each strictly
1858/// separates the other's endpoints.
1859///
1860/// Touching at an endpoint, and lying collinearly along one another, both
1861/// return `false`: each is boundary contact, and boundary contact is not
1862/// shared area.
1863fn segments_cross_properly(p1: Point, p2: Point, p3: Point, p4: Point) -> bool {
1864    let d1 = cross(p3, p4, p1);
1865    let d2 = cross(p3, p4, p2);
1866    let d3 = cross(p1, p2, p3);
1867    let d4 = cross(p1, p2, p4);
1868    ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
1869        && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
1870}
1871
1872/// Whether the two segments touch without crossing — an endpoint on the other
1873/// segment, or a collinear overlap. Only the all-boundary case asks this: a
1874/// zero-width query path entering and leaving a rectangle exactly through two
1875/// of its corners crosses nothing transversally, and still goes straight
1876/// through the middle of it.
1877fn segments_touch_collinearly(p1: Point, p2: Point, p3: Point, p4: Point) -> bool {
1878    let d1 = cross(p3, p4, p1);
1879    let d2 = cross(p3, p4, p2);
1880    let d3 = cross(p1, p2, p3);
1881    let d4 = cross(p1, p2, p4);
1882    (d1 == 0.0 && on_segment(p3, p4, p1))
1883        || (d2 == 0.0 && on_segment(p3, p4, p2))
1884        || (d3 == 0.0 && on_segment(p1, p2, p3))
1885        || (d4 == 0.0 && on_segment(p1, p2, p4))
1886}
1887
1888fn on_segment(a: Point, b: Point, p: Point) -> bool {
1889    p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y)
1890}
1891
1892/// Inclusive rectangle overlap, used **only** to reject: a conservative
1893/// reject never drops a pair the exact test would have kept. Consistent with
1894/// [`Rect::contains`], which is what a point test uses.
1895fn rects_overlap(a: Rect, b: Rect) -> bool {
1896    a.x <= b.right() && b.x <= a.right() && a.y <= b.bottom() && b.y <= a.bottom()
1897}
1898
1899/// Exclusive rectangle overlap — the scene's own `rects_intersect`, repeated
1900/// here so the box-against-box fast path answers exactly what
1901/// [`Scene::items_in_rect`](crate::Scene::items_in_rect) has always answered.
1902fn rects_intersect_exclusive(a: Rect, b: Rect) -> bool {
1903    a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
1904}
1905
1906/// `Rect::contains` with the boundary excluded.
1907fn rect_contains_strictly(r: Rect, p: Point) -> bool {
1908    p.x > r.x && p.x < r.right() && p.y > r.y && p.y < r.bottom()
1909}
1910
1911fn rect_contains_rect(outer: Rect, inner: Rect) -> bool {
1912    inner.x >= outer.x
1913        && inner.y >= outer.y
1914        && inner.right() <= outer.right()
1915        && inner.bottom() <= outer.bottom()
1916}
1917
1918fn rounded_rect_contains(rect: Rect, radius: f32, p: Point) -> bool {
1919    rect.contains(p) && corner_distance_sq(rect, radius, p).is_none_or(|(d, r2)| d <= r2)
1920}
1921
1922fn rounded_rect_contains_strictly(rect: Rect, radius: f32, p: Point) -> bool {
1923    rect_contains_strictly(rect, p)
1924        && corner_distance_sq(rect, radius, p).is_none_or(|(d, r2)| d < r2)
1925}
1926
1927/// `(distance², radius²)` from `p` to the inner (corner-centre) rectangle — a
1928/// point further than the radius from it is in a transparent corner. `None`
1929/// when there is no rounding to test against.
1930fn corner_distance_sq(rect: Rect, radius: f32, p: Point) -> Option<(f32, f32)> {
1931    let r = radius.min(rect.width * 0.5).min(rect.height * 0.5);
1932    if r <= 0.0 {
1933        return None;
1934    }
1935    let cx = p.x.clamp(rect.x + r, rect.right() - r);
1936    let cy = p.y.clamp(rect.y + r, rect.bottom() - r);
1937    let dx = p.x - cx;
1938    let dy = p.y - cy;
1939    Some((dx * dx + dy * dy, r * r))
1940}
1941
1942fn ellipse_contains(rect: Rect, p: Point) -> bool {
1943    ellipse_norm(rect, p).is_some_and(|n| n <= 1.0)
1944}
1945
1946fn ellipse_contains_strictly(rect: Rect, p: Point) -> bool {
1947    ellipse_norm(rect, p).is_some_and(|n| n < 1.0)
1948}
1949
1950/// `(p - centre)` in units of the ellipse's own radii, squared: `<= 1` inside,
1951/// `== 1` on the outline. `None` for a degenerate ellipse, which contains
1952/// nothing.
1953fn ellipse_norm(rect: Rect, p: Point) -> Option<f32> {
1954    let rx = rect.width * 0.5;
1955    let ry = rect.height * 0.5;
1956    if rx <= 0.0 || ry <= 0.0 {
1957        return None;
1958    }
1959    let dx = (p.x - (rect.x + rx)) / rx;
1960    let dy = (p.y - (rect.y + ry)) / ry;
1961    Some(dx * dx + dy * dy)
1962}
1963
1964/// The largest and smallest factor by which `t`'s linear part stretches a
1965/// direction — the singular values of its 2x2 matrix, smallest first.
1966///
1967/// [`Transform2D::geometric_scale`] is their geometric mean (`sqrt |det|`),
1968/// which is the one right number only when the two agree. Under `scale(1, 10)`
1969/// they are 1 and 10 and the mean is 3.16 — too wide for the untouched axis
1970/// and far too narrow for the stretched one, so a band scaled by it is wrong
1971/// in both directions at once. Returns `(1.0, 1.0)` for a non-finite matrix.
1972fn linear_scale_bounds(t: &Transform2D) -> (f32, f32) {
1973    let [a, b, c, d, _, _] = t.m;
1974    let sum_sq = a * a + b * b + c * c + d * d;
1975    let det = (a * d - b * c).abs();
1976    let disc = (sum_sq * sum_sq - 4.0 * det * det).max(0.0).sqrt();
1977    let hi = ((sum_sq + disc) * 0.5).max(0.0).sqrt();
1978    let lo = ((sum_sq - disc) * 0.5).max(0.0).sqrt();
1979    if hi.is_finite() && lo.is_finite() {
1980        (lo, hi)
1981    } else {
1982        (1.0, 1.0)
1983    }
1984}
1985
1986/// Whether `t` maps a circle onto a circle — a rotation, a uniform scale, a
1987/// translation, a reflection, or any composition of them.
1988///
1989/// Exactly the transforms that carry a **distance**, which is what a stroke
1990/// band is: for any other affine the image of the band's disc is an ellipse,
1991/// and no single width describes it. Equivalently, the linear part's two
1992/// singular values agree.
1993///
1994/// A non-finite or singular matrix answers `false` — a collapsed axis is the
1995/// most anisotropic map there is, and [`linear_scale_bounds`]'s `(1.0, 1.0)`
1996/// fallback for a non-finite one keeps that honest for the finite case only.
1997fn is_similarity(t: &Transform2D) -> bool {
1998    let (lo, hi) = linear_scale_bounds(t);
1999    hi - lo <= 1e-4 * hi.max(1.0)
2000}
2001
2002/// Whether a query against `region` has to be answered in **scene** space
2003/// rather than in the item's own frame.
2004///
2005/// Only when the region carries a band that `local_to_scene`'s inverse cannot
2006/// carry. Everything else — a rectangle marquee, a filled lasso, any item
2007/// under a rotation or a uniform scale — maps exactly and stays on the
2008/// one-map path. See [`ItemShape::contained_by_scene_region`].
2009fn needs_scene_frame(region: &SceneRegion, local_to_scene: &Transform2D) -> bool {
2010    region.band_half_width() > 0.0 && !is_similarity(local_to_scene)
2011}
2012
2013/// Deviation allowed when a band's round cap or join is written out as a
2014/// polygon, in the frame the band is round in.
2015///
2016/// Much finer than [`SHAPE_FLATTEN_TOLERANCE`], and deliberately: that one
2017/// approximates a curve an item is *drawn* from, so the same polyline answers
2018/// the point query and the region query and they agree by construction. This
2019/// one approximates a band only for the duration of a region query, while the
2020/// point query keeps testing the true round band — so any slack here is a
2021/// disagreement between a marquee and a click, which is the incoherence this
2022/// module exists to remove.
2023const BAND_FLATTEN_TOLERANCE: f32 = 0.01;
2024
2025/// Cap on the points spent per semicircular cap, so a very wide band cannot
2026/// allocate without bound. Past it the deviation grows with the half-width,
2027/// at `half * (1 - cos(pi / (2 * MAX_CAP_STEPS)))` — under a hundredth of a
2028/// unit for any band narrower than 8 units, and a twentieth at 40.
2029const MAX_CAP_STEPS: usize = 32;
2030
2031/// The band of half-width `half` around `outline`, written out as a fillable
2032/// path: one convex capsule per segment, every one wound the same way.
2033///
2034/// Read under [`FillRule::Winding`] that is exactly their union — a point
2035/// inside `k` capsules has winding `±k`, a point inside none has winding `0` —
2036/// which is what lets a band be carried through an affine map that could not
2037/// carry its width. Even-odd would read the overlaps as holes, so the
2038/// republished part always declares `Winding` whatever rule the item's own
2039/// interior carries; the band is a union by construction and has no rule of
2040/// its own.
2041///
2042/// Walks `segments(false)`: a subpath's implied closing chord is not part of a
2043/// stroke, the same rule [`outline_within`] and [`segment_within_band`] keep.
2044fn band_as_path(outline: &[Subpath], half: f32) -> Path {
2045    let mut path = Path::new();
2046    if !half.is_finite() || half <= 0.0 {
2047        return path;
2048    }
2049    let steps = cap_steps(half);
2050    for sp in outline {
2051        for (a, b) in sp.segments(false) {
2052            append_capsule(&mut path, a, b, half, steps);
2053        }
2054    }
2055    path
2056}
2057
2058/// Segments per semicircular cap that keep the inscribed polygon within
2059/// [`BAND_FLATTEN_TOLERANCE`] of the true arc.
2060///
2061/// A chord subtending `alpha` on a circle of radius `r` deviates by
2062/// `r * (1 - cos(alpha / 2))`; solve for `alpha` and spend `ceil(pi / alpha)`
2063/// on the half turn.
2064fn cap_steps(half: f32) -> usize {
2065    let ratio = (1.0 - BAND_FLATTEN_TOLERANCE / half).clamp(-1.0, 1.0);
2066    let alpha = 2.0 * ratio.acos();
2067    if alpha <= f32::EPSILON {
2068        return MAX_CAP_STEPS;
2069    }
2070    ((std::f32::consts::PI / alpha).ceil() as usize).clamp(1, MAX_CAP_STEPS)
2071}
2072
2073/// Append the capsule of radius `half` around `a → b` as one closed convex
2074/// polygon.
2075///
2076/// The traversal is built from the segment's own direction `u` and its left
2077/// normal `n`, and reversing the segment reverses both — so every capsule
2078/// comes out with the same orientation whichever way its segment runs, which
2079/// is what the winding union in [`band_as_path`] rests on. The polygon is
2080/// **inscribed**, so the band it describes is never wider than the true one;
2081/// see [`BAND_FLATTEN_TOLERANCE`] for why that direction is the safe one.
2082fn append_capsule(path: &mut Path, a: Point, b: Point, half: f32, steps: usize) {
2083    let dx = b.x - a.x;
2084    let dy = b.y - a.y;
2085    let len = (dx * dx + dy * dy).sqrt();
2086    let straight = len > 1e-6;
2087    // A zero-length segment is a disc; any axis will do for it.
2088    let (ux, uy) = if straight {
2089        (dx / len, dy / len)
2090    } else {
2091        (1.0, 0.0)
2092    };
2093    let (nx, ny) = (-uy, ux);
2094    // `c` along the normal, `s` along the direction, both in units of `half`.
2095    let at = |p: Point, c: f32, s: f32| {
2096        Point::new(
2097            p.x + half * (c * nx + s * ux),
2098            p.y + half * (c * ny + s * uy),
2099        )
2100    };
2101    path.move_to(at(a, 1.0, 0.0));
2102    if straight {
2103        path.line_to(at(b, 1.0, 0.0));
2104    }
2105    // Cap at `b`: from `+n` round through `+u` to `-n`.
2106    for i in 1..=steps {
2107        let (s, c) = (std::f32::consts::PI * i as f32 / steps as f32).sin_cos();
2108        path.line_to(at(b, c, s));
2109    }
2110    if straight {
2111        path.line_to(at(a, -1.0, 0.0));
2112    }
2113    // Cap at `a`: from `-n` round through `-u` back to `+n`, which `close`
2114    // supplies rather than a duplicated vertex.
2115    for i in 1..steps {
2116        let (s, c) = (std::f32::consts::PI * i as f32 / steps as f32).sin_cos();
2117        path.line_to(at(a, -c, -s));
2118    }
2119    path.close();
2120}
2121
2122/// Whether the transform maps an axis-aligned rectangle onto another
2123/// axis-aligned rectangle — a pure translate/scale, or a quarter turn.
2124fn axis_preserving(t: &Transform2D) -> bool {
2125    const EPS: f32 = 1e-5;
2126    let [a, b, c, d, _, _] = t.m;
2127    (b.abs() < EPS && c.abs() < EPS) || (a.abs() < EPS && d.abs() < EPS)
2128}
2129
2130fn transformed_rect_path(r: Rect, t: &Transform2D) -> Path {
2131    Path::polygon(&[
2132        t.apply_point(Point::new(r.x, r.y)),
2133        t.apply_point(Point::new(r.right(), r.y)),
2134        t.apply_point(Point::new(r.right(), r.bottom())),
2135        t.apply_point(Point::new(r.x, r.bottom())),
2136    ])
2137}
2138
2139fn union_all(boxes: &[Rect]) -> Rect {
2140    let mut acc: Option<Rect> = None;
2141    for b in boxes {
2142        acc = Some(match acc {
2143            None => *b,
2144            Some(a) => {
2145                let x = a.x.min(b.x);
2146                let y = a.y.min(b.y);
2147                Rect::new(
2148                    x,
2149                    y,
2150                    a.right().max(b.right()) - x,
2151                    a.bottom().max(b.bottom()) - y,
2152                )
2153            }
2154        });
2155    }
2156    acc.unwrap_or(Rect::ZERO)
2157}
2158
2159#[cfg(test)]
2160mod tests;