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

commitf96eb7ceb96f31b83810acb110440454d34e773d
parenta362ac835b
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 01:40
fix: the spreadsheet and the group markers follow the frame and upstream edits

The spreadsheet read the selected node again only when that node or its
OWN parameters changed, and it and the selected-group markers ran from
sync_nodes alone, which a frame change does not call. During playback
the spreadsheet showed the frame it had been opened on, and an edit
upstream of the selection left it showing the values from before.

sync_selection_readouts is the two of them, keyed on the node, its
parameters, the geometry version and the frame. It runs from sync_nodes,
from the end of every scene rebuild — as the pull arrows do — and from
the tick's frame change when the graph holds no simnet and nothing
rebuilds. A refresh keeps the spreadsheet's scroll and sort.

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

 CLAUDE.md     | 14 +++++++++-
 src/app.rs    | 37 ++++++++++++++++++++++---
 src/main.rs   | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/render.rs |  1 +
 4 files changed, 134 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index fb0217e..0cf4f56 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1205,7 +1205,19 @@ 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
+`the_spreadsheet_and_group_markers_share_the_sim_cache` counts the steps.
+
+**The spreadsheet and the group markers follow the frame and upstream
+edits** (`State::sync_selection_readouts`, since 2026-09-29). Their keys
+are the selected node, its parameters, the geometry version and the
+frame; they run from `sync_nodes`, from the end of every scene rebuild —
+as the pull arrows do — and from the tick's frame change when the graph
+holds no simnet and so nothing rebuilds. The spreadsheet's key had been
+the node and its OWN parameters, and both ran from `sync_nodes` alone,
+which a frame change does not call: during playback the spreadsheet
+showed the frame it had been opened on, and an edit upstream of the
+selection left it showing the values from before. A refresh keeps the
+spreadsheet's scroll and sort. 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 8732872..886e035 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2147,6 +2147,8 @@ pub struct State {
     pub show_playbar: bool,
     pub last_spreadsheet_node_name: Option<String>,
     pub last_spreadsheet_node_params: Option<Vec<(String, String)>>,
+    /// The frame and geometry version the spreadsheet's rows were read at.
+    pub last_spreadsheet_read_at: (i32, u64),
     pub grid_thickness: f32,
     pub focused_pane: usize,
     pub graph_scroll_speed: f32,
@@ -5770,6 +5772,22 @@ 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);
 
