graphic design tool
git clone https://git.lucas.co/cce-designer.git
perf: the spreadsheet and the group markers share the sim cache
sync_nodes evaluates the selected node for the spreadsheet and for the
selected-group markers, against a node borrowed off State — and because
of that borrow each used a throwaway sim cache, so with either reading
a simnet or anything downstream of one, every refresh solved the
simulation again from the seed. The shared cache is taken out before
the borrow begins and put back after it, as the scene rebuild takes it:
the frame is solved once, by whoever asks first, and resumed from by
everyone after.
Also corrects CLAUDE.md's size for app.rs, which the doc-claims guard
caught.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 12 +++++++++--
src/app.rs | 21 ++++++++++++------
src/geometry.rs | 9 ++++++++
src/main.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 99 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e8565c0..fb0217e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -121,7 +121,7 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
`cce_ui::cosmic_text`; `glyphon` is not a dependency of this crate at all, having
gone from cce-ui with the wgpu path).
-- `src/app.rs` (~9.8k lines) — the heart: `State` (the entire app model), `McpAction` /
+- `src/app.rs` (~10.8k lines) — the heart: `State` (the entire app model), `McpAction` /
`CustomEvent`, node-template loading, pane layout. `tick_frame` (simulation:
config polling, inertia, widget ticks) and `stage_frame` (renderer staging) are the
two halves of the old render loop. GPU mesh updates are staged CPU-side
@@ -1197,7 +1197,15 @@ need.
What a resume arrives at is what a solve from the seed arrives at, state
and feedback both: `a_scrub_resumes_from_a_checkpoint_and_arrives_at_the_same_state`
compares them frame by frame and counts the steps each cost
-(`SimCache::steps_run`). On the project this was measured on, a scrub
+(`SimCache::steps_run`). **Every evaluation goes through the one cache**
+(`State::sim_cache`): the scene rebuild, the pull arrows, the params
+pane's pickers, and since 2026-09-29 the spreadsheet and the
+selected-group markers, which `sync_nodes` evaluates against a node
+borrowed off `State` and which each used a throwaway cache for that
+reason — a full solve from the seed at every refresh of either. The
+cache is taken out before that borrow begins and put back after it, as
+the scene rebuild takes it.
+`the_spreadsheet_and_group_markers_share_the_sim_cache` counts the steps. On the project this was measured on, a scrub
back over sixty frames from frame 120 went from a mean of 15 ms a frame
to 1.3, and from frame 240 from 67 to 4. The disk cache (`Cache` on the
simnet) is unchanged and still holds the one latest frame.
diff --git a/src/app.rs b/src/app.rs
index 9282338..8732872 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5770,6 +5770,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
let path_strs = self.current_path_names();
self.path_mut().set_path(&path_strs);
+ // The shared sim cache, taken out BEFORE the selected node is
+ // borrowed off self and put back once that borrow is dead: the two
+ // evaluations below are of whatever is selected, and when that is
+ // a simnet, or anything downstream of one, the scene rebuild has
+ // already solved this frame. Each used a throwaway cache until
+ // 2026-09-29 — "the shared one cannot be reached from here" — so a
+ // spreadsheet or a selected group downstream of a simulation solved
+ // it again from the seed at every refresh, at frame 240 of the
+ // project this was found on two and a half times what the frame
+ // itself cost.
+ let mut sim_cache = std::mem::take(&mut self.sim_cache);
+
// The spreadsheet (and the group markers with it) read the
// SPREADSHEET's binding: its pin when set, else the active editor —
// exactly the parameters pane's rule with its own pin.
@@ -5817,11 +5829,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
if let Some(node) = selected_node {
let mut visited = Vec::new();
let mut ocl_error = None;
- // A throwaway cache: `selected_node` borrows self, so the
- // shared one cannot be reached from here. The answer is the
- // same either way — a simnet just re-solves for the
- // spreadsheet, which only runs when the selection changed.
- let mut sim_cache = crate::geometry::SimCache::default();
let mut sim = crate::geometry::EvalSim::new(sim_frame, sim_start, &mut sim_cache);
if let Some(geom) = generate_single_node_geometry_with_errors(&self.fs_root, node, &mut visited, &mut ocl_error, &mut sim) {
let (h, r) = Self::geometry_to_spreadsheet_data(&geom);
@@ -5852,8 +5859,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
let group_name = node_param_str(node, "Group Name", "group1");
let mut visited = Vec::new();
let mut ocl_error = None;
- // Throwaway sim cache, as for the spreadsheet above.
- let mut sim_cache = crate::geometry::SimCache::default();
let mut sim = crate::geometry::EvalSim::new(sim_frame, sim_start, &mut sim_cache);
if let Some(geom) = generate_single_node_geometry_with_errors(&self.fs_root, node, &mut visited, &mut ocl_error, &mut sim) {
member_verts = crate::geometry::group_member_positions(&geom, &group_name);
@@ -5861,6 +5866,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
group_update = Some(member_verts);
}
+ // `selected_node` is not read past here.
+ self.sim_cache = sim_cache;
if let Some((headers, rows)) = spreadsheet_update {
self.spreadsheet_mut().set_spreadsheet_data(headers, rows);
diff --git a/src/geometry.rs b/src/geometry.rs
index c6c446e..296ee2e 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -958,6 +958,13 @@ struct Checkpoint {
prev: Detail,
}
+// Every run of a chain on this thread, through whichever cache — so a
+// test can see a solve that went through a cache it has no hold of.
+#[cfg(test)]
+thread_local! {
+ pub static STEPS_ON_THIS_THREAD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
+}
+
/// How many frames apart checkpoints start out.
pub const CHECKPOINT_EVERY: i32 = 10;
/// The most kept for one simnet, however large its states.
@@ -6438,6 +6445,8 @@ pub fn resolve_simnet_geometry_with_errors(
state.restore_live_from(&prev);
prev_frame = prev;
sim.cache.steps_run += 1;
+ #[cfg(test)]
+ STEPS_ON_THIS_THREAD.with(|s| s.set(s.get() + 1));
}
done += 1;
// A frame on the interval is kept as the solve passes it — not the
diff --git a/src/main.rs b/src/main.rs
index 3c6761c..44d4131 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13408,4 +13408,70 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
+
+ /// The spreadsheet and the selected-group markers evaluate through the
+ /// SHARED sim cache: with either reading something downstream of a
+ /// simnet, a refresh costs no steps beyond the ones the frame itself
+ /// took. Each used a cache of its own until 2026-09-29, and solved the
+ /// simulation again from the seed.
+ #[test]
+ fn the_spreadsheet_and_group_markers_share_the_sim_cache() {
+ let steps = || crate::geometry::STEPS_ON_THIS_THREAD.with(|s| s.get());
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.rebuild_positions();
+ state.apply_layout();
+ state.focused_pane = LEFT_MENUBAR_IDX;
+ state.param_editor = crate::slots::CONTENT_IDX;
+ state.show_spreadsheet = true;
+ let mut redraw = false;
+ // sphere1 -> sim (a pull inside) -> tagged (a Group reading the sim).
+ state.apply_action(McpAction::AddNode { template_name: "Simnet".into(), name: Some("sim".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+ state.apply_action(McpAction::AddNode { template_name: "Group".into(), name: Some("tagged".into()), x: 7.0, y: 8.0 }, &mut redraw).unwrap();
+ let slot_of = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).expect(name);
+ let (sim, tagged) = (slot_of(&state, "sim"), slot_of(&state, "tagged"));
+ state.apply_action(McpAction::SetParam { slot: sim, name: "Input".into(), value: "sphere1".into() }, &mut redraw).unwrap();
+ state.apply_action(McpAction::SetParam { slot: tagged, name: "Input".into(), value: "sim".into() }, &mut redraw).unwrap();
+ state.apply_action(McpAction::SetParam { slot: tagged, name: "Mode".into(), value: "Random".into() }, &mut redraw).unwrap();
+ state.apply_action(McpAction::SetParam { slot: tagged, name: "Count".into(), value: "5".into() }, &mut redraw).unwrap();
+ {
+ let simnet = &mut state.current_dir_mut().children[sim];
+ let template = crate::app::load_fs_tree().children.into_iter().find(|t| t.node_type == "attribute").unwrap();
+ let mut node = template.clone();
+ node.id = "pull-in-sim".into();
+ node.name = "pull1".into();
+ for (name, value) in [("Input", "input1"), ("Operation", "Modify"), ("Attribute Name", "Pos"), ("Value", "0.01:0.00:0.00"), ("Combine", "Add")] {
+ node.params.iter_mut().find(|p| p.name == name).unwrap().set_text(value.to_string());
+ }
+ simnet.children.push(node);
+ let output = simnet.children.iter_mut().find(|c| c.node_type == "output").expect("a simnet has an output");
+ output.params.iter_mut().find(|p| p.name == "Input").unwrap().set_text("pull1".to_string());
+ }
+ state.slots.playbar.inner_mut().current_frame = 61.0;
+ state.sync_nodes();
+ state.rebuild_scene_geometry();
+
+ // Select the Group downstream of the simulation: the spreadsheet
+ // fills and the markers stage. Whatever solving the frame takes is
+ // done ONCE, by whoever asks first…
+ state.apply_action(McpAction::Select { slot: tagged }, &mut redraw).unwrap();
+ state.sync_nodes();
+ assert_eq!(state.group_members.len(), 5, "the markers were staged from the simulated geometry");
+ let solved = steps();
+ assert!(solved >= 60, "the fixture simulates: {solved} steps");
+ assert!(solved < 120, "the spreadsheet and the markers solved it between them once, not once each: {solved}");
+ // …and nobody after: the simnet itself in the spreadsheet, the
+ // group again, a scene rebuild — all through the one cache.
+ state.apply_action(McpAction::Select { slot: sim }, &mut redraw).unwrap();
+ state.sync_nodes();
+ assert_eq!(steps(), solved, "the simnet in the spreadsheet");
+ state.apply_action(McpAction::Select { slot: tagged }, &mut redraw).unwrap();
+ state.sync_nodes();
+ assert_eq!(steps(), solved, "the group again");
+ state.rebuild_scene_geometry();
+ state.sync_nodes();
+ assert_eq!(steps(), solved, "and the scene");
+ // And the cache is back where it lives, its solve intact.
+ assert!(!state.sim_cache.checkpoint_frames(&state.current_dir().children[sim].id).is_empty());
+ }
}