Skip to main content

facett_core/render/
prim.rs

1//! **The L0 SDF instance model** — the domain-agnostic primitive vocabulary both
2//! the CPU raster ([`super::cpu::sdf`]) and the GPU pipeline
3//! ([`super::gpu::sdf_pipeline`], feature `wgpu`) draw from.
4//!
5//! Everything here is a flat, `bytemuck`-able POD describing **one instance** of a
6//! signed-distance shape in **screen space** (pixels). The renderer expands each
7//! into a screen-aligned AA quad whose fragment shader (GPU) or per-pixel coverage
8//! loop (CPU) evaluates the same signed-distance function — so the two backends
9//! produce matching pixels (the `sdf_primitives` parity test pins this).
10//!
11//! There is no map/graph here on purpose: a node, a city marker, a POI dot, and a
12//! waypoint are all just a [`CircleInstance`]/[`MarkerInstance`]; a road, an edge,
13//! and a leader line are all a [`LineInstance`]. The skins (Phase C) translate
14//! their domain into these.
15//!
16//! ## Coordinate + colour contract
17//! - Positions, radii, widths, and AA bands are in **screen pixels** (`f32`).
18//! - Colours are linear-ish straight `[r, g, b, a]` in `[0, 1]` — exactly what
19//!   [`super::gpu::types::color32_to_f32`] produces, so a `Color32` flows through
20//!   unchanged.
21//! - `aa` is the **half-width of the anti-alias band** in pixels: coverage ramps
22//!   `1 → 0` across `[edge − aa, edge + aa]`. `aa = 0` ⇒ a hard (aliased) edge.
23
24#[cfg(feature = "wgpu")]
25use bytemuck::{Pod, Zeroable};
26
27/// Shape discriminant carried in the instance so one quad pipeline draws every
28/// SDF kind. Kept as a `u32` (not a Rust `enum`) so it is GPU-uploadable verbatim
29/// and matches the `SHAPE_*` constants in `sdf.wgsl`.
30pub mod shape {
31    /// A filled, anti-aliased disc.
32    pub const CIRCLE: u32 = 0;
33    /// An annulus (filled ring with a hole) — `inner` is the hole radius.
34    pub const RING: u32 = 1;
35    /// A filled axis-aligned rounded square marker (`inner` = corner radius).
36    pub const SQUARE: u32 = 2;
37    /// A filled upward equilateral-ish triangle marker.
38    pub const TRIANGLE: u32 = 3;
39    /// A filled diamond (square rotated 45°).
40    pub const DIAMOND: u32 = 4;
41}
42
43/// A screen-aligned quad instance carrying an SDF shape — the **one** instance
44/// type the GPU pipeline draws (a `CircleInstance`/`RingInstance`/`MarkerInstance`
45/// all lower into this). The vertex shader expands `center ± (radius + aa)` into a
46/// quad; the fragment shader evaluates the SDF picked by `shape`.
47///
48/// 48 bytes, `#[repr(C)]` — the GPU instance-buffer layout (matches `sdf.wgsl`).
49#[repr(C)]
50#[derive(Clone, Copy, Debug, PartialEq)]
51#[cfg_attr(feature = "wgpu", derive(Pod, Zeroable))]
52pub struct QuadInstance {
53    /// Centre in screen pixels.
54    pub center: [f32; 2],
55    /// Outer radius (the disc / ring outer / marker half-extent), pixels.
56    pub radius: f32,
57    /// Inner radius — ring hole / rounded-square corner radius, pixels. `0` for a
58    /// plain circle / sharp marker.
59    pub inner: f32,
60    /// Straight RGBA in `[0, 1]`.
61    pub color: [f32; 4],
62    /// Anti-alias band half-width in pixels.
63    pub aa: f32,
64    /// One of the [`shape`] tags.
65    pub shape: u32,
66    /// Padding to a 16-byte multiple (48 bytes) so an instance array is naturally
67    /// aligned for the GPU.
68    pub _pad: [f32; 2],
69}
70
71impl QuadInstance {
72    /// The quad's half-extent: outer radius plus the AA band (the vertex shader
73    /// and the CPU raster both inflate the bound by this so the AA fringe is not
74    /// clipped).
75    #[inline]
76    pub fn half_extent(&self) -> f32 {
77        self.radius + self.aa
78    }
79}
80
81/// A filled anti-aliased disc. Lowers to a [`QuadInstance`] with `shape = CIRCLE`.
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct CircleInstance {
84    pub center: [f32; 2],
85    pub radius: f32,
86    pub color: [f32; 4],
87    pub aa: f32,
88}
89
90impl CircleInstance {
91    pub fn lower(self) -> QuadInstance {
92        QuadInstance {
93            center: self.center,
94            radius: self.radius,
95            inner: 0.0,
96            color: self.color,
97            aa: self.aa,
98            shape: shape::CIRCLE,
99            _pad: [0.0, 0.0],
100        }
101    }
102}
103
104/// An anti-aliased annulus (filled ring with a hole). `inner < radius`.
105#[derive(Clone, Copy, Debug, PartialEq)]
106pub struct RingInstance {
107    pub center: [f32; 2],
108    /// Outer radius.
109    pub radius: f32,
110    /// Inner (hole) radius — must be `< radius`.
111    pub inner: f32,
112    pub color: [f32; 4],
113    pub aa: f32,
114}
115
116impl RingInstance {
117    pub fn lower(self) -> QuadInstance {
118        QuadInstance {
119            center: self.center,
120            radius: self.radius,
121            inner: self.inner.clamp(0.0, self.radius),
122            color: self.color,
123            aa: self.aa,
124            shape: shape::RING,
125            _pad: [0.0, 0.0],
126        }
127    }
128}
129
130/// A filled marker glyph (square / triangle / diamond) sized by `radius`.
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct MarkerInstance {
133    pub center: [f32; 2],
134    /// Half-extent of the marker (a square spans `2*radius`).
135    pub radius: f32,
136    /// Corner radius for [`shape::SQUARE`]; ignored by triangle/diamond.
137    pub corner: f32,
138    pub color: [f32; 4],
139    pub aa: f32,
140    /// One of [`shape::SQUARE`] / [`shape::TRIANGLE`] / [`shape::DIAMOND`].
141    pub shape: u32,
142}
143
144impl MarkerInstance {
145    pub fn lower(self) -> QuadInstance {
146        QuadInstance {
147            center: self.center,
148            radius: self.radius,
149            inner: self.corner,
150            color: self.color,
151            aa: self.aa,
152            shape: self.shape,
153            _pad: [0.0, 0.0],
154        }
155    }
156}
157
158/// A thick anti-aliased line segment (a "capsule": a stadium of half-width
159/// `half_width` around the segment `a → b`). The GPU pipeline expands it into an
160/// oriented quad covering the segment ± `(half_width + aa)`; the CPU raster does
161/// the same with a per-pixel distance-to-segment.
162///
163/// 48 bytes, `#[repr(C)]` — the GPU line instance-buffer layout (matches
164/// `line.wgsl`).
165#[repr(C)]
166#[derive(Clone, Copy, Debug, PartialEq)]
167#[cfg_attr(feature = "wgpu", derive(Pod, Zeroable))]
168pub struct LineInstance {
169    /// Segment start, screen pixels.
170    pub a: [f32; 2],
171    /// Segment end, screen pixels.
172    pub b: [f32; 2],
173    /// Half the stroke width (the segment's distance ceiling), pixels.
174    pub half_width: f32,
175    /// Anti-alias band half-width, pixels.
176    pub aa: f32,
177    /// `0` = butt cap (rectangle, no rounding past the endpoints); `1` = round cap
178    /// (the full stadium). Matches `CAP_*` in `line.wgsl`.
179    pub cap: u32,
180    /// Padding to 16-byte alignment (the colour starts on a vec4 boundary).
181    pub _pad0: u32,
182    /// Straight RGBA in `[0, 1]`.
183    pub color: [f32; 4],
184}
185
186/// Cap styles for [`LineInstance`].
187pub mod cap {
188    /// Square/butt cap — the line ends flush at the endpoints (no overshoot).
189    pub const BUTT: u32 = 0;
190    /// Round cap — a half-disc of `half_width` past each endpoint.
191    pub const ROUND: u32 = 1;
192}
193
194impl LineInstance {
195    /// A round-capped line between two points.
196    pub fn round(a: [f32; 2], b: [f32; 2], half_width: f32, aa: f32, color: [f32; 4]) -> Self {
197        Self { a, b, half_width, aa, cap: cap::ROUND, _pad0: 0, color }
198    }
199    /// A butt-capped line between two points.
200    pub fn butt(a: [f32; 2], b: [f32; 2], half_width: f32, aa: f32, color: [f32; 4]) -> Self {
201        Self { a, b, half_width, aa, cap: cap::BUTT, _pad0: 0, color }
202    }
203    /// Axis-aligned bounding box (inflated by `half_width + aa`) the raster needs
204    /// to visit — `(min_x, min_y, max_x, max_y)`.
205    #[inline]
206    pub fn bounds(&self) -> (f32, f32, f32, f32) {
207        let r = self.half_width + self.aa;
208        let min_x = self.a[0].min(self.b[0]) - r;
209        let max_x = self.a[0].max(self.b[0]) + r;
210        let min_y = self.a[1].min(self.b[1]) - r;
211        let max_y = self.a[1].max(self.b[1]) + r;
212        (min_x, min_y, max_x, max_y)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    /// INJECT-ASSERT: each typed primitive lowers to a `QuadInstance` carrying the
221    /// right shape tag + geometry (so one quad pipeline draws them all).
222    #[test]
223    fn typed_primitives_lower_to_the_right_quad_shape() {
224        let c = CircleInstance { center: [10.0, 20.0], radius: 5.0, color: [1.0, 0.0, 0.0, 1.0], aa: 1.0 }
225            .lower();
226        assert_eq!(c.shape, shape::CIRCLE);
227        assert_eq!(c.inner, 0.0);
228        assert_eq!(c.center, [10.0, 20.0]);
229        assert_eq!(c.half_extent(), 6.0);
230
231        let r = RingInstance { center: [0.0, 0.0], radius: 8.0, inner: 4.0, color: [0.0; 4], aa: 1.5 }
232            .lower();
233        assert_eq!(r.shape, shape::RING);
234        assert_eq!(r.inner, 4.0);
235
236        // inner is clamped into [0, radius] so a malformed ring can't make the SDF
237        // produce a negative-thickness annulus.
238        let bad = RingInstance { center: [0.0, 0.0], radius: 3.0, inner: 9.0, color: [0.0; 4], aa: 1.0 }
239            .lower();
240        assert_eq!(bad.inner, 3.0, "inner clamped to radius");
241
242        let m = MarkerInstance {
243            center: [1.0, 2.0],
244            radius: 6.0,
245            corner: 1.0,
246            color: [0.0; 4],
247            aa: 1.0,
248            shape: shape::DIAMOND,
249        }
250        .lower();
251        assert_eq!(m.shape, shape::DIAMOND);
252        assert_eq!(m.inner, 1.0, "corner carried in inner");
253    }
254
255    /// INJECT-ASSERT: a line's inflated bounds enclose both endpoints + the
256    /// stroke + AA on every side.
257    #[test]
258    fn line_bounds_inflate_by_halfwidth_plus_aa() {
259        let l = LineInstance::round([10.0, 10.0], [30.0, 14.0], 3.0, 1.0, [1.0; 4]);
260        let (mnx, mny, mxx, mxy) = l.bounds();
261        assert_eq!(mnx, 10.0 - 4.0);
262        assert_eq!(mny, 10.0 - 4.0);
263        assert_eq!(mxx, 30.0 + 4.0);
264        assert_eq!(mxy, 14.0 + 4.0);
265        assert_eq!(l.cap, cap::ROUND);
266        assert_eq!(LineInstance::butt([0.0; 2], [1.0, 0.0], 1.0, 0.0, [0.0; 4]).cap, cap::BUTT);
267    }
268}