+        self.sync_selection_readouts();
+        self.sync_pull_arrows();
+    }
+
+    /// The readouts of what is SELECTED: the spreadsheet's rows and the
+    /// selected Group's viewport markers. Each evaluates the selected node
+    /// when its key moves, and the key is everything the answer depends on
+    /// — the node and its parameters, the geometry version (which every
+    /// scene rebuild bumps, so an edit anywhere upstream is in it) and the
+    /// frame. Until 2026-09-29 the spreadsheet's key was the node and its
+    /// OWN parameters alone, so it showed the frame and the upstream values
+    /// it had been opened on until the selection itself was touched; and
+    /// both ran from `sync_nodes` only, which a frame change does not call.
+    /// From there and from the end of every scene rebuild now, as the pull
+    /// arrows are.
+    pub(crate) fn sync_selection_readouts(&mut self) {
         // 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
@@ -5806,10 +5824,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let mut current_name = None;
         let mut current_params = None;
 
+        // What the rows were read at: a frame and a geometry version.
+        let read_at = (sim_frame, self.rt_geometry_version);
         if let Some(node) = selected_node {
             current_name = Some(node.id.clone());
             current_params = Some(node.params.iter().map(|p| (p.name.clone(), p.text().to_string())).collect::<Vec<_>>());
-            if self.last_spreadsheet_node_name == current_name && self.last_spreadsheet_node_params == current_params {
+            if self.last_spreadsheet_node_name == current_name
+                && self.last_spreadsheet_node_params == current_params
+                && self.last_spreadsheet_read_at == read_at
+            {
                 cache_hit = true;
             }
         } else {
@@ -5845,11 +5868,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         // the Highlight bake and of which node holds the display flag. The
         // geometry version keeps the key honest against upstream edits (the
         // scene rebuild bumps it); a non-group selection clears the markers.
+        // The version and the frame both: a frame change rebuilds the
+        // scene, and bumps the version, only when the graph holds a simnet.
         let group_key = selected_node.filter(|n| n.node_type.eq_ignore_ascii_case("group")).map(|n| {
             (
                 n.id.clone(),
                 n.params.iter().map(|p| (p.name.clone(), p.text().to_string())).collect::<Vec<_>>(),
-                self.rt_geometry_version,
+                self.rt_geometry_version.wrapping_add((sim_frame as u64).wrapping_mul(0x9E3779B97F4A7C15)),
             )
         });
         let mut group_update = None;
@@ -5873,6 +5898,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.spreadsheet_mut().set_spreadsheet_data(headers, rows);
             self.last_spreadsheet_node_name = current_name;
             self.last_spreadsheet_node_params = current_params;
+            self.last_spreadsheet_read_at = read_at;
         }
         if let Some(members) = group_update {
             self.group_members = members;
@@ -5883,7 +5909,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             // Marker Scale): re-size without re-evaluating.
             self.rebuild_group_marker_verts();
         }
-        self.sync_pull_arrows();
     }
 
     /// How many points of a pull get an arrow. A dozen show the direction
@@ -6291,6 +6316,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             show_playbar: false,
             last_spreadsheet_node_name: None,
             last_spreadsheet_node_params: None,
+            last_spreadsheet_read_at: (i32::MIN, 0),
             grid_thickness: settings.viewport.grid_thickness,
             focused_pane: LEFT_MENUBAR_IDX,
             // Config-owned; update_inertial_settings overwrites these from
@@ -10093,6 +10119,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             if crate::geometry::contains_simnet(&self.fs_root) {
                 self.rebuild_scene_geometry();
                 self.viewport_dirty = true;
+            } else {
+                // No simulation, so no rebuild — but a node whose value is
+                // an expression of the frame still reads differently, and
+                // what is selected is read again.
+                self.sync_selection_readouts();
             }
         }
         // Every slot (and, through the adapter, its embedded children) was
diff --git a/src/main.rs b/src/main.rs
index 44d4131..8ecb1c8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13474,4 +13474,90 @@ mod tests {
         // 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());
     }
