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

nordisk/facett

facett-graph3d/src/logo3d.rs

//! **3D znippy rabbit logo** — a solid, lit, slowly auto-rotating logo built by
//! extruding the parametric rabbit silhouette ([`facett_core::rabbit::rabbit_mesh`])
//! into front + back faces + side walls. This is facett-graph3d's *3D test data*
//! (the mascot, replacing any square/placeholder cloud).
//!
//! Two render paths share one [`Facet::ui`]:
//!
//! - **CPU painter** (default, always available, headless-testable) — the mesh
//!   triangles are projected (orbit yaw + tilt), back-face culled, **depth
//!   sorted** (painter's algorithm), and shaded by `normal · light` against the
//!   theme accent. egui tessellates them, so the wgpu snapshot backend rasterises
//!   a genuinely lit, depth-ordered 3D logo.
//! - **wgpu fast path** (feature `wgpu`) — a depth-tested `egui_wgpu` paint
//!   callback with a real Z-buffer ([`gpu`]). The host opts in via
//!   [`RabbitLogo3D::with_gpu`] after installing the renderer
//!   ([`gpu::install_renderer`]); without it the CPU painter draws the logo.
//!
//! The spin is driven by an **injected clock** (FC-7) exactly like
//! [`crate::Graph3D`], so a snapshot at a fixed clock is bit-stable.

use egui::{Color32, Pos2, Sense};
use facett_core::rabbit::{RabbitMesh, rabbit_mesh};
use facett_core::{Facet, FacetCaps, theme};

#[cfg(feature = "wgpu")]
pub mod gpu;

/// Fraction of the viewport's half-min-extent the fitted logo 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`.
const FIT_MARGIN: f32 = 0.94;

/// The 3D extruded-rabbit logo viewer.
pub struct RabbitLogo3D {
    pub title: String,
    /// The extruded mesh (positions + normals + indices), built once.
    mesh: RabbitMesh,
    /// Total extrusion thickness in design units.
    pub depth: f32,
    /// Orbit yaw (radians) about Y, and a fixed tilt about X for a 3/4 view.
    pub yaw: f32,
    pub tilt: f32,
    pub spin: bool,
    spin_rate: f32,
    /// Injected animation clock (seconds; FC-7). `Some` → pure spin function.
    clock_s: Option<f64>,
    /// Host opt-in to the GPU depth-tested path (feature `wgpu`).
    #[cfg(feature = "wgpu")]
    use_gpu: bool,
}

impl RabbitLogo3D {
    /// Build the logo with the default chunky depth (`0.34`).
    pub fn new(title: impl Into<String>) -> Self {
        Self::with_depth(title, 0.34)
    }

    /// Build with an explicit extrusion depth.
    pub fn with_depth(title: impl Into<String>, depth: f32) -> Self {
        Self {
            title: title.into(),
            mesh: rabbit_mesh(depth),
            depth,
            yaw: 0.5,
            tilt: 0.32,
            spin: true,
            spin_rate: 0.35,
            clock_s: None,
            #[cfg(feature = "wgpu")]
            use_gpu: false,
        }
    }

    /// **wgpu feature.** Opt the logo into the depth-tested GPU paint callback.
    #[cfg(feature = "wgpu")]
    pub fn with_gpu(mut self, on: bool) -> Self {
        self.use_gpu = on;
        self
    }

    /// Pin the animation clock (seconds; FC-7) — the spin becomes a pure function.
    pub fn set_clock(&mut self, seconds: f64) {
        self.clock_s = Some(seconds);
    }
    /// Builder form of [`set_clock`](Self::set_clock).
    pub fn with_clock(mut self, seconds: f64) -> Self {
        self.clock_s = Some(seconds);
        self
    }
    /// Advance the injected clock by `dt` seconds (deterministic stepping).
    pub fn tick(&mut self, dt: f64) {
        self.clock_s = Some(self.clock_s.unwrap_or(0.0) + dt);
    }

    pub fn mesh(&self) -> &RabbitMesh {
        &self.mesh
    }

