Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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()
    }
});