graphic design tool
git clone https://git.lucas.co/cce-designer.git
src/thumbnail.rs (7.7K)
1 //! `cce-designer --thumbnail <project> <out.png> [--size N]` — headless
2 //! path-traced project thumbnails (RT-renderer phase 3).
3 //!
4 //! Loads a project's `state.json`, regenerates its geometry from the node
5 //! graph (the same `network_sphere_vertices_with_errors` the viewport uses,
6 //! wrangles included), auto-frames a camera on the scene bounds, and
7 //! renders through `cce_ui::vk::RtOffscreen` — no window, no compositor, any
8 //! graphics-capable Vulkan device. cce-files shells out to this for its
9 //! preview cache.
10
11 use std::path::Path;
12
13 use glam::{Mat4, Vec3};
14
15 use crate::app::Project;
16 use crate::geometry::{network_sphere_vertices_with_errors, rt_scene_from_verts};
17
18 const SAMPLES: u32 = 96;
19
20 /// Render `project` (a project directory or a `state.json` path) to a square
21 /// `size`×`size` PNG at `out`, with `samples` paths per pixel (None = 96).
22 pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: Option<i32>) -> Result<(), String> {
23 let state_file = if project.is_dir() { project.join("state.json") } else { project.to_path_buf() };
24 let content = std::fs::read_to_string(&state_file)
25 .map_err(|e| format!("read {}: {e}", state_file.display()))?;
26 let mut proj: Project =
27 serde_json::from_str(&content).map_err(|e| format!("parse {}: {e}", state_file.display()))?;
28 // Same template merge the app applies on load, so a thumbnail of an old
29 // scene shows what opening it would show.
30 let templates = crate::app::flatten_node_templates(&crate::app::load_fs_tree());
31 proj.sanitize_node_names();
32 proj.migrate_format();
33 crate::app::merge_template_defs(&mut proj.root, &templates);
34
35 let mut ocl_error = None;
36 // Without `--frame`, a headless thumbnail has no timeline and simnets
37 // render at their seed. WITH it, the solve runs to that frame — which is
38 // the only way to look at a simulation without a Wayland session, and so
39 // the only way to check that a growth chain does what it claims.
40 //
41 // The start frame is the playbar's default of 1, the same number the app
42 // uses, so a frame number here means what it means in the window. A simnet
43 // with its own Start Frame answers for itself either way.
44 let mut sim_cache = crate::geometry::SimCache::default();
45 let mut sim = crate::geometry::EvalSim::new(frame.unwrap_or(0), 1, &mut sim_cache);
46 // Thumbnails always show the whole scene from the top, regardless of the
47 // network level the project was saved at: root as both eval and walk root.
48 let geom = network_sphere_vertices_with_errors(&proj.root, &proj.root, &mut ocl_error, &mut sim);
49 if let Some(e) = ocl_error {
50 // Non-fatal: a failing node just contributes nothing, like the viewport.
51 eprintln!("thumbnail: node error (geometry partially skipped): {e}");
52 }
53 let verts = crate::geometry::detail_vertices(&geom);
54 let (tris, mats) = rt_scene_from_verts(&verts);
55
56 // The image the top level shows stands in the scene as it does in the
57 // viewport, at its physical size in the project's world unit.
58 let page = crate::page::displayed_page(&proj.root, &proj.root);
59 let corners = page.as_ref().map(|page| {
60 crate::page::PageShown {
61 node_id: String::new(),
62 size: page.size,
63 pixels: (page.width, page.height),
64 origin: page.origin,
65 }
66 .world_corners(world_unit_mm(&proj))
67 });
68 let rgba = page.as_ref().map(|page| page.to_rgba8());
69
70 let (eye, center, near, far) = view_of(&tris, corners);
71 let proj_m = Mat4::perspective_rh(FOV, 1.0, near, far);
72 let view_m = Mat4::look_at_rh(eye, center, Vec3::Y);
73 let camera =
74 cce_ui::vk::RtCamera { inv_mvp: (proj_m * view_m).inverse().to_cols_array_2d() };
75
76 let mut off = cce_ui::vk::RtOffscreen::new();
77 let image = match (&page, &rgba, corners) {
78 (Some(page), Some(rgba), Some(corners)) => Some(cce_ui::vk::RtImagePixels {
79 pixels: rgba,
80 width: page.width,
81 height: page.height,
82 corners,
83 opacity: 1.0,
84 }),
85 _ => None,
86 };
87 off.set_scene_with_image(&tris, &mats, image);
88 // The project's Environment node, at the frame rendered.
89 off.set_environment(crate::environment::Environment::of_scene(&proj.root, frame.unwrap_or(0)).to_rt());
90 // Behind the scene, the Background Color the project was saved with, as
91 // the traced viewport shows it; the sky, which still lights the scene,
92 // for a save without display settings.
93 off.set_background(
94 proj.view_state
95 .display
96 .as_ref()
97 .map(|d| cce_ui::colors::to_linear_rgb(d.viewport.bg_color)),
98 );
99 let pixels = off.render(camera, size, size, samples.unwrap_or(SAMPLES));
100
101 let file = std::fs::File::create(out).map_err(|e| format!("create {}: {e}", out.display()))?;
102 let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), size, size);
103 encoder.set_color(png::ColorType::Rgba);
104 encoder.set_depth(png::BitDepth::Eight);
105 crate::page::mark_srgb(&mut encoder);
106 let mut writer = encoder.write_header().map_err(|e| format!("png header: {e}"))?;
107 writer.write_image_data(&pixels).map_err(|e| format!("png write: {e}"))?;
108 writer.finish().map_err(|e| format!("png finish: {e}"))?;
109 Ok(())
110 }
111
112 /// The thumbnail camera's vertical field of view.
113 const FOV: f32 = 0.9;
114
115 /// One world unit of the project in millimetres: the unit its display
116 /// settings name, or the app's own default for a save from before it
117 /// carried them.
118 fn world_unit_mm(proj: &Project) -> f32 {
119 let name = proj
120 .view_state
121 .display
122 .as_ref()
123 .map(|d| d.viewport.world_unit.clone())
124 .unwrap_or_else(|| crate::app::ViewportSettings::default().world_unit);
125 let unit = cce_ui::units::Unit::parse(&name).unwrap_or(cce_ui::units::Unit::Mm);
126 let metric = cce_ui::units::metric();
127 cce_ui::units::Len::new(1.0, unit).convert(cce_ui::units::Unit::Mm, &metric).value
128 }
129
130 /// Where the thumbnail is taken from: the eye, what it looks at, and the
131 /// near and far planes.
132 ///
133 /// Geometry is seen from a pleasant high diagonal, its bounding sphere fitted
134 /// to the view, and an image standing with it is inside that sphere. An
135 /// image ALONE is seen square on and fitted edge to edge: from the diagonal
136 /// a picture is a slanted sliver of itself, and the thumbnail of a picture
137 /// is the picture. An empty scene still renders (sky).
138 pub(crate) fn view_of(
139 tris: &[cce_ui::vk::RtTriangle],
140 image: Option<[[f32; 3]; 4]>,
141 ) -> (Vec3, Vec3, f32, f32) {
142 if let (true, Some(corners)) = (tris.is_empty(), image) {
143 let [tl, tr, br, _] = corners.map(Vec3::from_array);
144 let center = (tl + br) * 0.5;
145 let longest = (tr - tl).length().max((br - tr).length()).max(1e-3);
146 let dist = longest / (2.0 * (FOV * 0.5).tan()) * 1.05;
147 return (center + Vec3::Z * dist, center, dist * 0.01, dist * 4.0);
148 }
149 let points = tris
150 .iter()
151 .flat_map(|t| [t.p0, t.p1, t.p2])
152 .chain(image.into_iter().flatten())
153 .map(Vec3::from_array);
154 let (center, radius) = bounds(points);
155 let dist = (radius / (FOV * 0.5).sin()).max(0.5) * 1.15;
156 let eye = center + Vec3::new(1.0, 0.65, 1.0).normalize() * dist;
157 let near = (dist - radius * 2.0).max(dist * 0.01);
158 let far = dist + radius * 4.0 + 1.0;
159 (eye, center, near, far)
160 }
161
162 fn bounds(points: impl Iterator<Item = Vec3>) -> (Vec3, f32) {
163 let mut min = Vec3::splat(f32::INFINITY);
164 let mut max = Vec3::splat(f32::NEG_INFINITY);
165 for v in points {
166 min = min.min(v);
167 max = max.max(v);
168 }
169 if !min.is_finite() {
170 return (Vec3::ZERO, 1.0);
171 }
172 let center = (min + max) * 0.5;
173 let radius = (max - center).length().max(1e-3);
174 (center, radius)
175 }