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

commit50c781186ca148948a3825d0c3d3580492791190
parent1e0fb7dbd3
authorLucas Galante <[email protected]>
date2026-09-18 20:07
feat(simnet): substeps, and a dt the chain can scale by

One step per frame ties step size to the frame rate, which is the wrong
coupling for anything meant to model a physical process: whether an iterative
solve converges or oscillates is decided by how big a step it takes, not by how
often the playbar advances. Substeps runs the chain N times per frame.

On its own that would just run the simulation faster, so the solver writes `dt`
(= 1/substeps) as a derivative detail attribute. A chain that scales its rate
by it covers the same ground however finely the frame is cut — four substeps of
a quarter each is one frame's worth, exactly as one substep of a whole is. That
is the difference between subdividing a step and speeding the sim up, and it is
what makes Substeps a stability control.

`dt` is not plumbed into the nodes as a special case: the chain reads it with
Promote (detail onto points) and Composite (into a rate), out of the vocabulary
that already exists. test_dt_makes_substeps_a_stability_control_not_a_speed_
control assembles exactly that chain and holds 1, 2, 4 and 16 substeps to the
same answer.

Derivatives clear per SUBSTEP rather than per frame: each run of the chain is a
step, and derivative data describes the state it was computed from.

Substeps is clamped to 64 in code rather than trusted from the parameter's
declared range — a hand-edited project file reaches the solver too, and a
hundred thousand substeps is indistinguishable from a hang. It is also part of
the simnet's subtree, so changing it changes the cache key and the sim restarts
rather than resuming arithmetic done under a different step size.

Co-Authored-By: Claude Opus 5 <[email protected]>

 nodes/simnet.json |  29 +++++++-
 shapeshifter.md   |   8 +-
 src/geometry.rs   | 219 ++++++++++++++++++++++++++++++++++++++++++++++++------
 3 files changed, 229 insertions(+), 27 deletions(-)

diff --git a/nodes/simnet.json b/nodes/simnet.json
index 0cb1df3..6388adb 100644
--- a/nodes/simnet.json
+++ b/nodes/simnet.json
@@ -4,22 +4,43 @@
   "inputs": 1,
   "outputs": 1,
   "params": [
-    { "name": "Input", "default": "", "type": "text" }
+    {
+      "name": "Input",
+      "default": "",
+      "type": "text"
+    },
+    {
+      "name": "Substeps",
+      "type": "spinbox",
+      "default": "1",
+      "min": 1.0,
+      "max": 64.0,
+      "step": 1.0
+    }
   ],
   "children": [
     {
       "name": "input1",
       "type": "input",
       "params": [],
-      "position": [4.0, 1.0]
+      "position": [
+        4.0,
+        1.0
+      ]
     },
     {
       "name": "output1",
       "type": "output",
       "params": [
-        { "name": "Input", "default": "input1" }
+        {
+          "name": "Input",
+          "default": "input1"
+        }
       ],
-      "position": [4.0, 3.0]
+      "position": [
+        4.0,
+        3.0
+      ]
     }
   ]
 }
diff --git a/shapeshifter.md b/shapeshifter.md
index bea6a8e..c1fd807 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -183,7 +183,13 @@ Touches: `geometry.rs`, `kernel_cpu.rs`, `nodes/*.json`.
 > Range Auto re-measures every run, because a simulation's interesting range
 > moves every frame.
 >
-> Outstanding: substeps, an explicit seed frame, and a disk cache.
+> Substeps run the chain N times per frame, so step size stops being tied to
+> the frame rate. The solver writes `dt` (= 1/substeps) as a derivative detail
+> attribute; a chain that scales its rate by it — Promote onto points, then
+> Composite — covers the same ground however finely the frame is cut, which is
+> what makes substeps a stability control rather than a speed control.
+>
+> Outstanding: an explicit seed frame, and a disk cache.
 
 `simnet` is already the Developer Solver — feedback stack, per-node cache keyed
 on the subtree, restart on edit, one step per played frame. What it lacks is a
diff --git a/src/geometry.rs b/src/geometry.rs
index e516845..870886e 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -4993,29 +4993,54 @@ pub fn resolve_simnet_geometry_with_errors(
         _ => (seed, 0),
     };
 
