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. The arc
6//! band bounds the same shape another way: the GPU measures a point from the
7//! sweep's middle, the CPU from the end it lies near, so a sweep that changes
8//! leaves the pixels away from its moving end.
9//!
10//! Angle convention (shared with `cranpose_ui_graphics::stroke`): radians, `0`
11//! along +X, increasing clockwise on screen (y-down device space).
12
13use cranpose_ui_graphics::{ArcGeometry, CornerRadii, Point, Rect, StrokeCap, StrokeJoin};
14
15mod edge_coverage;
16
17const INV_SQRT2: f32 = std::f32::consts::FRAC_1_SQRT_2;
18
19/// Signed distance to a rounded box centered at the origin.
20///
21/// `radii` is ordered exactly like the WGSL `vec4`: top-left, top-right,
22/// bottom-left, bottom-right.
23pub fn sdf_rounded_rect(p: Point, half_size: (f32, f32), radii: [f32; 4]) -> f32 {
24    let reach = RoundedReach::at(p, half_size, radii);
25    reach.inside + reach.outside_squared.sqrt() - reach.radius
26}
27
28// The parts of `sdf_rounded_rect`'s distance at a point: the distance inside
29// the box, the square of the distance outside it and the radius of the
30// corner whose quarter holds the point.
31struct RoundedReach {
32    inside: f32,
33    outside_squared: f32,
34    radius: f32,
35}
36
37impl RoundedReach {
38    fn at(p: Point, half_size: (f32, f32), radii: [f32; 4]) -> Self {
39        let radius = match (p.x > 0.0, p.y > 0.0) {
40            (false, false) => radii[0],
41            (true, false) => radii[1],
42            (false, true) => radii[2],
43            (true, true) => radii[3],
44        };
45        let qx = p.x.abs() - half_size.0 + radius;
46        let qy = p.y.abs() - half_size.1 + radius;
47        Self {
48            inside: qx.max(qy).min(0.0),
49            outside_squared: qx.max(0.0).powi(2) + qy.max(0.0).powi(2),
50            radius,
51        }
52    }
53}
54
55/// Signed distance to the outline of a rounded box stroked with a centered
56/// stroke of width `2 * half_width`.
57///
58/// `half_size` is the **inflated** box (geometry plus `half_width` on every
59/// side), matching what the renderer hands the shader.
60pub fn sdf_stroked_rounded_rect(
61    p: Point,
62    half_size: (f32, f32),
63    radii: [f32; 4],
64    half_width: f32,
65    join: StrokeJoin,
66) -> f32 {
67    StrokedBox::new(Point::ZERO, half_size, radii, half_width, join).distance(p)
68}
69
70/// The band a stroke covers around a rounded box, with the sizes and radii
71/// of its two edges worked out once, for the distances of many points.
72pub struct StrokedBox {
73    center: Point,
74    outer: ((f32, f32), [f32; 4]),
75    inner: ((f32, f32), [f32; 4]),
76    // Where a bevel join cuts each corner, along |x| + |y|.
77    bevel: Option<f32>,
78}
79
80impl StrokedBox {
81    /// The band [`sdf_stroked_rounded_rect`] measures, around a box centred
82    /// on `center`.
83    pub fn new(
84        center: Point,
85        half_size: (f32, f32),
86        radii: [f32; 4],
87        half_width: f32,
88        join: StrokeJoin,
89    ) -> Self {
90        let hw = half_width.max(0.0);
91        let geom = ((half_size.0 - hw).max(0.0), (half_size.1 - hw).max(0.0));
92
93        let mut outer_radii = [radii[0] + hw, radii[1] + hw, radii[2] + hw, radii[3] + hw];
94        if join != StrokeJoin::Round {
95            for (out, r) in outer_radii.iter_mut().zip(radii.iter()) {
96                if *r < 0.0001 {
97                    *out = 0.0;
98                }
99            }
100        }
101        let inner_radii = [
102            (radii[0] - hw).max(0.0),
103            (radii[1] - hw).max(0.0),
104            (radii[2] - hw).max(0.0),
105            (radii[3] - hw).max(0.0),
106        ];
107        Self {
108            center,
109            outer: ((geom.0 + hw, geom.1 + hw), outer_radii),
110            inner: (
111                ((geom.0 - hw).max(0.0), (geom.1 - hw).max(0.0)),
112                inner_radii,
113            ),
114            bevel: (join == StrokeJoin::Bevel).then_some(geom.0 + geom.1 + hw),
115        }
116    }
117
118    /// The band of the stroke [`stroked_rect_coverage`] covers.
119    pub fn of_rect(
120        rect: Rect,
121        radii: Option<CornerRadii>,
122        half_width: f32,
123        join: StrokeJoin,
124    ) -> Self {
125        let half_size = (rect.width * 0.5, rect.height * 0.5);
126        let radii = radii.unwrap_or_default();
127        Self::new(
128            Point::new(rect.x + half_size.0, rect.y + half_size.1),
129            half_size,
130            [
131                radii.top_left,
132                radii.top_right,
133                radii.bottom_left,
134                radii.bottom_right,
135            ],
136            half_width,
137            join,
138        )
139    }
140
141    /// The signed distance from `point` to the edge of the band.
142    pub fn distance(&self, point: Point) -> f32 {
143        let p = Point::new(point.x - self.center.x, point.y - self.center.y);
144        let outer = RoundedReach::at(p, self.outer.0, self.outer.1);
145        let inner = RoundedReach::at(p, self.inner.0, self.inner.1);
146        let outer_outside = outer.outside_squared.sqrt();
147        // Unless a corner's radius is under the band's half width, its two
148        // edges' circles share a centre, and the point lies as far outside
149        // both boxes: one root serves both. Equal bits give an equal root.
150        let inner_outside = if inner.outside_squared.to_bits() == outer.outside_squared.to_bits() {
151            outer_outside
152        } else {
153            inner.outside_squared.sqrt()
154        };
155        let mut dist = (outer.inside + outer_outside - outer.radius)
156            .max(-(inner.inside + inner_outside - inner.radius));
157        if let Some(cut) = self.bevel {
158            let chamfer = (p.x.abs() + p.y.abs() - cut) * INV_SQRT2;
159            dist = dist.max(chamfer);
160        }
161        dist
162    }
163}
164
165/// Signed distance to a circular band limited to an angular sweep — the shape
166/// behind both stroked arcs and filled annular sectors.
167pub fn sdf_arc_band(p: Point, arc: &ArcGeometry) -> f32 {
168    ArcBand::new(arc).distance(p)
169}
170
171/// An arc's band with the directions of its two ends worked out once, for
172/// the distances of many points. A point's distance reads only the end it
173/// lies near: an arc whose sweep changes keeps the distance of every point
174/// away from the end that moved, so the pixels there keep their colour.
175pub struct ArcBand {
176    center: Point,
177    mid_radius: f32,
178    half_thickness: f32,
179    // The unit vectors from the centre along the angles the sweep starts and
180    // ends at.
181    start: Point,
182    end: Point,
183    span: Span,
184    cap: StrokeCap,
185}
186
187// How much of a turn a sweep spans, which decides how its two ends bound it.
188#[derive(Clone, Copy)]
189enum Span {
190    // Up to half a turn: a point lies in the sweep when it lies after the
191    // start and before the end.
192    Narrow,
193    // Over half a turn: when it lies after the start or before the end.
194    Wide,
195    // A whole turn: every point does.
196    Full,
197}
198
199impl ArcBand {
200    /// The band of `arc`.
201    pub fn new(arc: &ArcGeometry) -> Self {
202        let sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU);
203        let direction = |angle: f32| {
204            let (sin, cos) = angle.sin_cos();
205            Point::new(cos, sin)
206        };
207        Self {
208            center: arc.center,
209            mid_radius: arc.mid_radius(),
210            half_thickness: arc.half_thickness().max(0.0),
211            start: direction(arc.start_angle),
212            end: direction(arc.start_angle + sweep),
213            span: if sweep >= cranpose_ui_graphics::TAU {
214                Span::Full
215            } else if sweep > std::f32::consts::PI {
216                Span::Wide
217            } else {
218                Span::Narrow
219            },
220            cap: arc.cap,
221        }
222    }
223
224    /// [`sdf_arc_band`] at `p`.
225    pub fn distance(&self, p: Point) -> f32 {
226        let ra = self.mid_radius;
227        let rb = self.half_thickness;
228        let dx = p.x - self.center.x;
229        let dy = p.y - self.center.y;
230        // How far the point lies past each end's line, away from the sweep;
231        // negative on the sweep's side.
232        let past_start = self.start.y * dx - self.start.x * dy;
233        let past_end = self.end.x * dy - self.end.y * dx;
234        let inside = match self.span {
235            Span::Narrow => past_start <= 0.0 && past_end <= 0.0,
236            Span::Wide => past_start <= 0.0 || past_end <= 0.0,
237            Span::Full => true,
238        };
239        if inside {
240            let band = ((dx * dx + dy * dy).sqrt() - ra).abs() - rb;
241            return match self.cap {
242                StrokeCap::Butt => band
243                    .max(butt_cut(self.start, past_start, dx, dy))
244                    .max(butt_cut(self.end, past_end, dx, dy)),
245                StrokeCap::Round | StrokeCap::Square => band,
246            };
247        }
248        // Past the sweep, the cap of the nearer end.
249        let cap = |end: Point, past: f32| {
250            let round = ((dx - ra * end.x).powi(2) + (dy - ra * end.y).powi(2)).sqrt() - rb;
251            match self.cap {
252                StrokeCap::Butt => round.max(past),
253                StrokeCap::Square => round.max(past - rb),
254                StrokeCap::Round => round,
255            }
256        };
257        match (self.cap, past_start > 0.0, past_end > 0.0) {
258            // A butt end bounds only the points past its own line; a round
259            // or a square cap reaches back past the other end's line on a
260            // short sweep.
261            (StrokeCap::Butt, true, false) => cap(self.start, past_start),
262            (StrokeCap::Butt, false, true) => cap(self.end, past_end),
263            _ => cap(self.start, past_start).min(cap(self.end, past_end)),
264        }
265    }
266
267    /// The antialiased coverage of `p`, as [`arc_coverage`] gives it.
268    pub fn coverage(&self, p: Point) -> f32 {
269        coverage_for_distance(self.distance(p))
270    }
271}
272
273// The distance a butt end's line cuts a point of the sweep by: the point's
274// distance before the line, for a point within a quarter turn of the end.
275// Further round the circle the line no longer bounds the band.
276fn butt_cut(end: Point, past: f32, dx: f32, dy: f32) -> f32 {
277    if past <= 0.0 && end.x * dx + end.y * dy > 0.0 {
278        past
279    } else {
280        f32::NEG_INFINITY
281    }
282}
283
284/// Antialiased coverage for a signed distance, matching the shader's
285/// `1.0 - smoothstep(-0.5, 0.5, d)`.
286pub fn coverage_for_distance(distance: f32) -> f32 {
287    if !distance.is_finite() {
288        return 0.0;
289    }
290    let t = (distance + 0.5).clamp(0.0, 1.0);
291    // Inside and outside the edge's pixel the smoothstep is exactly 0 or 1.
292    if t <= 0.0 {
293        return 1.0;
294    }
295    if t >= 1.0 {
296        return 0.0;
297    }
298    edge_coverage::edge_coverage(t)
299}
300
301/// Coverage of `point` by a stroked rect/round-rect whose (already inflated)
302/// bounds are `rect`.
303pub fn stroked_rect_coverage(
304    point: Point,
305    rect: Rect,
306    radii: Option<CornerRadii>,
307    half_width: f32,
308    join: StrokeJoin,
309) -> f32 {
310    coverage_for_distance(StrokedBox::of_rect(rect, radii, half_width, join).distance(point))
311}
312
313/// Coverage of `point` by an arc band.
314pub fn arc_coverage(point: Point, arc: &ArcGeometry) -> f32 {
315    ArcBand::new(arc).coverage(point)
316}
317
318#[cfg(test)]
319#[path = "tests/shape_sdf_tests.rs"]
320mod tests;