facett-model3d/src/lib.rs
//! **facett-model3d** — a reusable 3D-model viewer [`Facet`]. It loads a glTF/`.glb`
//! mesh ([`load_glb`]), slowly auto-rotates it (injected-clock deterministic, FC-7,
//! exactly like `facett-graph3d`'s `RabbitLogo3D`), and draws it through facett's
//! **existing CPU painter path** — project → back-face cull → painter's-algorithm
//! depth sort → Lambert shade against each group's glTF base colour. No three-d /
//! rend3 / wgpu render pass, so it lights up identically native **and** in the wasm
//! demo and stays headless-snapshot-testable.
//!
//! Its one domain feature is a **status tint**: a named sub-mesh (default `"eyes"`)
//! is shaded against a green→yellow→red ramp keyed by a `status ∈ [0,1]` scalar
//! (1.0 = healthy/green, 0.0 = critical/red), via
//! [`facett_core::render::decor::gradient_by_scalar`]. The bundled procedural skull
//! ([`Model3D::skull`]) is the demo fixture — a status console as a spinning skull
//! whose eyes go red as things degrade.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`Model3DState`]** — the complete observable, serializable, round-trippable
//! **control** state: the orbit yaw + fixed tilt, the auto-spin toggle + rate, the
//! FC-7 injected clock, and the status scalar / target group / inline-slider flag.
//! It IS the [`Model3D`] Model: [`Model3D::state`] hands back a `&Model3DState` and
//! a headless driver ([`facett_core::harness`]) snapshots it after feeding a
//! `Vec<Msg>`. The **mesh** (vertex/normal/index data + glTF base colours) is the
//! *input* the viewer renders — held on the view, NOT in the Model (it is not the
//! state a driver mutates, and its raw geometry isn't the observable control
//! surface); the derived vertex/triangle/group tallies + the resolved status RGB
//! are published through a form-3 `custom_state_json`.
//! - **[`Msg`] + [`Model3D::update`]** — the single mutation path (FC-2): every input
//! (drag-orbit, auto-spin tick, tilt, spin toggle, view reset, status scalar/group,
//! inline-slider toggle, clock tick/pin) is a [`Msg`]; a headless driver reaches the
//! identical transitions the live gestures drive.
//! - **[`Model3D::view`]** (the [`facett_core::Elm`] view) — a **pure** paint (FC-9):
//! it reads `&self`, paints the toolbar + the CPU-lit spinning model, and *returns*
//! the [`Msg`]s the interactions produced; the
//! [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them via
//! [`update`](Model3D::update). It NEVER mutates the Model — the live auto-spin
//! advance is returned as a [`Msg::Spin`] (FC-7), not written into `self`.
use egui::{Color32, Pos2, Sense};
use facett_core::render::decor;
use facett_core::{FacetCaps, theme};
use serde::{Deserialize, Serialize};
pub mod glb;
pub mod mesh;
pub mod skull;
#[cfg(test)]
mod tests;
pub use mesh::{LoadError, Mesh3d, MeshGroup, load_glb};
/// The bundled procedural skull, baked to `.glb` by the `gen_skull` bin. A
/// license-clean, self-contained 3D fixture with a `"skull"` group and a status-
/// tinted `"eyes"` group. Regenerate with `cargo run -p facett-model3d --bin gen_skull`.
pub const SKULL_GLB: &[u8] = include_bytes!("../assets/skull.glb");
/// A stripped, geometry-only build of the **CC0 Khronos "Avocado" sample**
/// (textures removed, tinted avocado-green since this renderer has no texture
/// lane). Source: glTF-Sample-Assets (CC0). Demonstrates loading a real-world
/// glTF, not just the procedural skull.
pub const AVOCADO_GLB: &[u8] = include_bytes!("../assets/avocado.glb");
/// The status ramp: `0.0` critical **red** → `0.5` **yellow** → `1.0` healthy
/// **green** (straight RGBA). Sampled by [`decor::gradient_by_scalar`].
pub const STATUS_RAMP: &[(f32, [f32; 4])] = &[
(0.0, [0.90, 0.12, 0.12, 1.0]), // red
(0.5, [0.95, 0.80, 0.12, 1.0]), // yellow
(1.0, [0.20, 0.85, 0.32, 1.0]), // green
];
/// The default sub-mesh name the status tint targets.
pub const DEFAULT_STATUS_GROUP: &str = "eyes";
/// The default orbit yaw (radians) — a slight 3/4 turn.
const DEFAULT_YAW: f32 = 0.5;
/// The default fixed tilt (radians) about X for a 3/4 view.
const DEFAULT_TILT: f32 = 0.30;
/// The default auto-spin rate (radians per second of clock).
const DEFAULT_SPIN_RATE: f32 = 0.35;
/// Fraction of the viewport's half-min-extent the fitted model may occupy
/// ([`facett_core::render::cpu::fit_scale`]). `0.94` leaves a 6% frame of breathing
/// room around the bounding sphere at **every** pose — the guarantee that replaced
/// the old hard-coded `0.42 · min_elem` (which, for the bundled meshes, worked out
/// to an effective `0.443` only by luck of their aspect ratios).
const FIT_MARGIN: f32 = 0.94;
/// **The complete observable control state (FC-1 / FC-3)** of a [`Model3D`]: the
/// orbit/tilt pose, the auto-spin toggle + rate, the FC-7 injected clock, and the
/// status readout (scalar + target group + inline-slider flag). It is serializable
/// and round-trippable so a headless driver ([`facett_core::harness`]) snapshots it
/// after feeding a `Vec<Msg>`. The rendered **mesh** is NOT part of this Model — it
/// is the input geometry the viewer paints, held on the view.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Model3DState {
/// Orbit yaw (radians) about Y.
pub yaw: f32,
/// A fixed tilt (radians) about X for a 3/4 view.
pub tilt: f32,
/// Auto-spin toggle (a drag pauses it).
pub spin: bool,
/// Auto-spin rate (radians per second of clock).
pub spin_rate: f32,
/// Injected animation clock (seconds; FC-7). `Some` → pure spin function.
pub clock_s: Option<f64>,
/// Status scalar in `[0,1]`: `1.0` healthy (green) → `0.0` critical (red).
pub status: f32,
/// Which group the status tint paints (case-insensitive). `None` = tint nothing.
pub status_group: Option<String>,
/// Draw an inline status slider above the model (opt-in; off by default so the
/// bare viewer and headless snapshots are unchanged).
pub status_control: bool,
}
impl Default for Model3DState {
fn default() -> Self {
Self {
yaw: DEFAULT_YAW,
tilt: DEFAULT_TILT,
spin: true,
spin_rate: DEFAULT_SPIN_RATE,
clock_s: None,
status: 1.0,
status_group: Some(DEFAULT_STATUS_GROUP.to_string()),
status_control: false,
}
}
}
/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the [`Model3D`] through, the same effect a live gesture
/// produces. Applied by [`Model3D::update`], the **only** mutation path for the
/// [`Model3DState`] Model.
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Orbit the model by a yaw delta (radians) — a drag gesture. Pauses auto-spin.
Orbit(f32),
/// Advance the auto-spin by a yaw delta (the ambient-clock live tick; FC-7).
/// Unlike [`Msg::Orbit`] it keeps the spin flag on.
Spin(f32),
/// Set the orbit yaw directly (programmatic / a pasted pose).
SetYaw(f32),
/// Set the fixed tilt (radians) directly.
SetTilt(f32),
/// Toggle the auto-spin on/off.
SetSpin(bool),
/// Reset the pose (yaw + tilt) to the default 3/4 view. The status is kept.
ResetView,
/// Set the status scalar in `[0,1]` (clamped) — the green→yellow→red readout.
SetStatus(f32),
/// Choose the status-tinted group by name (`Some`) or disable the tint (`None`).
SetStatusGroup(Option<String>),
/// Show/hide the inline status slider above the model.
SetStatusControl(bool),
/// Advance the injected animation clock by `dt` seconds (FC-7).
Tick(f64),
/// Pin the injected animation clock to an absolute second value (FC-7).
SetClock(f64),
}
/// Side work as data (FC-8). The viewer does no I/O — every [`Msg`] mutates only the
/// in-memory [`Model3DState`] — so this is uninhabited on purpose: the type-checked
/// statement that [`Model3D::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// A spinning, CPU-lit 3D-model viewer with a status-tinted sub-mesh.
pub struct Model3D {
pub title: String,
/// The loaded geometry the viewer paints: the *input* (not the Model — its raw
/// vertex/normal/index data + `[f32; 4]` glTF colours aren't the observable
/// control surface, and no [`Msg`] mutates it).
mesh: Mesh3d,
/// All observable control state (FC-1 / FC-3) — the [`facett_core::Elm`] Model.
state: Model3DState,
}
impl Model3D {
/// Build a viewer over an already-loaded [`Mesh3d`].
pub fn new(title: impl Into<String>, mesh: Mesh3d) -> Self {
Self { title: title.into(), mesh, state: Model3DState::default() }
}
/// Build from a glTF/`.glb` byte buffer (e.g. `include_bytes!`).
pub fn from_glb(title: impl Into<String>, bytes: &[u8]) -> Result<Self, LoadError> {
Ok(Self::new(title, load_glb(bytes)?))
}
/// The bundled spinning skull (eyes status-tinted) — the demo fixture. Loads
/// the embedded [`SKULL_GLB`]; infallible (the asset is a build artefact).
pub fn skull(title: impl Into<String>) -> Self {
Self::new(title, load_glb(SKULL_GLB).expect("bundled skull.glb is valid"))
}
/// The bundled CC0 "Avocado" sample ([`AVOCADO_GLB`]) — a real-world glTF. Its
/// single `"Avocado"` group is the status target, so the slider tints the whole
/// fruit green→yellow→red (a "ripeness" readout). Infallible.
pub fn avocado(title: impl Into<String>) -> Self {
let mut m = Self::new(title, load_glb(AVOCADO_GLB).expect("bundled avocado.glb is valid"));
m.state.status_group = Some("Avocado".to_string());
m
}
/// **Status** in `[0,1]` — `1.0` healthy (green status-group) → `0.0` critical
/// (red). Builder + setter forms.
pub fn with_status(mut self, s: f32) -> Self {
self.state.status = s.clamp(0.0, 1.0);
self
}
pub fn set_status(&mut self, s: f32) {
let _ = self.update(Msg::SetStatus(s));
}
pub fn status(&self) -> f32 {
self.state.status
}
/// Choose which sub-mesh the status tint paints (default `"eyes"`); `None`
/// disables the tint (every group keeps its glTF base colour).
pub fn with_status_group(mut self, name: impl Into<String>) -> Self {
self.state.status_group = Some(name.into());
self
}
pub fn without_status_group(mut self) -> Self {
self.state.status_group = None;
self
}
/// Draw an inline status slider above the model (lets a host expose the
/// green→yellow→red fade with no extra wiring). Off by default.
pub fn with_status_control(mut self, on: bool) -> Self {
self.state.status_control = on;
self
}
pub fn with_spin(mut self, on: bool) -> Self {
self.state.spin = on;
self
}
pub fn with_tilt(mut self, tilt: f32) -> Self {
self.state.tilt = tilt;
self
}
/// Pin the animation clock (seconds; FC-7) — the spin becomes a pure function.
pub fn set_clock(&mut self, seconds: f64) {
let _ = self.update(Msg::SetClock(seconds));
}
pub fn with_clock(mut self, seconds: f64) -> Self {
self.state.clock_s = Some(seconds);
self
}
pub fn tick(&mut self, dt: f64) {
let _ = self.update(Msg::Tick(dt));
}
pub fn mesh(&self) -> &Mesh3d {
&self.mesh
}
/// **FC-3** — read the complete observable control state (pose + spin + clock +
/// status) at any frame boundary.
pub fn state(&self) -> &Model3DState {
&self.state
}
/// The resolved status colour (straight RGBA) at the current status.
pub fn status_color(&self) -> [f32; 4] {
decor::gradient_by_scalar(STATUS_RAMP, self.state.status)
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. Every live gesture routes its
/// [`Msg`] here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::Orbit(d) => {
self.state.yaw += d;
self.state.spin = false;
}
Msg::Spin(d) => self.state.yaw += d,
Msg::SetYaw(y) => self.state.yaw = y,
Msg::SetTilt(t) => self.state.tilt = t,
Msg::SetSpin(on) => self.state.spin = on,
Msg::ResetView => {
self.state.yaw = DEFAULT_YAW;
self.state.tilt = DEFAULT_TILT;
}
Msg::SetStatus(s) => self.state.status = s.clamp(0.0, 1.0),
Msg::SetStatusGroup(g) => self.state.status_group = g,
Msg::SetStatusControl(on) => self.state.status_control = on,
Msg::Tick(dt) => self.state.clock_s = Some(self.state.clock_s.unwrap_or(0.0) + dt),
Msg::SetClock(s) => self.state.clock_s = Some(s),
}
Vec::new()
}
/// **FC-9 render** — a **pure** function of `&self`: it paints the (opt-in)
/// status control, the CPU-lit spinning model (project → back-face cull →
/// painter's-algorithm depth sort → Lambert shade), and *returns* the [`Msg`]s
/// the interactions produced (status slider, drag-orbit, and the FC-7 auto-spin
/// advance). It MUST NOT mutate the [`Model3DState`] Model; the
/// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies the
/// returned messages through [`update`](Self::update).
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let mut msgs: Vec<Msg> = Vec::new();
let th = theme(ui);
let s = &self.state;
// ── opt-in inline status control: slider + a live colour swatch ───────
if s.status_control {
ui.horizontal(|ui| {
ui.label("status");
let mut val = s.status;
if ui.add(egui::Slider::new(&mut val, 0.0..=1.0).show_value(true)).changed() {
msgs.push(Msg::SetStatus(val));
}
let c = self.status_color();
let (rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 14.0), Sense::hover());
ui.painter().rect_filled(
rect,
3.0,
Color32::from_rgb((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8),
);
ui.label(if s.status > 0.66 {
"healthy"
} else if s.status > 0.33 {
"degraded"
} else {
"critical"
});
});
}
// FC-7 spin: live (ambient dt) advances the persisted yaw via a returned
// `Msg::Spin` (pure — no `self` mutation); an injected clock is a pure offset.
let live_advance = if s.spin && s.clock_s.is_none() {
let dt = ui.input(|i| i.stable_dt).min(0.1);
ui.ctx().request_repaint();
let d = dt * s.spin_rate;
if d != 0.0 {
msgs.push(Msg::Spin(d));
}
d
} else {
0.0
};
let render_yaw = if s.spin {
s.yaw + live_advance + s.clock_s.map(|c| c as f32 * s.spin_rate).unwrap_or(0.0)
} else {
s.yaw
};
const MIN_W: f32 = 160.0;
const MIN_H: f32 = 120.0;
let avail = ui.available_size();
let want = egui::vec2(avail.x.max(MIN_W), avail.y.max(MIN_H));
let (rect, resp) = ui.allocate_exact_size(want, Sense::click_and_drag());
if resp.dragged() {
let d = resp.drag_delta().x * 0.01;
if d != 0.0 {
msgs.push(Msg::Orbit(d));
}
}
let degs = render_yaw.to_degrees().rem_euclid(360.0);
let tris = self.mesh.triangle_count();
resp.widget_info(|| {
facett_core::a11y::Semantics::image(format!(
"3D model {} — yaw {degs:.0}° · {tris} triangles · status {:.0}%",
self.title,
s.status * 100.0
))
.value(s.status as f64)
.widget_info()
});
ui.painter_at(rect).rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
// **The viewport is what is actually VISIBLE, not what we asked for.** The
// `MIN_W`/`MIN_H` floor above can allocate a rect larger than the host left
// us (a short pane, a scrolled deck), and egui then crops the paint to the
// parent clip — amputating the model mid-body. Fitting + centring on the
// intersection keeps the whole solid inside the sliver that is on screen.
// When the pane is fully visible this IS `rect`, so nothing changes.
let vis = rect.intersect(ui.clip_rect());
let vis = if vis.is_positive() { vis } else { rect };
let mut tris = self.project_tris(vis, render_yaw, s.tilt);
// **The clip + depth stage** (the fix), shared with `facett-graph3d`'s rabbit
// logo so both panes run one once-written tail (L5):
// · scissor every face to the viewport, and never hand a degenerate sliver
// to `epaint`'s feathering miter (that was the pane-crossing spokes);
// · resolve occlusion with a **per-pixel z-buffer** instead of a
// painter's-algorithm centroid sort, which cannot order interpenetrating
// surfaces — it tore the cut Avocado's skin against its own interior into
// a triangle-wide sawtooth.
let key = format!("facett-model3d:{}", self.title);
let stats = facett_core::render::cpu::paint_tris_zbuffered(ui, vis, &key, &mut tris);
if stats.visible_triangles == 0 {
ui.painter_at(rect).text(
vis.center(),
egui::Align2::CENTER_CENTER,
"no model",
egui::FontId::proportional(13.0),
th.text_dim,
);
}
// ── CPU render-lane emit: this FC-9 view path RAN ─────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-model3d::Model3D::view",
"ui_render",
self.mesh.vertex_count() > 0
&& render_yaw.is_finite()
&& stats.visible_triangles <= self.mesh.triangle_count(),
&format!(
"vertices={} triangles={} drawn_faces={} groups={} status={:.2} yaw_deg={degs:.0}",
self.mesh.vertex_count(),
self.mesh.triangle_count(),
stats.visible_triangles,
self.mesh.groups.len(),
s.status
),
);
// ── the CLIP oracle: no ink escaped the viewport this frame. ──────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-model3d::Model3D::view",
"scissor",
stats.ink_outside_rect == 0,
&stats.detail(),
);
msgs
}
/// **The projection stage, as a pure function.** Project → back-face cull (or
/// two-sided keep, for an open shell) → Lambert shade every group into the
/// screen-space triangle list the clip + z-buffer stages consume. `viewport` is
/// the rect the model is fitted + centred on (the widget rect ∩ the host clip).
/// Exposed so a headless pose sweep drives *exactly* the geometry the live pane
/// paints — no egui, no GPU, no re-implementation.
#[must_use]
pub fn project_tris(
&self,
viewport: egui::Rect,
yaw: f32,
tilt: f32,
) -> Vec<facett_core::render::cpu::ShadedTri> {
self.project_inner(viewport, yaw, tilt, true)
}
/// **Every triangle, culled by nothing** — the model's full projected footprint.
/// This is the *denominator* of the "did the render lose geometry it should have
/// kept" oracle: rasterise this and the real [`project_tris`](Self::project_tris)
/// output and compare covered pixels. A closed solid legitimately covers the same
/// footprint with half the faces (the far half is hidden *behind*, not missing);
/// an **open** shell does not — back-face culling the half Avocado left the pane
/// showing background straight through the fruit, and coverage fell to ~half.
#[must_use]
pub fn project_footprint(
&self,
viewport: egui::Rect,
yaw: f32,
tilt: f32,
) -> Vec<facett_core::render::cpu::ShadedTri> {
self.project_inner(viewport, yaw, tilt, false)
}
fn project_inner(
&self,
viewport: egui::Rect,
yaw: f32,
tilt: f32,
cull: bool,
) -> Vec<facett_core::render::cpu::ShadedTri> {
// Light from the upper-front-left (same as the rabbit logo).
let light = {
let l = [-0.4f32, 0.6, 0.7];
let n = (l[0] * l[0] + l[1] * l[1] + l[2] * l[2]).sqrt();
[l[0] / n, l[1] / n, l[2] / n]
};
let center = viewport.center();
// Pose-invariant fit: the mesh's own bounding-sphere radius, not a hard-coded
// `0.42 · min_elem` that only happened to suit the meshes shipped so far.
let scale = facett_core::render::cpu::fit_scale(viewport, self.mesh.bounding_radius(), FIT_MARGIN);
let to_px = |v: [f32; 3]| Pos2::new(center.x + v[0] * scale, center.y - v[1] * scale);
let status_rgb = self.status_color();
// Collect every visible (front-facing) triangle across ALL groups into one
// list, so the painter's-algorithm depth sort resolves occlusion between the
// eyes and the bone correctly.
// `cull = false` is the FOOTPRINT mode (`project_footprint`): keep every
// triangle, so the caller can measure the model's full projected extent as
// the denominator of the "did the render lose geometry" oracle. The live
// paint always culls.
//
// NOTE (measured, not assumed): the bundled Avocado is an *open* shell (a
// half avocado, 41 unpaired rim edges), and textbook advice would be to draw
// an open shell two-sided. That was tried and **measured to be a no-op** —
// from the open side the shell's inner surface is itself front-facing, so a
// single-sided cull already covers 100% of the footprint at all 24 yaws
// (`back_face_culling_an_open_shell_is_not_what_broke_it`). Keeping the cull
// single-sided is therefore both correct and half the raster work.
let two_sided = !cull;
let mut faces: Vec<facett_core::render::cpu::ShadedTri> = Vec::new();
for g in &self.mesh.groups {
let base = if self.is_status_group(&g.name) { status_rgb } else { g.base_color };
let proj: Vec<[f32; 3]> = g.positions.iter().map(|&p| self.rotate(p, yaw, tilt)).collect();
for t in g.indices.as_chunks::<3>().0 {
let (i0, i1, i2) = (t[0] as usize, t[1] as usize, t[2] as usize);
let (a, b, c) = (proj[i0], proj[i1], proj[i2]);
// Screen-space face normal z for back-face culling (CCW-front).
let cross_z = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
let front = cross_z > 0.0;
if !front && !two_sided {
continue;
}
// Lighting: averaged, rotated vertex normal · light — flipped for a
// back face we chose to keep, so the cavity reads as an inside wall.
let facing = if front { 1.0f32 } else { -1.0 };
let nv = {
let n0 = self.rotate(g.normals[i0], yaw, tilt);
let n1 = self.rotate(g.normals[i1], yaw, tilt);
let n2 = self.rotate(g.normals[i2], yaw, tilt);
let mut sn = [n0[0] + n1[0] + n2[0], n0[1] + n1[1] + n2[1], n0[2] + n1[2] + n2[2]];
let len = (sn[0] * sn[0] + sn[1] * sn[1] + sn[2] * sn[2]).sqrt().max(1e-6);
for v in &mut sn {
*v *= facing / len;
}
sn
};
let diff = (nv[0] * light[0] + nv[1] * light[1] + nv[2] * light[2]).max(0.0);
let shade = (0.28 + 0.72 * diff).clamp(0.0, 1.0);
let col = Color32::from_rgb(
(base[0] * 255.0 * shade) as u8,
(base[1] * 255.0 * shade) as u8,
(base[2] * 255.0 * shade) as u8,
);
faces.push(facett_core::render::cpu::ShadedTri {
pts: [to_px(a), to_px(b), to_px(c)],
vz: [a[2], b[2], c[2]],
col,
});
}
}
faces
}
/// **The clip oracles for one pose, headlessly.** Runs the exact
/// [`project_faces`](Self::project_faces) → clip pipeline the live pane runs and
/// hands back the [`FaceStats`](facett_core::render::cpu::FaceStats) — chiefly
/// `ink_outside_rect`, which MUST be `0` at every pose.
#[must_use]
pub fn clip_stats(&self, viewport: egui::Rect, yaw: f32, tilt: f32) -> facett_core::render::cpu::FaceStats {
let mut tris = self.project_tris(viewport, yaw, tilt);
facett_core::render::cpu::tri_stats(&mut tris, viewport)
}
/// Rotate a point by yaw (Y) then tilt (X). Returns `(x, y, z)` view-space —
/// the same convention as `facett-graph3d`'s logo renderer.
fn rotate(&self, p: [f32; 3], yaw: f32, tilt: f32) -> [f32; 3] {
let (sy, cy) = yaw.sin_cos();
let x1 = p[0] * cy + p[2] * sy;
let z1 = -p[0] * sy + p[2] * cy;
let (st, ct) = tilt.sin_cos();
let y2 = p[1] * ct - z1 * st;
let z2 = p[1] * st + z1 * ct;
[x1, y2, z2]
}
/// Is `group` the status-tinted one?
fn is_status_group(&self, group: &str) -> bool {
self.state.status_group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case(group))
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for Model3D {
type Model = Model3DState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &Model3DState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
Model3D::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
Model3D::view(self, ui)
}
}
// The bridge macro writes `impl Facet for Model3D` from the `Elm` impl: `title`, the
// FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the `state_json` + `caps`
// overrides below. **Form 3** (`custom_state_json`) because the viewer publishes a
// RICHER `state_json` than a plain `serde(state())`: the title + the derived
// vertex/triangle/group tallies + the resolved status RGB (all computed from the mesh
// + the status ramp, held OUTSIDE the `Model3DState` Model) that facett-demo's
// mega_matrix reads — so the default `state_json` is suppressed and this one supplied
// with the EXACT SAME keys the hand-written `impl Facet` published.
facett_core::impl_facet_via_elm!(Model3D, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
let s = self.state();
let c = self.status_color();
serde_json::json!({
"title": self.title,
"vertices": self.mesh.vertex_count(),
"triangles": self.mesh.triangle_count(),
"groups": self.mesh.group_names(),
// Mesh soundness, published as DATA so a headless test asserts what the
// renderer decided: an OPEN shell (boundary_edges > 0) is drawn
// two-sided, because culling its back faces would show background
// straight through the model. `inside_out_triangles` must be 0.
"watertight": self.mesh.is_watertight(),
"boundary_edges": self.mesh.boundary_edges(),
"inside_out_triangles": self.mesh.inside_out_triangles(),
"two_sided": !self.mesh.is_watertight(),
"yaw": s.yaw,
"tilt": s.tilt,
"spin": s.spin,
"spin_rate": s.spin_rate,
"clock_s": s.clock_s,
"status": s.status,
"status_group": s.status_group,
"status_control": s.status_control,
// The resolved eye colour as 0..255 RGB, so a headless test can assert
// the green→yellow→red fade as DATA (no pixels).
"status_rgb": [(c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8],
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable()
}
});