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"]);
}
}