git.lucas.co / cce-model
3D model viewer: STL and OBJ
git clone https://git.lucas.co/cce-model.git

commita9763e662db1d9cb8f04bb0aca0b8339f38cab24
parent5113be5d5c
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 12:31
Path-traced view: r, refines when still, idles at its cap (milestone 4)

r swaps the raster view for cce-ui's path tracer. Whenever the camera and
pane have been still for 150 ms, each frame adds a sample; at the cap
(256 on mains, 32 on battery, read from /sys/class/power_supply when r is
pressed) nothing more is staged and no frame is asked for, so the GPU and
the process go idle with the image left in the backdrop. Any orbit, pan,
zoom or resize drops straight back to the raster view, and tracing resumes
once the camera rests again. The HUD counts samples.

The traced scene is built on a worker: a material per distinct triangle
colour (quantized, so a vertex-coloured scan does not become a material a
triangle) over a wide ground plane at the grid's height, lit by a grey sky
with the sun on the raster bake's key light, so colours do not shift when
switching. The background is the raster gradient's middle.

set_rt_scene builds the BVH on the UI thread (the stage is only lent
there): 2.3 s for 5M triangles. A frame saying "traced: preparing..." goes
out first, so the freeze is explained.

Checked in a scale-2 shadow (Intel iGPU): the table to 256 samples in 19 s
then 0 CPU ticks over 3 s; mid-drag raster with the grid back, resumed 1.5
s after release; r off; the 5M torus traced. Not checked: the battery cap
on a real unplug (unit-tested against a fake power_supply tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md    |  25 +++++++-
 src/main.rs  | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 src/trace.rs | 157 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 361 insertions(+), 10 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cae80e0..879673d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,9 +5,10 @@ A viewer for 3D model files. Read the workspace guide
 particular to this crate. The plan it is built to — formats, milestones,
 what each one must pass — is the design doc "cce-model: a general-purpose
 3D viewer" (claude.ai/code/artifact/1ebec744-8990-4884-ad92-73c72e0ea265).
-Milestones 1–3 are here: STL, OBJ, glTF/GLB and PLY read by the shared
+Milestones 1–4 are here: STL, OBJ, glTF/GLB and PLY read by the shared
 `cce-mesh-io` crate, a grid floor, wireframe and normals overlays, sizes in
-the file's units, ←/→ through the folder and drag-and-drop.
+the file's units, ←/→ through the folder, drag-and-drop, and a path-traced
+view (`r`).
 
 ## Shape
 
@@ -32,6 +33,10 @@ the file's units, ←/→ through the folder and drag-and-drop.
   (`Message::Overlays`, tied to the load's generation). No wireframe past
   `MAX_WIRE_TRIANGLES` (2M): it paints the model solid and costs 360 MB;
   normals are thinned to `MAX_NORMALS` and drawn as long as their spacing.
+- `src/trace.rs` — the traced view's scene (a material per distinct
+  triangle colour, quantized; a wide ground plane at the grid's height), its
+  grey sky with the sun on the raster key light, the sample caps (256 on
+  mains, 32 on battery) and the `/sys/class/power_supply` battery check.
 - `src/units.rs` — sizes in mm/m only when the format says what its unit
   is (`cce_mesh_io::Unit`); OBJ and PLY sizes are bare numbers.
 - `src/files.rs` — the folder's models for ←/→ (any file opened from
@@ -60,6 +65,19 @@ the file's units, ←/→ through the folder and drag-and-drop.
   slots and marks every CPU-side list pending for a replacement renderer.
 - **The clip planes keep 3 radii** around the pivot, for the grid's
   corners, not just the model's 1.
+- **The traced view is a state machine in `stage_3d`.** Any change of
+  camera or pane (`view_key`) resets the samples and the wait; only after
+  `STILL` (150 ms) does a frame stage `stage_rt` instead of the raster
+  scene, one sample a frame; at the cap it stages nothing and stops asking
+  for frames, so the GPU and the process go idle (0 CPU ticks measured)
+  with the traced image left in the backdrop. One frame past each edge is
+  deliberate: the HUD paints BEFORE `stage_3d`, so the cap and the
+  "preparing" notice each need a frame of their own to be seen.
+- **`set_rt_scene` builds the BVH on the UI thread** (the stage is only
+  lent there): 2.3 s for 5M triangles on the shadow's iGPU. The window says
+  "traced: preparing…" first; moving it off-thread needs cce-ui.
+- **The tracer has no smooth normals**: a coarse sphere shows its facets
+  when traced. Milestone 5's territory (cce-ui's materials and vertices).
 - No root plate, on purpose (`style-audit: opt-out` in `display_list`): the
   scene is the window's content, and a root plate would frost over it.
 
@@ -80,7 +98,8 @@ then `ctl windows` for the id and `shot-window <id>`. The window is
 640×360. `pointer-press` / `pointer-move-by` / `pointer-release` drive an
 orbit, `pointer-pinch 1.6` a zoom, `pointer-scroll 0 -12 finger` then
 `pointer-scroll finger-stop` a scroll orbit and its coast, `keypress 11` the
-`0` key, 17/49/34 `w`/`n`/`g`, 105/106 ←/→. A new viewer window can map
+`0` key, 17/49/34/19 `w`/`n`/`g`/`r`, 105/106 ←/→. A traced run logs
+`traced N samples in M ms; idle` at its cap. A new viewer window can map
 WITHOUT focus (keys then reach nothing, or a stale window): `ctl
 focus-window cce-model` after each launch, and close windows by pid
 (checking `/proc/<pid>/environ` for the shadow's display) — one launched by
diff --git a/src/main.rs b/src/main.rs
index b60053e..ff3ccaa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,23 +9,32 @@
 //! first time either is asked for, so a big model does not pay for them
 //! unless they are wanted.
 //!
+//! `r` swaps in a path-traced view (cce-ui's tracer): whenever the camera
+//! has been still for `STILL`, each frame adds a sample until the cap
+//! (`trace::SAMPLES_ON_MAINS`, fewer on battery), after which nothing more
+//! is staged and the GPU is left idle with the result on screen. Any
+//! camera move drops straight back to the raster view.
+//!
 //! Mouse: drag orbits, shift+drag or middle-drag pans, ctrl+wheel and pinch
 //! zoom, a two-finger scroll orbits (and coasts), as in cce-designer. A file
 //! dropped on the window opens. Keys: o open · ←/→ the folder's other models
-//! · 0 frame all · g grid · w wireframe · n normals · q quit.
+//! · 0 frame all · g grid · w wireframe · n normals · r traced · q quit.
 
 mod camera;
 mod files;
 mod light;
 mod overlay;
+mod trace;
 mod units;
 
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
+use std::time::{Duration, Instant};
 
 use cce_mesh_io::{Mesh, Unit, UpAxis};
 use cce_ui::engine::{
-    AppSender, Application, LogicalPosition, LogicalSize, MeshId, SceneDraw, Stage3D, Vertex3D, WindowSettings,
+    AppSender, Application, LogicalPosition, LogicalSize, MeshId, RtCamera, RtMaterial, RtTriangle, SceneDraw, Stage3D,
+    Vertex3D, WindowSettings,
 };
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
@@ -47,7 +56,14 @@ const SKY_BOTTOM: [f32; 3] = [0.03, 0.03, 0.035];
 /// How strongly the wireframe shows over the model.
 const WIRE_OPACITY: f32 = 0.55;
 
-const HINTS: &str = "o open   ·   ←/→ folder   ·   0 frame   ·   g grid   ·   w wireframe   ·   n normals";
+const HINTS: &str =
+    "o open   ·   ←/→ folder   ·   0 frame   ·   g grid   ·   w wireframe   ·   n normals   ·   r traced";
+
+/// How long the camera must rest before the traced view takes over.
+const STILL: Duration = Duration::from_millis(150);
+
+/// Where the system lists its power supplies, for the battery check.
+const POWER_SUPPLIES: &str = "/sys/class/power_supply";
 
 #[derive(Debug, Clone)]
 enum Message {
@@ -56,6 +72,8 @@ enum Message {
     Loaded { generation: u64, path: PathBuf, result: Result<Arc<Model>, String> },
     /// The wireframe and the normals of the model of `generation`.
     Overlays { generation: u64, edges: Arc<Vec<Vertex3D>>, normals: Arc<Vec<Vertex3D>> },
+    /// The traced view's scene for the model of `generation`.
+    TraceScene { generation: u64, scene: Arc<TraceScene> },
     Quit,
 }
 
@@ -74,6 +92,15 @@ struct Model {
     verts: Vec<Vertex3D>,
     grid: Vec<Vertex3D>,
     grid_step: f32,
+    /// The grid's height in the fitted space: where the traced ground is.
+    floor_y: f32,
+}
+
+/// The tracer's triangles and materials, built on a worker.
+#[derive(Debug)]
+struct TraceScene {
+    triangles: Vec<RtTriangle>,
+    materials: Vec<RtMaterial>,
 }
 
 impl Model {
@@ -99,6 +126,7 @@ impl Model {
             mesh,
             grid: grid.lines,
             grid_step: grid.step,
+            floor_y: (lo.y - centre.y) / radius,
         })
     }
 }
@@ -160,6 +188,24 @@ struct ModelApp {
     show_grid: bool,
     show_wire: bool,
     show_normals: bool,
+    /// The path-traced view: asked for, its scene once built, whether the
+    /// current renderer holds it, and how far it has refined.
+    traced: bool,
+    trace_scene: Option<Arc<TraceScene>>,
+    building_trace: bool,
+    trace_uploaded: bool,
+    /// A frame saying "preparing" has gone out ahead of the upload, which
+    /// freezes the window while cce-ui builds the tracer's BVH (2.4 s for
+    /// 5M triangles), so the freeze is explained before it happens.
+    trace_announced: bool,
+    samples: u32,
+    sample_cap: u32,
+    /// The camera and pane last staged, and when they last changed: the
+    /// tracer waits for `STILL` after any change.
+    view_key: [u32; 8],
+    moved_at: Instant,
+    /// When the current refinement began, to log how long it took.
+    trace_began: Option<Instant>,
     camera: Camera,
     gpu: Option<Gpu>,
     pending: Pending,
@@ -253,6 +299,43 @@ impl ModelApp {
         });
     }
 
