facett-helix/src/tubeview.rs
//! **TubeView** — the [`Facet`] that draws a [`TubeField`] as a **real 3D tube/pipe**:
//! an orbiting, lit, translucent cylinder with data smeared on its walls and a glowing
//! data core running down the centre axis. It builds a [`facett_map3d::Mesh3`] via
//! [`crate::mesh::tube_mesh`], projects it with a [`facett_map3d::OrbitCamera`],
//! depth-sorts (painter's algorithm), lambert-shades each face, and paints it — the
//! translucent wall lets the opaque core show through, so the axis data reads *inside*
//! the pipe.
//!
//! FC contract, same as the rest of facett:
//! - **Serializable state** ([`state_json`](Facet::state_json)): the field resolution +
//! ranges, mesh vertex/triangle counts, camera pose — the picture asserted as DATA.
//! - **[`TubeMsg`] + [`update`](TubeView::update)**: the robot-/CLI-addressable surface
//! (orbit, spin, tick the clock).
//! - **Injected clock** ([`with_clock`](TubeView::with_clock)): the spin is a pure
//! function of it (determinism).
use egui::{Color32, Pos2, Sense, vec2};
use facett_core::{Facet, FacetCaps, theme};
use facett_map3d::{OrbitCamera, V3, ViewPreset};
use serde::{Deserialize, Serialize};
use crate::mesh::tube_mesh;
use crate::tube::TubeField;
/// Spin rate (radians / second) for the auto-orbit.
const SPIN_RATE: f32 = 0.5;
/// Wall opacity — translucent so the core data axis shows through the pipe.
const WALL_ALPHA: f32 = 0.5;
/// A robot-/CLI-addressable control message. Applied by [`TubeView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum TubeMsg {
/// Advance the injected clock by `dt` seconds (drives the spin).
Tick(f64),
/// Pin the injected clock to `seconds`.
SetClock(f64),
/// Set the orbit azimuth (radians); also stops the auto-spin.
SetAzimuth(f32),
/// Start / stop the auto-orbit.
ToggleSpin(bool),
/// Snap to a canonical camera preset.
Preset(ViewPreset),
}
/// The 3D tube [`Facet`] over a [`TubeField`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TubeView {
pub title: String,
pub field: TubeField,
/// Outer wall radius (design units).
pub radius: f32,
/// Axial length (design units); the tube spans `y ∈ [-height/2, +height/2]`.
pub height: f32,
/// Orbit azimuth (radians).
azimuth: f32,
/// Orbit elevation (radians).
elevation: f32,
/// Auto-orbit the camera.
spin: bool,
/// Injected clock (seconds). `Some` ⇒ the spin is a pure function of it.
clock_s: Option<f64>,
}
/// One projected, shaded, depth-keyed triangle ready to paint. Public so the render
/// path is unit-testable headless (project → cull → shade, without egui/GPU).
#[derive(Clone, Copy, Debug)]
pub struct Face {
pub pts: [Pos2; 3],
pub color: Color32,
/// Mean camera-space depth (larger = farther; painter's algorithm sorts on it).
pub depth: f32,
}
impl TubeView {
/// Build a view over a [`TubeField`].
pub fn new(title: impl Into<String>, field: TubeField) -> Self {
let (az, el) = ViewPreset::Isometric.angles();
Self { title: title.into(), field, radius: 0.7, height: 2.0, azimuth: az, elevation: el, spin: true, clock_s: None }
}
/// A self-contained demo tube (the [`TubeField::demo`] helical fixture).
pub fn demo() -> Self {
Self::new("tube", TubeField::demo())
}
/// Pin the animation clock (seconds) — makes the spin deterministic.
#[must_use]
pub fn with_clock(mut self, seconds: f64) -> Self {
self.clock_s = Some(seconds);
self
}
/// Apply a [`TubeMsg`]. Returns `true` if it changed observable state.
pub fn update(&mut self, msg: TubeMsg) -> bool {
match msg {
TubeMsg::Tick(dt) => {
self.clock_s = Some(self.clock_s.unwrap_or(0.0) + dt);
true
}
TubeMsg::SetClock(s) => {
let changed = self.clock_s != Some(s);
self.clock_s = Some(s);
changed
}
TubeMsg::SetAzimuth(a) => {
let changed = self.azimuth != a || self.spin;
self.azimuth = a;
self.spin = false;
changed
}
TubeMsg::ToggleSpin(on) => {
let changed = self.spin != on;
self.spin = on;
changed
}
TubeMsg::Preset(p) => {
let (az, el) = p.angles();
let changed = self.azimuth != az || self.elevation != el;
self.azimuth = az;
self.elevation = el;
changed
}
}
}
#[must_use]
pub fn azimuth(&self) -> f32 {
self.azimuth
}
/// The effective azimuth for an ambient clock value — auto-spin advances it.
fn azimuth_for(&self, time_s: f64) -> f32 {
if self.spin { self.azimuth + time_s as f32 * SPIN_RATE } else { self.azimuth }
}
/// Build the orbit camera for a given clock (pure).
fn camera_at(&self, time_s: f64) -> OrbitCamera {
let mut cam = OrbitCamera { target: V3::new(0.0, 0.0, 0.0), ..OrbitCamera::default() };
cam.goal_azimuth = self.azimuth_for(time_s);
cam.goal_elevation = self.elevation;
cam.goal_distance = 3.2;
cam.goal_target = V3::new(0.0, 0.0, 0.0);
cam.settle();
cam
}
/// **The pure render core** — project the tube mesh into a `w×h` pane and return the
/// shaded, depth-sorted [`Face`]s (far → near). Wall faces are translucent so the
/// opaque core axis shows through. No egui, no GPU — headless-testable.
#[must_use]
pub fn faces(&self, w: f32, h: f32) -> Vec<Face> {
let clock = self.clock_s.unwrap_or(0.0);
let cam = self.camera_at(clock);
let mesh = tube_mesh(&self.field, self.radius, self.height);
let center = (w * 0.5, h * 0.5);
let half_h = h * 0.5;
// Wall is the first `wall_tri` triangles (built first in `tube_mesh`); the rest
// are the core. Derive the split from the field resolution.
let wall_tris = if self.field.is_valid() { 2 * self.field.theta_bins * (self.field.z_slices - 1) } else { 0 };
// Lambert light (design frame, y-up).
let light = V3::new(0.4, 0.9, 0.5).normalized();
let mut out = Vec::with_capacity(mesh.triangle_count());
for tri in 0..mesh.triangle_count() {
let i0 = mesh.indices[tri * 3] as usize;
let i1 = mesh.indices[tri * 3 + 1] as usize;
let i2 = mesh.indices[tri * 3 + 2] as usize;
let p0 = mesh.positions[i0];
let p1 = mesh.positions[i1];
let p2 = mesh.positions[i2];
let a = cam.project(V3::new(p0[0], p0[1], p0[2]), center, half_h);
let b = cam.project(V3::new(p1[0], p1[1], p1[2]), center, half_h);
let c = cam.project(V3::new(p2[0], p2[1], p2[2]), center, half_h);
if !(a.visible && b.visible && c.visible) {
continue;
}
// Geometric normal for lambert shading.
let e1 = V3::new(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
let e2 = V3::new(p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]);
let n = e1.cross(e2).normalized();
let lambert = n.dot(light).abs(); // two-sided (we see both wall faces)
let shade = (0.35 + 0.65 * lambert).clamp(0.0, 1.0);
let src = mesh.colors[tri];
let lit = Color32::from_rgb(
(src.r() as f32 * shade) as u8,
(src.g() as f32 * shade) as u8,
(src.b() as f32 * shade) as u8,
);
let color = if tri < wall_tris { lit.gamma_multiply(WALL_ALPHA) } else { lit };
out.push(Face {
pts: [Pos2::new(a.x, a.y), Pos2::new(b.x, b.y), Pos2::new(c.x, c.y)],
color,
depth: (a.depth + b.depth + c.depth) / 3.0,
});
}
// Painter's algorithm: far first.
out.sort_by(|x, y| y.depth.partial_cmp(&x.depth).unwrap_or(std::cmp::Ordering::Equal));
out
}
/// **The pure view** (FC-9): paint the tube and return the produced [`TubeMsg`]s.
pub fn render(&self, ui: &mut egui::Ui) -> Vec<TubeMsg> {
let mut msgs = Vec::new();
let th = theme(ui);
ui.horizontal_wrapped(|ui| {
let mut spin = self.spin;
if ui.checkbox(&mut spin, "spin").changed() {
msgs.push(TubeMsg::ToggleSpin(spin));
}
ui.separator();
for p in [ViewPreset::Isometric, ViewPreset::Front, ViewPreset::Top, ViewPreset::Oblique] {
if ui.button(p.name()).clicked() {
msgs.push(TubeMsg::Preset(p));
}
}
});
ui.separator();
let ambient = ui.input(|i| i.time);
if self.clock_s.is_none() && self.spin {
ui.ctx().request_repaint();
}
let (rect, _resp) = ui.allocate_exact_size(ui.available_size().max(vec2(320.0, 280.0)), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
// Build faces at this pane size + clock, then translate into `rect`.
let clock_view = self.clock_s.unwrap_or(ambient);
let view = if self.clock_s.is_some() { self.clone() } else { self.clone().with_clock(clock_view) };
let faces = view.faces(rect.width(), rect.height());
let offset = rect.min.to_vec2();
for f in &faces {
let pts = [f.pts[0] + offset, f.pts[1] + offset, f.pts[2] + offset];
painter.add(egui::Shape::convex_polygon(pts.to_vec(), f.color, egui::Stroke::NONE));
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-helix::TubeView::ui",
"ui_render",
!faces.is_empty(),
&format!("faces={} theta={} z={}", faces.len(), self.field.theta_bins, self.field.z_slices),
);
msgs
}
}
impl Facet for TubeView {
fn title(&self) -> &str {
&self.title
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable()
}
fn ui(&mut self, ui: &mut egui::Ui) {
for m in self.render(ui) {
self.update(m);
}
}
fn state_json(&self) -> serde_json::Value {
let mesh = tube_mesh(&self.field, self.radius, self.height);
let (wlo, whi) = self.field.wall_range();
let (clo, chi) = self.field.core_range();
serde_json::json!({
"title": self.title,
"theta_bins": self.field.theta_bins,
"z_slices": self.field.z_slices,
"wall_len": self.field.wall.len(),
"core_len": self.field.core.len(),
"valid": self.field.is_valid(),
"radius": self.radius,
"height": self.height,
"vertex_count": mesh.vertex_count(),
"triangle_count": mesh.triangle_count(),
"wall_min": wlo,
"wall_max": whi,
"core_min": clo,
"core_max": chi,
"azimuth": self.azimuth,
"elevation": self.elevation,
"spin": self.spin,
"clock_s": self.clock_s,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn demo_state_is_data() {
let v = TubeView::demo();
let st = v.state_json();
assert_eq!(st["theta_bins"], 48);
assert_eq!(st["z_slices"], 40);
assert_eq!(st["valid"], true);
assert!(st["vertex_count"].as_u64().unwrap() > 100);
assert!(st["triangle_count"].as_u64().unwrap() > 100);
assert!(st["wall_max"].as_f64().unwrap() > st["wall_min"].as_f64().unwrap());
}
#[test]
fn spin_and_clock_are_addressable() {
let mut v = TubeView::demo();
assert!(v.update(TubeMsg::SetClock(1.0)));
assert!(v.update(TubeMsg::ToggleSpin(false)));
assert_eq!(v.state_json()["spin"], false);
// SetAzimuth stops the spin and moves the camera.
let mut v2 = TubeView::demo();
assert!(v2.update(TubeMsg::SetAzimuth(1.23)));
assert!((v2.azimuth() - 1.23).abs() < 1e-6);
assert_eq!(v2.state_json()["spin"], false);
}
#[test]
fn injected_clock_makes_the_spin_deterministic() {
let a = TubeView::demo().with_clock(2.0).state_json();
let b = TubeView::demo().with_clock(2.0).state_json();
assert_eq!(a, b, "same clock ⇒ byte-identical state (FC-7)");
// A different clock spins the azimuth-derived faces to a different layout.
let fa = TubeView::demo().with_clock(0.0).faces(400.0, 400.0);
let fb = TubeView::demo().with_clock(2.0).faces(400.0, 400.0);
let ca = fa[0].pts[0];
let cb = fb[0].pts[0];
assert!((ca.x - cb.x).abs() + (ca.y - cb.y).abs() > 0.5, "the spin moved the geometry");
}
#[test]
fn faces_render_non_blank_with_many_colours() {
// A "non-blank render" proof at the projection level: the tube projects many
// visible, on-pane triangles spanning many distinct shades (wall smear + core).
let v = TubeView::demo().with_clock(0.0);
let faces = v.faces(500.0, 500.0);
assert!(faces.len() > 200, "the tube projects many visible faces (got {})", faces.len());
// Every face lands inside the pane (no infinity smear).
let on_pane = faces
.iter()
.filter(|f| f.pts.iter().all(|p| p.x >= -50.0 && p.x <= 550.0 && p.y >= -50.0 && p.y <= 550.0))
.count();
assert!(on_pane as f32 / faces.len() as f32 > 0.9, "most faces are on-pane");
// Distinct colour buckets (5-bit per channel) — a lit, data-coloured tube.
let mut shades = std::collections::HashSet::new();
for f in &faces {
let k = ((f.color.r() as u32 >> 3) << 10) | ((f.color.g() as u32 >> 3) << 5) | (f.color.b() as u32 >> 3);
shades.insert(k);
}
assert!(shades.len() >= 8, "the tube shows many shades (got {})", shades.len());
}
#[test]
fn model_round_trips_through_serde() {
let mut v = TubeView::demo();
v.update(TubeMsg::SetClock(1.5));
v.update(TubeMsg::Preset(ViewPreset::Front));
let json = serde_json::to_string(&v).unwrap();
let back: TubeView = serde_json::from_str(&json).unwrap();
assert_eq!(back, v, "the observable Model round-trips through serde");
}
}