+
+    /// The spreadsheet reads what is selected again whenever the answer
+    /// may have changed: the frame moved — with a simulation in the graph
+    /// or without one — or something upstream was edited. It used to read
+    /// again only when the selected node or its OWN parameters changed, so
+    /// during playback it showed the frame it had been opened on. The
+    /// selected Group's markers follow the frame the same way.
+    #[test]
+    fn the_spreadsheet_and_markers_follow_the_frame_and_upstream_edits() {
+        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;
+        let slot_of = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).expect(name);
+        let sphere = slot_of(&state, "sphere1");
+        // Where the markers stand, as plain numbers.
+        let marks = |state: &State| -> Vec<[f32; 3]> { state.group_members.iter().map(|v| v.position).collect() };
+
+        // No simulation in the graph. The frame moves; nothing rebuilds the
+        // scene; what is selected is still read again.
+        assert!(!crate::geometry::contains_simnet(&state.fs_root));
+        state.apply_action(McpAction::Select { slot: sphere }, &mut redraw).unwrap();
+        state.sync_nodes();
+        state.tick_frame(1.0 / 60.0);
+        let opened_at = state.last_spreadsheet_read_at;
+        assert_eq!(opened_at.0, state.sim_frame());
+        state.slots.playbar.inner_mut().current_frame = 7.0;
+        state.tick_frame(1.0 / 60.0);
+        assert_eq!(state.last_spreadsheet_read_at.0, 7, "read again at the new frame");
+        // The same frame again reads nothing again.
+        let at = state.last_spreadsheet_read_at;
+        state.tick_frame(1.0 / 60.0);
+        state.sync_nodes();
+        assert_eq!(state.last_spreadsheet_read_at, at);
+
+        // An edit UPSTREAM of the selection: the selected node and its own
+        // parameters are as they were, and the rows are read again.
+        state.apply_action(McpAction::AddNode { template_name: "Group".into(), name: Some("tagged".into()), x: 7.0, y: 8.0 }, &mut redraw).unwrap();
+        let tagged = slot_of(&state, "tagged");
+        for (name, value) in [("Input", "sphere1"), ("Mode", "Random"), ("Count", "5")] {
+            state.apply_action(McpAction::SetParam { slot: tagged, name: name.into(), value: value.into() }, &mut redraw).unwrap();
+        }
+        state.apply_action(McpAction::Select { slot: tagged }, &mut redraw).unwrap();
+        state.sync_nodes();
+        let (before, markers) = (state.last_spreadsheet_read_at, marks(&state));
+        assert_eq!(markers.len(), 5);
+        state.apply_action(McpAction::SetParam { slot: sphere, name: "Radius".into(), value: "0.9".into() }, &mut redraw).unwrap();
+        assert_eq!(state.param_editor_selected(), Some(tagged), "the selection did not move");
+        assert_ne!(state.last_spreadsheet_read_at, before, "the rows were read again");
+        assert_ne!(marks(&state), markers, "and the markers moved out with the sphere");
+
+        // With a simulation, playback: every frame the playbar arrives at
+        // is the frame the rows and the markers were read at.
+        state.apply_action(McpAction::AddNode { template_name: "Simnet".into(), name: Some("sim".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+        let sim = slot_of(&state, "sim");
+        state.apply_action(McpAction::SetParam { slot: sim, name: "Input".into(), value: "sphere1".into() }, &mut redraw).unwrap();
+        {
+            let simnet = &mut state.current_dir_mut().children[sim];
+            let mut pull = crate::app::load_fs_tree().children.into_iter().find(|t| t.node_type == "attribute").unwrap();
+            pull.id = "pull-in-sim".into();
+            pull.name = "pull1".into();
+            for (name, value) in [("Input", "input1"), ("Operation", "Modify"), ("Attribute Name", "Pos"), ("Value", "0.05:0.00:0.00"), ("Combine", "Add")] {
+                pull.params.iter_mut().find(|p| p.name == name).unwrap().set_text(value.to_string());
+            }
+            simnet.children.push(pull);
+            let output = simnet.children.iter_mut().find(|c| c.node_type == "output").unwrap();
+            output.params.iter_mut().find(|p| p.name == "Input").unwrap().set_text("pull1".to_string());
+        }
+        state.apply_action(McpAction::SetParam { slot: tagged, name: "Input".into(), value: "sim".into() }, &mut redraw).unwrap();
+        state.apply_action(McpAction::Select { slot: tagged }, &mut redraw).unwrap();
+        state.slots.playbar.inner_mut().current_frame = 10.0;
+        state.tick_frame(1.0 / 60.0);
+        let mut last = marks(&state);
+        state.slots.playbar.inner_mut().playing = true;
+        state.slots.playbar.inner_mut().fps = 60.0;
+        for _ in 0..3 {
+            state.tick_frame(1.0 / 60.0);
+            assert_eq!(state.last_spreadsheet_read_at.0, state.sim_frame(), "the rows are this frame's");
+            assert_ne!(marks(&state), last, "the markers moved with the simulation");
+            last = marks(&state);
+        }
+    }
 }
diff --git a/src/render.rs b/src/render.rs
index a35f640..98f610a 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1191,6 +1191,7 @@ impl State {
         self.overlay_dirty = true;
         // The pull arrows measure the selected node against this new
         // geometry version; a playing simnet reaches here every frame.
+        self.sync_selection_readouts();
         self.sync_pull_arrows();
 
         // Last, not first: the page's status line would otherwise be