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

commit67dd30cd454c7171cc3f15e9a343c51f96394625
parenta5140f339a
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 00:59
feat: Strength and Scale By on the Attribute node's Modify

Two rows that scale the pull. Strength is a number; Scale By names a
point attribute whose value weighs each point; the amount that lands at
a point is their product. They scale the EFFECT — the change the node
makes — so they mean one thing under every Combine: an Add moves by that
much of Value, a Set goes that far toward it, a Multiply that far toward
the product.

Strength being a number, it takes an expression: `$F / 10` ramps a pull
in, which the Value row — text holding three numbers — cannot do. With
the trackball it splits the vector into a direction and how hard. At an
amount of exactly one the combined value is written as it always was,
so an older save, which the template merge gives a Strength of 1,
solves to the same numbers. A Scale By naming no attribute is an error
on the node and moves nothing.

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

 CLAUDE.md            |  16 +++++++
 nodes/attribute.json |  12 +++++
 src/geometry.rs      | 128 +++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 151 insertions(+), 5 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index daf3a77..4a0ad56 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1814,6 +1814,22 @@ Marker Scale). The scale is display only: the sampled pairs are kept
 unscaled on `pull_arrow_pairs`, and `rebuild_pull_arrow_verts` stretches
 each arrow from its fixed base, so a slider drag re-evaluates nothing.
 
+**Strength and Scale By scale the pull** (the Attribute node's Modify,
+since 2026-09-29). `Strength` is a `float`, `Scale By` an `attribute`
+naming a point attribute whose value weighs each point; the amount that
+lands at a point is their product. They scale the EFFECT — the change the
+node makes, `old + (combined - old) * amount` — so they mean one thing
+under every Combine: an Add moves by that much of Value, a Set goes that
+far toward it, a Multiply that far toward the product. Two rows rather
+than a longer vector because the Value row is text holding three numbers
+and takes no expression per component, while Strength is a number: `$F /
+10` ramps a pull in, and the trackball keeps the direction while one
+slider sets how hard. At an amount of exactly one the combined value is
+written as it always was, bit for bit, so a save from before the rows
+(which the template merge gives a Strength of 1) solves to the same
+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.
+
 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 e84258d..b633ddc 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -37,6 +37,18 @@
       "default": "Set",
       "show_when": "Operation == Modify"
     },
