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