Skip to main content

teksilo_scene/items/
path.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`PathItem`] — vector path with optional fill and stroke.
5//!
6//! `PathItem` renders an arbitrary vector path in local item coordinates.
7//! The path can be filled, stroked, or both, and it hit-tests as exactly that
8//! union: a stroke-only path is clickable along its drawn line and nowhere
9//! else, so a connector whose bounding box is mostly empty does not swallow
10//! clicks aimed past it — making this the natural workhorse for connector
11//! lines between cards in a node graph or story corkboard.
12//!
13//! Strokes come in two flavours: a **logical** stroke (`.stroke`) scales
14//! with the view zoom, making thick scene-space edges; a **cosmetic** stroke
15//! (`.stroke_cosmetic`) holds a constant device-pixel width at any zoom,
16//! ideal for hairline connector wires that should stay crisp and thin. A
17//! cosmetic stroke's *clickable band* follows the rendered line at any zoom,
18//! because the width is converted from device pixels at test time.
19//!
20//! ## Bounds are derived, not declared
21//!
22//! A `PathItem` computes its own `local_bounds` from its geometry (plus the
23//! stroke's half-width and the grab slack). There is no caller-supplied AABB
24//! to get wrong, which is what makes the rectangle the spatial index buckets
25//! on and the shape the narrow phase tests provably the same geometry.
26//! [`Scene::set_local_bounds`](crate::Scene::set_local_bounds) therefore
27//! **moves and scales the path** to fit the rectangle you give it, and the
28//! item re-derives its bounds from the result — landing on the rectangle you
29//! asked for on every axis the path has extent on, and staying there if you
30//! ask again. On an axis it has none — a perfectly horizontal stroke has no
31//! height — the box is the band's own thickness and no request can widen it.
32//!
33//! ## When to use
34//!
35//! Use `PathItem` for connector lines, polygon overlays, freehand shapes,
36//! or any vector decoration that needs exact-shape click detection along its
37//! stroke. For solid rectangular regions, prefer the cheaper [`RectItem`](crate::RectItem).
38//!
39//! ## Example
40//!
41//! ```ignore
42//! use teksilo_scene::{SceneModel, PathItem};
43//! use teksilo_canvas::{Path, Point};
44//! use teksilo_tokens::Color;
45//!
46//! let model = SceneModel::new();
47//!
48//! let mut path = Path::new();
49//! path.move_to(Point::new(0.0, 0.0))
50//!     .line_to(Point::new(200.0, 0.0))
51//!     .line_to(Point::new(200.0, 100.0));
52//!
53//! let item = PathItem::new(path)
54//!     .stroke_cosmetic(Color::new(0.3, 0.3, 0.3, 1.0), 1.5)
55//!     .hit_stroke_width(12.0);
56//!
57//! model.add_item(item, Point::new(50.0, 50.0));
58//! ```
59
60use std::rc::Rc;
61
62use accesskit::Role;
63use teksilo_canvas::{Canvas, FillRule, Path, Rect, StrokeStyle, Transform2D};
64use teksilo_core::accessibility::AccessNodeBuilder;
65use teksilo_core::binding::BindingLevel;
66use teksilo_core::build_context::BuildContext;
67use teksilo_core::color_prop::ColorProp;
68use teksilo_core::widget_id::WidgetId;
69use teksilo_tokens::Color;
70
71use crate::flags::ItemFlags;
72use crate::item::{AppearanceWrite, SceneItem, SceneItemA11yContext, SceneItemPaintContext};
73use crate::items::{AccessSubtreeMode, ItemA11yOverrides};
74use crate::shape::{HIT_BAND_SLACK, ItemShape, ShapeGeometry};
75use teksilo_i18n::LocalizedString;
76
77/// An arbitrary vector path with optional fill and stroke, in local
78/// item coordinates.
79///
80/// The path's commands are evaluated in local space. A logical stroke scales
81/// with the view zoom; a [`stroke_cosmetic`](Self::stroke_cosmetic) stroke
82/// holds a constant device-pixel width at any zoom (crisp connectors). The
83/// item's `local_bounds` — the rectangle the spatial index buckets on — is
84/// **derived** from the geometry and the stroke, so it always encloses what
85/// the item can be clicked on.
86#[derive(Debug)]
87pub struct PathItem {
88    /// The path plus its memoised flattening. Shared by handle so publishing
89    /// the item's [`SceneItem::shape`] every layout pass costs a refcount
90    /// bump, not a copy of the command list.
91    geometry: Rc<ShapeGeometry>,
92    local_bounds: Rect,
93    fill: Option<ColorProp>,
94    /// The rule used to fill **and** to hit-test the interior. One field, so a
95    /// ring painted with a hole is a ring you can click through.
96    fill_rule: FillRule,
97    stroke: Option<(ColorProp, StrokeStyle)>,
98    /// Hit-only band width, independent of the painted stroke.
99    hit_stroke_width: Option<f32>,
100    label: Option<String>,
101    flags: ItemFlags,
102    a11y: ItemA11yOverrides,
103}
104
105impl PathItem {
106    /// A path in local coordinates — `(0, 0)` is the item's anchor.
107    ///
108    /// `local_bounds` is derived from the path (and re-derived whenever the
109    /// stroke or hit band changes), so it always encloses the clickable area.
110    pub fn new(path: Path) -> Self {
111        let mut item = Self {
112            geometry: ShapeGeometry::shared(path),
113            local_bounds: Rect::ZERO,
114            fill: None,
115            fill_rule: FillRule::Winding,
116            stroke: None,
117            hit_stroke_width: None,
118            label: None,
119            flags: ItemFlags::default(),
120            a11y: ItemA11yOverrides::default(),
121        };
122        item.recompute_bounds();
123        item
124    }
125
126    /// Fill colour. Accepts a plain [`Color`], a theme role, a
127    /// `Signal<Color>`, or a `Signal<Role>` — resolved against the active
128    /// theme at paint time.
129    pub fn fill(mut self, color: impl Into<ColorProp>) -> Self {
130        self.fill = Some(color.into());
131        self
132    }
133
134    /// The [`FillRule`] used for **both** painting the fill and hit-testing
135    /// the interior.
136    ///
137    /// [`FillRule::EvenOdd`] gives a ring authored as two subpaths a real
138    /// hole — one that is transparent *and* clicks through. There is
139    /// deliberately no hit-only fill rule: a shape that paints as a disc and
140    /// hit-tests as a ring is the two-sources-of-truth bug this whole type
141    /// exists to prevent.
142    pub fn fill_rule(mut self, rule: FillRule) -> Self {
143        self.fill_rule = rule;
144        self
145    }
146
147    /// Stroke colour and width in **scene-coordinate** pixels — the stroke
148    /// scales with the view zoom.
149    pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
150        self.stroke = Some((color.into(), StrokeStyle::solid(width.max(0.0))));
151        self.recompute_bounds();
152        self
153    }
154
155    /// Cosmetic stroke: the connector holds a constant **device-pixel** width
156    /// at any zoom (it never thins out or thickens). The renderer keeps the
157    /// path body sharp at the current zoom, so joins/caps stay correct.
158    pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self {
159        self.stroke = Some((color.into(), StrokeStyle::hairline(width.max(0.0))));
160        self.recompute_bounds();
161        self
162    }
163
164    /// Stroke with an explicit [`StrokeStyle`] — dashed, dotted, or custom caps
165    /// / joins. E.g. `.stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))`
166    /// distinguishes a pending connector from a solid confirmed one. The style
167    /// is stored verbatim (dash pattern/offset, `Logical` vs `Device` space).
168    pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self {
169        self.stroke = Some((color.into(), style));
170        self.recompute_bounds();
171        self
172    }
173
174    /// Widen the clickable band without widening the drawn line (Konva's
175    /// `hitStrokeWidth`): a 1 dp wire, 12 dp grabbable. In local units, so it
176    /// is a target size rather than a rendered thickness. Also widens the
177    /// derived `local_bounds`, so the broad phase keeps up with it.
178    pub fn hit_stroke_width(mut self, width: f32) -> Self {
179        self.hit_stroke_width = Some(width.max(0.0));
180        self.recompute_bounds();
181        self
182    }
183
184    /// Human-readable label.
185    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
186        let ls: LocalizedString = label.into();
187        self.label = Some(ls.resolve_now());
188        self
189    }
190
191    /// Opt the path into drag-to-move.
192    pub fn draggable(mut self, draggable: bool) -> Self {
193        self.flags.set(ItemFlags::IS_DRAGGABLE, draggable);
194        self
195    }
196
197    /// The path's commands, in local coordinates.
198    pub fn path(&self) -> &Path {
199        self.geometry.path()
200    }
201
202    crate::items::item_a11y_builders!();
203
204    /// The hit band's width, hit-only override first. `None` when the item
205    /// has no band at all.
206    fn band_width(&self) -> Option<f32> {
207        self.hit_stroke_width
208            .or(self.stroke.as_ref().map(|(_, s)| s.width))
209    }
210
211    /// How far the band inflates the geometry's own box on every side. The
212    /// one number `local_bounds` and `set_local_bounds` both go through, so
213    /// the box the setter lands on is the box the setter aimed at.
214    fn band_inflate(&self) -> f32 {
215        self.band_width()
216            .map(|w| w.max(0.0) * 0.5 + HIT_BAND_SLACK)
217            .unwrap_or(0.0)
218    }
219
220    /// Rebuild the derived AABB from the current geometry + band. Called by
221    /// every builder that can change either. Reads the geometry directly
222    /// rather than going through `shape()`, which would be circular: the
223    /// no-fill-no-band shape *is* `local_bounds`.
224    fn recompute_bounds(&mut self) {
225        self.local_bounds = self.geometry.bounds().expand(self.band_inflate());
226    }
227}
228
229impl SceneItem for PathItem {
230    fn local_bounds(&self) -> Rect {
231        self.local_bounds
232    }
233
234    /// Fit the path to `bounds` — a translate and a scale — and re-derive the
235    /// box from the moved geometry.
236    ///
237    /// # It aims at the geometry, not at the box
238    ///
239    /// The requested rectangle is the *whole* clickable box, band included, so
240    /// the fit targets `bounds` **deflated by the band's half-width** and the
241    /// re-derived box then lands back on `bounds` exactly. Mapping the old box
242    /// onto the new one instead would re-add the band to a rectangle that
243    /// already contained it, overshooting by the band on every call: asking
244    /// three times for the same rectangle used to give three different
245    /// answers, each closer than the last and none of them the one asked for,
246    /// and each one re-bucketed the spatial index and emitted an
247    /// `ItemChange::LocalBoundsChanged` with a new value. Idempotent now: the
248    /// second identical call finds the fit is the identity and returns without
249    /// touching the geometry — which also keeps the memoised flattening that
250    /// [`ShapeGeometry`] exists for, instead of throwing it away per call.
251    ///
252    /// # What it cannot honour
253    ///
254    /// An axis the path has no extent on — a perfectly horizontal stroke, a
255    /// single point — cannot be stretched to fill one, so the box stays the
256    /// band's own thickness there however tall a rectangle is asked for. That
257    /// is reported rather than hidden: [`Scene::set_local_bounds`](crate::Scene::set_local_bounds)
258    /// reads the item's box back and stores what the item settled on.
259    fn set_local_bounds(&mut self, bounds: Rect) {
260        let inflate = self.band_inflate();
261        // The geometry's share of the requested box. A request narrower than
262        // the band itself asks for a negative extent; clamp rather than
263        // mirror the path.
264        let target = Rect::new(
265            bounds.x + inflate,
266            bounds.y + inflate,
267            (bounds.width - 2.0 * inflate).max(0.0),
268            (bounds.height - 2.0 * inflate).max(0.0),
269        );
270        let src = self.geometry.bounds();
271        let sx = if src.width.abs() > 1e-6 {
272            target.width / src.width
273        } else {
274            1.0
275        };
276        let sy = if src.height.abs() > 1e-6 {
277            target.height / src.height
278        } else {
279            1.0
280        };
281        // Within a float epsilon of the identity: the box is already where it
282        // was asked to be, and rebuilding the geometry would only churn.
283        if (sx - 1.0).abs() < 1e-5
284            && (sy - 1.0).abs() < 1e-5
285            && (src.x - target.x).abs() < 1e-4
286            && (src.y - target.y).abs() < 1e-4
287        {
288            return;
289        }
290        let fit = Transform2D::translate(-src.x, -src.y)
291            .then(&Transform2D::scale(sx, sy))
292            .then(&Transform2D::translate(target.x, target.y));
293        self.geometry = ShapeGeometry::shared(self.geometry.path().transformed(&fit));
294        self.recompute_bounds();
295    }
296
297    fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>) {
298        if let Some(prop) = &self.fill {
299            canvas.fill_path_with_rule(
300                self.geometry.path(),
301                prop.resolve(ctx.theme, ctx.enabled),
302                self.fill_rule,
303            );
304        }
305        if let Some((prop, style)) = &self.stroke {
306            canvas.stroke_path(
307                self.geometry.path(),
308                prop.resolve(ctx.theme, ctx.enabled),
309                style.clone(),
310            );
311        }
312    }
313
314    fn set_fill(&mut self, fill: Option<ColorProp>) -> AppearanceWrite<ColorProp> {
315        AppearanceWrite::Accepted {
316            was: std::mem::replace(&mut self.fill, fill),
317        }
318    }
319
320    fn set_stroke(
321        &mut self,
322        stroke: Option<(ColorProp, StrokeStyle)>,
323    ) -> AppearanceWrite<(ColorProp, StrokeStyle)> {
324        let was = std::mem::replace(&mut self.stroke, stroke);
325        self.recompute_bounds();
326        AppearanceWrite::Accepted { was }
327    }
328
329    fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId) {
330        let registry = ctx.binding_registry();
331        if let Some(p) = &self.fill {
332            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
333        }
334        if let Some((p, _)) = &self.stroke {
335            p.register_if_bound(view_id, registry, BindingLevel::RepaintOnly);
336        }
337    }
338
339    /// The union of whatever the item actually draws: its filled interior
340    /// under [`PathItem::fill_rule`] when it has a fill, and a stroke band
341    /// when it has a stroke (or an explicit
342    /// [`hit_stroke_width`](PathItem::hit_stroke_width)).
343    ///
344    /// A path with **neither** falls back to its local AABB. It draws nothing,
345    /// so there is no silhouette to derive one from, and an invisible
346    /// rectangle that still catches clicks is a legitimate use — an enlarged
347    /// grab area parked behind a thin connector. An item that wants clicks to
348    /// fall *through* says so with [`ItemShape::none`], which is what a
349    /// logical-only [`GroupItem`](crate::GroupItem) returns.
350    fn shape(&self) -> ItemShape {
351        if self.fill.is_none() && self.band_width().is_none() {
352            return ItemShape::bounds(self.local_bounds);
353        }
354        let mut shape = ItemShape::path(self.geometry.clone());
355        shape = if self.fill.is_some() {
356            shape.filled(self.fill_rule)
357        } else {
358            shape.unfilled()
359        };
360        match self.hit_stroke_width {
361            Some(w) => shape.hit_stroke_width(w),
362            None => match self.stroke.as_ref() {
363                Some((_, s)) => shape.stroked(s.width, s.space),
364                None => shape,
365            },
366        }
367    }
368
369    fn thumbnail_color(&self) -> Color {
370        // Connector-line and outline use cases dominate stroke-only
371        // paths; fill takes precedence when present. Role-based colours
372        // have no theme here, so they fall through to the neutral grey.
373        crate::items::fill_or_stroke_hint(self.fill.as_ref(), self.stroke.as_ref())
374            .unwrap_or_else(|| Color::new(0.6, 0.6, 0.6, 1.0))
375    }
376
377    fn label(&self) -> Option<String> {
378        self.label.clone()
379    }
380
381    fn initial_flags(&self) -> ItemFlags {
382        self.flags
383    }
384
385    fn access_subtree_mode(&self) -> AccessSubtreeMode {
386        self.a11y.subtree_mode()
387    }
388
389    fn accessibility(&self, builder: &mut AccessNodeBuilder, _ctx: &SceneItemA11yContext) {
390        builder.set_role(Role::GraphicsObject);
391        if let Some(label) = self.label() {
392            builder.set_name(label);
393        }
394        self.a11y.apply(builder);
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use teksilo_canvas::Point;
402
403    #[test]
404    fn path_item_derives_local_bounds_from_geometry() {
405        let mut path = Path::new();
406        path.move_to(Point::new(0.0, 0.0))
407            .line_to(Point::new(100.0, 0.0))
408            .line_to(Point::new(100.0, 50.0));
409        let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
410        // Geometry box (0,0,100,50) inflated by half the stroke plus the grab
411        // slack: 1 + 2 = 3.
412        assert_eq!(item.local_bounds(), Rect::new(-3.0, -3.0, 106.0, 56.0));
413    }
414
415    #[test]
416    fn path_item_local_bounds_always_enclose_its_shape() {
417        // The one invariant that makes a broad phase on `local_bounds` and a
418        // narrow phase on `shape()` provably the same geometry. Delete
419        // `recompute_bounds` from `stroke()` and this reddens.
420        let mut path = Path::new();
421        path.move_to(Point::new(0.0, 0.0))
422            .quad_to(Point::new(50.0, 100.0), Point::new(100.0, 0.0));
423        for item in [
424            PathItem::new(path.clone()),
425            PathItem::new(path.clone()).stroke(Color::BLACK, 6.0),
426            PathItem::new(path.clone()).stroke_cosmetic(Color::BLACK, 1.0),
427            PathItem::new(path.clone()).fill(Color::RED),
428            PathItem::new(path.clone())
429                .stroke(Color::BLACK, 1.0)
430                .hit_stroke_width(20.0),
431        ] {
432            let lb = item.local_bounds();
433            let sb = item.shape().bounding_rect();
434            assert!(
435                lb.x <= sb.x + 1e-3
436                    && lb.y <= sb.y + 1e-3
437                    && lb.right() >= sb.right() - 1e-3
438                    && lb.bottom() >= sb.bottom() - 1e-3,
439                "local_bounds {lb:?} must enclose shape bounds {sb:?}"
440            );
441        }
442    }
443
444    #[test]
445    fn path_item_hit_tests_per_segment_when_stroke_only() {
446        let mut path = Path::new();
447        path.move_to(Point::new(0.0, 0.0))
448            .line_to(Point::new(100.0, 100.0));
449        let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
450        let shape = item.shape();
451
452        assert!(shape.contains(Point::new(50.0, 50.0), 1.0));
453        assert!(shape.contains(Point::new(52.0, 50.0), 1.0));
454        assert!(!shape.contains(Point::new(80.0, 20.0), 1.0));
455        assert!(!shape.contains(Point::new(200.0, 200.0), 1.0));
456    }
457
458    #[test]
459    fn path_item_filled_hit_tests_its_interior_not_its_box() {
460        let mut path = Path::new();
461        path.move_to(Point::new(0.0, 0.0))
462            .line_to(Point::new(100.0, 0.0))
463            .line_to(Point::new(0.0, 100.0))
464            .close();
465        let item = PathItem::new(path).fill(Color::RED);
466        let shape = item.shape();
467        assert!(
468            shape.contains(Point::new(20.0, 20.0), 1.0),
469            "inside triangle"
470        );
471        // Inside the AABB, outside the triangle — this used to hit.
472        assert!(
473            !shape.contains(Point::new(90.0, 90.0), 1.0),
474            "the empty corner of a triangle's box is not the triangle"
475        );
476        assert!(!shape.contains(Point::new(200.0, 50.0), 1.0));
477    }
478
479    #[test]
480    fn path_item_fill_rule_drives_paint_and_hit_together() {
481        // Two concentric squares. EvenOdd punches a hole; the hole must be
482        // both un-painted and un-clickable, from the same one field.
483        let mut path = Path::new();
484        path.move_to(Point::new(0.0, 0.0))
485            .line_to(Point::new(100.0, 0.0))
486            .line_to(Point::new(100.0, 100.0))
487            .line_to(Point::new(0.0, 100.0))
488            .close();
489        path.move_to(Point::new(25.0, 25.0))
490            .line_to(Point::new(75.0, 25.0))
491            .line_to(Point::new(75.0, 75.0))
492            .line_to(Point::new(25.0, 75.0))
493            .close();
494
495        let solid = PathItem::new(path.clone()).fill(Color::RED);
496        assert!(solid.shape().contains(Point::new(50.0, 50.0), 1.0));
497
498        let ring = PathItem::new(path)
499            .fill(Color::RED)
500            .fill_rule(FillRule::EvenOdd);
501        assert!(
502            !ring.shape().contains(Point::new(50.0, 50.0), 1.0),
503            "the hole must not be clickable"
504        );
505        assert!(
506            ring.shape().contains(Point::new(10.0, 50.0), 1.0),
507            "the band is"
508        );
509
510        // ... and the SAME rule reaches the painted fill.
511        let theme = teksilo_core::presets::intui::light();
512        let mut canvas = Canvas::new();
513        let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
514        ring.paint(&mut canvas, &ctx);
515        let frame = canvas.into_render_frame();
516        assert_eq!(frame.paths[0].fill_rule, FillRule::EvenOdd);
517    }
518
519    #[test]
520    fn path_item_close_segment_hit_tested() {
521        let mut path = Path::new();
522        path.move_to(Point::new(0.0, 0.0))
523            .line_to(Point::new(100.0, 0.0))
524            .line_to(Point::new(50.0, 100.0))
525            .close();
526        let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
527        assert!(item.shape().contains(Point::new(25.0, 50.0), 1.0));
528    }
529
530    #[test]
531    fn path_item_curve_hit_tests_exactly() {
532        // This is the inversion of the old `path_item_curve_falls_back_to_aabb`.
533        // A quadratic with control (50,100) reaches y = 50 at its apex, so a
534        // point at y = 99 is 49 units off the stroke and must MISS — the old
535        // per-segment walk bailed to the AABB on the first curve command and
536        // reported a hit there.
537        let mut path = Path::new();
538        path.move_to(Point::new(0.0, 0.0))
539            .quad_to(Point::new(50.0, 100.0), Point::new(100.0, 0.0));
540        let item = PathItem::new(path).stroke(Color::BLACK, 2.0);
541        let shape = item.shape();
542        assert!(!shape.contains(Point::new(50.0, 99.0), 1.0));
543        assert!(shape.contains(Point::new(50.0, 50.0), 1.0), "the apex hits");
544    }
545
546    #[test]
547    fn path_item_with_neither_fill_nor_stroke_keeps_its_box() {
548        let mut path = Path::new();
549        path.move_to(Point::new(0.0, 0.0))
550            .line_to(Point::new(100.0, 100.0));
551        let item = PathItem::new(path);
552        assert!(item.shape().contains(Point::new(90.0, 10.0), 1.0));
553    }
554
555    #[test]
556    fn path_item_hit_stroke_width_widens_the_grab_band() {
557        let mut path = Path::new();
558        path.move_to(Point::new(0.0, 0.0))
559            .line_to(Point::new(100.0, 0.0));
560        let thin = PathItem::new(path.clone()).stroke(Color::BLACK, 1.0);
561        assert!(!thin.shape().contains(Point::new(50.0, 5.0), 1.0));
562        let fat = PathItem::new(path)
563            .stroke(Color::BLACK, 1.0)
564            .hit_stroke_width(12.0);
565        assert!(fat.shape().contains(Point::new(50.0, 5.0), 1.0));
566    }
567
568    #[test]
569    fn path_item_stroke_styled_stores_dash_pattern() {
570        // #5: a dashed connector keeps its dash pattern verbatim.
571        let mut path = Path::new();
572        path.move_to(Point::new(0.0, 0.0))
573            .line_to(Point::new(100.0, 0.0));
574        let item =
575            PathItem::new(path).stroke_styled(Color::BLACK, StrokeStyle::dashed(2.0, 6.0, 4.0));
576        let (_, style) = item.stroke.as_ref().expect("stroke set");
577        assert!(style.dash_pattern.is_some(), "dashed stroke keeps pattern");
578    }
579
580    #[test]
581    fn path_item_paint_resolves_colours() {
582        // #1/#2: fill + stroke resolve against the ctx theme and emit.
583        let theme = teksilo_core::presets::intui::light();
584        let mut path = Path::new();
585        path.move_to(Point::new(0.0, 0.0))
586            .line_to(Point::new(50.0, 50.0));
587        let item = PathItem::new(path).stroke(Color::RED, 2.0);
588        let mut canvas = Canvas::new();
589        let ctx = SceneItemPaintContext::new(Transform2D::identity(), None, &theme);
590        item.paint(&mut canvas, &ctx);
591        assert!(!canvas.into_render_frame().draw_order.is_empty());
592    }
593
594    #[test]
595    fn cosmetic_path_hit_band_tracks_zoom() {
596        // A cosmetic stroke's width is in device px, so its scene-coord hit
597        // band must shrink as the view zooms in. A point 3 scene-units off a
598        // cosmetic 4px line is inside the band at 1× but outside at 4×.
599        let mut path = Path::new();
600        path.move_to(Point::new(0.0, 0.0))
601            .line_to(Point::new(100.0, 0.0));
602        let item = PathItem::new(path).stroke_cosmetic(Color::BLACK, 4.0);
603        let shape = item.shape();
604        let p = Point::new(50.0, 3.0);
605        assert!(
606            shape.contains(p, 1.0),
607            "cosmetic band at 1x: width 4 → tolerance 4 → hit"
608        );
609        assert!(
610            !shape.contains(p, 4.0),
611            "cosmetic band shrinks at 4x: width 1 → tolerance 2.5 → miss"
612        );
613
614        // A LOGICAL stroke's width is already in scene units, so its band is
615        // unaffected by the view scale (regression guard).
616        let mut path2 = Path::new();
617        path2
618            .move_to(Point::new(0.0, 0.0))
619            .line_to(Point::new(100.0, 0.0));
620        let logical = PathItem::new(path2).stroke(Color::BLACK, 4.0);
621        assert!(logical.shape().contains(p, 1.0), "logical band hit at 1x");
622        assert!(
623            logical.shape().contains(p, 4.0),
624            "logical band unchanged by zoom"
625        );
626    }
627
628    #[test]
629    fn set_local_bounds_moves_and_scales_the_geometry() {
630        // Study §1.9: the old impl updated the AABB and left the path where it
631        // was, so the index bucketed a rectangle the geometry had never
632        // occupied. The geometry follows now.
633        let mut path = Path::new();
634        path.move_to(Point::new(0.0, 0.0))
635            .line_to(Point::new(100.0, 0.0));
636        let mut item = PathItem::new(path).stroke(Color::BLACK, 0.0);
637        let before = item.local_bounds();
638        // Slide it 200 to the right, same size.
639        item.set_local_bounds(Rect::new(
640            before.x + 200.0,
641            before.y,
642            before.width,
643            before.height,
644        ));
645        let shape = item.shape();
646        assert!(
647            shape.contains(Point::new(250.0, 0.0), 1.0),
648            "moved with its box"
649        );
650        assert!(
651            !shape.contains(Point::new(50.0, 0.0), 1.0),
652            "left the old place"
653        );
654        let lb = item.local_bounds();
655        let sb = shape.bounding_rect();
656        assert!((lb.x - sb.x).abs() < 1e-3 && (lb.width - sb.width).abs() < 1e-3);
657    }
658
659    #[test]
660    fn set_local_bounds_on_a_degenerate_box_translates_without_dividing_by_zero() {
661        // A single-point path has a zero-size box on both axes; the fit
662        // transform must degrade to a pure translate rather than divide by it.
663        let mut path = Path::new();
664        path.move_to(Point::new(0.0, 0.0));
665        let mut item = PathItem::new(path);
666        assert_eq!(item.local_bounds(), Rect::new(0.0, 0.0, 0.0, 0.0));
667        item.set_local_bounds(Rect::new(10.0, 20.0, 0.0, 0.0));
668        let lb = item.local_bounds();
669        assert!(lb.width.is_finite() && lb.height.is_finite());
670        assert!((lb.x - 10.0).abs() < 1e-3 && (lb.y - 20.0).abs() < 1e-3);
671    }
672
673    #[test]
674    fn a_long_ink_stroke_hit_tests_exactly() {
675        // Flattening removed the old "bail to the AABB on the first curve"
676        // guard, so a 500-segment ink stroke would otherwise walk every
677        // segment per pointer sample to conclude a miss. Two levels of
678        // rejection stand in front of that walk now (the shape's own box, then
679        // a box per run of segments); what this asserts is that neither of
680        // them changes an answer — their arithmetic is pinned in `shape/tests`.
681        let mut path = Path::new();
682        path.move_to(Point::new(0.0, 0.0));
683        for i in 1..=500 {
684            let x = i as f32;
685            path.line_to(Point::new(x, (x * 0.2).sin() * 40.0));
686        }
687        let item = PathItem::new(path).stroke(Color::BLACK, 1.0);
688        let shape = item.shape();
689        // Far outside the box.
690        assert!(!shape.contains(Point::new(250.0, 400.0), 1.0));
691        // Inside the box, between two arcs of the wave.
692        assert!(!shape.contains(Point::new(250.0, 20.0), 1.0));
693        // On the stroke.
694        assert!(shape.contains(Point::new(250.0, (250.0f32 * 0.2).sin() * 40.0), 1.0));
695    }
696}