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

commitd5b3881e2e604c1e400c68c2d83ad584c63a43a2
parent466516a569
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 17:10
feat: the path tracer draws the image, in the viewport and in thumbnails

The traced scene is handed the image the raster pass draws, and is handed
over again when the image is recomposed or the world unit changes. A
thumbnail of an image alone is taken square on.

Needs cce-ui's set_rt_scene_with_image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md        |  20 ++++++++++-
 src/app.rs       |  22 ++++++++++--
 src/main.rs      |  64 ++++++++++++++++++++++++++++++++++
 src/render.rs    |   2 ++
 src/thumbnail.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++-----------
 5 files changed, 187 insertions(+), 23 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9f3f7dc..c0829cc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1606,7 +1606,25 @@ about a fifth of its size in an ordinary pane, and without a mip chain a
 ruled page was moiré head-on and worse at a slant. `State::page_shown` is what the stage pass
 places it by. Until then a pane of its own (`PAGE_IDX`, an `ImageView`)
 took the viewport's rect whenever the level held a page, so a picture and
-a model could not be seen together. The path tracer does not draw it.
+a model could not be seen together.
+
+**The path tracer draws it too** (since 2026-09-29), in the viewport's
+traced mode and in `--thumbnail`. The stage pass hands the tracer the same
+upload at the same corners (`set_rt_scene_with_image`, a `cce_ui::vk::
+RtImage`), and cce-ui adds the quad to the traced scene as two triangles
+under a textured material — so both tiers meet it as any triangle, and a
+scene that is an image ALONE is not an empty one, which the tracer used to
+skip. Traced, the image is a SURFACE: its colour is the albedo, lit by the
+sky and shadowed by the geometry as the geometry is, where the raster pass
+draws it unlit; and where it is clear a ray goes through, by chance in
+proportion to the alpha. The traced scene's key is the geometry's version,
+the image's (`State::page_version`, moved by every recomposition) and the
+world unit, which sizes the image. The thumbnail has no 2D pass to share
+an upload with and hands over the pixels (`RtImagePixels`); an image alone
+is taken square on and fitted edge to edge (`thumbnail::view_of`), since
+from the diagonal a picture is a slanted sliver of itself, and one with
+geometry is inside the diagonal view's bounds. The denoiser softens fine
+text on the image until the samples have settled.
 
 **The display flag is exclusive within its CONTEXT**
 (`set_child_geometry_visible`): the page nodes and the geometry nodes each
diff --git a/src/app.rs b/src/app.rs
index 315eba4..d5c693e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2487,7 +2487,12 @@ pub struct State {
     /// Bumped by `rebuild_scene_geometry`; part of the RT-scene cache key.
     pub rt_geometry_version: u64,
     /// The `rt_geometry_version` the RT scene was last built from.
-    pub last_rt_scene_key: Option<u64>,
+    /// The traced scene as it was last handed over: the geometry's version,
+    /// the image's, and the world unit's bits, which size the image.
+    pub last_rt_scene_key: Option<(u64, u64, u32)>,
+    /// Counts the recompositions of the shown image, as
+    /// `rt_geometry_version` counts the geometry's.
+    pub page_version: u64,
     pub ui_context: cce_ui::context::UiContext,
 }
 
@@ -6735,6 +6740,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             rt_sphere_verts: Vec::new(),
             rt_geometry_version: 0,
             last_rt_scene_key: None,
+            page_version: 0,
             ui_context: cce_ui::context::UiContext::new(),
         };
 
