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

commit991ad3afdfd3afa833de3b13a25fb1cd5fd28b8d
parent67dd30cd45
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 01:03
feat: Per Frame — the pull's amount accounts for the substep size

Inside a simnet the chain runs once per substep, so a pull that lands
whole each run pulls four times as far a frame at four substeps: the
substep count, which is there to steady a solve, became its speed. With
Per Frame on, the Attribute node's Modify reads the solver's dt off the
state and scales what accumulates — an Add lands dt of its amount, a
Multiply the dt-th power of its factor, which compounds back to the
factor over a frame. A Set does not accumulate and is left alone.

A switch rather than the rule, on in the template: a chain that adds one
to a counter to count its runs wants the step, not the frame. A node
that does not carry the row reads off; a saved instance gets it, on,
from the template merge, so an existing pull in a simnet with substeps
above one moves a substep-count slower after this.

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

 CLAUDE.md            | 17 +++++++++
 nodes/attribute.json |  6 ++++
 src/geometry.rs      | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 120 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4a0ad56..0ce1dda 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1830,6 +1830,23 @@ written as it always was, bit for bit, so a save from before the rows
 numbers. A Scale By naming no attribute is an error on the node and moves
 nothing. The arrows are measured, so they show the scaled pull.
 
+**Per Frame makes the amount a rate.** Inside a simnet the chain runs once
+per SUBSTEP, so a pull that lands whole each run pulls four times as far
+a frame at four substeps — the substep count, which is there to steady a
+solve, becomes its speed. With `Per Frame` on (the template's default)
+the node reads the solver's `dt` off the state and scales what
+ACCUMULATES: an Add lands `dt` of its amount, a Multiply the `dt`-th
+power of its factor, which is what compounds back to the factor over a
+frame. A Set does not accumulate and is left alone, and the row is shown
+only for the other two. Outside a simnet there is no `dt`. It is a
+switch rather than the rule because the other use of an Add is real: a
+chain that adds one to a counter to COUNT its runs
+(`test_substeps_run_the_chain_more_than_once_per_frame`) wants the step,
+not the frame. A node that does not carry the row reads off, which is
+every hand-built node; a saved instance gets the row, on, from the
+template merge — so an existing pull in a simnet with substeps above one
+moves a substep-count slower after this, which is the point.
+
 At most `State::PULL_ARROWS_MAX` (12) points get one, picked by farthest-point
 sampling (`spread_sample`) so they cover the region the pull covers rather
 than bunching wherever the point numbering runs locally. A node inside a
diff --git a/nodes/attribute.json b/nodes/attribute.json
index b633ddc..b0bcd84 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -49,6 +49,12 @@
       "default": "",
       "show_when": "Operation == Modify"
     },
+    {
+      "name": "Per Frame",
+      "type": "toggle",
+      "default": "true",
+      "show_when": "Operation == Modify && Combine != Set"
+    },
     {
       "name": "Group",
       "type": "group",
diff --git a/src/geometry.rs b/src/geometry.rs
index ca8521e..dc6622f 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -4215,7 +4215,39 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
                 Some(affected.iter().map(|&p| geom.points().value(&scale_by, p).map_or(0.0, |v| v.as_f32())).collect())
             };
             let amount = |i: usize| strength * weights.as_ref().map_or(1.0, |w| w[i]);
