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

commit02e8613b123e0f9454db98d9e7b1a41d7707be9a
parentab38a273f7
authorLucas Galante <[email protected]>
date2026-08-23 13:26
feat: simnet — a network that iterates its chain over the timeline

A `simnet` node is a subnet whose inner chain is a simulation STEP. The chain
between its `input` and `output` children runs once per timeline frame: step 1
consumes the simnet's own Input (exactly a subnet's seed), and every later
step consumes the step before it. The mechanism is a feedback stack on the new
EvalSim context — mid-solve, the simnet pushes iteration N-1's state, and the
`input` node inside it yields that instead of resolving to the outer graph.

EvalSim (frame, start frame, SimCache, feedback) now threads through every
evaluator beside the error slot. Solves cache per node id on State::sim_cache,
keyed on a hash of the simnet subtree + seed geometry: playing forward costs
one step per frame, editing anything in the chain (or upstream of the seed)
restarts the sim, and scrubbing backwards restarts from the seed, because a
step is not invertible. At or before the timeline's start frame a sim IS its
seed. A step that yields nothing (unwired chain, failed kernel) holds the
previous state rather than collapsing the sim to empty.

The scene walk treats a simnet's children as machinery, not content: recursing
into them the way subnets are recursed into would draw one un-iterated pass of
the chain alongside the solved result. Frame changes rebuild the scene only
when the graph contains a simnet at all.

Convenience wrappers without a timeline in scope (thumbnails, spreadsheet
preview, bare network_sphere_vertices) evaluate at frame 0 == seed.

set_frame joins the MCP surface — the playbar was pointer-only, which left no
way to drive a simulation headlessly; live verification scrubbed a
Line-seeded, Transform-stepped simnet to frames 1/4/8/12 and pixel-diffed the
captures.

 CLAUDE.md         |  14 +-
 nodes/simnet.json |  25 ++++
 src/api.rs        |  11 ++
 src/app.rs        |  44 +++++-
 src/geometry.rs   | 415 ++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/main.rs       |   5 +
 src/render.rs     |  10 +-
 src/thumbnail.rs  |   5 +-
 src/window.rs     |   9 ++
 9 files changed, 510 insertions(+), 28 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e0c0b3c..4eaa527 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -119,7 +119,19 @@ engine's shaping/glyph pass (the app has no `FontSystem` or buffer cache of its
   order over the widget slots, circular-pane clipping via `PaintItem::clip_circle`,
   network fade via text alpha. Rebuilt every drawn frame; the engine tessellates,
   shapes, and draws it.
-- `src/geometry.rs` — node-graph evaluation. Each OpenCL node's kernel code is
+- `src/geometry.rs` — node-graph evaluation. Every evaluator threads an
+  `EvalSim` (current frame + `SimCache` + feedback stack) alongside the error
+  slot. The `simnet` node type iterates: the chain between its `input` and
+  `output` children is one simulation STEP; step 1 eats the simnet's own
+  `Input` (like a subnet), each later step eats the previous state, which the
+  `input` node reads off the feedback stack instead of jumping to the outer
+  graph. Solves run up to the playbar frame and cache per node id on `State::
+  sim_cache` (playing forward = one step per frame); the cache key hashes the
+  simnet subtree + seed, so edits restart the sim, and backward scrubs restart
+  from the seed (steps are not invertible). The scene walk does NOT recurse
+  into a simnet's children — that would draw one un-iterated pass of the chain
+  on top of the solved result. Frame changes invalidate the scene only when the
+  graph `contains_simnet`. Each OpenCL node's kernel code is
   preprocessed: `chf("name", default)` / `chi` / `chv` calls are parsed into dynamic
   UI parameters (`parse_dynamic_params`) and rewritten to `param_values[i]` reads
   (`preprocess_opencl_code`). `network_sphere_vertices_with_errors` walks the graph
diff --git a/nodes/simnet.json b/nodes/simnet.json
new file mode 100644
index 0000000..0cb1df3
--- /dev/null
+++ b/nodes/simnet.json
@@ -0,0 +1,25 @@
+{
+  "name": "Simnet",
+  "type": "simnet",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    { "name": "Input", "default": "", "type": "text" }
+  ],
+  "children": [
+    {
+      "name": "input1",
+      "type": "input",
+      "params": [],
+      "position": [4.0, 1.0]
+    },
+    {
+      "name": "output1",
+      "type": "output",
+      "params": [
+        { "name": "Input", "default": "input1" }
+      ],
+      "position": [4.0, 3.0]
+    }
+  ]
+}
diff --git a/src/api.rs b/src/api.rs
index 356fe62..c34e567 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -198,6 +198,17 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
                 "required": ["pane", "detached"],
             }),
         ),