@@ -11086,10 +11092,20 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // the camera/pane changes, so camera drags stay interactive
                 // (1-spp noise) and stillness converges.
                 if rt_mode {
-                    let key = self.rt_geometry_version;
+                    let unit_mm = self.world_unit_mm();
+                    let key = (self.rt_geometry_version, self.page_version, unit_mm.to_bits());
                     if self.last_rt_scene_key != Some(key) {
                         let (rt_tris, rt_mats) = self.collect_rt_scene();
-                        renderer.set_rt_scene(&rt_tris, &rt_mats);
+                        // The image the raster pass draws, traced: the same
+                        // upload, at the same corners.
+                        let image = self.page_image.zip(self.page_shown.as_ref()).map(
+                            |(image, shown)| cce_ui::vk::RtImage {
+                                image,
+                                corners: shown.world_corners(unit_mm),
+                                opacity: 1.0,
+                            },
+                        );
+                        renderer.set_rt_scene_with_image(&rt_tris, &rt_mats, image);
                         self.last_rt_scene_key = Some(key);
                     }
                     let aspect = cw as f32 / ch as f32;
diff --git a/src/main.rs b/src/main.rs
index 8335802..3949b7c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15282,4 +15282,68 @@ mod tests {
         state.rebuild_scene_geometry();
         assert!(state.page_image.is_some_and(|id| id != first), "a picture of another size kept the old image");
     }
+
+    // --- The image, path traced --------------------------------------------
+
+    /// The traced scene is handed over again when the image changes: the
+    /// image has a version as the geometry has, which a recomposition moves
+    /// and a rebuild with no image in it does not.
+    #[test]
+    fn a_recomposed_image_is_a_new_traced_scene() {
+        let mut state = State::new(false);
+        let before = state.page_version;
+        state.rebuild_scene_geometry();
+        assert_eq!(state.page_version, before, "a scene with no image moved the image's version");
+
+        let mut state = state_showing_image(64, 32, 100);
+        let shown = state.page_version;
+        assert!(shown > before, "showing an image did not move its version");
+        state.rebuild_scene_geometry();
+        assert!(state.page_version > shown, "a recomposed image is the scene the tracer has");
+
+        // Hidden, the tracer has to be told it went.
+        let slot = state.current_dir().children.iter().position(|n| n.node_type == "page").unwrap();
+        state.current_dir_mut().set_child_geometry_visible(slot, false);
+        let hidden = state.page_version;
+        state.rebuild_scene_geometry();
+        assert!(state.page_version > hidden, "the tracer keeps an image that is gone");
+        let gone = state.page_version;
+        state.rebuild_scene_geometry();
+        assert_eq!(state.page_version, gone);
+    }
+
+    /// A thumbnail of an image alone is taken square on, the image fitted
+    /// edge to edge; with geometry beside it the view is the diagonal one and
+    /// holds both.
+    #[test]
+    fn a_thumbnail_of_an_image_is_taken_square_on() {
+        use crate::thumbnail::view_of;
+        let corners = [[1.0, 3.0, 2.0], [5.0, 3.0, 2.0], [5.0, 1.0, 2.0], [1.0, 1.0, 2.0]];
+        let seen = |eye: Vec3, center: Vec3, near: f32, far: f32, p: [f32; 3]| {
+            let m = Mat4::perspective_rh(0.9, 1.0, near, far) * Mat4::look_at_rh(eye, center, Vec3::Y);
+            m.project_point3(Vec3::from_array(p))
+        };
+
+        let (eye, center, near, far) = view_of(&[], Some(corners));
+        assert!((center - Vec3::new(3.0, 2.0, 2.0)).length() < 1e-4, "not looking at the image's middle: {center:?}");
+        assert!((eye.x - 3.0).abs() < 1e-4 && (eye.y - 2.0).abs() < 1e-4 && eye.z > 2.0, "not square on, from the side it faces: {eye:?}");
+        let [tl, tr, br, bl] = corners.map(|c| seen(eye, center, near, far, c));
+        assert!((tl.x + tr.x).abs() < 1e-4 && (tl.y + bl.y).abs() < 1e-4 && (tr.x - br.x).abs() < 1e-4);
+        assert!(tr.x > 0.9 && tr.x <= 1.0, "the long side spans {} of the view", tr.x);
+        assert!(tl.z > 0.0 && tl.z < 1.0, "the image is outside the near and far planes");
+
+        let tri = cce_ui::vk::RtTriangle { p0: [0.0, 0.0, 0.0], p1: [1.0, 0.0, 0.0], p2: [0.0, 1.0, 0.0], material: 0 };
+        let (eye, center, near, far) = view_of(&[tri], Some(corners));
+        assert!(eye.x > center.x && eye.y > center.y && eye.z > center.z, "geometry is seen from the diagonal");
+        for p in corners.into_iter().chain([tri.p0, tri.p1, tri.p2]) {
+            let v = seen(eye, center, near, far, p);
+            assert!(v.x.abs() < 1.0 && v.y.abs() < 1.0 && v.z > 0.0 && v.z < 1.0, "{p:?} is out of the view: {v:?}");
+        }
+
+        // As before, for a scene with no image in it.
+        let (eye, center, _, _) = view_of(&[tri], None);
+        assert!((center - Vec3::new(0.5, 0.5, 0.0)).length() < 1e-4 && eye.z > 0.0);
+        let (_, center, _, _) = view_of(&[], None);
+        assert_eq!(center, Vec3::ZERO);
+    }
 }