+    // Substeps run the chain more than once per frame. A step's size is what
+    // decides whether an iterative solve converges or oscillates, and one step
+    // per frame ties that to the frame rate, which is the wrong coupling for
+    // anything meant to model a physical process.
+    //
+    // Clamped, and deliberately not by trusting the parameter's declared
+    // range: a hand-edited project file reaches here too, and a solve of a
+    // hundred thousand substeps is indistinguishable from a hang.
+    let substeps = (node_param_f32(target, "Substeps", 1.0).round() as i64).clamp(1, 64);
+    // What one substep is worth, as a fraction of a frame. The chain reads it
+    // by promoting it onto points and compositing it into a rate — halve the
+    // step and a rate scaled by `dt` covers the same ground in twice as many
+    // steps, which is what makes substeps a stability control rather than a
+    // speed control.
+    let dt = 1.0 / substeps as f32;
+
     while done < due {
-        // The step boundary, and the contract that makes a chain composable:
-        //
-        // Going in, every Derivative attribute is zeroed. The chain is
-        // expected to rebuild them from live data this step, and one it
-        // forgets reads zero rather than quietly carrying last step's value
-        // forward — which is the failure that looks like a physics bug and
-        // is not one.
-        state.clear_derivatives();
-        let prev = state.clone();
-
-        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();
-
-        // Coming out, any Live attribute the chain DROPPED is restored from
-        // the previous state by point identity. A node that rebuilds geometry
-        // mid-chain — a kernel generator today, a remesh in Phase 3 — no
-        // longer silently takes the simulation's memory with it.
-        state.restore_live_from(&prev);
+        for _ in 0..substeps {
+            // The step boundary, and the contract that makes a chain
+            // composable:
+            //
+            // Going in, every Derivative attribute is zeroed. The chain is
+            // expected to rebuild them from live data this step, and one it
+            // forgets reads zero rather than quietly carrying last step's
+            // value forward — which is the failure that looks like a physics
+            // bug and is not one. Per SUBSTEP, not per frame: each run of the
+            // chain is a step, and derivative data describes the state it was
+            // computed from.
+            state.clear_derivatives();
+            state
+                .detail_mut()
+                .create_kind("dt", AttribValue::Float(dt), crate::detail::AttribKind::Derivative);
+            let prev = state.clone();
+
+            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();
+
+            // Coming out, any Live attribute the chain DROPPED is restored
+            // from the previous state by point identity. A node that rebuilds
+            // geometry mid-chain — a kernel generator today, a remesh in Phase
+            // 3 — no longer silently takes the simulation's memory with it.
+            state.restore_live_from(&prev);
+        }
         done += 1;
     }
 
@@ -6141,6 +6166,156 @@ mod simnet_tests {
         node("id-root", "root", "node", vec![], vec![sphere, seed, sim])
     }
 