+            // The step's size. Inside a simnet the chain runs once per
+            // SUBSTEP and the solver leaves `dt` on the state — a frame
+            // over the substep count — so a change that ACCUMULATES has to
+            // be a rate, or four substeps pull four times as far a frame
+            // and the substep count, which is there to steady a solve,
+            // becomes its speed. An Add lands `dt` of its amount; a
+            // Multiply the `dt`-th power of its factor, which is what
+            // compounds back to the factor over a frame (a factor that is
+            // not positive has no such power, and lands `dt` of its amount
+            // instead). A Set does not accumulate — setting twice is
+            // setting once — and is left alone. Outside a simnet there is
+            // no `dt` and the step is the whole of it.
+            //
+            // `Per Frame` is the switch, on in the template. A node that
+            // does not carry the row reads OFF: that is every node built
+            // before it, and the chains that Add one to a counter to count
+            // the runs of the chain, which a rate would make a count of
+            // frames.
+            let per_frame = node_param_bool(target, "Per Frame", false);
+            let dt = if per_frame { geom.detail().value("dt", 0).map_or(1.0, |v| v.as_f32()) } else { 1.0 };
+            let dt = if dt.is_finite() && dt > 0.0 { dt } else { 1.0 };
+            let (adds, multiplies) = (combine_mode == "add", combine_mode == "multiply");
             let blend = |old: &[f32], new: &mut [f32], k: f32| {
+                if multiplies && dt != 1.0 {
+                    for (n, o) in new.iter_mut().zip(old) {
+                        // The factor this point takes at amount k, over a
+                        // whole frame: 1 at none of it, Value at all of it.
+                        let factor = if o.abs() > 1e-12 { 1.0 + (*n / o - 1.0) * k } else { 1.0 };
+                        *n = if factor > 0.0 { o * factor.powf(dt) } else { o + (o * factor - o) * dt };
+                    }
+                    return;
+                }
+                let k = if adds { k * dt } else { k };
                 if k != 1.0 {
                     for (n, o) in new.iter_mut().zip(old) {
                         *n = o + (*n - o) * k;
@@ -8473,6 +8505,71 @@ mod simnet_tests {
         }
     }
 
+    /// Per Frame makes the pull a RATE: inside a simnet it lands the same
+    /// distance a frame whatever the substep count, where without it four
+    /// substeps pull four times as far. A Multiply compounds to its factor
+    /// over the frame, a Set — which does not accumulate — is left alone,
+    /// and a node without the row keeps the per-step behaviour it had.
+    #[test]
+    fn per_frame_makes_the_pull_independent_of_the_substep_count() {
+        let sim_of = |substeps: &str, combine: &str, value: &str, extra: Vec<(&str, &str)>| {
+            let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+            let mover = node("id-shift", "Shift 1", "attribute", vec![
+                param("Input", "Sphere 1"), param("Operation", "Modify"), param("Attribute Name", "Pos"),
+                param("Value", "3.00:0.00:0.00"), param("Combine", "Add"), param("Group", ""),
+            ], vec![]);
+            let inner_input = node("id-in", "input1", "input", vec![], vec![]);
+            let mut params = vec![
+                param("Input", "input1"), param("Operation", "Modify"), param("Attribute Name", "Pos"),
+                param("Value", value), param("Combine", combine), param("Group", ""),
+            ];
+            params.extend(extra.into_iter().map(|(k, v)| param(k, v)));
+            let pull = node("id-pull", "pull1", "attribute", params, vec![]);
+            let inner_output = node("id-out", "output1", "output", vec![param("Input", "pull1")], vec![]);
+            let sim_node = node("id-sim", "Simnet 1", "simnet",
+                vec![param("Input", "Shift 1"), param("Substeps", substeps)],
+                vec![inner_input, pull, inner_output]);
+            node("id-root", "root", "node", vec![], vec![sphere, mover, sim_node])
+        };
+        let at = |root: &FsNode, frame: i32| min_x(&solve_at(root, frame));
+        let seed = at(&sim_of("1", "Add", "1.00:0.00:0.00", vec![]), 1);
+
+        // Add: five frames on from the seed, one unit a frame, at any count.
+        for substeps in ["1", "4", "16"] {
+            let root = sim_of(substeps, "Add", "1.00:0.00:0.00", vec![("Per Frame", "true")]);
+            assert!((at(&root, 6) - (seed + 5.0)).abs() < 1e-3, "{substeps} substeps: {}", at(&root, 6) - seed);
+        }
+        // With Strength: half a unit a frame.
+        let root = sim_of("4", "Add", "1.00:0.00:0.00", vec![("Per Frame", "true"), ("Strength", "0.50")]);
+        assert!((at(&root, 6) - (seed + 2.5)).abs() < 1e-3);
+        // Off, or on a node without the row: per step, four times as far.
+        for extra in [vec![("Per Frame", "false")], vec![]] {
+            let root = sim_of("4", "Add", "1.00:0.00:0.00", extra);
+            assert!((at(&root, 6) - (seed + 20.0)).abs() < 1e-3, "{}", at(&root, 6) - seed);
+        }
+
+        // Multiply compounds to its factor over a frame: doubling a frame,
+        // whatever the count. (The seed sits well clear of zero.)
+        assert!(seed > 0.1, "the fixture's points are on the positive side: {seed}");
+        for substeps in ["1", "4", "16"] {
+            let root = sim_of(substeps, "Multiply", "2.00:1.00:1.00", vec![("Per Frame", "true")]);
+            let got = at(&root, 4);
+            assert!((got - seed * 8.0).abs() < seed * 8.0 * 1e-3, "{substeps} substeps: {got} against {}", seed * 8.0);
+        }
+        // A Set does not accumulate: it sets, at any count, switch or no.
+        let root = sim_of("4", "Set", "7.00:0.00:0.00", vec![("Per Frame", "true")]);
+        assert!((at(&root, 3) - 7.0).abs() < 1e-4);
+
+        // Outside a simnet there is no step to account for.
+        let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+        let pull = node("id-pull", "Pull 1", "attribute", vec![
+            param("Input", "Sphere 1"), param("Operation", "Modify"), param("Attribute Name", "Pos"),
+            param("Value", "1.00:0.00:0.00"), param("Combine", "Add"), param("Group", ""), param("Per Frame", "true"),
+        ], vec![]);
+        let root = node("id-root", "root", "node", vec![], vec![sphere, pull]);
+        assert!((min_x(&eval(&root, "Pull 1")) - (min_x(&eval(&root, "Sphere 1")) + 1.0)).abs() < 1e-5);
+    }
+
     /// Farthest-point sampling takes the extremes before anything between
     /// them, and hands every point back when there are no more than asked.
     #[test]