    /// **The projection stage, as a pure function.** Project → back-face cull →
    /// Lambert shade the extruded mesh into the screen-space triangle list the clip +
    /// z-buffer stages consume, tinted against `accent`. `viewport` is the rect the
    /// logo is fitted + centred on (the widget rect ∩ the host clip). Exposed so a
    /// headless pose sweep drives *exactly* the geometry the live pane paints.
    #[must_use]
    pub fn project_tris(
        &self,
        viewport: egui::Rect,
        yaw: f32,
        tilt: f32,
        accent: Color32,
    ) -> Vec<facett_core::render::cpu::ShadedTri> {
        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 this one mesh.
        let scale = facett_core::render::cpu::fit_scale(viewport, self.mesh.bounding_radius(), FIT_MARGIN);
        let proj: Vec<[f32; 3]> = self.mesh.positions.iter().map(|&p| self.rotate(p, yaw, tilt)).collect();
        let to_px = |v: [f32; 3]| Pos2::new(center.x + v[0] * scale, center.y - v[1] * scale);

        // Light comes from the upper-front-left; shade each tri by its rotated
        // face normal · light, against the theme accent (the lit logo colour).
        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 n_tris = self.mesh.indices.len() / 3;
        let mut faces: Vec<facett_core::render::cpu::ShadedTri> = Vec::with_capacity(n_tris);
        for t in 0..n_tris {
            let i0 = self.mesh.indices[t * 3] as usize;
            let i1 = self.mesh.indices[t * 3 + 1] as usize;
            let i2 = self.mesh.indices[t * 3 + 2] as usize;
            let (a, b, c) = (proj[i0], proj[i1], proj[i2]);
            // Geometric (screen-space) face normal z for back-face culling.
            let abx = b[0] - a[0];
            let aby = b[1] - a[1];
            let acx = c[0] - a[0];
            let acy = c[1] - a[1];
            let cross_z = abx * acy - aby * acx;
            if cross_z <= 0.0 {
                continue; // back-facing (CCW-front convention after projection)
            }
            // Lighting: rotate the (averaged) vertex normal and dot with light.
            let nv = {
                let n0 = self.rotate(self.mesh.normals[i0], yaw, tilt);
                let n1 = self.rotate(self.mesh.normals[i1], yaw, tilt);
                let n2 = self.rotate(self.mesh.normals[i2], yaw, tilt);
                let mut nx = n0[0] + n1[0] + n2[0];
                let mut ny = n0[1] + n1[1] + n2[1];
                let mut nz = n0[2] + n1[2] + n2[2];
                let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
                nx /= len;
                ny /= len;
                nz /= len;
                [nx, ny, nz]
            };
            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); // ambient + diffuse
            let col = Color32::from_rgb(
                (f32::from(accent.r()) * shade) as u8,
                (f32::from(accent.g()) * shade) as u8,
                (f32::from(accent.b()) * 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,
        accent: Color32,
    ) -> facett_core::render::cpu::FaceStats {
        let mut tris = self.project_tris(viewport, yaw, tilt, accent);
        facett_core::render::cpu::tri_stats(&mut tris, viewport)
    }

    /// Rotate a point by yaw (Y) then tilt (X). Returns `(x, y, z)` view-space.
    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]
    }
}

impl Facet for RabbitLogo3D {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        let th = theme(ui);
        // FC-7 spin: live (ambient dt) vs injected-clock (pure function).
        if self.spin && self.clock_s.is_none() {
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            self.yaw += dt * self.spin_rate;
            ui.ctx().request_repaint();
        }
        let render_yaw = if self.spin {
            self.yaw + self.clock_s.map(|c| c as f32 * self.spin_rate).unwrap_or(0.0)
        } else {
            self.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() {
            self.yaw += resp.drag_delta().x * 0.01;
            self.spin = false;
        }
        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 znippy rabbit logo — yaw {degs:.0}° · {tris} triangles"))
                .value(render_yaw as f64)
                .widget_info()
        });

        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);

        // ── GPU depth-tested path (feature `wgpu`, host opted in) ─────────────
        #[cfg(feature = "wgpu")]
        let drew_on_gpu = self.draw_gpu(ui, rect, render_yaw, self.tilt);
        #[cfg(not(feature = "wgpu"))]
        let drew_on_gpu = false;
        if drew_on_gpu {
            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-graph3d::RabbitLogo3D::ui",
                "ui_render",
                self.mesh.vertex_count() > 0 && self.mesh.triangle_count() > 0 && render_yaw.is_finite(),
                &format!(
                    "path=gpu vertices={} triangles={} yaw_deg={degs:.0}",
                    self.mesh.vertex_count(),
                    self.mesh.triangle_count()
                ),
            );
            return;
        }

        // ── CPU painter: project + cull + depth-sort + light the triangles ────
        // **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 logo mid-ear. 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, self.tilt, th.accent);
        // **The clip + depth stage** (the fix), shared with `facett-model3d`'s glTF
        // viewer 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 the logo's four
        //     interpenetrating extruded shells — it split the ears from the head.
        let key = format!("facett-graph3d:{}", self.title);
        let stats = facett_core::render::cpu::paint_tris_zbuffered(ui, vis, &key, &mut tris);
        if stats.visible_triangles == 0 {
            painter.text(vis.center(), egui::Align2::CENTER_CENTER, "no logo", egui::FontId::proportional(13.0), th.text_dim);
        }
        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-graph3d::RabbitLogo3D::ui",
            "ui_render",
            self.mesh.vertex_count() > 0
                && render_yaw.is_finite()
                && stats.visible_triangles <= self.mesh.triangle_count(),
            &format!(
                "path=cpu vertices={} triangles={} drawn_faces={} yaw_deg={degs:.0}",
                self.mesh.vertex_count(),
                self.mesh.triangle_count(),
                stats.visible_triangles
            ),
        );
        // ── the CLIP oracle: no ink escaped the viewport this frame. ──────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-graph3d::RabbitLogo3D::ui",
            "scissor",
            stats.ink_outside_rect == 0,
            &stats.detail(),
        );
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "vertices": self.mesh.vertex_count(),
            "triangles": self.mesh.triangle_count(),
            "depth": self.depth,
            "yaw": self.yaw,
            "tilt": self.tilt,
            "spin": self.spin,
            "spin_rate": self.spin_rate,
            "clock_s": self.clock_s,
        })
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable()
    }
}

