Skip to main content

cranpose_render_common/
shape_sdf.rs

1//! CPU mirror of the signed-distance functions in `shaders/shape.wgsl`.
2//!
3//! The software (pixels) renderer rasterizes the *same* shapes as the GPU. Both
4//! evaluate these formulas, so a stroked rect or an arc looks the same on either
5//! backend instead of the CPU path quietly degrading to a filled box.
6//!
7//! Angle convention (shared with `cranpose_ui_graphics::stroke`): radians, `0`
8//! along +X, increasing clockwise on screen (y-down device space).
9
10use cranpose_ui_graphics::{ArcGeometry, CornerRadii, Point, Rect, StrokeCap, StrokeJoin};
11
12const INV_SQRT2: f32 = std::f32::consts::FRAC_1_SQRT_2;
13
14/// Signed distance to a rounded box centered at the origin.
15///
16/// `radii` is ordered exactly like the WGSL `vec4`: top-left, top-right,
17/// bottom-left, bottom-right.
18pub fn sdf_rounded_rect(p: Point, half_size: (f32, f32), radii: [f32; 4]) -> f32 {
19    let radius = match (p.x > 0.0, p.y > 0.0) {
20        (false, false) => radii[0],
21        (true, false) => radii[1],
22        (false, true) => radii[2],
23        (true, true) => radii[3],
24    };
25    let qx = p.x.abs() - half_size.0 + radius;
26    let qy = p.y.abs() - half_size.1 + radius;
27    let inside = qx.max(qy).min(0.0);
28    let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt();
29    inside + outside - radius
30}
31
32/// Signed distance to the outline of a rounded box stroked with a centered
33/// stroke of width `2 * half_width`.
34///
35/// `half_size` is the **inflated** box (geometry plus `half_width` on every
36/// side), matching what the renderer hands the shader.
37pub fn sdf_stroked_rounded_rect(
38    p: Point,
39    half_size: (f32, f32),
40    radii: [f32; 4],
41    half_width: f32,
42    join: StrokeJoin,
43) -> f32 {
44    let hw = half_width.max(0.0);
45    let geom = ((half_size.0 - hw).max(0.0), (half_size.1 - hw).max(0.0));
46
47    let mut outer_radii = [radii[0] + hw, radii[1] + hw, radii[2] + hw, radii[3] + hw];
48    if join != StrokeJoin::Round {
49        // Miter/bevel keep a square corner square; an already-rounded corner
50        // has no join and keeps the true parallel offset.
51        for (out, r) in outer_radii.iter_mut().zip(radii.iter()) {
52            if *r < 0.0001 {
53                *out = 0.0;
54            }
55        }
56    }
57    let inner_radii = [
58        (radii[0] - hw).max(0.0),
59        (radii[1] - hw).max(0.0),
60        (radii[2] - hw).max(0.0),
61        (radii[3] - hw).max(0.0),
62    ];
63
64    let outer = sdf_rounded_rect(p, (geom.0 + hw, geom.1 + hw), outer_radii);
65    let inner = sdf_rounded_rect(
66        p,
67        ((geom.0 - hw).max(0.0), (geom.1 - hw).max(0.0)),
68        inner_radii,
69    );
70    let mut dist = outer.max(-inner);
71
72    if join == StrokeJoin::Bevel {
73        let chamfer = (p.x.abs() + p.y.abs() - (geom.0 + geom.1 + hw)) * INV_SQRT2;
74        dist = dist.max(chamfer);
75    }
76    dist
77}
78
79/// Signed distance to a circular band limited to an angular sweep — the shape
80/// behind both stroked arcs and filled annular sectors.
81pub fn sdf_arc_band(p: Point, arc: &ArcGeometry) -> f32 {
82    let ra = arc.mid_radius();
83    let rb = arc.half_thickness().max(0.0);
84    let sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU);
85    let half_sweep = sweep * 0.5;
86    let mid = arc.start_angle + half_sweep;
87
88    let (sm, cm) = mid.sin_cos();
89    let dx = p.x - arc.center.x;
90    let dy = p.y - arc.center.y;
91    // Rotate into the frame the analytic arc SDF expects: band straddling +Y.
92    let qx = (-sm * dx + cm * dy).abs();
93    let qy = cm * dx + sm * dy;
94
95    // sin() of a half sweep in [0, PI] is non-negative in exact math; the max()
96    // pins a full turn to exactly (0, -1) so a closed ring has no seam.
97    let sc = (half_sweep.sin().max(0.0), half_sweep.cos());
98
99    let mut dist = if sc.1 * qx > sc.0 * qy {
100        ((qx - sc.0 * ra).powi(2) + (qy - sc.1 * ra).powi(2)).sqrt() - rb
101    } else {
102        ((qx * qx + qy * qy).sqrt() - ra).abs() - rb
103    };
104
105    // Distance to the radial boundary plane, positive outside the wedge.
106    let plane = sc.1 * qx - sc.0 * qy;
107    match arc.cap {
108        StrokeCap::Butt => dist = dist.max(plane),
109        StrokeCap::Square => dist = dist.max(plane - rb),
110        StrokeCap::Round => {}
111    }
112    dist
113}
114
115/// Antialiased coverage for a signed distance, matching the shader's
116/// `1.0 - smoothstep(-0.5, 0.5, d)`.
117pub fn coverage_for_distance(distance: f32) -> f32 {
118    if !distance.is_finite() {
119        return 0.0;
120    }
121    let t = ((distance + 0.5).clamp(0.0, 1.0)) as f64;
122    let smooth = t * t * (3.0 - 2.0 * t);
123    (1.0 - smooth) as f32
124}
125
126/// Coverage of `point` by a stroked rect/round-rect whose (already inflated)
127/// bounds are `rect`.
128pub fn stroked_rect_coverage(
129    point: Point,
130    rect: Rect,
131    radii: Option<CornerRadii>,
132    half_width: f32,
133    join: StrokeJoin,
134) -> f32 {
135    let half_size = (rect.width * 0.5, rect.height * 0.5);
136    let local = Point::new(
137        point.x - (rect.x + half_size.0),
138        point.y - (rect.y + half_size.1),
139    );
140    let radii = radii.unwrap_or_default();
141    let distance = sdf_stroked_rounded_rect(
142        local,
143        half_size,
144        [
145            radii.top_left,
146            radii.top_right,
147            radii.bottom_left,
148            radii.bottom_right,
149        ],
150        half_width,
151        join,
152    );
153    coverage_for_distance(distance)
154}
155
156/// Coverage of `point` by an arc band.
157pub fn arc_coverage(point: Point, arc: &ArcGeometry) -> f32 {
158    coverage_for_distance(sdf_arc_band(point, arc))
159}
160
161#[cfg(test)]
162mod tests {
163    use std::f32::consts::{FRAC_PI_2, PI};
164
165    use cranpose_ui_graphics::TAU;
166
167    use super::*;
168
169    fn arc(inner: f32, outer: f32, start: f32, sweep: f32, cap: StrokeCap) -> ArcGeometry {
170        ArcGeometry::new(Point::ZERO, inner, outer, start, sweep, cap)
171    }
172
173    #[test]
174    fn rounded_rect_sdf_matches_known_distances() {
175        // A 20x20 box, no radii: the center is 10 inside, a point 5 to the
176        // right of the right edge is 5 outside.
177        let d_center = sdf_rounded_rect(Point::ZERO, (10.0, 10.0), [0.0; 4]);
178        assert!((d_center + 10.0).abs() < 1e-4, "{d_center}");
179        let d_outside = sdf_rounded_rect(Point::new(15.0, 0.0), (10.0, 10.0), [0.0; 4]);
180        assert!((d_outside - 5.0).abs() < 1e-4, "{d_outside}");
181    }
182
183    #[test]
184    fn stroked_rect_covers_only_the_band_around_the_edge() {
185        // Geometry is 20x20 (half 10), stroke width 4 => inflated half 12.
186        let half = (12.0, 12.0);
187        let on_edge = sdf_stroked_rounded_rect(
188            Point::new(10.0, 0.0),
189            half,
190            [0.0; 4],
191            2.0,
192            StrokeJoin::Miter,
193        );
194        assert!(on_edge < 0.0, "the edge itself must be inside the stroke");
195        let inside =
196            sdf_stroked_rounded_rect(Point::new(4.0, 0.0), half, [0.0; 4], 2.0, StrokeJoin::Miter);
197        assert!(inside > 0.0, "the interior must be empty for a stroke");
198        let outside = sdf_stroked_rounded_rect(
199            Point::new(16.0, 0.0),
200            half,
201            [0.0; 4],
202            2.0,
203            StrokeJoin::Miter,
204        );
205        assert!(outside > 0.0, "well outside must be empty");
206    }
207
208    #[test]
209    fn miter_join_keeps_a_square_corner_round_join_does_not() {
210        // Geometry 20x20 (half 10), width 4 (hw 2) => inflated half 12.
211        // The outer miter corner is exactly (12, 12).
212        let corner = Point::new(11.9, 11.9);
213        let miter =
214            sdf_stroked_rounded_rect(corner, (12.0, 12.0), [0.0; 4], 2.0, StrokeJoin::Miter);
215        let round =
216            sdf_stroked_rounded_rect(corner, (12.0, 12.0), [0.0; 4], 2.0, StrokeJoin::Round);
217        let bevel =
218            sdf_stroked_rounded_rect(corner, (12.0, 12.0), [0.0; 4], 2.0, StrokeJoin::Bevel);
219        assert!(miter < 0.0, "miter fills the corner point: {miter}");
220        assert!(round > 0.0, "round cuts the corner off: {round}");
221        assert!(bevel > 0.0, "bevel cuts the corner off: {bevel}");
222        // The bevel is the chord between the two arc endpoints, so along the
223        // diagonal it sits *inside* the round join's arc and cuts more.
224        assert!(
225            bevel > round,
226            "the bevel chord must cut deeper than the round arc: \
227             bevel={bevel} round={round}"
228        );
229    }
230
231    #[test]
232    fn full_ring_has_no_seam_at_the_wrap_point() {
233        let ring = arc(8.0, 12.0, 0.0, TAU, StrokeCap::Butt);
234        // Sample all the way round, including exactly at the wrap angle.
235        for step in 0..64 {
236            let angle = step as f32 / 64.0 * TAU;
237            let (sin, cos) = angle.sin_cos();
238            let p = Point::new(cos * 10.0, sin * 10.0);
239            let d = sdf_arc_band(p, &ring);
240            assert!(
241                d < 0.0,
242                "the ring centerline must be covered at angle {angle}: d={d}"
243            );
244        }
245    }
246
247    #[test]
248    fn butt_caps_cut_the_band_at_the_radial_ends() {
249        // 0 -> 90 degrees, band 8..12.
250        let band = arc(8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Butt);
251        // Just inside the sweep at 45 degrees, on the centerline.
252        let inside = Point::new(10.0 * INV_SQRT2, 10.0 * INV_SQRT2);
253        assert!(sdf_arc_band(inside, &band) < 0.0);
254        // Just past the end cap (angle slightly > 90 degrees) must be empty.
255        let past_end = Point::new(-1.0, 10.0);
256        assert!(
257            sdf_arc_band(past_end, &band) > 0.0,
258            "butt cap must not bulge past the radial end"
259        );
260        // Just before the start cap likewise.
261        let before_start = Point::new(10.0, -1.0);
262        assert!(sdf_arc_band(before_start, &band) > 0.0);
263    }
264
265    #[test]
266    fn round_caps_bulge_past_the_radial_ends_and_square_caps_project() {
267        let round = arc(8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Round);
268        let square = arc(8.0, 12.0, 0.0, FRAC_PI_2, StrokeCap::Square);
269        // 1 unit before the start angle, on the centerline (radius 10).
270        let before_start = Point::new(10.0, -1.0);
271        assert!(
272            sdf_arc_band(before_start, &round) < 0.0,
273            "round cap must cover the semicircle past the end"
274        );
275        assert!(
276            sdf_arc_band(before_start, &square) < 0.0,
277            "square cap must cover the projection past the end"
278        );
279        // 3 units before the start is past both caps (rb = 2).
280        let far = Point::new(10.0, -3.0);
281        assert!(sdf_arc_band(far, &round) > 0.0);
282        assert!(sdf_arc_band(far, &square) > 0.0);
283    }
284
285    #[test]
286    fn annular_sector_has_flat_radial_edges() {
287        // The defining property: at the start angle the boundary is a straight
288        // radial line, so points at the same angle but different radii are all
289        // exactly on the edge.
290        let sector = arc(6.0, 12.0, 0.0, PI, StrokeCap::Butt);
291        for radius in [6.5, 8.0, 10.0, 11.5] {
292            // Just inside the sweep.
293            let p = Point::new(radius * (0.01f32).cos(), radius * (0.01f32).sin());
294            assert!(
295                sdf_arc_band(p, &sector) < 0.0,
296                "radius {radius} just inside the sweep must be covered"
297            );
298            // Just outside the sweep (negative angle).
299            let q = Point::new(radius * (-0.2f32).cos(), radius * (-0.2f32).sin());
300            assert!(
301                sdf_arc_band(q, &sector) > 0.0,
302                "radius {radius} just outside the sweep must be empty"
303            );
304        }
305    }
306
307    #[test]
308    fn wedge_with_zero_inner_radius_reaches_the_center() {
309        let wedge = arc(0.0, 10.0, 0.0, FRAC_PI_2, StrokeCap::Butt);
310        assert!(sdf_arc_band(Point::new(0.5, 0.5), &wedge) < 0.0);
311        assert!(sdf_arc_band(Point::new(-0.5, -0.5), &wedge) > 0.0);
312    }
313
314    #[test]
315    fn degenerate_arcs_never_produce_nan_coverage() {
316        for geometry in [
317            arc(0.0, 0.0, 0.0, 0.0, StrokeCap::Butt),
318            arc(5.0, 5.0, 0.0, 1.0, StrokeCap::Round),
319            arc(0.0, 10.0, 0.0, 0.0, StrokeCap::Square),
320            ArcGeometry::new(Point::ZERO, f32::NAN, 1.0, 0.0, 1.0, StrokeCap::Butt),
321        ] {
322            for p in [Point::ZERO, Point::new(3.0, -4.0), Point::new(-9.0, 9.0)] {
323                let value = arc_coverage(p, &geometry);
324                assert!(value.is_finite(), "coverage must stay finite: {value}");
325                assert!((0.0..=1.0).contains(&value), "{value}");
326            }
327        }
328    }
329
330    #[test]
331    fn coverage_saturates_and_antialiases() {
332        assert_eq!(coverage_for_distance(-5.0), 1.0);
333        assert_eq!(coverage_for_distance(5.0), 0.0);
334        assert!((coverage_for_distance(0.0) - 0.5).abs() < 1e-5);
335        assert_eq!(coverage_for_distance(f32::NAN), 0.0);
336    }
337
338    #[test]
339    fn stroked_rect_coverage_uses_the_inflated_bounds() {
340        // Geometry (10,10)-(30,30) stroked at width 4 => bounds (8,8)-(32,32).
341        let bounds = Rect {
342            x: 8.0,
343            y: 8.0,
344            width: 24.0,
345            height: 24.0,
346        };
347        let on_edge =
348            stroked_rect_coverage(Point::new(10.0, 20.0), bounds, None, 2.0, StrokeJoin::Miter);
349        assert!(on_edge > 0.9, "the stroked edge must be opaque: {on_edge}");
350        let interior =
351            stroked_rect_coverage(Point::new(20.0, 20.0), bounds, None, 2.0, StrokeJoin::Miter);
352        assert_eq!(interior, 0.0, "a stroke must not fill its interior");
353    }
354}