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    /// The part of `outer` outside `inner` under the non-zero rule: Flutter's
83    /// even-odd `Path()..addRect(outer)..addPath(inner)` for an `inner` path
84    /// of one winding inside `outer`. `inner` is wound against the rectangle.
85    #[must_use]
86    pub fn rect_excluding(outer: Rect, inner: &Self) -> Self {
87        let left = f64::from(outer.origin.x);
88        let top = f64::from(outer.origin.y);
89        let right = left + f64::from(outer.size.width);
90        let bottom = top + f64::from(outer.size.height);
91        let mut path = BezPath::new();
92        path.move_to((left, top));
93        path.line_to((right, top));
94        path.line_to((right, bottom));
95        path.line_to((left, bottom));
96        path.close_path();
97        let mut hole = (*inner.path).clone();
98        if hole.area().signum() == path.area().signum() {
99            hole = hole.reverse_subpaths();
100        }
101        path.extend(hole);
102        Self::from_bez_path(path)
103    }
104
105    /// Returns a copy of this path with an affine transform applied to its
106    /// geometry.  Path identity is intentionally not reused: the transformed
107    /// geometry is a distinct immutable mesh candidate.  This is used by
108    /// higher-level icon primitives to fit a canonical 24px path into the
109    /// requested logical size without changing pointer/layout coordinates.
110    #[must_use]
111    pub fn transformed(&self, transform: Transform) -> Self {
112        let mut path = (*self.path).clone();
113        path.apply_affine(transform.to_kurbo());
114        let bounds = (!path.is_empty() && path.is_finite()).then(|| {
115            let bounds = path.bounding_box();
116            Rect::from_origin_size(
117                Offset::new(bounds.x0 as f32, bounds.y0 as f32),
118                Size::new(
119                    (bounds.x1 - bounds.x0) as f32,
120                    (bounds.y1 - bounds.y0) as f32,
121                ),
122            )
123        });
124        Self {
125            id: next_path_id(),
126            path: Arc::new(path),
127            bounds,
128        }
129    }
130}
131impl Default for Path {
132    fn default() -> Self {
133        PathBuilder::default().build()
134    }
135}
136#[derive(Default)]
137pub struct PathBuilder {
138    path: BezPath,
139}
140impl PathBuilder {
141    pub fn move_to(&mut self, p: Offset) -> &mut Self {
142        self.path.move_to(point(p));
143        self
144    }
145    pub fn line_to(&mut self, p: Offset) -> &mut Self {
146        self.path.line_to(point(p));
147        self
148    }
149    pub fn quadratic_to(&mut self, c: Offset, p: Offset) -> &mut Self {
150        self.path.quad_to(point(c), point(p));
151        self
152    }
153    pub fn cubic_to(&mut self, a: Offset, b: Offset, p: Offset) -> &mut Self {
154        self.path.curve_to(point(a), point(b), point(p));
155        self
156    }
157    /// Flutter `Path.conicTo`: a rational quadratic Bézier with weight
158    /// `weight`, drawn as cubics. The conic is halved twice (Skia's
159    /// `SkConic::chop`) and each quarter becomes the cubic whose handles
160    /// sit `4w / (3(1 + w))` of the way to its control point: for a
161    /// circular arc of angle θ (weight `cos(θ/2)`) that is the usual
162    /// `(4/3)·tan(θ/4)·r` handle length.
163    pub fn conic_to(&mut self, control: Offset, end: Offset, weight: f32) -> &mut Self {
164        let Some(start) = self
165            .path
166            .elements()
167            .last()
168            .and_then(|element| element.end_point())
169        else {
170            return self.move_to(end);
171        };
172        let weight = f64::from(weight);
173        if !(weight.is_finite() && weight > 0.0) {
174            return self.line_to(end);
175        }
176        let mut pieces = vec![(start, point(control), point(end), weight)];
177        for _ in 0..2 {
178            pieces = pieces
179                .into_iter()
180                .flat_map(|(p0, p1, p2, w)| {
181                    let scale = 1.0 / (1.0 + w);
182                    let mid = ((p0.to_vec2() + p1.to_vec2() * (2.0 * w) + p2.to_vec2())
183                        * (0.5 * scale))
184                        .to_point();
185                    let half = ((1.0 + w) / 2.0).sqrt();
186                    [
187                        (
188                            p0,
189                            ((p0.to_vec2() + p1.to_vec2() * w) * scale).to_point(),
190                            mid,
191                            half,
192                        ),
193                        (
194                            mid,
195                            ((p1.to_vec2() * w + p2.to_vec2()) * scale).to_point(),
196                            p2,
197                            half,
198                        ),
199                    ]
200                })
201                .collect();
202        }
203        for (p0, p1, p2, w) in pieces {
204            let k = 4.0 * w / (3.0 * (1.0 + w));
205            self.path.curve_to(p0.lerp(p1, k), p2.lerp(p1, k), p2);
206        }
207        self
208    }
209
210    /// Adds a closed rounded rectangle, its arcs as cubics within 0.01
211    /// logical pixels.
212    pub fn rounded_rect(&mut self, rect: Rect, radii: CornerRadii) -> &mut Self {
213        let radii = radii.normalized(rect.size);
214        let shape = RoundedRect::new(
215            f64::from(rect.origin.x),
216            f64::from(rect.origin.y),
217            f64::from(rect.origin.x + rect.size.width),
218            f64::from(rect.origin.y + rect.size.height),
219            RoundedRectRadii::new(
220                f64::from(radii.top_left),
221                f64::from(radii.top_right),
222                f64::from(radii.bottom_right),
223                f64::from(radii.bottom_left),
224            ),
225        );
226        self.path.extend(shape.path_elements(0.01));
227        self
228    }
229
230    /// Flutter `Path.addRSuperellipse` for circular corner radii: a closed
231    /// rounded superellipse, the corner shape of iOS.
232    pub fn rounded_superellipse(&mut self, rect: Rect, radii: CornerRadii) -> &mut Self {
233        crate::rsuperellipse::rounded_superellipse(self, rect, radii);
234        self
235    }
236
237    /// Adds every contour of `path`, as Flutter `Path.addPath` with no
238    /// offset.
239    pub fn append(&mut self, path: &Path) -> &mut Self {
240        self.path.extend(path.bez_path().elements().iter().copied());
241        self
242    }
243
244    pub fn close(&mut self) -> &mut Self {
245        if !self.path.elements().is_empty() {
246            self.path.close_path();
247        }
248        self
249    }
250    #[must_use]
251    pub fn build(self) -> Path {
252        Path::from_bez_path(self.path)
253    }
254}
255
256fn point(offset: Offset) -> Point {
257    Point::new(f64::from(offset.x), f64::from(offset.y))
258}
259
260/// Target world-space curve deviation for analytic clip fallbacks: a
261/// tenth of a logical pixel after the world transform is applied.
262const CLIP_PATH_WORLD_TOLERANCE: f64 = 0.1;
263
264/// Local-space arc-subdivision tolerance for one world transform,
265/// targeting [`CLIP_PATH_WORLD_TOLERANCE`] in world space. kurbo
266/// subdivides arcs into cubics in the shape's local space, so the local
267/// tolerance divides the target by the transform's magnification bound;
268/// the floor bounds segment counts where magnification exceeds 100x,
269/// past which the world bound relaxes linearly instead. Tolerance is a
270/// pure function of the world transform, so memoized fallback paths stay
271/// valid exactly while their world does.
272pub(crate) fn fallback_tolerance(world: Transform) -> f64 {
273    (CLIP_PATH_WORLD_TOLERANCE / f64::from(world.magnification_bound())).clamp(1e-3, 1.0)
274}
275
276/// Exact local-space path of an axis-aligned rect: straight edges only,
277/// so no tolerance applies.
278pub(crate) fn rect_as_path(rect: Rect) -> Path {
279    let mut builder = PathBuilder::default();
280    builder
281        .move_to(rect.origin)
282        .line_to(Offset::new(rect.origin.x + rect.size.width, rect.origin.y))
283        .line_to(Offset::new(
284            rect.origin.x + rect.size.width,
285            rect.origin.y + rect.size.height,
286        ))
287        .line_to(Offset::new(rect.origin.x, rect.origin.y + rect.size.height))
288        .close();
289    builder.build()
290}
291
292/// Local-space path of a rounded rect, arcs subdivided into cubics
293/// within `tolerance`. Used only where uniform corner radii cannot
294/// survive the world transform; an approximation by construction, never
295/// an exact curve.
296pub(crate) fn rrect_as_path(rect: Rect, radii: CornerRadii, tolerance: f64) -> Path {
297    let shape = RoundedRect::new(
298        f64::from(rect.origin.x),
299        f64::from(rect.origin.y),
300        f64::from(rect.origin.x + rect.size.width),
301        f64::from(rect.origin.y + rect.size.height),
302        RoundedRectRadii::new(
303            f64::from(radii.top_left),
304            f64::from(radii.top_right),
305            f64::from(radii.bottom_right),
306            f64::from(radii.bottom_left),
307        ),
308    );
309    Path::from_bez_path(shape.to_path(tolerance))
310}
311
312/// Local-space path of the ellipse inscribed in `rect`, arcs subdivided
313/// into cubics within `tolerance`. Used only where the world transform
314/// would rotate the oval.
315pub(crate) fn ellipse_as_path(rect: Rect, tolerance: f64) -> Path {
316    let shape = Ellipse::new(
317        Point::new(
318            f64::from(rect.origin.x) + f64::from(rect.size.width) * 0.5,
319            f64::from(rect.origin.y) + f64::from(rect.size.height) * 0.5,
320        ),
321        Vec2::new(
322            f64::from(rect.size.width) * 0.5,
323            f64::from(rect.size.height) * 0.5,
324        ),
325        0.0,
326    );
327    Path::from_bez_path(shape.to_path(tolerance))
328}