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

commit98009408e2f207b36dc31acaee1b2248437963b9
parent02aaafbf93
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 12:45
feat(attribute): Composite's Length is the length of Name

Length reads Name and no source, so Source B is hidden for it. Format 7
carries an older save across: a Length node's Source B moves into Name
and the attribute it wrote becomes Result, computing the same numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md            | 15 ++++++++++----
 nodes/attribute.json |  4 ++--
 src/app.rs           | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/geometry.rs      | 18 ++++++++++++-----
 src/main.rs          | 52 +++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 133 insertions(+), 13 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7548c41..abdb1eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1223,7 +1223,9 @@ is RESOLVED from its holder and rewritten — `from_max` → `from.y`, `to_max`
 `manual_range.y` — since `from` and `to` are also wires on Transfer, Copy
 and Distance, which are left alone. Checked on the user's project (four
 Attribute nodes, none a Remap): the old build and the new export the same
-mesh at frames 1 and 30. Templates go through
+mesh at frames 1 and 30. **Format 7** (the same day): Composite's Length
+is the length of Name — see "Composite writes a Result" under "Pull
+arrows". Templates go through
 `infer_template_exprs` instead: a default that READS as a reference is one
 (`embryo.json` says `chf("../radius")` now). The same inference applies to a
 value typed into a plain row or scripted through `set_param`: a reference
@@ -3482,9 +3484,14 @@ out. Average is the mean of every operand given, not a pairwise fold. A
 Result of Pos / Col / P / Cd is refused: Modify writes those. Every operand
 is read before Result is written, since it may be one of them.
 `composite_point` is the per-point arithmetic, broadcasting a one-number
-operand (Name included) as above. Length is still the length of Source B,
-Name unread. `composite_writes_a_result_and_folds_up_to_four_operands` is
-the test.
+operand (Name included) as above. **Length is the length of Name** (the
+same day; it was Source B's, Name unread) and reads no source, so Source B
+is hidden for it. **Format 7** (`Project::migrate_composite_length`) carries
+a save across: a Length node's Source B moves into Name and the attribute it
+wrote (Name, or Result when set) becomes Result, which, existing, keeps its
+type — the same numbers. A Length with no Source B failed before and is left
+alone. `composite_writes_a_result_and_folds_up_to_four_operands` and
+`a_saved_composite_length_keeps_its_result` are the tests.
 
 **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
diff --git a/nodes/attribute.json b/nodes/attribute.json
index 12b894d..72eb272 100644
--- a/nodes/attribute.json
+++ b/nodes/attribute.json
@@ -158,7 +158,7 @@
       "label": "Source B",
       "type": "attribute",
       "default": "",
-      "show_when": "operation == Composite",
+      "show_when": "operation == Composite && combine_op != Length",
       "description": "The second point attribute combined with Name, point by point. A single-number source applies to every component.",
       "group": "composite"
     },
@@ -186,7 +186,7 @@
       "type": "choice:Add,Subtract,Multiply,Divide,Minimum,Maximum,Average,Difference,Dot,Distance,Length",
       "default": "Add",
       "show_when": "operation == Composite",