+    /// Build the tracer's scene on a worker, if the traced view is wanted
+    /// and it is not built or being built.
+    fn want_trace(&mut self) {
+        if !self.traced || self.trace_scene.is_some() || self.building_trace {
+            return;
+        }
+        let Some(model) = self.model.clone() else { return };
+        self.building_trace = true;
+        let (generation, sender) = (self.generation, self.sender.clone());
+        std::thread::spawn(move || {
+            let started = Instant::now();
+            let (triangles, materials) = trace::scene(&model.mesh, model.floor_y);
+            log::info!(
+                "[model] trace scene: {} triangles, {} materials in {:.0} ms",
+                triangles.len(),
+                materials.len(),
+                started.elapsed().as_secs_f64() * 1e3
+            );
+            let _ = sender.send(Message::TraceScene { generation, scene: Arc::new(TraceScene { triangles, materials }) });
+        });
+    }
+
+    fn toggle_trace(&mut self) {
+        self.traced = !self.traced;
+        self.samples = 0;
+        self.trace_began = None;
+        // Asked once a toggle: a plug pulled mid-session takes effect at the
+        // next `r` or the next model.
+        self.sample_cap = if trace::on_battery(Path::new(POWER_SUPPLIES)) {
+            trace::SAMPLES_ON_BATTERY
+        } else {
+            trace::SAMPLES_ON_MAINS
+        };
+        self.want_trace();
+        self.scene_dirty = true;
+    }
+
     fn frame_all(&mut self) {
         if self.model.is_some() {
             self.camera.frame(Vec3::ZERO, 1.0, self.aspect());
@@ -295,6 +378,16 @@ impl ModelApp {
         if self.show_grid {
             facts.push(format!("grid {}", units::length(m.grid_step, m.unit)));
         }
+        if self.traced {
+            let battery = if self.sample_cap < trace::SAMPLES_ON_MAINS { " (battery)" } else { "" };
+            facts.push(if self.building_trace || (self.trace_scene.is_some() && !self.trace_uploaded) {
+                "traced: preparing…".into()
+            } else if self.samples >= self.sample_cap {
+                format!("traced, {} samples{battery}", self.samples)
+            } else {
+                format!("traced {} / {}{battery}", self.samples, self.sample_cap)
+            });
+        }
         if self.building_overlays {
             facts.push("building overlays…".into());
         } else if self.show_wire && m.mesh.triangles.len() > overlay::MAX_WIRE_TRIANGLES {
@@ -317,6 +410,11 @@ fn group_thousands(n: usize) -> String {
     out
 }
 
+/// The camera and pane, as bits: any change restarts the tracer's wait.
+fn view_key(c: &Camera, pw: u32, ph: u32) -> [u32; 8] {
+    [c.yaw.to_bits(), c.pitch.to_bits(), c.distance.to_bits(), c.pivot.x.to_bits(), c.pivot.y.to_bits(), c.pivot.z.to_bits(), pw, ph]
+}
+
 /// A scene draw of `mesh` with every option at its plain default.
 fn draw(mesh: MeshId, mvp: [[f32; 4]; 4]) -> SceneDraw {
     SceneDraw {
@@ -351,6 +449,16 @@ impl Application for ModelApp {
             show_grid: true,
             show_wire: false,
             show_normals: false,
+            traced: false,
+            trace_scene: None,
+            building_trace: false,
+            trace_uploaded: false,
+            trace_announced: false,
+            samples: 0,
+            sample_cap: trace::SAMPLES_ON_MAINS,
+            view_key: [0; 8],
+            moved_at: Instant::now(),
+            trace_began: None,
             camera: Camera::default(),
             gpu: None,
             pending: Pending::default(),
@@ -392,8 +500,14 @@ impl Application for ModelApp {
                         self.overlays = None;
                         self.building_overlays = false;
                         self.pending = Pending { model: true, grid: true, edges: false, normals: false };
+                        self.trace_scene = None;
+                        self.building_trace = false;
+                        self.trace_uploaded = false;
+                        self.trace_announced = false;
+                        self.samples = 0;
                         self.frame_all();
                         self.want_overlays();
+                        self.want_trace();
                     }
                     Err(e) => {
                         log::warn!("[model] {}: {e}", path.display());
@@ -403,6 +517,17 @@ impl Application for ModelApp {
                 }
                 *needs_rebuild = true;
             }
+            Message::TraceScene { generation, scene } => {
+                if generation != self.generation {
+                    return;
+                }
+                self.building_trace = false;
+                self.trace_scene = Some(scene);
+                self.trace_uploaded = false;
+                self.trace_announced = false;
+                self.samples = 0;
+                *needs_rebuild = true;
+            }
             Message::Overlays { generation, edges, normals } => {
                 if generation != self.generation {
                     return;
@@ -454,6 +579,10 @@ impl Application for ModelApp {
         let has = self.model.is_some();
         let built = self.overlays.is_some();
         self.pending = Pending { model: has, grid: has, edges: built, normals: built };
+        // The tracer's scene and its accumulation died with the old renderer.
+        self.trace_uploaded = false;
+        self.trace_announced = false;
+        self.samples = 0;
         self.scene_dirty = true;
     }
 
@@ -483,11 +612,56 @@ impl Application for ModelApp {
         if pending.model || pending.grid || pending.edges || pending.normals {
             self.scene_dirty = true;
         }
+        let (pw, ph) = ((size.width as f64 * scale) as u32, (size.height as f64 * scale) as u32);
+        let view_proj = self.camera.view_proj(pw as f32 / ph.max(1) as f32);
+
+        // The traced view: once the camera has rested, a sample a frame up
+        // to the cap; then nothing, and the backdrop keeps the image.
+        let key = view_key(&self.camera, pw, ph);
+        if key != self.view_key {
+            self.view_key = key;
+            self.moved_at = Instant::now();
+            self.samples = 0;
+            self.trace_began = None;
+        }
+        let tracing = self.traced && self.model.is_some() && self.trace_scene.is_some();
+        if tracing && self.moved_at.elapsed() >= STILL {
+            if self.samples >= self.sample_cap {
+                return false;
+            }
+            if !self.trace_uploaded {
+                // The HUD is painted before this runs: return once so the
+                // frame saying "preparing" is presented, and upload on the
+                // next.
+                if !std::mem::replace(&mut self.trace_announced, true) {
+                    return true;
+                }
+                let scene = self.trace_scene.as_ref().unwrap();
+                let started = Instant::now();
+                stage.set_rt_scene(&scene.triangles, &scene.materials);
+                stage.set_rt_environment(trace::environment(light::KEY.normalize().to_array()));
+                stage.set_rt_background(Some(trace::BACKGROUND));
+                log::info!("[model] trace scene uploaded in {:.0} ms", started.elapsed().as_secs_f64() * 1e3);
+                self.trace_uploaded = true;
+            }
+            let began = *self.trace_began.get_or_insert_with(Instant::now);
+            stage.stage_rt((0, 0, pw, ph), RtCamera { inv_mvp: view_proj.inverse().to_cols_array_2d() });
+            self.samples += 1;
+            // Whatever is staged next (a move's raster frame) must restage.
+            self.scene_dirty = true;
+            if self.samples == self.sample_cap {
+                log::info!("[model] traced {} samples in {:.0} ms; idle", self.samples, began.elapsed().as_secs_f64() * 1e3);
+            }
+            // One frame more than samples: the HUD is painted before this
+            // runs, so the frame after the last sample is the one that says
+            // the cap was reached; that frame stages nothing (above).
+            return true;
+        }
         if !std::mem::replace(&mut self.scene_dirty, false) {
-            return false;
+            // Still waiting out STILL: keep the frames coming to notice it.
+            return tracing;
         }
-        let (pw, ph) = ((size.width as f64 * scale) as u32, (size.height as f64 * scale) as u32);
-        let mvp = self.camera.view_proj(pw as f32 / ph.max(1) as f32).to_cols_array_2d();
+        let mvp = view_proj.to_cols_array_2d();
         // One logical px, whatever the output scale (clamped by the device).
         let line_width = scale as f32;
         let lines = |mesh| SceneDraw { wireframe: true, line_width, ..draw(mesh, mvp) };
@@ -509,7 +683,7 @@ impl Application for ModelApp {
             }
         }
         stage.stage_scene((0, 0, pw, ph), draws);
-        false
+        tracing
     }
 
     fn handle_resize(&mut self, width: f32, height: f32, scale: f64) {
@@ -635,6 +809,7 @@ impl Application for ModelApp {
                 self.show_normals = !self.show_normals;
                 self.want_overlays();
             }
+            Key::Character(c) if c == "r" => self.toggle_trace(),
             Key::Character(c) if c == "q" => return Some(Message::Quit),
             Key::Named(NamedKey::ArrowLeft) => self.step_file(-1),
             Key::Named(NamedKey::ArrowRight) => self.step_file(1),
diff --git a/src/trace.rs b/src/trace.rs
new file mode 100644
index 0000000..0c52543
--- /dev/null
+++ b/src/trace.rs
@@ -0,0 +1,157 @@
+//! The path-traced view's scene, and how long to let it refine.
+//!
+//! cce-ui's tracer takes triangles with a material each (albedo and
+//! emission, linear) in the space the camera's inverse mvp unprojects into —
+//! the fitted space the raster pass draws in, so one camera serves both.
+//! The model goes in with a colour per triangle (its corner colours
+//! averaged, or its palette colour), deduplicated into materials, over a
+//! wide ground plane at the grid's height, so the traced model stands on
+//! something and casts a shadow; the grid's lines, which are not surfaces,
+//! have no place in it.
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use cce_mesh_io::Mesh;
+use cce_ui::engine::{RtEnvironment, RtMaterial, RtTriangle};
+
+/// Samples to refine to: enough for the tracer's denoiser to settle, and
+/// then the GPU is left alone. Fewer on battery, where every frame of
+/// compute is drawn from it.
+pub const SAMPLES_ON_MAINS: u32 = 256;
+pub const SAMPLES_ON_BATTERY: u32 = 32;
+
+/// The ground's half-width, in the fitted space (the model is 2 across):
+/// far enough that its edge is past the horizon from any framing.
+const GROUND_HALF: f32 = 60.0;
+/// Dark, near the raster background, so switching views does not turn the
+/// whole window grey.
+const GROUND_ALBEDO: [f32; 3] = [0.09, 0.09, 0.095];
+
+/// Corner colours are quantized this finely before they are shared as
+/// materials, so a vertex-coloured scan does not become a material a
+/// triangle.
+const COLOUR_STEPS: f32 = 64.0;
+
+/// What a camera ray that misses everything shows: the middle of the
+/// raster view's background gradient, so the switch does not flash.
+pub const BACKGROUND: [f32; 3] = [0.06, 0.063, 0.072];
+
+/// The sky and sun, with the sun where the raster bake's key light is, and
+/// the sky grey rather than the tracer's default blue: the model's own
+/// colours should not shift when the view is switched.
+pub fn environment(key: [f32; 3]) -> RtEnvironment {
+    RtEnvironment {
+        sun_direction: key,
+        sky_zenith: [0.62, 0.63, 0.66],
+        sky_nadir: [0.2, 0.2, 0.21],
+        ..RtEnvironment::default()
+    }
+}
+
+/// The model's triangles and a ground plane under them at `floor_y`.
+pub fn scene(mesh: &Mesh, floor_y: f32) -> (Vec<RtTriangle>, Vec<RtMaterial>) {
+    let mut materials: Vec<RtMaterial> = Vec::new();
+    let mut slot: HashMap<[u16; 3], u32> = HashMap::new();
+    let mut material_for = |c: [f32; 3]| -> u32 {
+        let key = c.map(|v| (v.clamp(0.0, 1.0) * COLOUR_STEPS).round() as u16);
+        *slot.entry(key).or_insert_with(|| {
+            materials.push(RtMaterial { albedo: key.map(|k| k as f32 / COLOUR_STEPS), emission: [0.0; 3] });
+            (materials.len() - 1) as u32
+        })
+    };
+    let mut triangles = Vec::with_capacity(mesh.triangles.len() + 2);
+    for (t, tri) in mesh.triangles.iter().enumerate() {
+        let colour = if mesh.corner_colors.is_some() {
+            let [a, b, c] = [0, 1, 2].map(|k| mesh.corner_color(t, k));
+            [0, 1, 2].map(|i| (a[i] + b[i] + c[i]) / 3.0)
+        } else {
+            mesh.corner_color(t, 0)
+        };
+        let [p0, p1, p2] = tri.map(|i| mesh.positions[i as usize].to_array());
+        triangles.push(RtTriangle { p0, p1, p2, material: material_for(colour) });
+    }
+    let ground = material_for(GROUND_ALBEDO);
+    let g = GROUND_HALF;
+    // Counter-clockwise seen from above.
+    let corner = |x: f32, z: f32| [x, floor_y, z];
+    triangles.push(RtTriangle { p0: corner(-g, -g), p1: corner(-g, g), p2: corner(g, g), material: ground });
+    triangles.push(RtTriangle { p0: corner(-g, -g), p1: corner(g, g), p2: corner(g, -g), material: ground });
+    (triangles, materials)
+}
+
+/// True when the machine is running from its battery: a mains supply is
+/// listed under `root` (`/sys/class/power_supply`) and none is online. A
+/// machine with no mains supply listed (a desktop) is never on battery.
+pub fn on_battery(root: &Path) -> bool {
+    let Ok(entries) = std::fs::read_dir(root) else { return false };
+    let mut mains = false;
+    for e in entries.flatten() {
+        let read = |name: &str| std::fs::read_to_string(e.path().join(name)).unwrap_or_default();
+        if read("type").trim() == "Mains" {
+            mains = true;
+            if read("online").trim() == "1" {
+                return false;
+            }
+        }
+    }
+    mains
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use glam::Vec3;
+
+    fn quad(corner_colors: Option<Vec<[f32; 3]>>) -> Mesh {
+        Mesh {
+            positions: vec![Vec3::ZERO, Vec3::X, Vec3::new(1.0, 1.0, 0.0), Vec3::Y],
+            triangles: vec![[0, 1, 2], [0, 2, 3]],
+            tri_color: vec![0, 0],
+            colors: vec![[0.5, 0.25, 0.0]],
+            corner_colors,
+        }
+    }
+
+    #[test]
+    fn the_model_stands_on_a_ground_plane() {
+        let (tris, mats) = scene(&quad(None), -1.0);
+        assert_eq!(tris.len(), 2 + 2);
+        assert_eq!(mats.len(), 2, "one model colour and the ground");
+        let ground = &tris[2];
+        assert!([ground.p0, ground.p1, ground.p2].iter().all(|p| p[1] == -1.0));
+        assert_eq!(mats[ground.material as usize].albedo, [0.09375, 0.09375, 0.09375], "the ground's colour, quantized");
+        // Its normal points up, so it is lit from above.
+        let [a, b, c] = [ground.p0, ground.p1, ground.p2].map(Vec3::from);
+        assert!((b - a).cross(c - a).y > 0.0);
+    }
+
+    #[test]
+    fn corner_colours_are_averaged_and_shared() {
+        let red = [1.0, 0.0, 0.0];
+        let (tris, mats) = scene(&quad(Some(vec![red; 6])), 0.0);
+        assert_eq!(tris[0].material, tris[1].material, "two red triangles share one material");
+        assert_eq!(mats[tris[0].material as usize].albedo, red);
+    }
+
+    #[test]
+    fn mains_online_is_not_battery() {
+        let root = std::env::temp_dir().join(format!("cce-model-power-{}", std::process::id()));
+        let supply = |name: &str, ty: &str, online: Option<&str>| {
+            let d = root.join(name);
+            std::fs::create_dir_all(&d).unwrap();
+            std::fs::write(d.join("type"), format!("{ty}\n")).unwrap();
+            if let Some(o) = online {
+                std::fs::write(d.join("online"), format!("{o}\n")).unwrap();
+            }
+        };
+        assert!(!on_battery(&root), "nothing listed: a desktop");
+        supply("BAT0", "Battery", None);
+        assert!(!on_battery(&root), "a battery and no mains supply listed");
+        supply("AC", "Mains", Some("0"));
+        assert!(on_battery(&root));
+        supply("AC", "Mains", Some("1"));
+        assert!(!on_battery(&root));
+        std::fs::remove_dir_all(&root).ok();
+    }
+}