graphic design tool
git clone https://git.lucas.co/cce-designer.git
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)
+}