Skip to main content

incular_rendering/
paths.rs

1use crate::geometry::CornerRadii;
2use crate::paint::FillRule;
3use incular_core::{Offset, Rect, Size, Transform};
4use kurbo::{BezPath, Ellipse, Point, RoundedRect, RoundedRectRadii, Shape, Vec2};
5use std::sync::{
6    Arc,
7    atomic::{AtomicU64, Ordering},
8};
9
10static NEXT_PATH_ID: AtomicU64 = AtomicU64::new(1);
11
12fn next_path_id() -> PathId {
13    PathId(
14        NEXT_PATH_ID
15            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
16            .expect("path identity space exhausted"),
17    )
18}
19
20/// Stable identity for immutable path geometry. It deliberately does not
21/// encode paint, placement, or transforms.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
23pub struct PathId(u64);
24#[derive(Clone, Debug, PartialEq)]
25pub struct Path {
26    id: PathId,
27    path: Arc<BezPath>,
28    bounds: Option<Rect>,
29}
30impl Path {
31    #[must_use]
32    pub fn builder() -> PathBuilder {
33        PathBuilder::default()
34    }
35    #[must_use]
36    pub fn bez_path(&self) -> &BezPath {
37        &self.path
38    }
39    #[must_use]
40    pub fn bounds(&self) -> Option<Rect> {
41        self.bounds
42    }
43    #[must_use]
44    pub const fn id(&self) -> PathId {
45        self.id
46    }
47    /// Renderer-neutral filled-path containment delegated to Kurbo's exact
48    /// segment winding implementation. It is intentionally independent from
49    /// GPU tessellation so pointer targeting follows the retained geometry.
50    #[must_use]
51    pub fn contains(&self, point: Offset, rule: FillRule) -> bool {
52        let winding = self
53            .path
54            .winding(Point::new(f64::from(point.x), f64::from(point.y)));
55        match rule {
56            FillRule::NonZero => winding != 0,
57            FillRule::EvenOdd => winding.unsigned_abs() % 2 == 1,
58        }
59    }
60
61    /// Wraps an already-built Bézier path with fresh identity and bounds.
62    /// Clip fallbacks convert analytic shapes through here; every other
63    /// construction keeps its existing entry point.
64    pub(crate) fn from_bez_path(path: BezPath) -> Self {
65        let bounds = (!path.is_empty() && path.is_finite()).then(|| {
66            let bounds = path.bounding_box();
67            Rect::from_origin_size(
68                Offset::new(bounds.x0 as f32, bounds.y0 as f32),
69                incular_core::Size::new(
70                    (bounds.x1 - bounds.x0) as f32,
71                    (bounds.y1 - bounds.y0) as f32,
72                ),
73            )
74        });
75        Self {
76            id: next_path_id(),
77            path: Arc::new(path),
78            bounds,
79        }
80    }
81
82    /// Returns a copy of this path with an affine transform applied to its
83    /// geometry.  Path identity is intentionally not reused: the transformed
84    /// geometry is a distinct immutable mesh candidate.  This is used by
85    /// higher-level icon primitives to fit a canonical 24px path into the
86    /// requested logical size without changing pointer/layout coordinates.
87    #[must_use]
88    pub fn transformed(&self, transform: Transform) -> Self {
89        let mut path = (*self.path).clone();
90        path.apply_affine(transform.to_kurbo());
91        let bounds = (!path.is_empty() && path.is_finite()).then(|| {
92            let bounds = path.bounding_box();
93            Rect::from_origin_size(
94                Offset::new(bounds.x0 as f32, bounds.y0 as f32),
95                Size::new(
96                    (bounds.x1 - bounds.x0) as f32,
97                    (bounds.y1 - bounds.y0) as f32,
98                ),
99            )
100        });
101        Self {
102            id: next_path_id(),
103            path: Arc::new(path),
104            bounds,
105        }
106    }
107}
108impl Default for Path {
109    fn default() -> Self {
110        PathBuilder::default().build()
111    }
112}
113#[derive(Default)]
114pub struct PathBuilder {
115    path: BezPath,
116}
117impl PathBuilder {
118    pub fn move_to(&mut self, p: Offset) -> &mut Self {
119        self.path.move_to(point(p));
120        self
121    }
122    pub fn line_to(&mut self, p: Offset) -> &mut Self {
123        self.path.line_to(point(p));
124        self
125    }
126    pub fn quadratic_to(&mut self, c: Offset, p: Offset) -> &mut Self {
127        self.path.quad_to(point(c), point(p));
128        self
129    }
130    pub fn cubic_to(&mut self, a: Offset, b: Offset, p: Offset) -> &mut Self {
131        self.path.curve_to(point(a), point(b), point(p));
132        self
133    }
134    pub fn close(&mut self) -> &mut Self {
135        if !self.path.elements().is_empty() {
136            self.path.close_path();
137        }
138        self
139    }
140    #[must_use]
141    pub fn build(self) -> Path {
142        Path::from_bez_path(self.path)
143    }
144}
145
146fn point(offset: Offset) -> Point {
147    Point::new(f64::from(offset.x), f64::from(offset.y))
148}
149
150/// Target world-space curve deviation for analytic clip fallbacks: a
151/// tenth of a logical pixel after the world transform is applied.
152const CLIP_PATH_WORLD_TOLERANCE: f64 = 0.1;
153
154/// Local-space arc-subdivision tolerance for one world transform,
155/// targeting [`CLIP_PATH_WORLD_TOLERANCE`] in world space. kurbo
156/// subdivides arcs into cubics in the shape's local space, so the local
157/// tolerance divides the target by the transform's magnification bound;
158/// the floor bounds segment counts where magnification exceeds 100x,
159/// past which the world bound relaxes linearly instead. Tolerance is a
160/// pure function of the world transform, so memoized fallback paths stay
161/// valid exactly while their world does.
162pub(crate) fn fallback_tolerance(world: Transform) -> f64 {
163    (CLIP_PATH_WORLD_TOLERANCE / f64::from(world.magnification_bound())).clamp(1e-3, 1.0)
164}
165
166/// Exact local-space path of an axis-aligned rect: straight edges only,
167/// so no tolerance applies.
168pub(crate) fn rect_as_path(rect: Rect) -> Path {
169    let mut builder = PathBuilder::default();
170    builder
171        .move_to(rect.origin)
172        .line_to(Offset::new(rect.origin.x + rect.size.width, rect.origin.y))
173        .line_to(Offset::new(
174            rect.origin.x + rect.size.width,
175            rect.origin.y + rect.size.height,
176        ))
177        .line_to(Offset::new(rect.origin.x, rect.origin.y + rect.size.height))
178        .close();
179    builder.build()
180}
181
182/// Local-space path of a rounded rect, arcs subdivided into cubics
183/// within `tolerance`. Used only where uniform corner radii cannot
184/// survive the world transform; an approximation by construction, never
185/// an exact curve.
186pub(crate) fn rrect_as_path(rect: Rect, radii: CornerRadii, tolerance: f64) -> Path {
187    let shape = RoundedRect::new(
188        f64::from(rect.origin.x),
189        f64::from(rect.origin.y),
190        f64::from(rect.origin.x + rect.size.width),
191        f64::from(rect.origin.y + rect.size.height),
192        RoundedRectRadii::new(
193            f64::from(radii.top_left),
194            f64::from(radii.top_right),
195            f64::from(radii.bottom_right),
196            f64::from(radii.bottom_left),
197        ),
198    );
199    Path::from_bez_path(shape.to_path(tolerance))
200}
201
202/// Local-space path of the ellipse inscribed in `rect`, arcs subdivided
203/// into cubics within `tolerance`. Used only where the world transform
204/// would rotate the oval.
205pub(crate) fn ellipse_as_path(rect: Rect, tolerance: f64) -> Path {
206    let shape = Ellipse::new(
207        Point::new(
208            f64::from(rect.origin.x) + f64::from(rect.size.width) * 0.5,
209            f64::from(rect.origin.y) + f64::from(rect.size.height) * 0.5,
210        ),
211        Vec2::new(
212            f64::from(rect.size.width) * 0.5,
213            f64::from(rect.size.height) * 0.5,
214        ),
215        0.0,
216    );
217    Path::from_bez_path(shape.to_path(tolerance))
218}