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

commit0db58d693e1d25050353cbb96ff6ffacd6bcbf32
parent0d404e7d0c
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 20:03
fix: a node inside a simnet is read as the scene shows it

The spreadsheet, the markers on its selected rows and the selected
group's markers evaluated the node bare, so inside a simnet they read
the seed at every frame and stood still through a playback.

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

 CLAUDE.md       | 11 +++++++++++
 src/app.rs      |  6 ++----
 src/geometry.rs | 40 ++++++++++++++++++++++++++++++++++++++++
 src/main.rs     | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 107 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b224e4e..d5d69ed 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1634,6 +1634,17 @@ wears and at Group Marker Size.
 
 `selected_spreadsheet_rows_are_marked_in_the_scene` drives it by pointer.
 
+**What reads a selected node for display reads it as the scene shows it**
+(`geometry::node_geometry_as_shown`, the same day): the spreadsheet's
+rows, the markers on them and the selected group's. A node inside a
+simnet is evaluated as the frame's last substep saw it, the feedback of
+the nearest simnet above it pushed — the rule the dived-in scene walk and
+the pull arrows already drew by. Until then these evaluated the node
+bare, so inside a simnet `input` read the seed and the rows, and a
+selected row's marker, stood at the first frame while the scene beside
+them played. `rows_selected_inside_a_simnet_follow_the_simulation` is the
+test.
+
 ### The volume representation
 
 `src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
diff --git a/src/app.rs b/src/app.rs
index 8b21991..35a13d1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -6312,10 +6312,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             let mut points: Vec<[f32; 3]> = Vec::new();
 
             if let Some(node) = selected_node {
-                let mut visited = Vec::new();
                 let mut ocl_error = None;
                 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) {
+                if let Some(geom) = crate::geometry::node_geometry_as_shown(&self.fs_root, node, &mut ocl_error, &mut sim) {
                     let (h, r) = Self::geometry_to_spreadsheet_data(&geom);
                     headers = h;
                     rows = r;
@@ -6345,10 +6344,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             let mut member_verts = Vec::new();
             if let Some(node) = selected_node.filter(|n| n.node_type.eq_ignore_ascii_case("group")) {
                 let group_name = node_param_str(node, "Group Name", "group1");
-                let mut visited = Vec::new();
                 let mut ocl_error = None;
                 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) {
+                if let Some(geom) = crate::geometry::node_geometry_as_shown(&self.fs_root, node, &mut ocl_error, &mut sim) {
                     member_verts = crate::geometry::group_member_positions(&geom, &group_name);
                 }
             }
diff --git a/src/geometry.rs b/src/geometry.rs
index 0d0b863..b8fbcf2 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -3507,6 +3507,46 @@ pub fn moves_points(node: &FsNode) -> bool {
         && node_param_str(node, "Attribute Name", "").trim().eq_ignore_ascii_case("Pos")
 }
 
+/// `target`'s geometry as the scene SHOWS it: a node inside a simnet as the
+/// current frame's last substep saw it, with the feedback stack holding the
+/// state that substep consumed, and any other node as it evaluates.
+///
+/// What reads a selected node for display goes through here — the
+/// spreadsheet's rows, the markers on them, the selected group's. Until
+/// 2026-09-29 they evaluated the node bare, so inside a simnet the `input`
+/// child read the simnet's seed and the rows showed the first frame at
+/// every frame, while the scene beside them played. The simnet is the
+/// nearest one above the node, so a node in a subnet inside a simnet is
+/// read the same way.
+pub fn node_geometry_as_shown(
+    root: &FsNode,
+    target: &FsNode,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let mut simnet = None;
+    let mut at = target;
+    while let Some(parent) = find_parent_node(root, &at.id) {
+        if parent.node_type.eq_ignore_ascii_case("simnet") {
+            simnet = Some(parent);
+            break;
+        }
+        at = parent;
+    }
+    let mut pushed = false;
+    if let Some(simnet) = simnet {
+        if let Some(fed) = simnet_step_feedback(root, simnet, &mut Vec::new(), ocl_error, sim) {
+            sim.feedback.push((simnet.id.clone(), fed));
+            pushed = true;
+        }
+    }
+    let geom = generate_single_node_geometry_with_errors(root, target, &mut Vec::new(), ocl_error, sim);
+    if pushed {
+        sim.feedback.pop();
+    }
+    geom
+}
+
 /// Where `target` moves each point it moves, as `(before, after)` positions:
 /// its input's `P` against its own. Measured rather than read off Value, so
 /// Set and Multiply — whose vector differs point to point — and an
diff --git a/src/main.rs b/src/main.rs
index ec21db0..381a946 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15263,6 +15263,60 @@ mod tests {
         assert!(state.row_marker_verts.is_empty());
     }
 
+    /// A node INSIDE a simnet is read as the scene draws it there: as the
+    /// frame's last substep saw it, not from the seed. The spreadsheet's
+    /// rows, and the markers on the rows selected, follow the simulation.
+    #[test]
+    fn rows_selected_inside_a_simnet_follow_the_simulation() {
+        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;
+        state.apply_action(McpAction::AddNode { template_name: "Simnet".into(), name: Some("sim".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+        let sim = state.current_dir().children.iter().position(|c| c.name == "sim").unwrap();
+        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());
+        }
+        // Dive in and select the pull.
+        state.current_path.push(sim);
+        state.sync_nodes();
+        let pull = state.current_dir().children.iter().position(|c| c.name == "pull1").unwrap();
+        state.apply_action(McpAction::Select { slot: pull }, &mut redraw).unwrap();
+        state.slots.playbar.inner_mut().current_frame = 5.0;
+        state.tick_frame(1.0 / 60.0);
+        state.sync_nodes();
+        assert!(!state.spreadsheet_points.is_empty());
+        state.spreadsheet_mut().set_selected_rows(&[3]);
+        state.rebuild_row_marker_verts();
+        let middle = |state: &State| {
+            let n = state.row_marker_verts.len() as f32;
+            state.row_marker_verts.iter().fold(0.0f32, |m, v| m + v.position[0] / n)
+        };
+        let (row_at, marker_at) = (state.spreadsheet_points[3][0], middle(&state));
+        assert!((row_at - marker_at).abs() < 1e-3);
+
+        state.slots.playbar.inner_mut().current_frame = 15.0;
+        state.tick_frame(1.0 / 60.0);
+        let moved = state.spreadsheet_points[3][0] - row_at;
+        assert!(moved > 0.3, "ten frames of the pull moved the row's point {moved}");
+        assert!((middle(&state) - state.spreadsheet_points[3][0]).abs() < 1e-3, "and its marker with it");
+        assert_eq!(state.selected_spreadsheet_points(), vec![3]);
+    }
+
     /// 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