graphic design tool
git clone https://git.lucas.co/cce-designer.git
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();