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

commit1a2a69132ee41d9d743c9a245538d630154a87a3
parent991ad3afdf
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 01:04
fix: Composite broadcasts a single number across a vector

A Source B of one component is now every component's, so a Float3
composited with a Float scales, shifts or clamps the whole vector — by a
constant, or per point by a weight. The componentwise operations paired
the number with X and zero with the rest: Multiply kept X and zeroed Y
and Z, Add and the others touched X alone. A Source B of two or more
components still pairs off by position, and Dot, Distance and Length,
which reduce to one number, are unchanged.

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

 CLAUDE.md       |  8 +++++++
 src/geometry.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 77 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0ce1dda..8424c87 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1830,6 +1830,14 @@ 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.
 
+**Composite broadcasts a single number** (since 2026-09-29): a Source B
+of ONE component is every component's, so a Float3 times a Float is the
+vector scaled — by a constant, or per point by a weight. The componentwise
+operations used to pair the number with X and zero with the rest, which
+kept X and zeroed Y and Z under Multiply and touched X alone under Add. A
+Source B of two or more components still pairs off by position; Dot,
+Distance and Length reduce to one number and did not change.
+
 **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
diff --git a/src/geometry.rs b/src/geometry.rs
index dc6622f..1089d37 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -4390,7 +4390,18 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
                         }
                         _ => (0..k)
                             .map(|i| {
-                                let (x, y) = (at(&a, i), at(&b, i));
+                                // A single number is every component's:
+                                // a Float3 times a Float is the vector
+                                // scaled. Until 2026-09-29 the components
+                                // Source B lacked read zero, so that
+                                // product kept X and zeroed Y and Z — and
+                                // a sum or a minimum touched X alone. A
+                                // Source B of two or more components still
+                                // pairs off by position, the missing ones
+                                // zero: only ONE number has an obvious
+                                // meaning for all of them.
+                                let y = if b.len() == 1 { b[0] } else { at(&b, i) };
+                                let x = at(&a, i);
                                 match op.as_str() {
                                     "subtract" => x - y,
                                     "multiply" => x * y,
@@ -6572,6 +6583,63 @@ mod simnet_tests {
         assert!(err.is_some(), "a missing Source B must be reported");
     }
 
+    /// A single number is every component's: a Float3 composited with a
+    /// Float scales, shifts or clamps the whole vector. It used to pair the
+    /// number with X and zero with the rest, so the product kept X and
+    /// zeroed Y and Z. Wider pairs still go by position, and the three
+    /// operations that reduce to one number are as they were.
+    #[test]
+    fn test_composite_broadcasts_a_single_number_across_a_vector() {
+        let mut before = ramped_mass();
+        before.points_mut().create("v", AttribValue::Float3([1.0, 2.0, 3.0]));
+        before.points_mut().create("w", AttribValue::Float(0.5));
+        before.points_mut().create("uv", AttribValue::Float2([10.0, 20.0]));
+        let v = |d: &Detail| match d.points().value("v", 3).unwrap() {
+            AttribValue::Float3(x) => x,
+            other => panic!("v is still a Float3: {other:?}"),
+        };
+        let with = |b: &str, op: &str| {
+            let (g, err) = run_attr(
+                &before,
+                &[("Attribute Name", "v"), ("Operation", "Composite"), ("Source B", b), ("Combine Op", op)],
+            );
+            assert!(err.is_none(), "{op} with {b}: {err:?}");
+            v(&g)
+        };
+        for (op, want) in [
+            ("Multiply", [0.5, 1.0, 1.5]),
+            ("Add", [1.5, 2.5, 3.5]),
+            ("Subtract", [0.5, 1.5, 2.5]),
+            ("Divide", [2.0, 4.0, 6.0]),
+            ("Minimum", [0.5, 0.5, 0.5]),
+            ("Maximum", [1.0, 2.0, 3.0]),
+            ("Average", [0.75, 1.25, 1.75]),
+            ("Difference", [0.5, 1.5, 2.5]),
+        ] {
+            assert_eq!(with("w", op), want, "{op}");
+        }
+        // Two numbers against three pair off by position, as before: the
+        // third has nothing to meet.
+        assert_eq!(with("uv", "Add"), [11.0, 22.0, 3.0]);
+        assert_eq!(with("uv", "Multiply"), [10.0, 40.0, 0.0]);
+        // The reductions are not componentwise, and did not change.
+        assert_eq!(with("w", "Length"), [0.5, 0.5, 0.5]);
+        assert_eq!(with("w", "Dot"), [0.5, 0.5, 0.5]);
+
+        // A per-point weight scales a vector per point: the ramped mass,
+        // which differs from point to point, times the same vector.
+        let (g, err) = run_attr(
+            &before,
+            &[("Attribute Name", "v"), ("Operation", "Composite"), ("Source B", "mass"), ("Combine Op", "Multiply")],
+        );
+        assert!(err.is_none(), "{err:?}");
+        for p in [0, 3, g.num_points() - 1] {
+            let m = before.points().value("mass", p).unwrap().as_f32();
+            let got = match g.points().value("v", p).unwrap() { AttribValue::Float3(x) => x, _ => unreachable!() };
+            assert_eq!(got, [m, 2.0 * m, 3.0 * m], "point {p} weighs {m}");
+        }
+    }
+
     #[test]
     fn test_analysis_writes_its_answers_to_detail_attributes() {
         let before = ramped_mass();