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