git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit6870d21bbab50f3be74667099629514dc4d19978
parentf7649ec6b8
authorLucas Galante <[email protected]>
date2026-07-14 18:29
feat: --thumbnail — headless path-traced project thumbnails (phase 3)

`cce-designer --thumbnail <project> <out.png> [--size N]` loads a
project's state.json, regenerates its geometry from the node graph
(same network_sphere_vertices path as the viewport, OpenCL included),
auto-frames a camera on the scene's bounding sphere, renders 96 spp
through cce_ui::vk::RtOffscreen, and writes a PNG — no Wayland, no
window; runs before any compositor connection and exits. cce-files
shells out to this for its project-preview cache.

The verts→RT-scene conversion (color-deduped Lambertian materials)
moves to geometry::rt_scene_from_verts, shared by the viewport's RT
mode and the thumbnailer.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01G5djCURa3LVnRU8WacanLC

 Cargo.toml       |  1 +
 src/geometry.rs  | 29 ++++++++++++++++++++
 src/main.rs      | 24 +++++++++++++++++
 src/render.rs    | 37 +++----------------------
 src/thumbnail.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 139 insertions(+), 34 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index b33b36a..5a7fc97 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,7 @@ opencl3 = "0.9"
 libc = "0.2"
 log = "0.4"
 env_logger = "0.11"
+png = "0.17"
 
 [[bin]]
 name = "cce-designer"
diff --git a/src/geometry.rs b/src/geometry.rs
index 22fdd12..074f1d5 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -111,6 +111,35 @@ pub struct Vertex3D {
     pub color: [f32; 3],
 }
 
