facett-core/src/render/prim.rs
//! **The L0 SDF instance model** — the domain-agnostic primitive vocabulary both
//! the CPU raster ([`super::cpu::sdf`]) and the GPU pipeline
//! ([`super::gpu::sdf_pipeline`], feature `wgpu`) draw from.
//!
//! Everything here is a flat, `bytemuck`-able POD describing **one instance** of a
//! signed-distance shape in **screen space** (pixels). The renderer expands each
//! into a screen-aligned AA quad whose fragment shader (GPU) or per-pixel coverage
//! loop (CPU) evaluates the same signed-distance function — so the two backends
//! produce matching pixels (the `sdf_primitives` parity test pins this).
//!
//! There is no map/graph here on purpose: a node, a city marker, a POI dot, and a
//! waypoint are all just a [`CircleInstance`]/[`MarkerInstance`]; a road, an edge,
//! and a leader line are all a [`LineInstance`]. The skins (Phase C) translate
//! their domain into these.
//!
//! ## Coordinate + colour contract
//! - Positions, radii, widths, and AA bands are in **screen pixels** (`f32`).
//! - Colours are linear-ish straight `[r, g, b, a]` in `[0, 1]` — exactly what
//! [`super::gpu::types::color32_to_f32`] produces, so a `Color32` flows through
//! unchanged.
//! - `aa` is the **half-width of the anti-alias band** in pixels: coverage ramps
//! `1 → 0` across `[edge − aa, edge + aa]`. `aa = 0` ⇒ a hard (aliased) edge.
#[cfg(feature = "wgpu")]
use bytemuck::{Pod, Zeroable};
/// Shape discriminant carried in the instance so one quad pipeline draws every
/// SDF kind. Kept as a `u32` (not a Rust `enum`) so it is GPU-uploadable verbatim
/// and matches the `SHAPE_*` constants in `sdf.wgsl`.
pub mod shape {
/// A filled, anti-aliased disc.
pub const CIRCLE: u32 = 0;
/// An annulus (filled ring with a hole) — `inner` is the hole radius.
pub const RING: u32 = 1;
/// A filled axis-aligned rounded square marker (`inner` = corner radius).
pub const SQUARE: u32 = 2;
/// A filled upward equilateral-ish triangle marker.
pub const TRIANGLE: u32 = 3;
/// A filled diamond (square rotated 45°).
pub const DIAMOND: u32 = 4;
}
/// A screen-aligned quad instance carrying an SDF shape — the **one** instance
/// type the GPU pipeline draws (a `CircleInstance`/`RingInstance`/`MarkerInstance`
/// all lower into this). The vertex shader expands `center ± (radius + aa)` into a
/// quad; the fragment shader evaluates the SDF picked by `shape`.
///
/// 48 bytes, `#[repr(C)]` — the GPU instance-buffer layout (matches `sdf.wgsl`).
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "wgpu", derive(Pod, Zeroable))]
pub struct QuadInstance {
/// Centre in screen pixels.
pub center: [f32; 2],
/// Outer radius (the disc / ring outer / marker half-extent), pixels.
pub radius: f32,
/// Inner radius — ring hole / rounded-square corner radius, pixels. `0` for a
/// plain circle / sharp marker.
pub inner: f32,
/// Straight RGBA in `[0, 1]`.
pub color: [f32; 4],
/// Anti-alias band half-width in pixels.
pub aa: f32,
/// One of the [`shape`] tags.
pub shape: u32,
/// Padding to a 16-byte multiple (48 bytes) so an instance array is naturally
/// aligned for the GPU.
pub _pad: [f32; 2],
}
impl QuadInstance {
/// The quad's half-extent: outer radius plus the AA band (the vertex shader
/// and the CPU raster both inflate the bound by this so the AA fringe is not
/// clipped).
#[inline]
pub fn half_extent(&self) -> f32 {
self.radius + self.aa
}
}
/// A filled anti-aliased disc. Lowers to a [`QuadInstance`] with `shape = CIRCLE`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CircleInstance {
pub center: [f32; 2],
pub radius: f32,
pub color: [f32; 4],
pub aa: f32,
}
impl CircleInstance {
pub fn lower(self) -> QuadInstance {
QuadInstance {
center: self.center,
radius: self.radius,
inner: 0.0,
color: self.color,
aa: self.aa,
shape: shape::CIRCLE,
_pad: [0.0, 0.0],
}
}
}
/// An anti-aliased annulus (filled ring with a hole). `inner < radius`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RingInstance {
pub center: [f32; 2],
/// Outer radius.
pub radius: f32,
/// Inner (hole) radius — must be `< radius`.
pub inner: f32,
pub color: [f32; 4],
pub aa: f32,
}
impl RingInstance {
pub fn lower(self) -> QuadInstance {
QuadInstance {
center: self.center,
radius: self.radius,
inner: self.inner.clamp(0.0, self.radius),
color: self.color,
aa: self.aa,
shape: shape::RING,
_pad: [0.0, 0.0],
}
}
}
/// A filled marker glyph (square / triangle / diamond) sized by `radius`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MarkerInstance {
pub center: [f32; 2],
/// Half-extent of the marker (a square spans `2*radius`).
pub radius: f32,
/// Corner radius for [`shape::SQUARE`]; ignored by triangle/diamond.
pub corner: f32,
pub color: [f32; 4],
pub aa: f32,
/// One of [`shape::SQUARE`] / [`shape::TRIANGLE`] / [`shape::DIAMOND`].
pub shape: u32,
}
impl MarkerInstance {
pub fn lower(self) -> QuadInstance {
QuadInstance {
center: self.center,
radius: self.radius,
inner: self.corner,
color: self.color,
aa: self.aa,
shape: self.shape,
_pad: [0.0, 0.0],
}
}
}
/// A thick anti-aliased line segment (a "capsule": a stadium of half-width
/// `half_width` around the segment `a → b`). The GPU pipeline expands it into an
/// oriented quad covering the segment ± `(half_width + aa)`; the CPU raster does
/// the same with a per-pixel distance-to-segment.
///
/// 48 bytes, `#[repr(C)]` — the GPU line instance-buffer layout (matches
/// `line.wgsl`).
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "wgpu", derive(Pod, Zeroable))]
pub struct LineInstance {
/// Segment start, screen pixels.
pub a: [f32; 2],
/// Segment end, screen pixels.
pub b: [f32; 2],
/// Half the stroke width (the segment's distance ceiling), pixels.
pub half_width: f32,
/// Anti-alias band half-width, pixels.
pub aa: f32,
/// `0` = butt cap (rectangle, no rounding past the endpoints); `1` = round cap
/// (the full stadium). Matches `CAP_*` in `line.wgsl`.
pub cap: u32,
/// Padding to 16-byte alignment (the colour starts on a vec4 boundary).
pub _pad0: u32,
/// Straight RGBA in `[0, 1]`.
pub color: [f32; 4],
}
/// Cap styles for [`LineInstance`].
pub mod cap {
/// Square/butt cap — the line ends flush at the endpoints (no overshoot).
pub const BUTT: u32 = 0;
/// Round cap — a half-disc of `half_width` past each endpoint.
pub const ROUND: u32 = 1;
}
impl LineInstance {
/// A round-capped line between two points.
pub fn round(a: [f32; 2], b: [f32; 2], half_width: f32, aa: f32, color: [f32; 4]) -> Self {
Self { a, b, half_width, aa, cap: cap::ROUND, _pad0: 0, color }
}
/// A butt-capped line between two points.
pub fn butt(a: [f32; 2], b: [f32; 2], half_width: f32, aa: f32, color: [f32; 4]) -> Self {
Self { a, b, half_width, aa, cap: cap::BUTT, _pad0: 0, color }
}
/// Axis-aligned bounding box (inflated by `half_width + aa`) the raster needs
/// to visit — `(min_x, min_y, max_x, max_y)`.
#[inline]
pub fn bounds(&self) -> (f32, f32, f32, f32) {
let r = self.half_width + self.aa;
let min_x = self.a[0].min(self.b[0]) - r;
let max_x = self.a[0].max(self.b[0]) + r;
let min_y = self.a[1].min(self.b[1]) - r;
let max_y = self.a[1].max(self.b[1]) + r;
(min_x, min_y, max_x, max_y)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// INJECT-ASSERT: each typed primitive lowers to a `QuadInstance` carrying the
/// right shape tag + geometry (so one quad pipeline draws them all).
#[test]
fn typed_primitives_lower_to_the_right_quad_shape() {
let c = CircleInstance { center: [10.0, 20.0], radius: 5.0, color: [1.0, 0.0, 0.0, 1.0], aa: 1.0 }
.lower();
assert_eq!(c.shape, shape::CIRCLE);
assert_eq!(c.inner, 0.0);
assert_eq!(c.center, [10.0, 20.0]);
assert_eq!(c.half_extent(), 6.0);
let r = RingInstance { center: [0.0, 0.0], radius: 8.0, inner: 4.0, color: [0.0; 4], aa: 1.5 }
.lower();
assert_eq!(r.shape, shape::RING);
assert_eq!(r.inner, 4.0);
// inner is clamped into [0, radius] so a malformed ring can't make the SDF
// produce a negative-thickness annulus.
let bad = RingInstance { center: [0.0, 0.0], radius: 3.0, inner: 9.0, color: [0.0; 4], aa: 1.0 }
.lower();
assert_eq!(bad.inner, 3.0, "inner clamped to radius");
let m = MarkerInstance {
center: [1.0, 2.0],
radius: 6.0,
corner: 1.0,
color: [0.0; 4],
aa: 1.0,
shape: shape::DIAMOND,
}
.lower();
assert_eq!(m.shape, shape::DIAMOND);
assert_eq!(m.inner, 1.0, "corner carried in inner");
}
/// INJECT-ASSERT: a line's inflated bounds enclose both endpoints + the
/// stroke + AA on every side.
#[test]
fn line_bounds_inflate_by_halfwidth_plus_aa() {
let l = LineInstance::round([10.0, 10.0], [30.0, 14.0], 3.0, 1.0, [1.0; 4]);
let (mnx, mny, mxx, mxy) = l.bounds();
assert_eq!(mnx, 10.0 - 4.0);
assert_eq!(mny, 10.0 - 4.0);
assert_eq!(mxx, 30.0 + 4.0);
assert_eq!(mxy, 14.0 + 4.0);
assert_eq!(l.cap, cap::ROUND);
assert_eq!(LineInstance::butt([0.0; 2], [1.0, 0.0], 1.0, 0.0, [0.0; 4]).cap, cap::BUTT);
}
}