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

commit540939e515fd8296029c43fd5d0224b08d227784
parent0e755c3731
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 12:22
perf(page): recompose a shown page only when its chain changed

rebuild_page ran after every geometry rebuild — any edit at the level,
every frame of a playing sim — and recomposed the page, converted it to
RGBA8 and re-uploaded it each time: for a Letter sheet at 300 DPI with a
grid, border, shape and text, ~49 ms of compose + ~24 ms of conversion
+ a 34 MB upload with mips, on the UI thread.

- page::chain_key hashes what resolve_page reads (each chain node's id,
  type, bypass and params, as a save writes them) in ~5 us; rebuild_page
  keeps the picture and re-says its status line when the key, the image
  and the shown page are all still there. A rebuild with a 2550x3300 page
  shown that changes nothing in it: 31 ms -> 69 us.
- to_rgba8 fills in place and lets the saturating cast do the clamp
  (identical bytes, NaN included): 40 -> 24 ms for Letter at 300 DPI.
- page_version now counts recompositions only, as documented; the
  tracer is re-handed its scene by the geometry version anyway. Tests
  updated to edit the sheet where they meant "recompose".

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

 src/app.rs    |  8 ++++++
 src/main.rs   | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/page.rs   | 49 +++++++++++++++++++++++++++++++++---
 src/render.rs | 21 ++++++++++++++--
 4 files changed, 152 insertions(+), 6 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index b8dd873..717fa8b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2797,6 +2797,13 @@ pub struct State {
     /// The page that image is of: what the scene pass places it by and the
     /// framing commands fit the camera to. None when the level shows none.
     pub page_shown: Option<crate::page::PageShown>,
+    /// What the page on show was composed from ([`crate::page::chain_key`])
+    /// and the status line that announced it. A geometry rebuild — any edit
+    /// at the level, every frame of a playing sim — recomposes the page, and
+    /// a Letter sheet at 300 DPI is ~90 ms of compose and conversion plus a
+    /// 34 MB upload; with this the ones that change nothing it reads cost a
+    /// hash of its chain.
+    pub page_composed: Option<(u64, String)>,
     /// Whether `renderer_init` has run before. There is no separate reconnect
     /// callback: the runner calls `renderer_init` once per renderer, so the
     /// first call is this process's own and every later one is a REPLACEMENT
@@ -8049,6 +8056,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             sim_cache: crate::geometry::SimCache::default(),
             page_image: None,
             page_shown: None,
+            page_composed: None,
             seen_renderer: false,
             deselected_cell: None,
             orbit_drag: None,
diff --git a/src/main.rs b/src/main.rs
index 0c533c1..08ab677 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7103,6 +7103,73 @@ mod tests {
         assert!(border_ink(&shown) > 0.9, "a hidden node still displayed");
     }
 
+    /// The page cache's key moves with everything the page is composed from
+    /// and with nothing else: an edit beside the chain must not recompose a
+    /// 300 DPI sheet, and an edit inside it must.
+    #[test]
+    fn test_the_page_key_follows_the_chain_and_only_the_chain() {
+        use crate::page::chain_key;
+        fn pnode(id: &str, name: &str, ty: &str, params: &[(&str, &str)]) -> FsNode {
+            FsNode {
+                id: id.to_string(),
+                name: name.to_string(),
+                node_type: ty.to_string(),
+                children: vec![],
+                params: params
+                    .iter()
+                    .map(|(n, v)| crate::app::ParamDef::new(n.to_string(), "text".to_string(), v.to_string()))
+                    .collect(),
+                geometry_visible: true,
+                bypassed: false,
+                position: (0.0, 0.0),
+                inputs: 1,
+                outputs: 1,
+            }
+        }
+        let mut root = pnode("r", "root", "node", &[]);
+        root.children = vec![
+            pnode("p", "page1", "page", &[("preset", "Letter"), ("resolution", "72")]),
+            pnode("g", "grid1", "page_grid", &[("input", "page1"), ("cell_size", "0.5")]),
+            pnode("b", "border1", "page_border", &[("input", "grid1"), ("width", "0.1")]),
+            pnode("s", "sphere1", "sphere", &[("radius", "1")]),
+        ];
+        let key = |root: &FsNode| chain_key(root, &root.children[2]);
+        let base = key(&root);
+        assert_eq!(base, key(&root.clone()), "the same chain keys the same");
+
+        let mut beside = root.clone();
+        beside.children[3].params[0].set_text("2".to_string());
+        assert_eq!(base, key(&beside), "a geometry edit beside the page moved its key");
+
+        let mut edited = root.clone();
+        edited.children[1].params[1].set_text("0.25".to_string());
+        assert_ne!(base, key(&edited), "a grid edit under the border kept the key");
+
+        let mut sheet = root.clone();
+        sheet.children[0].params[1].set_text("300".to_string());
+        assert_ne!(base, key(&sheet), "the sheet's resolution kept the key");
+
+        let mut bypassed = root.clone();
+        bypassed.children[1].bypassed = true;
+        assert_ne!(base, key(&bypassed), "bypassing the grid kept the key");
+
+        let mut rewired = root.clone();
+        rewired.children[2].params[0].set_text("page1".to_string());
+        assert_ne!(base, key(&rewired), "rewiring the border past the grid kept the key");
+    }
+
+    /// The 8-bit conversion clamps through the cast now; out-of-range and
+    /// NaN channels must land where the explicit clamp put them.
+    #[test]
+    fn test_page_rgba8_clamps_and_rounds() {
+        let mut page = crate::page::Page::new([1.0, 1.0], 2, [0.0; 4]);
+        page.pixels[0] = [-1.0, 0.5, 2.0, f32::NAN];
+        page.pixels[1] = [0.0, 1.0, 0.0019, 0.002];
+        page.pixels[2] = [f32::INFINITY, f32::NEG_INFINITY, 0.999, 1.0001];
+        let out = page.to_rgba8();
+        assert_eq!(&out[..12], &[0, 128, 255, 0, 0, 255, 0, 1, 255, 0, 255, 255]);
+    }
+
     /// Text lands on the sheet, and alignment moves it.
     #[test]
     fn test_page_text_puts_ink_where_it_is_aligned() {
@@ -18931,7 +18998,13 @@ mod tests {
         let first = state.page_image.expect("nothing uploaded");
         state.rebuild_scene_geometry();
         assert_eq!(state.page_image, Some(first), "the same picture took a new image");
+        // New contents at the same size: recomposed into the same image.
         let page = state.current_dir().children.iter().position(|n| n.node_type == "page").unwrap();
+        state.current_dir_mut().children[page].params.iter_mut().find(|p| p.name == "color").unwrap().set_text("0.50:0.20:0.10");
+        let version = state.page_version;
+        state.rebuild_scene_geometry();
+        assert!(state.page_version > version, "the colour edit was not recomposed");
+        assert_eq!(state.page_image, Some(first), "the same size took a new image");
         state.current_dir_mut().children[page].params.iter_mut().find(|p| p.name == "width").unwrap().set_text("80");
         state.rebuild_scene_geometry();
         assert!(state.page_image.is_some_and(|id| id != first), "a picture of another size kept the old image");
@@ -18941,7 +19014,8 @@ mod tests {
 
     /// 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.
+    /// and a rebuild with no image in it does not — nor, since the page
+    /// cache (2026-10-06), one that changes nothing the image is made of.
     #[test]
     fn a_recomposed_image_is_a_new_traced_scene() {
         let mut state = State::new(false);
@@ -18953,6 +19027,10 @@ mod tests {
         let shown = state.page_version;
         assert!(shown > before, "showing an image did not move its version");
         state.rebuild_scene_geometry();
+        assert_eq!(state.page_version, shown, "a rebuild that changed nothing in the image recomposed it");
+        let page = state.current_dir().children.iter().position(|n| n.node_type == "page").unwrap();
+        state.current_dir_mut().children[page].params.iter_mut().find(|p| p.name == "color").unwrap().set_text("0.50:0.20:0.10");
+        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.
diff --git a/src/page.rs b/src/page.rs
index a83cc95..0e384e2 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -359,10 +359,14 @@ impl Page {
     /// The page as 8-bit sRGB RGBA, row-major from the top — what both the GPU
     /// upload and the PNG encoder want.
     pub fn to_rgba8(&self) -> Vec<u8> {
-        let mut out = Vec::with_capacity(self.pixels.len() * 4);
-        for p in &self.pixels {
+        // Filled in place rather than pushed, and clamped by the cast: a
+        // float-to-int `as` saturates (NaN to 0), which is exactly what a
+        // clamp to 0..=1 then gave, and leaves the loop free to vectorize.
+        // The push-and-clamp version was ~40 ms for Letter at 300 DPI.
+        let mut out = vec![0u8; self.pixels.len() * 4];
+        for (o, p) in out.chunks_exact_mut(4).zip(&self.pixels) {
             for c in 0..4 {
-                out.push((p[c].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
+                o[c] = (p[c] * 255.0 + 0.5) as u8;
             }
         }
         out
@@ -1048,6 +1052,45 @@ fn with_fonts<R>(
     f(fonts, cache)
 }
 
+/// A fingerprint of everything [`resolve_page`] reads to compose `target`:
+/// each node on its input chain — id, type, whether it is bypassed, and its
+/// parameters as a save writes them — down to the sheet at the bottom. Two
+/// equal keys compose the same page. Nothing in the page context reads the
+/// frame or the scene — its parameters are read as written, never through
+/// `resolve_param_refs` — so nothing else needs to be in it; a page node
+/// that starts evaluating expressions must put the frame in here too.
+pub fn chain_key(root: &FsNode, target: &FsNode) -> u64 {
+    use std::hash::{Hash, Hasher};
+    let mut h = std::collections::hash_map::DefaultHasher::new();
+    let mut visited: Vec<&str> = Vec::new();
+    let mut node = target;
+    loop {
+        if visited.contains(&node.id.as_str()) {
+            break;
+        }
+        visited.push(&node.id);
+        let kind = node.node_type.to_ascii_lowercase();
+        let bypassed = crate::geometry::is_bypassed(node);
+        (&node.id, &kind, bypassed).hash(&mut h);
+        if let Ok(json) = serde_json::to_string(&node.params) {
+            json.hash(&mut h);
+        }
+        // The sheet ends the chain; so does anything that is not a page
+        // node, which composes nothing whatever is under it.
+        if (kind == "page" && !bypassed) || (!is_page_node(&kind) && kind != "export") {
+            break;
+        }
+        match crate::geometry::param_node(root, node, "input") {
+            Some(input) => node = input,
+            None => {
+                "no input".hash(&mut h);
+                break;
+            }
+        }
+    }
+    h.finish()
+}
+
 /// The page a network level displays, if it displays one.
 ///
 /// The same rule the viewport follows for geometry: draw what is visible at
diff --git a/src/render.rs b/src/render.rs
index 6287e31..4f4eb86 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1218,6 +1218,21 @@ impl State {
     /// it as a quad standing in the scene (`stage_frame`), where until
     /// 2026-09-29 a pane of its own took the viewport's place.
     pub(crate) fn rebuild_page(&mut self) {
+        let key = crate::page::displayed_page_node(self.viewport_editor_dir())
+            .map(|n| crate::page::chain_key(&self.fs_root, n));
+        // Nothing the page reads has changed, and its picture is still up:
+        // say what it is again (the geometry pass just said something else)
+        // and leave the image alone.
+        if let (Some(key), Some((was, status)), Some(_), Some(_)) =
+            (key, self.page_composed.as_ref(), self.page_image, self.page_shown.as_ref())
+        {
+            if key == *was {
+                let status = status.clone();
+                self.update_status_text(&status);
+                return;
+            }
+        }
+        self.page_composed = None;
         let level = self.viewport_editor_dir();
         let node_id = crate::page::displayed_page_node(level).map(|n| n.id.clone());
         let page = crate::page::displayed_page(&self.fs_root, level);
@@ -1245,7 +1260,7 @@ impl State {
                 // of its size in an ordinary pane, and its hairlines crawl.
                 None => cce_ui::vk::upload_rgba_mipmapped(page.to_rgba8(), w, h),
             });
-            self.update_status_text(&format!(
+            let status = format!(
                 "Image: {} x {} {} at {} DPI ({}x{} px)",
                 trim_number(page.in_unit(page.size[0])),
                 trim_number(page.in_unit(page.size[1])),
@@ -1253,7 +1268,9 @@ impl State {
                 page.dpi,
                 w,
                 h
-            ));
+            );
+            self.update_status_text(&status);
+            self.page_composed = key.map(|k| (k, status));
             self.page_shown = Some(crate::page::PageShown {
                 node_id,
                 size: page.size,