#[cfg(feature = "wgpu")]
impl RabbitLogo3D {
    /// GPU path: add the depth-tested `egui_wgpu` paint callback. Returns `true`
    /// if it was added (a wgpu RenderState + installed renderer were present),
    /// else `false` so the CPU painter draws the logo.
    fn draw_gpu(&mut self, ui: &mut egui::Ui, rect: egui::Rect, yaw: f32, tilt: f32) -> bool {
        if !self.use_gpu {
            return false;
        }
        // CONS-CORE Phase C: send the geometry **every frame** (like facett-map3d) —
        // the renderer's `upload` is idempotent (keeps the first mesh), so whichever
        // frame's callback prepare runs first does the upload. This is robust under
        // egui_kittest's `run_steps` (the old consume-once latch could drop the mesh
        // on a frame whose callback didn't render, leaving index_count == 0).
        let geometry = Some(Box::new(gpu::build_gpu_geometry(&self.mesh)));
        let accent = theme(ui).accent;
        // Offscreen target size = the widget rect in physical pixels (CONS-CORE
        // Phase C: the logo now renders into the shared offscreen colour+depth
        // target, sized to this pane, then blits — the parity fold with map3d).
        let ppp = ui.ctx().pixels_per_point();
        let px_w = (rect.width() * ppp).round().max(1.0) as u32;
        let px_h = (rect.height() * ppp).round().max(1.0) as u32;
        ui.painter().add(egui_wgpu::Callback::new_paint_callback(
            rect,
            gpu::LogoPaintCallback {
                geometry,
                yaw,
                tilt,
                accent: [accent.r(), accent.g(), accent.b()],
                px_w,
                px_h,
            },
        ));
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn logo_builds_a_real_extruded_mesh() {
        let logo = RabbitLogo3D::new("rabbit3d");
        let j = logo.state_json();
        assert!(j["vertices"].as_u64().unwrap() > 100, "real mesh");
        assert!(j["triangles"].as_u64().unwrap() > 50, "many tris");
        assert_eq!(j["title"], "rabbit3d");
    }

    /// Inject a fixed clock → render headlessly → the lit logo tessellates to
    /// vertices (non-blank) AND the AccessKit value carries the yaw. Inject-assert.
    #[test]
    fn headless_render_lights_and_depth_sorts_the_logo() {
        use facett_core::harness;
        let mut logo = RabbitLogo3D::new("rabbit3d").with_clock(1.0);
        let r = harness::headless_render(&mut logo);
        assert!(r.drew(), "the lit, depth-sorted logo tessellates to vertices");
        assert!(r.state["triangles"].as_u64().unwrap() > 50);
        assert_eq!(r.state["clock_s"], 1.0);
    }

    /// FC-7 determinism: same state + same injected clock → identical vertex
    /// count across two renders (pure spin function, no accumulation).
    #[test]
    fn injected_clock_makes_the_spin_reproducible() {
        use facett_core::harness;
        let mut a = RabbitLogo3D::new("r").with_clock(2.5);
        let mut b = RabbitLogo3D::new("r").with_clock(2.5);
        let ra = harness::headless_render(&mut a);
        let rb = harness::headless_render(&mut b);
        assert_eq!(ra.vertices, rb.vertices, "same clock → same drawn geometry");
        assert_eq!(ra.state["yaw"], rb.state["yaw"]);
    }
}