+    {
+      "name": "Strength",
+      "type": "float",
+      "default": "1.00",
+      "show_when": "Operation == Modify"
+    },
+    {
+      "name": "Scale By",
+      "type": "attribute",
+      "default": "",
+      "show_when": "Operation == Modify"
+    },
     {
       "name": "Group",
       "type": "group",
diff --git a/src/geometry.rs b/src/geometry.rs
index a44bbb5..ca8521e 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -4196,14 +4196,44 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
             }
         }
         "modify" => {
-            if !value_ok {
+            // How much of the change lands, per point: Strength, times the
+            // point's value of the Scale By attribute when one is named.
+            // They scale the EFFECT — the difference the node makes — so
+            // they mean the same under every Combine: an Add moves by that
+            // much of Value, a Set goes that far toward it, a Multiply that
+            // far toward the product. At exactly one the combined value is
+            // written as it always was, bit for bit.
+            let strength = node_param_f32(target, "Strength", 1.0);
+            let scale_by = node_param_str(target, "Scale By", "");
+            let scale_by = scale_by.trim().to_string();
+            let weights: Option<Vec<f32>> = if scale_by.is_empty() {
+                None
+            } else if !geom.points().has(&scale_by) {
+                fail = format!("Scale By '{}' is not a point attribute", scale_by);
+                None
+            } else {
+                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]);
+            let blend = |old: &[f32], new: &mut [f32], k: f32| {
+                if k != 1.0 {
+                    for (n, o) in new.iter_mut().zip(old) {
+                        *n = o + (*n - o) * k;
+                    }
+                }
+            };
+            if !fail.is_empty() {
+                // Scale By names nothing: the message is set, nothing moves.
+            } else if !value_ok {
                 fail = format!("Value '{}' does not parse as numbers", value_str);
             } else if builtin {
                 match fit(3) {
                     Some(src) => {
-                        for &p in &affected {
-                            let mut v = if is_col { geom.color(p) } else { geom.pos(p).to_array() };
+                        for (i, &p) in affected.iter().enumerate() {
+                            let old = if is_col { geom.color(p) } else { geom.pos(p).to_array() };
+                            let mut v = old;
                             combine(&mut v, &src);
+                            blend(&old, &mut v, amount(i));
                             if is_col {
                                 geom.set_color(p, v);
                             } else {
@@ -4219,10 +4249,12 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
                     Some(ty) => match fit(ty.components()) {
                         None => fail = format!("Value '{}' does not fit '{}'", value_str, name),
                         Some(src) => {
-                            for &p in &affected {
+                            for (i, &p) in affected.iter().enumerate() {
                                 let Some(cur) = geom.points().value(&name, p) else { continue };
-                                let mut buf = attrib_components(cur);
+                                let old = attrib_components(cur);
+                                let mut buf = old.clone();
                                 combine(&mut buf, &src);
+                                blend(&old, &mut buf, amount(i));
                                 let _ = geom.points_mut().set_value(&name, p, components_attrib(ty, &buf));
                             }
                         }
@@ -8355,6 +8387,92 @@ mod simnet_tests {
         assert_eq!(arrow_vertices(&shown, [1.0; 3]).len(), 12 * 10);
     }
 
+    /// Strength and Scale By scale the pull's EFFECT — the change the node
+    /// makes — so they mean one thing under every Combine: an Add moves by
+    /// that much of Value, a Set goes that far toward it. Scale By is a
+    /// point attribute, so the pull can fall off across the mesh; Strength
+    /// is a number, so it takes an expression and the pull can ramp with
+    /// the frame. At one, nothing changes from before they existed.
+    #[test]
+    fn strength_and_scale_by_scale_the_pulls_effect() {
+        let pull_of = |extra: Vec<(&str, &str)>, combine: &str, value: &str| {
+            let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+            let mut params = vec![
+                param("Input", "Sphere 1"),
+                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", "Pull 1", "attribute", params, vec![]);
+            node("id-root", "root", "node", vec![], vec![sphere, pull])
+        };
+        let moved_by = |root: &FsNode, frame: i32| -> (Vec<Vec3>, Option<String>) {
+            let mut err = None;
+            let mut cache = SimCache::default();
+            let mut sim = EvalSim::new(frame, 1, &mut cache);
+            let before = generate_single_node_geometry_with_errors(root, &root.children[0], &mut Vec::new(), &mut err, &mut sim).unwrap();
+            let after = generate_single_node_geometry_with_errors(root, &root.children[1], &mut Vec::new(), &mut err, &mut sim).unwrap();
+            ((0..after.num_points()).map(|p| after.pos(p) - before.pos(p)).collect(), err)
+        };
+        let up = Vec3::new(0.0, 0.06, 0.0);
+        let all = |d: &[Vec3], want: Vec3| d.iter().all(|v| v.distance(want) < 1e-5);
+
+        // Absent (an older save) and at one: the pull as it always was.
+        let (d, err) = moved_by(&pull_of(vec![], "Add", "0.00:0.06:0.00"), 1);
+        assert!(err.is_none() && all(&d, up), "{err:?}");
+        let (d, _) = moved_by(&pull_of(vec![("Strength", "1.00")], "Add", "0.00:0.06:0.00"), 1);
+        assert!(all(&d, up));
+        // Half, none, double.
+        for (strength, want) in [("0.50", up * 0.5), ("0.00", Vec3::ZERO), ("2.00", up * 2.0)] {
+            let (d, _) = moved_by(&pull_of(vec![("Strength", strength)], "Add", "0.00:0.06:0.00"), 1);
+            assert!(all(&d, want), "strength {strength}: {:?}", d[0]);
+        }
+        // Under Set it is how far toward Value each point goes: at a half,
+        // halfway from where it was to (0, 2, 0).
+        let root = pull_of(vec![("Strength", "0.50")], "Set", "0.00:2.00:0.00");
+        let base = eval(&root, "Sphere 1");
+        let out = eval(&root, "Pull 1");
+        for p in 0..out.num_points() {
+            let want = base.pos(p).lerp(Vec3::new(0.0, 2.0, 0.0), 0.5);
+            assert!(out.pos(p).distance(want) < 1e-5, "point {p}: {:?} against {want:?}", out.pos(p));
+        }
+
+        // Scale By: each point by its own value of the attribute — the
+        // sphere's UV, whose first component runs around it — times Strength.
+        let root = pull_of(vec![("Strength", "0.50"), ("Scale By", "UV")], "Add", "0.00:0.06:0.00");
+        let base = eval(&root, "Sphere 1");
+        let (d, err) = moved_by(&root, 1);
+        assert!(err.is_none(), "{err:?}");
+        let mut weights = Vec::new();
+        for (p, moved) in d.iter().enumerate() {
+            let w = base.points().value("UV", p).expect("the sphere carries UV").as_f32();
+            assert!(moved.distance(up * 0.5 * w) < 1e-5, "point {p} weighs {w}: {moved:?}");
+            weights.push(w);
+        }
+        let (lo, hi) = weights.iter().fold((f32::MAX, f32::MIN), |(l, h), w| (l.min(*w), h.max(*w)));
+        assert!(hi - lo > 0.5, "the fixture's weights vary, or this proves nothing: {lo}..{hi}");
+
+        // An attribute the input lacks is said, and nothing moves.
+        let (d, err) = moved_by(&pull_of(vec![("Scale By", "nothing_here")], "Add", "0.00:0.06:0.00"), 1);
+        assert!(err.as_deref().is_some_and(|e| e.contains("Scale By") && e.contains("nothing_here")), "{err:?}");
+        assert!(all(&d, Vec3::ZERO));
+
+        // Strength is a number, so it takes an expression: a pull that
+        // ramps in over ten frames.
+        let mut root = pull_of(vec![("Strength", "$F / 10")], "Add", "0.00:0.06:0.00");
+        let strength = root.children[1].params.iter_mut().find(|p| p.name == "Strength").unwrap();
+        strength.set_type("float");
+        strength.set_expr(true);
+        for frame in [2, 5, 10] {
+            let (d, err) = moved_by(&root, frame);
+            assert!(err.is_none(), "{err:?}");
+            assert!(all(&d, up * (frame as f32 / 10.0)), "frame {frame}: {:?}", d[0]);
+        }
+    }
+
     /// Farthest-point sampling takes the extremes before anything between
     /// them, and hands every point back when there are no more than asked.
     #[test]