+        tool(
+            "set_frame",
+            "Move the playhead. Simnets solve up to this frame.",
+            json!({
+                "type": "object",
+                "properties": {
+                    "frame": { "type": "number", "description": "Timeline frame" },
+                },
+                "required": ["frame"],
+            }),
+        ),
         tool(
             "menu_click",
             "Click a menubar item by indices (widget_idx must be a menubar widget slot).",
diff --git a/src/app.rs b/src/app.rs
index ba7b338..8cdcbe4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -216,6 +216,9 @@ pub enum McpAction {
     /// Move a pane out into its own window, or take it back — the corner menu's
     /// Detach/Reattach.
     SetPaneDetached { pane: String, detached: bool },
+    /// Move the playhead. Simnets solve up to this frame, so it is the only way
+    /// to drive a simulation without dragging the playbar.
+    SetFrame { frame: f32 },
 }
 
 #[derive(Debug, Clone)]
@@ -666,6 +669,11 @@ pub struct State {
     /// The plate corner menu — same `context_menu` thread-local again; the slot
     /// says which plate's control opened it (and doubles as the pressed state
     /// the corner control paints with).
+    /// Solved simulation states, kept across frames so playing forward costs one
+    /// step per frame instead of re-solving from the start frame every redraw.
+    pub sim_cache: crate::geometry::SimCache,
+    /// Frame the scene was last built at, so the timeline moving can invalidate it.
+    pub last_sim_frame: i32,
     pub plate_menu_slot: Option<usize>,
     pub plate_menu_actions: Vec<crate::plate_corner::PlateMenuAction>,
     /// Panes shrunk to their title stub, indexed by slot. Only the
@@ -997,6 +1005,16 @@ impl State {
 
     pub fn body_h(&self) -> f32 { self.height - HEADER_H - STATUS_H }
 
+    /// The timeline frame the graph is evaluated at — what a simnet solves up to.
+    pub fn sim_frame(&self) -> i32 {
+        self.slots.playbar.inner().current_frame.round() as i32
+    }
+
+    /// The timeline's first frame: where every sim sits at its seed.
+    pub fn sim_start_frame(&self) -> i32 {
+        self.slots.playbar.inner().start_frame.round() as i32
+    }
+
     pub fn get_col_geometries(&self) -> (f32, f32, f32, f32, f32, f32) {
         let left_visible = self.show_network && !self.circular_network_pane && !self.is_detached_network && !self.detached_circular_network;
         let center_visible = self.show_viewport || self.show_spreadsheet;
@@ -2410,6 +2428,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         }
 
 
+        let sim_frame = self.sim_frame();
+        let sim_start = self.sim_start_frame();
         let mut cache_hit = false;
         let mut current_name = None;
         let mut current_params = None;
@@ -2433,7 +2453,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             if let Some(node) = selected_node {
                 let mut visited = Vec::new();
                 let mut ocl_error = None;
-                if let Some(geom) = generate_single_node_geometry_with_errors(&self.fs_root, node, &mut visited, &mut ocl_error) {
+                // 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);
                     headers = h;
                     rows = r;
@@ -2628,6 +2654,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             node_menu_actions: Vec::new(),
             viewport_menu_active: false,
             viewport_menu_actions: Vec::new(),
+            sim_cache: crate::geometry::SimCache::default(),
+            last_sim_frame: i32::MIN,
             plate_menu_slot: None,
             plate_menu_actions: Vec::new(),
             collapsed_panes: [false; WIDGET_COUNT],
@@ -4952,6 +4980,20 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let now = Instant::now();
         self.last_frame = now;
 
+        // A simnet's geometry is a function of the frame, so advancing the
+        // timeline invalidates the scene the way editing a node does. Gated on
+        // the graph actually containing one: without this, every frame of
+        // playback would rebuild the scene for a graph that cannot have
+        // changed.
+        let frame_now = self.sim_frame();
+        if frame_now != self.last_sim_frame {
+            self.last_sim_frame = frame_now;
+            if crate::geometry::contains_simnet(&self.fs_root) {
+                self.rebuild_scene_geometry();
+                self.viewport_dirty = true;
+            }
+        }
+
         // A detached window the user closed hands its pane back here, so a
         // closed window cannot strand the pane as a stub nothing can revive.
         let reclaimed = self.poll_detached_children();
diff --git a/src/geometry.rs b/src/geometry.rs
index b24050c..4157e67 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -346,9 +346,94 @@ pub fn find_parent_node<'a>(root: &'a FsNode, child_id: &str) -> Option<&'a FsNo
     visit(root, child_id)
 }
 
+/// One simnet's solved state, kept between evaluations so playing forward costs
+/// one iteration per frame instead of re-solving the whole history every redraw.
+struct SimSolve {
+    /// Identity of everything the solve depends on — the simnet's own subtree and
+    /// its seed geometry. When this changes the cached state is meaningless and
+    /// the sim restarts from the seed.
+    key: u64,
+    /// The frame `state` is the solution FOR.
+    frame: i32,
+    state: Geometry,
+}
+
+/// Per-simnet solved states, keyed by node id. Owned by the caller (the app keeps
+/// one across frames; a one-shot render can pass a fresh one) rather than being a
+/// global, so two evaluations of different graphs cannot poison each other.
+#[derive(Default)]
+pub struct SimCache {
+    entries: std::collections::HashMap<String, SimSolve>,
+}
+
+impl SimCache {
+    pub fn clear(&mut self) {
+        self.entries.clear();
+    }
+}
+
+/// The simulation half of an evaluation: which frame the graph is being evaluated
+/// at, the solve cache, and the feedback stack that makes iteration possible.
+///
+/// The stack is what an `input` node inside a simnet reads instead of jumping to
+/// the outer graph: during iteration N its parent simnet has pushed the state
+/// from iteration N-1, and that — not the seed — is what the chain consumes.
+pub struct EvalSim<'a> {
+    pub frame: i32,
+    /// The timeline's first frame — the frame at which every sim shows its seed,
+    /// having taken no steps yet.
+    pub start_frame: i32,
+    pub cache: &'a mut SimCache,
+    feedback: Vec<(String, Geometry)>,
+}
+
+impl<'a> EvalSim<'a> {
+    pub fn new(frame: i32, start_frame: i32, cache: &'a mut SimCache) -> Self {
+        Self { frame, start_frame, cache, feedback: Vec::new() }
+    }
+
+    /// Steps the sim owes at the frame being evaluated. Scrubbing before the
+    /// start frame is not negative time — it is simply the seed.
+    fn steps_due(&self) -> i32 {
+        (self.frame - self.start_frame).max(0)
+    }
+
+    /// The state an `input` node should yield, if its parent simnet is mid-solve.
+    fn feedback_for(&self, simnet_id: &str) -> Option<&Geometry> {
+        self.feedback
+            .iter()
+            .rev()
+            .find(|(id, _)| id == simnet_id)
+            .map(|(_, g)| g)
+    }
+}
+
+/// Hash of everything a simnet's solve depends on: its own subtree (so editing any
+/// node in the chain restarts the sim) and the seed geometry (so an upstream change
+/// does too).
+fn sim_solve_key(simnet: &FsNode, seed: &Geometry) -> u64 {
+    use std::hash::{Hash, Hasher};
+    let mut h = std::collections::hash_map::DefaultHasher::new();
+    if let Ok(json) = serde_json::to_string(simnet) {
+        json.hash(&mut h);
+    }
+    seed.vertices.len().hash(&mut h);
+    for v in &seed.vertices {
+        for c in v.pos {
+            c.to_bits().hash(&mut h);
+        }
+    }
+    h.finish()
+}
+
+/// Evaluate with neither error reporting nor a persistent sim cache. Any simnet
+/// reached this way solves at frame 0 — that is, shows its seed — because there
+/// is no timeline in scope to say otherwise.
 pub fn generate_single_node_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Geometry> {
     let mut err = None;
-    generate_single_node_geometry_with_errors(root, target, visited, &mut err)
+    let mut cache = SimCache::default();
+    let mut sim = EvalSim::new(0, 0, &mut cache);
+    generate_single_node_geometry_with_errors(root, target, visited, &mut err, &mut sim)
 }
 
 pub fn generate_single_node_geometry_with_errors(
@@ -356,6 +441,7 @@ pub fn generate_single_node_geometry_with_errors(
     target: &FsNode,
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
 ) -> Option<Geometry> {
     // Cycle guard by ID, not name: subnet instances share child names
     // ("output1", "opencl1"), so a name guard falsely blocks a subnet that
@@ -395,16 +481,18 @@ pub fn generate_single_node_geometry_with_errors(
         }
         Some(geom)
     } else if target.node_type.eq_ignore_ascii_case("transform") {
-        resolve_transform_geometry_with_errors(root, target, visited, ocl_error)
+        resolve_transform_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("scatter") {
-        resolve_scatter_geometry_with_errors(root, target, visited, ocl_error)
+        resolve_scatter_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("group") {
-        resolve_group_geometry_with_errors(root, target, visited, ocl_error)
+        resolve_group_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("opencl") {
-        resolve_opencl_geometry_with_errors(root, target, visited, ocl_error)
+        resolve_opencl_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("simnet") {
+        resolve_simnet_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("node") {
         if let Some(output_node) = target.children.iter().find(|c| c.node_type.eq_ignore_ascii_case("output")) {
-            generate_single_node_geometry_with_errors(root, output_node, visited, ocl_error)
+            generate_single_node_geometry_with_errors(root, output_node, visited, ocl_error, sim)
         } else {
             None
         }
@@ -421,17 +509,25 @@ pub fn generate_single_node_geometry_with_errors(
             };
             let input_node = input_node.or_else(|| find_node_by_name(root, &input_name));
             if let Some(node) = input_node {
-                generate_single_node_geometry_with_errors(root, node, visited, ocl_error)
+                generate_single_node_geometry_with_errors(root, node, visited, ocl_error, sim)
             } else {
                 None
             }
         }
     } else if target.node_type.eq_ignore_ascii_case("input") {
         if let Some(parent) = find_parent_node(root, &target.id) {
+            // Inside a simnet that is mid-solve, the input IS the previous
+            // iteration's state — that feedback, not the outer graph, is what
+            // makes the chain iterate rather than recompute the same thing.
+            if let Some(prev) = sim.feedback_for(&parent.id) {
+                let fed = prev.clone();
+                visited.pop();
+                return Some(fed);
+            }
             let input_name = node_param_str(parent, "Input", "");
             if !input_name.is_empty() {
                 if let Some(input_node) = find_node_by_name(root, &input_name) {
-                    generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error)
+                    generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)
                 } else {
                     None
                 }
@@ -451,7 +547,9 @@ pub fn generate_single_node_geometry_with_errors(
 
 pub fn resolve_transform_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Geometry> {
     let mut err = None;
-    resolve_transform_geometry_with_errors(root, target, visited, &mut err)
+    let mut cache = SimCache::default();
+    let mut sim = EvalSim::new(0, 0, &mut cache);
+    resolve_transform_geometry_with_errors(root, target, visited, &mut err, &mut sim)
 }
 
 pub fn resolve_transform_geometry_with_errors(
@@ -459,13 +557,14 @@ pub fn resolve_transform_geometry_with_errors(
     target: &FsNode,
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
 ) -> Option<Geometry> {
     let input_name = node_param_str(target, "Input", "");
     if input_name.is_empty() {
         return None;
     }
     let input_node = find_node_by_name(root, &input_name)?;
-    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error)?;
+    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
     let translation = node_param_vec3(target, "Translation", Vec3::ZERO);
     for v in &mut geom.vertices {
         v.pos[0] += translation.x;
@@ -477,7 +576,9 @@ pub fn resolve_transform_geometry_with_errors(
 
 pub fn resolve_scatter_geometry(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Geometry> {
     let mut err = None;
-    resolve_scatter_geometry_with_errors(root, target, visited, &mut err)
+    let mut cache = SimCache::default();
+    let mut sim = EvalSim::new(0, 0, &mut cache);
+    resolve_scatter_geometry_with_errors(root, target, visited, &mut err, &mut sim)
 }
 
 /// The Group node: pass the input geometry through, tagging the elements
@@ -494,13 +595,14 @@ pub fn resolve_group_geometry_with_errors(
     target: &FsNode,
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
 ) -> Option<Geometry> {
     let input_name = node_param_str(target, "Input", "");
     if input_name.is_empty() {
         return None;
     }
     let input_node = find_node_by_name(root, &input_name)?;
-    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error)?;
+    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
 
     let group_name = node_param_str(target, "Group Name", "group1");
     let attr = format!("group:{}", group_name.trim());
@@ -586,6 +688,7 @@ pub fn resolve_scatter_geometry_with_errors(
     target: &FsNode,
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
 ) -> Option<Geometry> {
     if visited.contains(&target.id) {
         return None;
@@ -604,7 +707,7 @@ pub fn resolve_scatter_geometry_with_errors(
             return None;
         }
     };
-    let geom = match generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error) {
+    let geom = match generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim) {
         Some(g) => g,
         None => {
             visited.pop();
@@ -904,6 +1007,7 @@ pub fn resolve_opencl_geometry_with_errors(
     target: &FsNode,
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
 ) -> Option<Geometry> {
     let input_name = node_param_str(target, "Input", "");
     let mut geom = if !input_name.is_empty() {
@@ -914,7 +1018,7 @@ pub fn resolve_opencl_geometry_with_errors(
         let sibling = find_parent_node(root, &target.id)
             .and_then(|p| p.children.iter().find(|c| c.name == input_name || c.id == input_name));
         if let Some(input_node) = sibling.or_else(|| find_node_by_name(root, &input_name)) {
-            generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error).unwrap_or_default()
+            generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim).unwrap_or_default()
         } else {
             Geometry::default()
         }
@@ -1251,15 +1355,25 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "output"
         || nt == "scatter"
         || nt == "group"
+        || nt == "simnet"
 }
 
+/// The scene at the timeline's start frame, with a throwaway sim cache — every
+/// simnet shows its seed. Callers that have a timeline should build their own
+/// [`EvalSim`] and keep its [`SimCache`] across frames.
 pub fn network_sphere_vertices(root: &FsNode) -> Geometry {
     let mut err = None;
-    network_sphere_vertices_with_errors(root, &mut err)
+    let mut cache = SimCache::default();
+    let mut sim = EvalSim::new(0, 0, &mut cache);
+    network_sphere_vertices_with_errors(root, &mut err, &mut sim)
 }
 
-pub fn network_sphere_vertices_with_errors(root: &FsNode, ocl_error: &mut Option<String>) -> Geometry {
-    fn visit(root: &FsNode, node: &FsNode, parent_visible: bool, count: &mut usize, out: &mut Geometry, ocl_error: &mut Option<String>) {
+pub fn network_sphere_vertices_with_errors(
+    root: &FsNode,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Geometry {
+    fn visit(root: &FsNode, node: &FsNode, parent_visible: bool, count: &mut usize, out: &mut Geometry, ocl_error: &mut Option<String>, sim: &mut EvalSim) {
         let is_visible = parent_visible && node.geometry_visible;
         if node.node_type.eq_ignore_ascii_case("sphere") {
             let idx = *count;
@@ -1300,7 +1414,7 @@ pub fn network_sphere_vertices_with_errors(root: &FsNode, ocl_error: &mut Option
             *count += 1;
             if is_visible {
                 let mut visited = Vec::new();
-                if let Some(geom) = resolve_transform_geometry_with_errors(root, node, &mut visited, ocl_error) {
+                if let Some(geom) = resolve_transform_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
                     out.merge(geom);
                 }
             }
@@ -1309,7 +1423,7 @@ pub fn network_sphere_vertices_with_errors(root: &FsNode, ocl_error: &mut Option
             *count += 1;
             if is_visible {
                 let mut visited = Vec::new();
-                if let Some(geom) = resolve_scatter_geometry_with_errors(root, node, &mut visited, ocl_error) {
+                if let Some(geom) = resolve_scatter_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
                     out.merge(geom);
                 }
             }
@@ -1318,7 +1432,7 @@ pub fn network_sphere_vertices_with_errors(root: &FsNode, ocl_error: &mut Option
             *count += 1;
             if is_visible {
                 let mut visited = Vec::new();
-                if let Some(geom) = resolve_group_geometry_with_errors(root, node, &mut visited, ocl_error) {
+                if let Some(geom) = resolve_group_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
                     out.merge(geom);
                 }
             }
@@ -1327,20 +1441,34 @@ pub fn network_sphere_vertices_with_errors(root: &FsNode, ocl_error: &mut Option
             *count += 1;
             if is_visible {
                 let mut visited = Vec::new();
-                if let Some(geom) = resolve_opencl_geometry_with_errors(root, node, &mut visited, ocl_error) {
+                if let Some(geom) = resolve_opencl_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
                     out.merge(geom);
                 }
             }
+        } else if node.node_type.eq_ignore_ascii_case("simnet") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_simnet_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(geom);
+                }
+            }
+            // The chain inside a simnet is the simulation STEP, not scene
+            // content. Recursing into it the way a subnet is recursed into
+            // would merge one un-iterated pass of the chain alongside the
+            // solved result — the sim would draw itself twice, once wrong.
+            return;
         }
         for child in &node.children {
-            visit(root, child, is_visible, count, out, ocl_error);
+            visit(root, child, is_visible, count, out, ocl_error, sim);
         }
     }
 
     let mut out = Geometry::new();
     let mut count = 0;
     for child in &root.children {
-        visit(root, child, true, &mut count, &mut out, ocl_error);
+        visit(root, child, true, &mut count, &mut out, ocl_error, sim);
     }
     out
 }
@@ -2028,7 +2156,7 @@ mod tests {
 
         let mut visited = Vec::new();
         let mut err = None;
-        let geom = resolve_opencl_geometry_with_errors(&root, &opencl_node, &mut visited, &mut err).unwrap();
+        let geom = resolve_opencl_geometry_with_errors(&root, &opencl_node, &mut visited, &mut err, &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default())).unwrap();
         assert!(!geom.vertices.is_empty());
         assert!(err.is_none());
 
@@ -2141,3 +2269,242 @@ mod tests {
 }
 
 
+
+/// Solve a simnet up to the frame being evaluated.
+///
+/// The chain between the simnet's `input` and `output` children is one step of
+/// the simulation. Step 1 consumes the seed (the simnet's own `Input`, exactly
+/// like a subnet's); every later step consumes the step before it, which the
+/// `input` node picks up from the feedback stack. At the timeline's start frame
+/// the sim has taken no steps and IS the seed.
+pub fn resolve_simnet_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Geometry> {
+    let output_node = target
+        .children
+        .iter()
+        .find(|c| c.node_type.eq_ignore_ascii_case("output"))?
+        .clone();
+
+    let seed = {
+        let input_name = node_param_str(target, "Input", "");
+        if input_name.is_empty() {
+            Geometry::new()
+        } else {
+            find_node_by_name(root, &input_name)
+                .and_then(|n| generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim))
+                .unwrap_or_default()
+        }
+    };
+
+    let key = sim_solve_key(target, &seed);
+    let due = sim.steps_due();
+
+    // Resume from the cached solve when it is still valid and has not run PAST
+    // the frame asked for; scrubbing backwards has to restart from the seed,
+    // because a step is not invertible.
+    let (mut state, mut done) = match sim.cache.entries.get(&target.id) {
+        Some(prev) if prev.key == key && prev.frame <= due => (prev.state.clone(), prev.frame),
+        _ => (seed, 0),
+    };
+
+    while done < due {
+        sim.feedback.push((target.id.clone(), state));
+        let stepped = generate_single_node_geometry_with_errors(root, &output_node, visited, ocl_error, sim);
+        let fed_back = sim.feedback.pop().map(|(_, g)| g);
+        // A step that yields nothing (an unwired chain, a failed kernel) holds
+        // the previous state rather than collapsing the sim to empty geometry.
+        state = stepped.or(fed_back).unwrap_or_default();
+        done += 1;
+    }
+
+    sim.cache.entries.insert(
+        target.id.clone(),
+        SimSolve { key, frame: due, state: state.clone() },
+    );
+    Some(state)
+}
+
+/// Does this graph contain a simnet anywhere? The frame-change invalidation asks
+/// before rebuilding the scene, since for a graph without one the timeline
+/// changes nothing.
+pub fn contains_simnet(root: &FsNode) -> bool {
+    if root.node_type.eq_ignore_ascii_case("simnet") {
+        return true;
+    }
+    root.children.iter().any(contains_simnet)
+}
+
+#[cfg(test)]
+mod simnet_tests {
+    use super::*;
+    use crate::app::{FsNode, ParamDef};
+
+    fn param(name: &str, value: &str) -> ParamDef {
+        ParamDef {
+            name: name.to_string(),
+            label: String::new(),
+            param_type: "text".to_string(),
+            default: value.to_string(),
+            options: vec![],
+            min: None,
+            max: None,
+            step: None,
+        }
+    }
+
+    fn node(id: &str, name: &str, node_type: &str, params: Vec<ParamDef>, children: Vec<FsNode>) -> FsNode {
+        FsNode {
+            id: id.to_string(),
+            inputs: 1,
+            outputs: 1,
+            name: name.to_string(),
+            node_type: node_type.to_string(),
+            children,
+            params,
+            geometry_visible: true,
+            position: (0.0, 0.0),
+        }
+    }
+
+    /// A simnet whose chain is one Transform: each step shifts the geometry by
+    /// the same offset, so the solved position reads back the step COUNT. Uses
+    /// transform, not an OpenCL node, so the test is pure CPU.
+    fn stepping_graph() -> FsNode {
+        let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+        let inner_input = node("id-in", "input1", "input", vec![], vec![]);
+        let step = node(
+            "id-step",
+            "step1",
+            "transform",
+            vec![param("Input", "input1"), param("Translation", "1.00:0.00:0.00")],
+            vec![],
+        );
+        let inner_output = node("id-out", "output1", "output", vec![param("Input", "step1")], vec![]);
+        let sim = node(
+            "id-sim",
+            "Simnet 1",
+            "simnet",
+            vec![param("Input", "Sphere 1")],
+            vec![inner_input, step, inner_output],
+        );
+        node("id-root", "root", "node", vec![], vec![sphere, sim])
+    }
+
+    fn solve_at(root: &FsNode, frame: i32) -> Geometry {
+        let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap();
+        let mut cache = SimCache::default();
+        let mut sim = EvalSim::new(frame, 1, &mut cache);
+        let mut visited = Vec::new();
+        let mut err = None;
+        resolve_simnet_geometry_with_errors(root, sim_node, &mut visited, &mut err, &mut sim)
+            .expect("simnet solves")
+    }
+
+    fn min_x(g: &Geometry) -> f32 {
+        g.vertices.iter().map(|v| v.pos[0]).fold(f32::INFINITY, f32::min)
+    }
+
+    #[test]
+    fn test_simnet_at_start_frame_is_its_seed() {
+        let root = stepping_graph();
+        let seeded = solve_at(&root, 1);
+        let mut visited = Vec::new();
+        let mut cache = SimCache::default();
+        let mut sim = EvalSim::new(1, 1, &mut cache);
+        let mut err = None;
+        let raw = generate_single_node_geometry_with_errors(
+            &root,
+            root.children.iter().find(|c| c.name == "Sphere 1").unwrap(),
+            &mut visited,
+            &mut err,
+            &mut sim,
+        )
+        .expect("seed geometry");
+
+        assert!(!seeded.vertices.is_empty(), "the sim produced nothing at its start frame");
+        assert_eq!(seeded.vertices.len(), raw.vertices.len());
+        assert!((min_x(&seeded) - min_x(&raw)).abs() < 1e-4,
+            "at the start frame the sim has taken no steps, so it must BE the seed");
+    }
+
+    /// The point of the whole thing: frame N is N applications of the chain, not
+    /// one. A simnet that resolved its input to the outer graph every time would
+    /// sit at one step forever.
+    #[test]
+    fn test_simnet_iterates_once_per_frame() {
+        let root = stepping_graph();
+        let base = min_x(&solve_at(&root, 1));
+        for steps in 1..=4 {
+            let solved = solve_at(&root, 1 + steps);
+            let moved = min_x(&solved) - base;
+            assert!((moved - steps as f32).abs() < 1e-4,
+                "frame {} should be {} steps of +1.0, got {moved}", 1 + steps, steps);
+        }
+    }
+
+    /// Scrubbing before the start frame is not negative time.
+    #[test]
+    fn test_simnet_before_the_start_frame_holds_its_seed() {
+        let root = stepping_graph();
+        let base = min_x(&solve_at(&root, 1));
+        assert!((min_x(&solve_at(&root, -20)) - base).abs() < 1e-4);
+    }
+
+    /// Resuming from the cache must land on the same answer as solving cold, or
+    /// playback and scrubbing would disagree about the same frame.
+    #[test]
+    fn test_simnet_cache_resume_matches_a_cold_solve() {
+        let root = stepping_graph();
+        let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap();
+        let mut cache = SimCache::default();
+
+        // Step forward frame by frame through the shared cache.
+        let mut warm = 0.0;
+        for frame in 1..=6 {
+            let mut sim = EvalSim::new(frame, 1, &mut cache);
+            let mut visited = Vec::new();
+            let mut err = None;
+            let g = resolve_simnet_geometry_with_errors(&root, sim_node, &mut visited, &mut err, &mut sim).unwrap();
+            warm = min_x(&g);
+        }
+        let cold = min_x(&solve_at(&root, 6));
+        assert!((warm - cold).abs() < 1e-4, "resumed solve {warm} != cold solve {cold}");
+    }
+
+    /// Editing the chain has to restart the sim: a cached state solved from the
+    /// old chain is not a state of the new one.
+    #[test]
+    fn test_editing_the_chain_invalidates_the_cache() {
+        let mut root = stepping_graph();
+        let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap().clone();
+        let mut cache = SimCache::default();
+        {
+            let mut sim = EvalSim::new(5, 1, &mut cache);
+            let mut visited = Vec::new();
+            let mut err = None;
+            resolve_simnet_geometry_with_errors(&root, &sim_node, &mut visited, &mut err, &mut sim).unwrap();
+        }
+
+        // Double the step size; frame 5 (4 steps) must now read 8, not 4.
+        {
+            let sim_mut = root.children.iter_mut().find(|c| c.node_type == "simnet").unwrap();
+            let step = sim_mut.children.iter_mut().find(|c| c.name == "step1").unwrap();
+            step.params.iter_mut().find(|p| p.name == "Translation").unwrap().default =
+                "2.00:0.00:0.00".to_string();
+        }
+        let sim_node = root.children.iter().find(|c| c.node_type == "simnet").unwrap().clone();
+        let base = min_x(&solve_at(&root, 1));
+        let mut sim = EvalSim::new(5, 1, &mut cache);
+        let mut visited = Vec::new();
+        let mut err = None;
+        let g = resolve_simnet_geometry_with_errors(&root, &sim_node, &mut visited, &mut err, &mut sim).unwrap();
+        let moved = min_x(&g) - base;
+        assert!((moved - 8.0).abs() < 1e-4,
+            "stale cache: expected 4 steps of +2.0 = 8, got {moved}");
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 7076fed..54a1406 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -488,6 +488,7 @@ mod tests {
             &root.children[0],
             &mut visited,
             &mut ocl_err,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
         ).expect("Geometry generation failed");
         
         assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
@@ -533,6 +534,7 @@ mod tests {
             &root_2.children[0],
             &mut visited_2,
             &mut ocl_err_2,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
         ).expect("Geometry generation failed");
         
         assert!(ocl_err_2.is_none(), "OpenCL compilation error: {:?}", ocl_err_2);
@@ -602,6 +604,7 @@ mod tests {
             &root.children[1],
             &mut visited,
             &mut ocl_err,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
         ).expect("Extrude geometry generation failed");
         assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
         // 2304 sphere vertices = 768 triangles; 768 * 24 = 18432.
@@ -629,6 +632,7 @@ mod tests {
             &root2.children[1],
             &mut visited2,
             &mut ocl_err2,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
         ).expect("Extrude geometry generation failed (no base)");
         assert!(ocl_err2.is_none(), "OpenCL compilation error: {:?}", ocl_err2);
         assert_eq!(geom2.vertices.len(), 16128);
@@ -679,6 +683,7 @@ mod tests {
                 &root.children[0],
                 &mut visited,
                 &mut ocl_err,
+                &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
             ).expect("Geometry generation failed");
             assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
             geom
diff --git a/src/render.rs b/src/render.rs
index 49ce665..5950cc7 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -775,7 +775,15 @@ impl State {
 
     pub(crate) fn rebuild_scene_geometry(&mut self) {
         let mut ocl_error = None;
-        let geom = network_sphere_vertices_with_errors(&self.fs_root, &mut ocl_error);
+        // The sim cache lives on State so playing forward steps each simnet once
+        // per frame instead of re-solving its whole history every rebuild.
+        let (frame, start) = (self.sim_frame(), self.sim_start_frame());
+        let mut sim_cache = std::mem::take(&mut self.sim_cache);
+        let geom = {
+            let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
+            network_sphere_vertices_with_errors(&self.fs_root, &mut ocl_error, &mut sim)
+        };
+        self.sim_cache = sim_cache;
 
         fn has_visible_opencl(node: &FsNode) -> bool {
             if node.node_type.eq_ignore_ascii_case("opencl") && node.geometry_visible {
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index 4abcf9a..230e02d 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -27,7 +27,10 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>) -> Resul
         serde_json::from_str(&content).map_err(|e| format!("parse {}: {e}", state_file.display()))?;
 
     let mut ocl_error = None;
-    let geom = network_sphere_vertices_with_errors(&proj.root, &mut ocl_error);
+    // A headless thumbnail has no timeline: simnets render at their seed.
+    let mut sim_cache = crate::geometry::SimCache::default();
+    let mut sim = crate::geometry::EvalSim::new(0, 0, &mut sim_cache);
+    let geom = network_sphere_vertices_with_errors(&proj.root, &mut ocl_error, &mut sim);
     if let Some(e) = ocl_error {
         // Non-fatal: OpenCL nodes just contribute nothing, like the viewport.
         eprintln!("thumbnail: OpenCL error (geometry partially skipped): {e}");
diff --git a/src/window.rs b/src/window.rs
index 920d760..f93b1bb 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -894,6 +894,15 @@ impl State {
                 needs_redraw = true;
                 Ok(format!("{pane} detached={}", state.pane_is_detached(idx)))
             }
+            McpAction::SetFrame { frame } => {
+                let clamped = {
+                    let pb = state.slots.playbar.inner_mut();
+                    pb.current_frame = frame.clamp(pb.start_frame, pb.end_frame).round();
+                    pb.current_frame
+                };
+                needs_redraw = true;
+                Ok(format!("frame={clamped}"))
+            }
             McpAction::ToggleCircularPane => {
                 state.circular_network_pane = !state.circular_network_pane;
                 let val = state.circular_network_pane;