diff --git a/src/render.rs b/src/render.rs
index 420058d..e6d6e80 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1173,6 +1173,8 @@ impl State {
         }
         if had_page || self.page_shown.is_some() {
             self.viewport_dirty = true;
+            // The path tracer's scene holds the image too.
+            self.page_version += 1;
         }
     }
 
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index 30f4d38..7a7dbbb 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -53,21 +53,38 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: O
     let verts = crate::geometry::detail_vertices(&geom);
     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);
+    // The image the top level shows stands in the scene as it does in the
+    // viewport, at its physical size in the project's world unit.
+    let page = crate::page::displayed_page(&proj.root, &proj.root);
+    let corners = page.as_ref().map(|page| {
+        crate::page::PageShown {
+            node_id: String::new(),
+            size: page.size,
+            pixels: (page.width, page.height),
+            origin: page.origin,
+        }
+        .world_corners(world_unit_mm(&proj))
+    });
+    let rgba = page.as_ref().map(|page| page.to_rgba8());
+
+    let (eye, center, near, far) = view_of(&tris, corners);
+    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 image = match (&page, &rgba, corners) {
+        (Some(page), Some(rgba), Some(corners)) => Some(cce_ui::vk::RtImagePixels {
+            pixels: rgba,
+            width: page.width,
+            height: page.height,
+            corners,
+            opacity: 1.0,
+        }),
+        _ => None,
+    };
+    off.set_scene_with_image(&tris, &mats, image);
     let pixels = off.render(camera, size, size, samples.unwrap_or(SAMPLES));
 
     let file = std::fs::File::create(out).map_err(|e| format!("create {}: {e}", out.display()))?;
@@ -81,18 +98,65 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: O
     Ok(())
 }
 
-fn bounds(tris: &[cce_ui::vk::RtTriangle]) -> (Vec3, f32) {
-    if tris.is_empty() {
-        return (Vec3::ZERO, 1.0);
+/// The thumbnail camera's vertical field of view.
+const FOV: f32 = 0.9;
+
+/// One world unit of the project in millimetres: the unit its display
+/// settings name, or the app's own default for a save from before it
+/// carried them.
+fn world_unit_mm(proj: &Project) -> f32 {
+    let name = proj
+        .view_state
+        .display
+        .as_ref()
+        .map(|d| d.viewport.world_unit.clone())
+        .unwrap_or_else(|| crate::app::ViewportSettings::default().world_unit);
+    let unit = cce_ui::units::Unit::parse(&name).unwrap_or(cce_ui::units::Unit::Mm);
+    let metric = cce_ui::units::metric();
+    cce_ui::units::Len::new(1.0, unit).convert(cce_ui::units::Unit::Mm, &metric).value
+}
+
+/// Where the thumbnail is taken from: the eye, what it looks at, and the
+/// near and far planes.
+///
+/// Geometry is seen from a pleasant high diagonal, its bounding sphere fitted
+/// to the view, and an image standing with it is inside that sphere. An
+/// image ALONE is seen square on and fitted edge to edge: from the diagonal
+/// a picture is a slanted sliver of itself, and the thumbnail of a picture
+/// is the picture. An empty scene still renders (sky).
+pub(crate) fn view_of(
+    tris: &[cce_ui::vk::RtTriangle],
+    image: Option<[[f32; 3]; 4]>,
+) -> (Vec3, Vec3, f32, f32) {
+    if let (true, Some(corners)) = (tris.is_empty(), image) {
+        let [tl, tr, br, _] = corners.map(Vec3::from_array);
+        let center = (tl + br) * 0.5;
+        let longest = (tr - tl).length().max((br - tr).length()).max(1e-3);
+        let dist = longest / (2.0 * (FOV * 0.5).tan()) * 1.05;
+        return (center + Vec3::Z * dist, center, dist * 0.01, dist * 4.0);
     }
+    let points = tris
+        .iter()
+        .flat_map(|t| [t.p0, t.p1, t.p2])
+        .chain(image.into_iter().flatten())
+        .map(Vec3::from_array);
+    let (center, radius) = bounds(points);
+    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;
+    (eye, center, near, far)
+}
+
+fn bounds(points: impl Iterator<Item = Vec3>) -> (Vec3, f32) {
     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);
-        }
+    for v in points {
+        min = min.min(v);
+        max = max.max(v);
+    }
+    if !min.is_finite() {
+        return (Vec3::ZERO, 1.0);
     }
     let center = (min + max) * 0.5;
     let radius = (max - center).length().max(1e-3);