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}