-      "description": "How the sources are combined with Name, componentwise and left to right. Difference, Dot and Distance take Name and Source B alone; Length is the length of Source B. Dot, Distance and Length produce one number, written into every component.",
+      "description": "How the sources are combined with Name, componentwise and left to right. Difference, Dot and Distance take Name and Source B alone; Length is the length of Name and reads no source. Dot, Distance and Length produce one number, written into every component.",
       "group": "composite"
     },
     {
diff --git a/src/app.rs b/src/app.rs
index f2a3f72..eaa94b8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -391,7 +391,9 @@ pub struct Project {
     /// 4 (the same day) parameter names as identifiers; 5 (2026-10-02) the
     /// root as the object level, its geometry inside a `geometry` node
     /// (`context::wrap_root_geometry`); 6 (2026-10-06) a range's two ends as
-    /// one `float2` (`Project::migrate_range_rows`).
+    /// one `float2` (`Project::migrate_range_rows`); 7 (the same day)
+    /// Composite's Length as the length of Name, not of Source B
+    /// (`Project::migrate_composite_length`).
     /// `migrate_format` takes a file through each step it is behind, and a
     /// step must not run twice.
     #[serde(default)]
@@ -399,7 +401,7 @@ pub struct Project {
 }
 
 /// The format `Project` saves in — see its `format` field.
-pub const PROJECT_FORMAT: u32 = 6;
+pub const PROJECT_FORMAT: u32 = 7;
 
 /// One entry in a node's right-click context menu, parallel to the visible
 /// labels shown via `context_menu::show`.
@@ -1302,9 +1304,60 @@ impl Project {
         if self.format < 6 {
             self.migrate_range_rows();
         }
+        if self.format < 7 {
+            self.migrate_composite_length();
+        }
         self.format = PROJECT_FORMAT;
     }
 
+    /// Format 6 → 7: Composite's Length is the length of NAME, where it was
+    /// the length of Source B written into Name (or into Result, for the
+    /// day Result existed before this). So a saved Length node computes
+    /// what it did with Source B moved into Name and the attribute it wrote
+    /// named as Result — which, existing, keeps its type and takes the
+    /// length in every component, as before. Text and expression flag move
+    /// together. A node whose Source B is empty failed before and is left
+    /// as it is. A channel path elsewhere that reads one of these rows is
+    /// not followed: they hold attribute names, which nothing reads that way.
+    fn migrate_composite_length(&mut self) {
+        fn walk(node: &mut FsNode) {
+            let is = |n: &FsNode, row: &str, v: &str| node_param_str(n, row, "").trim().eq_ignore_ascii_case(v);
+            if node.node_type.eq_ignore_ascii_case("attribute")
+                && is(node, "operation", "composite")
+                && is(node, "combine_op", "length")
+            {
+                let row = |n: &FsNode, r: &str| n.params.iter().find(|p| p.name == r).cloned();
+                if let (Some(name), Some(b)) = (row(node, "attribute_name"), row(node, "source_b")) {
+                    if !b.text().trim().is_empty() {
+                        let result_empty = row(node, "result").is_none_or(|r| r.text().trim().is_empty());
+                        let set = |node: &mut FsNode, r: &str, from: &ParamDef| {
+                            match node.params.iter_mut().find(|p| p.name == r) {
+                                Some(p) => {
+                                    p.set_text(from.text().to_string());
+                                    p.set_expr(from.is_expr());
+                                }
+                                None => {
+                                    let mut p = ParamDef::new(r, "attribute", from.text());
+                                    p.set_expr(from.is_expr());
+                                    node.params.push(p);
+                                }
+                            }
+                        };
+                        set(node, "attribute_name", &b);
+                        if result_empty {
+                            set(node, "result", &name);
+                        }
+                        set(node, "source_b", &ParamDef::new("source_b", "attribute", ""));
+                    }
+                }
+            }
+            for c in &mut node.children {
+                walk(c);
+            }
+        }
+        walk(&mut self.root);
+    }
+
     /// Format 5 → 6: a range is one `float2` row where it was two numbers.
     /// The Attribute node's From Min / From Max become From, To Min / To
     /// Max become To, and To Max is also Normalize To, the goal Normalize
diff --git a/src/geometry.rs b/src/geometry.rs
index b05d142..11f5d42 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -5183,7 +5183,11 @@ pub(crate) fn apply_attribute(geom: &mut Detail, target: &FsNode, ocl_error: &mu
             let op = node_param_str(target, "combine_op", "Add").to_lowercase();
             let reduces = matches!(op.as_str(), "dot" | "distance" | "length");
             let folds = !reduces && op != "difference";
-            let mut sources = vec![("Source B", node_param_str(target, "source_b", "").trim().to_string())];
+            // Length is Name's own length and reads no source.
+            let mut sources = Vec::new();
+            if op != "length" {
+                sources.push(("Source B", node_param_str(target, "source_b", "").trim().to_string()));
+            }
             if folds {
                 for (label, row) in [("Source C", "source_c"), ("Source D", "source_d")] {
                     let n = node_param_str(target, row, "").trim().to_string();
@@ -5363,7 +5367,8 @@ pub fn remap_input_range(input: &Detail, target: &FsNode) -> Result<[f32; 2], St
 /// Y and Z, and a sum or a minimum touched X alone. An operand of two or
 /// more components pairs off by position, the missing ones zero: only ONE
 /// number has an obvious meaning for all of them. Dot, Distance and Length
-/// reduce to one number, written into every component.
+/// reduce to one number, written into every component; Length is Name's
+/// (Source B's until 2026-10-06, which format 7 carries a save across).
 fn composite_point(op: &str, a: &[f32], sources: &[Vec<f32>], k: usize) -> Vec<f32> {
     let at = |v: &[f32], i: usize| if v.len() == 1 { v[0] } else { v.get(i).copied().unwrap_or(0.0) };
     let b: &[f32] = sources.first().map_or(&[], |b| b.as_slice());
@@ -5373,7 +5378,7 @@ fn composite_point(op: &str, a: &[f32], sources: &[Vec<f32>], k: usize) -> Vec<f
     match op {
         "dot" => vec![(0..a.len().max(b.len())).map(|i| raw(a, i) * raw(b, i)).sum(); k],
         "distance" => vec![(0..a.len().max(b.len())).map(|i| (raw(a, i) - raw(b, i)).powi(2)).sum::<f32>().sqrt(); k],
-        "length" => vec![b.iter().map(|c| c * c).sum::<f32>().sqrt(); k],
+        "length" => vec![a.iter().map(|c| c * c).sum::<f32>().sqrt(); k],
         "average" => {
             let n = (1 + sources.len()) as f32;
             (0..k).map(|i| (at(a, i) + sources.iter().map(|s| at(s, i)).sum::<f32>()) / n).collect()
@@ -7821,8 +7826,11 @@ mod simnet_tests {
         // 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]);
+        // The reductions are not componentwise. Length is Name's own —
+        // |(1, 2, 3)| — and reads no Source B.
+        let l = 14f32.sqrt();
+        assert_eq!(with("w", "Length"), [l, l, l]);
+        assert_eq!(with("", "Length"), [l, l, l], "Length needs no Source B");
         assert_eq!(with("w", "Dot"), [0.5, 0.5, 0.5]);
 
         // A per-point weight scales a vector per point: the ramped mass,
diff --git a/src/main.rs b/src/main.rs
index 994cd1a..bb2d011 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -795,6 +795,58 @@ mod tests {
     /// list of attributes, `@Norm` in a wrangle (not `@Normal`) — once: a
     /// format-2 file naming `Norm` is left alone, since that attribute is
     /// someone's own.
+    /// Format 7: Composite's Length is the length of Name. A save from
+    /// before, which wrote |Source B| into Name (or Result), has Source B
+    /// moved into Name and the written attribute named as Result, and
+    /// computes what it computed.
+    #[test]
+    fn a_saved_composite_length_keeps_its_result() {
+        use crate::app::{FsNode, ParamDef, Project};
+        use crate::detail::AttribValue;
+        let node = |id: &str, params: &[(&str, &str)]| FsNode {
+            id: id.into(),
+            name: id.into(),
+            node_type: "attribute".into(),
+            children: vec![],
+            params: params.iter().map(|(k, v)| ParamDef::new(*k, "text", *v)).collect(),
+            geometry_visible: true,
+            bypassed: false,
+            position: (0.0, 0.0),
+            inputs: 1,
+            outputs: 1,
+        };
+        let length = [("attribute_name", "a"), ("operation", "Composite"), ("source_b", "b"), ("combine_op", "Length")];
+        let mut with_result = length.to_vec();
+        with_result.push(("result", "r"));
+        let mut root = node("root", &[]);
+        root.node_type = "subnet".into();
+        root.children = vec![
+            node("in_place", &length),
+            node("into_r", &with_result),
+            node("no_b", &[("attribute_name", "a"), ("operation", "Composite"), ("combine_op", "Length")]),
+            node("add", &[("attribute_name", "a"), ("operation", "Composite"), ("source_b", "b"), ("combine_op", "Add")]),
+        ];
+        let mut proj = Project { name: "p".into(), root, view_state: Default::default(), format: 6 };
+        proj.migrate_format();
+        let row = |i: usize, r: &str| {
+            proj.root.children[i].params.iter().find(|p| p.name == r).map(|p| p.text().to_string()).unwrap_or_default()
+        };
+        assert_eq!((row(0, "attribute_name"), row(0, "result"), row(0, "source_b")), ("b".into(), "a".into(), "".into()));
+        assert_eq!((row(1, "attribute_name"), row(1, "result")), ("b".into(), "r".into()), "a Result is kept");
+        assert_eq!((row(2, "attribute_name"), row(2, "result")), ("a".into(), "".into()), "no Source B: left alone");
+        assert_eq!(row(3, "source_b"), "b", "only Length moves");
+
+        // It computes what it did: |b| in every component of a.
+        let mut geom = crate::geometry::sphere_detail(glam::Vec3::ZERO, 0.5, 4, 6);
+        geom.points_mut().create("a", AttribValue::Float3([1.0, 1.0, 1.0]));
+        geom.points_mut().create("b", AttribValue::Float3([3.0, 4.0, 0.0]));
+        let mut err = None;
+        crate::geometry::apply_attribute(&mut geom, &proj.root.children[0], &mut err);
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(geom.points().value("a", 2), Some(AttribValue::Float3([5.0, 5.0, 5.0])));
+        assert_eq!(geom.points().value("b", 2), Some(AttribValue::Float3([3.0, 4.0, 0.0])));
+    }
+
     #[test]
     fn a_save_naming_norm_or_uv_names_n_or_uv() {
         use crate::app::{FsNode, ParamDef, Project, PROJECT_FORMAT};