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        for (out, r) in outer_radii.iter_mut().zip(radii.iter()) {
50            if *r < 0.0001 {
51                *out = 0.0;
52            }
53        }
54    }
55    let inner_radii = [
56        (radii[0] - hw).max(0.0),
57        (radii[1] - hw).max(0.0),
58        (radii[2] - hw).max(0.0),
59        (radii[3] - hw).max(0.0),
60    ];
61
62    let outer = sdf_rounded_rect(p, (geom.0 + hw, geom.1 + hw), outer_radii);
63    let inner = sdf_rounded_rect(
64        p,
65        ((geom.0 - hw).max(0.0), (geom.1 - hw).max(0.0)),
66        inner_radii,
67    );
68    let mut dist = outer.max(-inner);
69
70    if join == StrokeJoin::Bevel {
71        let chamfer = (p.x.abs() + p.y.abs() - (geom.0 + geom.1 + hw)) * INV_SQRT2;
72        dist = dist.max(chamfer);
73    }
74    dist
75}
76
77/// Signed distance to a circular band limited to an angular sweep — the shape
78/// behind both stroked arcs and filled annular sectors.
79pub fn sdf_arc_band(p: Point, arc: &ArcGeometry) -> f32 {
80    let ra = arc.mid_radius();
81    let rb = arc.half_thickness().max(0.0);
82    let sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU);
83    let half_sweep = sweep * 0.5;
84    let mid = arc.start_angle + half_sweep;
85
86    let (sm, cm) = mid.sin_cos();
87    let dx = p.x - arc.center.x;
88    let dy = p.y - arc.center.y;
89    let qx = (-sm * dx + cm * dy).abs();
90    let qy = cm * dx + sm * dy;
91
92    let sc = (half_sweep.sin().max(0.0), half_sweep.cos());
93
94    let mut dist = if sc.1 * qx > sc.0 * qy {
95        ((qx - sc.0 * ra).powi(2) + (qy - sc.1 * ra).powi(2)).sqrt() - rb
96    } else {
97        ((qx * qx + qy * qy).sqrt() - ra).abs() - rb
98    };
99
100    let plane = sc.1 * qx - sc.0 * qy;
101    match arc.cap {
102        StrokeCap::Butt => dist = dist.max(plane),
103        StrokeCap::Square => dist = dist.max(plane - rb),
104        StrokeCap::Round => {}
105    }
106    dist
107}
108
109/// Antialiased coverage for a signed distance, matching the shader's
110/// `1.0 - smoothstep(-0.5, 0.5, d)`.
111pub fn coverage_for_distance(distance: f32) -> f32 {
112    if !distance.is_finite() {
113        return 0.0;
114    }
115    let t = ((distance + 0.5).clamp(0.0, 1.0)) as f64;
116    let smooth = t * t * (3.0 - 2.0 * t);
117    (1.0 - smooth) as f32
118}
119
120/// Coverage of `point` by a stroked rect/round-rect whose (already inflated)
121/// bounds are `rect`.
122pub fn stroked_rect_coverage(
123    point: Point,
124    rect: Rect,
125    radii: Option<CornerRadii>,
126    half_width: f32,
127    join: StrokeJoin,
128) -> f32 {
129    let half_size = (rect.width * 0.5, rect.height * 0.5);
130    let local = Point::new(
131        point.x - (rect.x + half_size.0),
132        point.y - (rect.y + half_size.1),
133    );
134    let radii = radii.unwrap_or_default();
135    let distance = sdf_stroked_rounded_rect(
136        local,
137        half_size,
138        [
139            radii.top_left,
140            radii.top_right,
141            radii.bottom_left,
142            radii.bottom_right,
143        ],
144        half_width,
145        join,
146    );
147    coverage_for_distance(distance)
148}
149
150/// Coverage of `point` by an arc band.
151pub fn arc_coverage(point: Point, arc: &ArcGeometry) -> f32 {
152    coverage_for_distance(sdf_arc_band(point, arc))
153}
154
155#[cfg(test)]
156#[path = "tests/shape_sdf_tests.rs"]
157mod tests;