facett-core/src/render/scene3d.rs
//! **Scene3D — the CPU half of the one 3D GPU path** (flat quads, screen-width lines
//! and world-space text planes, depth-tested together). Not behind feature `wgpu`: a
//! host builds a [`Scene3D`] and a [`Camera3`] on every build, picks with
//! [`Camera3::project`] and paints its CPU fallback from the same data; the GPU lane
//! (`render::gpu::scene3d`, feature `wgpu`) draws it.
/// A flat parallelogram `origin + s·u + t·v` (`s, t ∈ 0..1`), one colour, alpha-blended,
/// depth-tested and depth-writing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quad3 {
pub origin: [f32; 3],
pub u: [f32; 3],
pub v: [f32; 3],
pub color: [u8; 4],
}
/// A line segment drawn as a ribbon `width_px` screen pixels wide, depth-tested. A
/// spline is the caller's: tessellate it into many of these.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Line3 {
pub a: [f32; 3],
pub b: [f32; 3],
pub width_px: f32,
pub color: [u8; 4],
}
/// Flat text on a plane in WORLD space: the first line's baseline starts at `origin`,
/// `right` is the world vector of one em along the baseline and `up` the world vector of
/// one em of height. Monospaced ([`MONO_ADVANCE`] em a glyph); `'\n'` starts a new line
/// [`LINE_ADVANCE`] em lower (along `-up`), `'\t'` is four columns.
#[derive(Clone, Debug, PartialEq)]
pub struct Text3 {
pub origin: [f32; 3],
pub right: [f32; 3],
pub up: [f32; 3],
pub text: String,
pub color: [u8; 4],
}
/// One frame of the 3D path. Colours are egui's (gamma-space RGBA), written as they are.
#[derive(Clone, Debug, PartialEq)]
pub struct Scene3D {
pub quads: Vec<Quad3>,
pub lines: Vec<Line3>,
pub texts: Vec<Text3>,
/// **Translucent sheets** (an unfolded source panel): drawn after every quad, line
/// and text, far to near, depth-tested but NOT depth-writing — what stands behind a
/// sheet (a spline, a card) shows through it, dimmed by its alpha, instead of being
/// cut away as a depth-writing [`Quad3`] would.
pub sheets: Vec<Quad3>,
/// The words ON the sheets, drawn after them (so a sheet never dims its own text).
pub sheet_texts: Vec<Text3>,
/// The background the pane is cleared to (RGBA, 0..1).
pub clear: [f32; 4],
/// A [`Text3`] whose em projects smaller than this many physical px is not drawn; it
/// fades in between this and 1.5× it. `0` draws every text.
pub min_text_px: f32,
}
impl Default for Scene3D {
fn default() -> Self {
Self { quads: Vec::new(), lines: Vec::new(), texts: Vec::new(), sheets: Vec::new(), sheet_texts: Vec::new(), clear: [0.02, 0.022, 0.03, 1.0], min_text_px: 6.0 }
}
}
/// The advance of one glyph, in em — the monospace font's (Hack: 1233 / 2048).
pub const MONO_ADVANCE: f32 = 1233.0 / 2048.0;
/// The distance between two baselines, in em.
pub const LINE_ADVANCE: f32 = 1.25;
/// A perspective look-at camera, y-up world. Screen space is physical px, y down.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera3 {
pub eye: [f32; 3],
pub target: [f32; 3],
pub up: [f32; 3],
/// Vertical field of view, radians.
pub fovy: f32,
pub near: f32,
pub far: f32,
}
impl Default for Camera3 {
fn default() -> Self {
Self { eye: [0.0, 0.0, 10.0], target: [0.0; 3], up: [0.0, 1.0, 0.0], fovy: 0.8, near: 0.05, far: 10_000.0 }
}
}
impl Camera3 {
/// The column-major view-projection (wgpu clip space: depth `0..1`).
#[must_use]
pub fn view_proj(&self, aspect: f32) -> [[f32; 4]; 4] {
self.matrix(aspect).to_cols_array_2d()
}
fn matrix(&self, aspect: f32) -> glam::Mat4 {
let v = |a: [f32; 3]| glam::Vec3::from_array(a);
let view = glam::camera::rh::view::look_at_mat4(v(self.eye), v(self.target), v(self.up));
let near = self.near.max(1e-6);
// wgpu clip space: depth 0..1 (the DirectX convention)
let proj = glam::camera::rh::proj::directx::perspective(self.fovy.clamp(1e-3, 3.1), aspect.max(1e-6), near, self.far.max(near * 1.001));
proj * view
}
/// Where `p` lands on a `w × h` px screen (y down) and its depth (`0..1`, near to
/// far) — the CPU twin of the shader's projection. `None` behind the camera.
#[must_use]
pub fn project(&self, p: [f32; 3], w: f32, h: f32) -> Option<([f32; 2], f32)> {
let c = self.matrix(w / h.max(1e-6)) * glam::Vec4::new(p[0], p[1], p[2], 1.0);
if c.w <= 1e-6 {
return None;
}
let n = c.truncate() / c.w;
Some(([(n.x * 0.5 + 0.5) * w, (0.5 - n.y * 0.5) * h], n.z))
}
/// How many px one em of `up` at `at` spans on a `w × h` screen (`0` when it cannot
/// be projected).
#[must_use]
pub fn em_px(&self, at: [f32; 3], up: [f32; 3], w: f32, h: f32) -> f32 {
let top = [at[0] + up[0], at[1] + up[1], at[2] + up[2]];
match (self.project(at, w, h), self.project(top, w, h)) {
(Some((a, _)), Some((b, _))) => ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt(),
_ => 0.0,
}
}
}
/// One glyph of a laid-out [`Text3`]: the character and the world point its pen starts at
/// (baseline-left).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlyphAt {
pub ch: char,
pub pen: [f32; 3],
}
/// Lay a text out: every visible glyph with its pen position (spaces advance and are not
/// listed).
#[must_use]
pub fn layout_text(t: &Text3) -> Vec<GlyphAt> {
let mut out = Vec::with_capacity(t.text.len());
let (mut col, mut row) = (0usize, 0usize);
for ch in t.text.chars() {
match ch {
'\n' => {
col = 0;
row += 1;
}
'\t' => col = (col / 4 + 1) * 4,
c if c.is_whitespace() || c.is_control() => col += 1,
c => {
let (dx, dy) = (col as f32 * MONO_ADVANCE, -(row as f32) * LINE_ADVANCE);
let pen = std::array::from_fn(|k| t.origin[k] + t.right[k] * dx + t.up[k] * dy);
out.push(GlyphAt { ch: c, pen });
col += 1;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_target_projects_to_the_screen_centre() {
let cam = Camera3 { eye: [3.0, 4.0, 12.0], target: [1.0, -2.0, 0.5], ..Camera3::default() };
let (xy, depth) = cam.project(cam.target, 800.0, 600.0).expect("in front");
assert!((xy[0] - 400.0).abs() < 1e-2 && (xy[1] - 300.0).abs() < 1e-2, "{xy:?}");
assert!(depth > 0.0 && depth < 1.0, "{depth}");
// up in the world is up on the screen (y down), and behind the eye is nothing
let above = cam.project([1.0, -1.0, 0.5], 800.0, 600.0).unwrap().0;
assert!(above[1] < 300.0, "{above:?}");
assert!(cam.project([3.0 * 2.0 - 1.0, 4.0 * 2.0 + 2.0, 12.0 * 2.0 - 0.5], 800.0, 600.0).is_none());
// nearer is a smaller depth
let near = cam.project([2.0, 1.0, 6.25], 800.0, 600.0).unwrap().1;
assert!(near < depth, "{near} {depth}");
}
#[test]
fn text_advances_monospaced_and_wraps_on_newline() {
let t = Text3 { origin: [1.0, 2.0, 3.0], right: [2.0, 0.0, 0.0], up: [0.0, 2.0, 0.0], text: "fn a\nb".into(), color: [255; 4] };
let g = layout_text(&t);
let chars: String = g.iter().map(|g| g.ch).collect();
assert_eq!(chars, "fnab", "spaces and newlines are not glyphs");
let adv = 2.0 * MONO_ADVANCE;
assert!((g[1].pen[0] - g[0].pen[0] - adv).abs() < 1e-5, "{g:?}");
assert!((g[2].pen[0] - (1.0 + 3.0 * adv)).abs() < 1e-5, "the space advanced: {g:?}");
assert_eq!(g[3].pen, [1.0, 2.0 - 2.0 * LINE_ADVANCE, 3.0], "a new line starts under the first");
}
#[test]
fn an_em_measures_its_projected_height() {
let cam = Camera3 { eye: [0.0, 0.0, 10.0], ..Camera3::default() };
let near = cam.em_px([0.0; 3], [0.0, 1.0, 0.0], 800.0, 600.0);
let far = Camera3 { eye: [0.0, 0.0, 30.0], ..cam }.em_px([0.0; 3], [0.0, 1.0, 0.0], 800.0, 600.0);
assert!(near > 10.0 && (near / far - 3.0).abs() < 0.2, "{near} {far}");
}
}