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(®ion.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(®ion.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;