+    /// A sim that adds a fixed 1.0 to `acc` every time the chain runs.
+    fn substep_graph(substeps: &str) -> FsNode {
+        let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+        let seed = node(
+            "id-seed",
+            "Seed 1",
+            "attribute",
+            vec![
+                param("Input", "Sphere 1"),
+                param("Operation", "Create"),
+                param("Attribute Name", "acc"),
+                param("Type", "Float"),
+                param("Value", "0.00"),
+            ],
+            vec![],
+        );
+        let inner_input = node("id-in", "input1", "input", vec![], vec![]);
+        let step = node(
+            "id-step",
+            "step1",
+            "attribute",
+            vec![
+                param("Input", "input1"),
+                param("Operation", "Modify"),
+                param("Attribute Name", "acc"),
+                param("Combine", "Add"),
+                param("Value", "1.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", "Seed 1"), param("Substeps", substeps)],
+            vec![inner_input, step, inner_output],
+        );
+        node("id-root", "root", "node", vec![], vec![sphere, seed, sim])
+    }
+
+    /// The same sim, but the step adds `dt` instead of a fixed amount — the
+    /// chain assembled out of the vocabulary: Promote lifts the solver's `dt`
+    /// detail attribute onto points, Composite adds it into `acc`.
+    fn substep_dt_graph(substeps: &str) -> FsNode {
+        let mut root = substep_graph(substeps);
+        let sim = root.children.iter_mut().find(|c| c.node_type == "simnet").unwrap();
+        let promote = node(
+            "id-prom",
+            "promote1",
+            "attribute",
+            vec![
+                param("Input", "input1"),
+                param("Operation", "Promote"),
+                param("Attribute Name", "dt"),
+                param("To Class", "Point"),
+            ],
+            vec![],
+        );
+        let step = node(
+            "id-step",
+            "step1",
+            "attribute",
+            vec![
+                param("Input", "promote1"),
+                param("Operation", "Composite"),
+                param("Attribute Name", "acc"),
+                param("Source B", "dt"),
+                param("Combine Op", "Add"),
+            ],
+            vec![],
+        );
+        sim.children = vec![
+            node("id-in", "input1", "input", vec![], vec![]),
+            promote,
+            step,
+            node("id-out", "output1", "output", vec![param("Input", "step1")], vec![]),
+        ];
+        root
+    }
+
+    #[test]
+    fn test_substeps_run_the_chain_more_than_once_per_frame() {
+        let acc = |root: &FsNode, frame: i32| -> f32 {
+            solve_at(root, frame).points().value("acc", 0).unwrap().as_f32()
+        };
+
+        // Three frames elapsed, a fixed +1 per run of the chain.
+        assert_eq!(acc(&substep_graph("1"), 4), 3.0);
+        assert_eq!(acc(&substep_graph("4"), 4), 12.0);
+        // The start frame has taken no steps whatever the substep count.
+        assert_eq!(acc(&substep_graph("8"), 1), 0.0);
+
+        // Substeps is part of the simnet's subtree, so changing it changes the
+        // cache key and the sim restarts rather than resuming someone else's
+        // arithmetic.
+        let one = sim_solve_key(
+            substep_graph("1").children.iter().find(|c| c.node_type == "simnet").unwrap(),
+            &Detail::new(),
+        );
+        let four = sim_solve_key(
+            substep_graph("4").children.iter().find(|c| c.node_type == "simnet").unwrap(),
+            &Detail::new(),
+        );
+        assert_ne!(one, four);
+    }
+
+    #[test]
+    fn test_dt_makes_substeps_a_stability_control_not_a_speed_control() {
+        let acc = |root: &FsNode, frame: i32| -> f32 {
+            solve_at(root, frame).points().value("acc", 0).unwrap().as_f32()
+        };
+
+        // A chain that scales its rate by the solver's `dt` covers the SAME
+        // ground however finely the frame is cut: four substeps of a quarter
+        // each is one frame's worth, exactly as one substep of a whole is.
+        // That is the difference between subdividing a step and running the
+        // simulation faster — and it is assembled from Promote and Composite
+        // rather than built into the solver.
+        let coarse = acc(&substep_dt_graph("1"), 5);
+        for substeps in ["2", "4", "16"] {
+            let fine = acc(&substep_dt_graph(substeps), 5);
+            assert!(
+                (fine - coarse).abs() < 1e-3,
+                "{substeps} substeps drifted: {fine} vs {coarse}"
+            );
+        }
+        assert!((coarse - 4.0).abs() < 1e-4, "four frames of one unit each: {coarse}");
+    }
+
+    #[test]
+    fn test_dt_is_derivative_and_reads_one_over_the_substep_count() {
+        let dt = |root: &FsNode| -> f32 {
+            solve_at(root, 3).detail().value("dt", 0).unwrap().as_f32()
+        };
+        assert_eq!(dt(&substep_graph("1")), 1.0);
+        assert_eq!(dt(&substep_graph("4")), 0.25);
+        assert_eq!(
+            solve_at(&substep_graph("4"), 3).detail().kind("dt"),
+            crate::detail::AttribKind::Derivative
+        );
+
+        // A hand-edited project reaches the solver too, and a hundred thousand
+        // substeps is indistinguishable from a hang. The declared range is a
+        // UI affordance; this is the guard.
+        let wild = substep_graph("100000");
+        let acc = solve_at(&wild, 2).points().value("acc", 0).unwrap().as_f32();
+        assert_eq!(acc, 64.0, "substeps are clamped to the documented ceiling");
+    }
+
     #[test]
     fn test_live_data_accumulates_across_steps_and_derivative_data_does_not() {
         let acc = |root: &FsNode, frame: i32| -> f32 {