+/// Colored triangles → the path tracer's scene schema, one Lambertian
+/// material per distinct (8-bit-quantized) vertex color. Shared by the
+/// viewport's RT mode and the `--thumbnail` renderer.
+pub fn rt_scene_from_verts(
+    verts: &[Vertex3D],
+) -> (Vec<cce_ui::vk::RtTriangle>, Vec<cce_ui::vk::RtMaterial>) {
+    let mut tris: Vec<cce_ui::vk::RtTriangle> = Vec::new();
+    let mut mats: Vec<cce_ui::vk::RtMaterial> = Vec::new();
+    let mut by_color: std::collections::HashMap<[u8; 3], u32> = std::collections::HashMap::new();
+    for tri in verts.chunks_exact(3) {
+        let key = [
+            (tri[0].color[0].clamp(0.0, 1.0) * 255.0) as u8,
+            (tri[0].color[1].clamp(0.0, 1.0) * 255.0) as u8,
+            (tri[0].color[2].clamp(0.0, 1.0) * 255.0) as u8,
+        ];
+        let material = *by_color.entry(key).or_insert_with(|| {
+            mats.push(cce_ui::vk::RtMaterial { albedo: tri[0].color, emission: [0.0; 3] });
+            (mats.len() - 1) as u32
+        });
+        tris.push(cce_ui::vk::RtTriangle {
+            p0: tri[0].position,
+            p1: tri[1].position,
+            p2: tri[2].position,
+            material,
+        });
+    }
+    (tris, mats)
+}
+
 pub fn cube_vertices() -> Vec<Vertex3D> {
     let s = 0.5;
     let data: &[([f32; 3], [f32; 3])] = &[
diff --git a/src/main.rs b/src/main.rs
index 463da13..74016fc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -48,6 +48,7 @@ pub mod geometry;
 pub mod project;
 pub mod render;
 pub mod shortcut;
+pub mod thumbnail;
 
 use app::{State, CustomEvent, HttpAction, ModifiersState};
 use window::{AppState, WindowEvent};
@@ -55,6 +56,29 @@ use api::start_http_server;
 
 fn main() {
     let args: Vec<String> = std::env::args().collect();
+
+    // Headless thumbnail mode: no Wayland, no window — render and exit.
+    //   cce-designer --thumbnail <project-dir-or-state.json> <out.png> [--size N]
+    if let Some(i) = args.iter().position(|a| a == "--thumbnail") {
+        let (Some(project), Some(out)) = (args.get(i + 1), args.get(i + 2)) else {
+            eprintln!("usage: cce-designer --thumbnail <project> <out.png> [--size N]");
+            std::process::exit(2);
+        };
+        let size = args
+            .windows(2)
+            .find(|w| w[0] == "--size")
+            .and_then(|w| w[1].parse::<u32>().ok())
+            .unwrap_or(256)
+            .clamp(16, 2048);
+        match thumbnail::run(std::path::Path::new(project), std::path::Path::new(out), size) {
+            Ok(()) => std::process::exit(0),
+            Err(e) => {
+                eprintln!("cce-designer --thumbnail: {e}");
+                std::process::exit(1);
+            }
+        }
+    }
+
     let is_detached_network = args.iter().any(|arg| arg == "--detached-network");
 
     let conn = Connection::connect_to_env().unwrap();
diff --git a/src/render.rs b/src/render.rs
index 6168c3a..0082042 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -415,42 +415,11 @@ impl State {
     pub(crate) fn collect_rt_scene(
         &self,
     ) -> (Vec<cce_ui::vk::RtTriangle>, Vec<cce_ui::vk::RtMaterial>) {
-        let mut tris: Vec<cce_ui::vk::RtTriangle> = Vec::new();
-        let mut mats: Vec<cce_ui::vk::RtMaterial> = Vec::new();
-        // Dedupe on 8-bit-quantized color: procedural (OpenCL) geometry can
-        // carry per-vertex gradients, and exact-match dedup would mint one
-        // material per triangle.
-        let mut by_color: std::collections::HashMap<[u8; 3], u32> =
-            std::collections::HashMap::new();
-        let mut push_verts = |tris: &mut Vec<cce_ui::vk::RtTriangle>,
-                              mats: &mut Vec<cce_ui::vk::RtMaterial>,
-                              verts: &[crate::geometry::Vertex3D]| {
-            for tri in verts.chunks_exact(3) {
-                let key = [
-                    (tri[0].color[0].clamp(0.0, 1.0) * 255.0) as u8,
-                    (tri[0].color[1].clamp(0.0, 1.0) * 255.0) as u8,
-                    (tri[0].color[2].clamp(0.0, 1.0) * 255.0) as u8,
-                ];
-                let material = *by_color.entry(key).or_insert_with(|| {
-                    mats.push(cce_ui::vk::RtMaterial {
-                        albedo: tri[0].color,
-                        emission: [0.0; 3],
-                    });
-                    (mats.len() - 1) as u32
-                });
-                tris.push(cce_ui::vk::RtTriangle {
-                    p0: tri[0].position,
-                    p1: tri[1].position,
-                    p2: tri[2].position,
-                    material,
-                });
-            }
-        };
-        push_verts(&mut tris, &mut mats, &self.rt_sphere_verts);
+        let mut verts = self.rt_sphere_verts.clone();
         if self.viewport().show_cube {
-            push_verts(&mut tris, &mut mats, &crate::geometry::cube_vertices());
+            verts.extend(crate::geometry::cube_vertices());
         }
-        (tris, mats)
+        crate::geometry::rt_scene_from_verts(&verts)
     }
 
     pub(crate) fn update_status_text(&mut self, text: &str) {
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
new file mode 100644
index 0000000..2dc2a6d
--- /dev/null
+++ b/src/thumbnail.rs
@@ -0,0 +1,82 @@
+//! `cce-designer --thumbnail <project> <out.png> [--size N]` — headless
+//! path-traced project thumbnails (RT-renderer phase 3).
+//!
+//! Loads a project's `state.json`, regenerates its geometry from the node
+//! graph (the same `network_sphere_vertices_with_errors` the viewport uses,
+//! OpenCL nodes included), auto-frames a camera on the scene bounds, and
+//! renders through `cce_ui::vk::RtOffscreen` — no window, no compositor, any
+//! graphics-capable Vulkan device. cce-files shells out to this for its
+//! preview cache.
+
+use std::path::Path;
+
+use glam::{Mat4, Vec3};
+
+use crate::app::Project;
+use crate::geometry::{network_sphere_vertices_with_errors, rt_scene_from_verts};
+
+const SAMPLES: u32 = 96;
+
+/// Render `project` (a project directory or a `state.json` path) to a square
+/// `size`×`size` PNG at `out`.
+pub fn run(project: &Path, out: &Path, size: u32) -> Result<(), String> {
+    let state_file = if project.is_dir() { project.join("state.json") } else { project.to_path_buf() };
+    let content = std::fs::read_to_string(&state_file)
+        .map_err(|e| format!("read {}: {e}", state_file.display()))?;
+    let proj: Project =
+        serde_json::from_str(&content).map_err(|e| format!("parse {}: {e}", state_file.display()))?;
+
+    let mut ocl_error = None;
+    let geom = network_sphere_vertices_with_errors(&proj.root, &mut ocl_error);
+    if let Some(e) = ocl_error {
+        // Non-fatal: OpenCL nodes just contribute nothing, like the viewport.
+        eprintln!("thumbnail: OpenCL error (geometry partially skipped): {e}");
+    }
+    let verts = geom.to_vertex3d_vec();
+    let (tris, mats) = rt_scene_from_verts(&verts);
+
+    // Frame the scene: bounding sphere fit into a 0.9 rad vertical FOV from a
+    // pleasant high-diagonal direction. An empty scene still renders (sky).
+    let (center, radius) = bounds(&tris);
+    let fov = 0.9f32;
+    let dist = (radius / (fov * 0.5).sin()).max(0.5) * 1.15;
+    let eye = center + Vec3::new(1.0, 0.65, 1.0).normalize() * dist;
+    let near = (dist - radius * 2.0).max(dist * 0.01);
+    let far = dist + radius * 4.0 + 1.0;
+    let proj_m = Mat4::perspective_rh(fov, 1.0, near, far);
+    let view_m = Mat4::look_at_rh(eye, center, Vec3::Y);
+    let camera =
+        cce_ui::vk::RtCamera { inv_mvp: (proj_m * view_m).inverse().to_cols_array_2d() };
+
+    let mut off = cce_ui::vk::RtOffscreen::new();
+    off.set_scene(&tris, &mats);
+    let pixels = off.render(camera, size, size, SAMPLES);
+
+    let file = std::fs::File::create(out).map_err(|e| format!("create {}: {e}", out.display()))?;
+    let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), size, size);
+    encoder.set_color(png::ColorType::Rgba);
+    encoder.set_depth(png::BitDepth::Eight);
+    encoder.set_srgb(png::SrgbRenderingIntent::Perceptual);
+    let mut writer = encoder.write_header().map_err(|e| format!("png header: {e}"))?;
+    writer.write_image_data(&pixels).map_err(|e| format!("png write: {e}"))?;
+    writer.finish().map_err(|e| format!("png finish: {e}"))?;
+    Ok(())
+}
+
+fn bounds(tris: &[cce_ui::vk::RtTriangle]) -> (Vec3, f32) {
+    if tris.is_empty() {
+        return (Vec3::ZERO, 1.0);
+    }
+    let mut min = Vec3::splat(f32::INFINITY);
+    let mut max = Vec3::splat(f32::NEG_INFINITY);
+    for t in tris {
+        for p in [t.p0, t.p1, t.p2] {
+            let v = Vec3::from_array(p);
+            min = min.min(v);
+            max = max.max(v);
+        }
+    }
+    let center = (min + max) * 0.5;
+    let radius = (max - center).length().max(1e-3);
+    (center, radius)
+}