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

commit2b4d6874d35fe5af872f368a5c478127823b07d5
parent4ee92131b4
authorLucas Galante <[email protected]>
date2026-08-25 10:04
feat: the Attribute node — create, modify, and delete vertex attributes

A native pass-through node (nodes/attribute.json, type "attribute"):
Operation picks the edit. Create inserts Attribute Name on every affected
vertex as the chosen Type (Float..Float4) parsed from Value; Modify combines
Value into vertices that already carry the attribute (Combine = Set / Add /
Multiply, componentwise, with a single-component Value broadcasting across
wider types); Delete removes it. The Pos and Col built-ins are reachable by
name for Modify — the node can displace or tint geometry — but cannot be
created or deleted. A non-empty Group name restricts every operation to the
vertices a Group node tagged group:<name>, composing the two nodes. Errors
(bad Value, width mismatch, editing a built-in) surface on the status line
and pass the geometry through unchanged.

No visited guard in the resolver: the dispatch already pushes the target id,
so a local guard refuses every call — the scatter resolver still carries
that latent bug (flagged separately).

 nodes/attribute.json |  43 ++++++++++++
 src/geometry.rs      | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs          | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 411 insertions(+)

diff --git a/nodes/attribute.json b/nodes/attribute.json
new file mode 100644
index 0000000..91be42a
--- /dev/null
+++ b/nodes/attribute.json
@@ -0,0 +1,43 @@
+{
+ "name": "Attribute",
+ "type": "attribute",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Input",
+   "type": "text",
+   "default": ""
+  },
+  {
+   "name": "Operation",
+   "type": "choice:Create,Modify,Delete",
+   "default": "Create"
+  },
+  {
+   "name": "Attribute Name",
+   "type": "text",
+   "default": "attr1"
+  },
+  {
+   "name": "Type",
+   "type": "choice:Float,Float2,Float3,Float4",
+   "default": "Float"
+  },
+  {
+   "name": "Value",
+   "type": "text",
+   "default": "1.00"
+  },
+  {
+   "name": "Combine",
+   "type": "choice:Set,Add,Multiply",
+   "default": "Set"
+  },
+  {
+   "name": "Group",
+   "type": "text",
+   "default": ""
+  }
+ ]
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 8dea668..efebd47 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -486,6 +486,8 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_scatter_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("group") {
         resolve_group_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("attribute") {
+        resolve_attribute_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("opencl") {
         resolve_opencl_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("simnet") {
@@ -675,6 +677,177 @@ pub fn resolve_group_geometry_with_errors(
     Some(geom)
 }
 
+/// The Attribute node: pass the input geometry through, running one
+/// attribute edit over it. Operation picks the edit —
+///
+/// - **Create** inserts `Attribute Name` on every affected vertex as the
+///   chosen Type parsed from Value, overwriting an existing tag.
+/// - **Modify** combines Value into vertices that already carry the
+///   attribute (Combine = Set / Add / Multiply, componentwise). The
+///   built-ins `Pos` and `Col` are reachable by name here (Float3), so the
+///   node can displace or tint geometry; they cannot be created or deleted.
+/// - **Delete** removes the attribute.
+///
+/// Value splits on `:`/`,`/space like every vector param; a single-component
+/// Value broadcasts across wider types (`0.5` scales a Float3 uniformly). A
+/// non-empty Group name restricts every operation to the vertices a Group
+/// node tagged `group:<name>`, composing the two nodes. Errors (bad Value,
+/// component mismatch, editing a built-in) surface on the status line and
+/// pass the geometry through unchanged.
+pub fn resolve_attribute_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Geometry> {
+    // No visited guard here: `generate_single_node_geometry_with_errors`
+    // pushes the target's id before dispatching to this resolver, so a local
+    // `visited.contains` check would see it and refuse every call (the trap
+    // that broke this node's first draft).
+    let input_name = node_param_str(target, "Input", "");
+    if input_name.is_empty() {
+        return None;
+    }
+    let input_node = find_node_by_name(root, &input_name)?;
+    let mut geom = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+
+    let name = node_param_str(target, "Attribute Name", "attr1").trim().to_string();
+    if name.is_empty() {
+        return Some(geom);
+    }
+    let op = node_param_str(target, "Operation", "Create").to_lowercase();
+    let combine_mode = node_param_str(target, "Combine", "Set").to_lowercase();
+    let builtin = name.eq_ignore_ascii_case("Pos") || name.eq_ignore_ascii_case("Col");
+
+    let mut fail = String::new();
+    let group = node_param_str(target, "Group", "");
+    let group_attr = {
+        let g = group.trim();
+        (!g.is_empty()).then(|| format!("group:{}", g))
+    };
+    let affected =
+        |v: &GVertex| group_attr.as_ref().map_or(true, |ga| v.attributes.contains_key(ga));
+
+    // Value, as raw components. Delete never reads it; Create/Modify reject
+    // the edit outright when any component fails to parse.
+    let value_str = node_param_str(target, "Value", "");
+    let raw: Vec<&str> = value_str
+        .split(|c| c == ':' || c == ',' || c == ' ')
+        .filter(|p| !p.is_empty())
+        .collect();
+    let comps: Vec<f32> = raw.iter().filter_map(|p| p.parse::<f32>().ok()).collect();
+    let value_ok = !comps.is_empty() && comps.len() == raw.len();
+    // Value resized to an attribute's width: exact match passes through, a
+    // single component broadcasts, anything else is a mismatch.
+    let fit = |n: usize| -> Option<Vec<f32>> {
+        if comps.len() == n {
+            Some(comps.clone())
+        } else if comps.len() == 1 {
+            Some(vec![comps[0]; n])
+        } else {
+            None
+        }
+    };
+    let combine = |dst: &mut [f32], src: &[f32]| {
+        for (d, s) in dst.iter_mut().zip(src) {
+            match combine_mode.as_str() {
+                "add" => *d += s,
+                "multiply" => *d *= s,
+                _ => *d = *s,
+            }
+        }
+    };
+
+    match op.as_str() {
+        "delete" => {
+            if builtin {
+                fail = format!("'{}' is built-in and cannot be deleted", name);
+            } else {
+                for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
+                    v.attributes.remove(&name);
+                }
+            }
+        }
+        "modify" => {
+            if !value_ok {
+                fail = format!("Value '{}' does not parse as numbers", value_str);
+            } else if builtin {
+                match fit(3) {
+                    Some(src) => {
+                        let tint_col = name.eq_ignore_ascii_case("Col");
+                        for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
+                            if tint_col {
+                                combine(&mut v.col, &src);
+                            } else {
+                                combine(&mut v.pos, &src);
+                            }
+                        }
+                    }
+                    None => fail = format!("Value '{}' does not fit Float3 '{}'", value_str, name),
+                }
+            } else {
+                for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
+                    let Some(existing) = v.attributes.get_mut(&name) else { continue };
+                    let src = match existing {
+                        GAttribute::Float(_) => fit(1),
+                        GAttribute::Float2(_) => fit(2),
+                        GAttribute::Float3(_) => fit(3),
+                        GAttribute::Float4(_) => fit(4),
+                    };
+                    let Some(src) = src else {
+                        fail = format!("Value '{}' does not fit '{}'", value_str, name);
+                        break;
+                    };
+                    match existing {
+                        GAttribute::Float(x) => combine(std::slice::from_mut(x), &src),
+                        GAttribute::Float2(x) => combine(x, &src),
+                        GAttribute::Float3(x) => combine(x, &src),
+                        GAttribute::Float4(x) => combine(x, &src),
+                    }
+                }
+            }
+        }
+        // Create (the default).
+        _ => {
+            if builtin {
+                fail = format!("'{}' is built-in and cannot be created", name);
+            } else if !value_ok {
+                fail = format!("Value '{}' does not parse as numbers", value_str);
+            } else {
+                let ty = node_param_str(target, "Type", "Float").to_lowercase();
+                let width = match ty.as_str() {
+                    "float2" => 2,
+                    "float3" => 3,
+                    "float4" => 4,
+                    _ => 1,
+                };
+                match fit(width) {
+                    Some(src) => {
+                        let make = || match width {
+                            2 => GAttribute::Float2([src[0], src[1]]),
+                            3 => GAttribute::Float3([src[0], src[1], src[2]]),
+                            4 => GAttribute::Float4([src[0], src[1], src[2], src[3]]),
+                            _ => GAttribute::Float(src[0]),
+                        };
+                        for v in geom.vertices.iter_mut().filter(|v| affected(v)) {
+                            v.attributes.insert(name.clone(), make());
+                        }
+                    }
+                    None => {
+                        fail = format!("Value '{}' does not fit {}", value_str, ty);
+                    }
+                }
+            }
+        }
+    }
+
+    if !fail.is_empty() && ocl_error.is_none() {
+        *ocl_error = Some(format!("Attribute '{}': {}", target.name, fail));
+    }
+    Some(geom)
+}
+
 /// Positions of the vertices a Group node tagged into `group:<name>` — the
 /// source data for the selected-Group viewport markers. Duplicate positions
 /// (the triangle soup repeats shared corners) are left in; `points_vertices`
@@ -1396,6 +1569,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "output"
         || nt == "scatter"
         || nt == "group"
+        || nt == "attribute"
         || nt == "simnet"
 }
 
@@ -1477,6 +1651,15 @@ pub fn network_sphere_vertices_with_errors(
                     out.merge(geom);
                 }
             }
+        } else if node.node_type.eq_ignore_ascii_case("attribute") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_attribute_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(geom);
+                }
+            }
         } else if node.node_type.eq_ignore_ascii_case("opencl") {
             let _idx = *count;
             *count += 1;
diff --git a/src/main.rs b/src/main.rs
index fedafdc..dccb4f1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -946,6 +946,191 @@ mod tests {
         assert_eq!(markers.len() % 240, 0);
     }
 
+    /// The Attribute node's three operations over a sphere: Create tags every
+    /// vertex, Modify combines into existing tags (and reaches the Pos/Col
+    /// built-ins), Delete removes them, a Group name restricts the edit to
+    /// tagged vertices, and a bad Value passes the geometry through with an
+    /// error instead of eating it.
+    #[test]
+    fn test_attribute_node_operations() {
+        let templates_root = crate::app::load_fs_tree();
+        let find = |name: &str| {
+            templates_root
+                .children
+                .iter()
+                .find(|t| t.name == name)
+                .unwrap_or_else(|| panic!("{name} template should be loaded"))
+        };
+        let instance = |template: &FsNode, id: &str, name: &str, params: &[(&str, &str)]| {
+            let mut inst = template.clone();
+            inst.id = id.to_string();
+            inst.name = name.to_string();
+            for child in &mut inst.children {
+                child.id = format!("{}_{}", inst.id, child.name);
+            }
+            for (pname, val) in params {
+                inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default =
+                    val.to_string();
+            }
+            inst
+        };
+        let root_with = |children: Vec<FsNode>| FsNode {
+            id: "root".to_string(),
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children,
+            params: vec![],
+            geometry_visible: true,
+            position: (0.0, 0.0),
+            inputs: 0,
+            outputs: 0,
+        };
+        let eval = |root: &FsNode, idx: usize| -> (Option<Geometry>, Option<String>) {
+            let mut visited = Vec::new();
+            let mut ocl_err = None;
+            let geom = crate::geometry::generate_single_node_geometry_with_errors(
+                root,
+                &root.children[idx],
+                &mut visited,
+                &mut ocl_err,
+                &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
+            );
+            (geom, ocl_err)
+        };
+
+        let sphere_t = find("Sphere");
+        let attr_t = find("Attribute");
+        let group_t = find("Group");
+
+        // Baseline sphere, for the Col comparison below.
+        let base_root = root_with(vec![instance(sphere_t, "s", "Sphere 1", &[])]);
+        let (base, err) = eval(&base_root, 0);
+        let base = base.expect("baseline sphere");
+        assert!(err.is_none());
+
+        // Create → Modify (multiply) → Delete, as a three-node chain.
+        let root = root_with(vec![
+            instance(sphere_t, "s", "Sphere 1", &[]),
+            instance(attr_t, "a1", "Attr 1", &[
+                ("Input", "Sphere 1"),
+                ("Operation", "Create"),
+                ("Attribute Name", "mass"),
+                ("Type", "Float"),
+                ("Value", "2.50"),
+            ]),
+            instance(attr_t, "a2", "Attr 2", &[
+                ("Input", "Attr 1"),
+                ("Operation", "Modify"),
+                ("Attribute Name", "mass"),
+                ("Combine", "Multiply"),
+                ("Value", "2.00"),
+            ]),
+            instance(attr_t, "a3", "Attr 3", &[
+                ("Input", "Attr 2"),
+                ("Operation", "Delete"),
+                ("Attribute Name", "mass"),
+            ]),
+        ]);
+        let (geom, err) = eval(&root, 1);
+        let geom = geom.expect("Create");
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(geom.vertices.len(), base.vertices.len());
+        assert!(geom.vertices.iter().all(|v| matches!(
+            v.attributes.get("mass"),
+            Some(GAttribute::Float(x)) if (x - 2.5).abs() < 1e-6
+        )));
+        let (geom, err) = eval(&root, 2);
+        let geom = geom.expect("Modify");
+        assert!(err.is_none(), "{err:?}");
+        assert!(geom.vertices.iter().all(|v| matches!(
+            v.attributes.get("mass"),
+            Some(GAttribute::Float(x)) if (x - 5.0).abs() < 1e-6
+        )));
+        let (geom, err) = eval(&root, 3);
+        let geom = geom.expect("Delete");
+        assert!(err.is_none(), "{err:?}");
+        assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
+
+        // Modify the Col built-in: multiply by a broadcast 0.5 halves every
+        // channel relative to the baseline.
+        let root = root_with(vec![
+            instance(sphere_t, "s", "Sphere 1", &[]),
+            instance(attr_t, "a1", "Tint", &[
+                ("Input", "Sphere 1"),
+                ("Operation", "Modify"),
+                ("Attribute Name", "Col"),
+                ("Combine", "Multiply"),
+                ("Value", "0.50"),
+            ]),
+        ]);
+        let (geom, err) = eval(&root, 1);
+        let geom = geom.expect("Col modify");
+        assert!(err.is_none(), "{err:?}");
+        for (v, b) in geom.vertices.iter().zip(&base.vertices) {
+            for k in 0..3 {
+                assert!((v.col[k] - b.col[k] * 0.5).abs() < 1e-5);
+            }
+        }
+
+        // Modify Pos with Add displaces the geometry upward.
+        let root = root_with(vec![
+            instance(sphere_t, "s", "Sphere 1", &[]),
+            instance(attr_t, "a1", "Lift", &[
+                ("Input", "Sphere 1"),
+                ("Operation", "Modify"),
+                ("Attribute Name", "Pos"),
+                ("Combine", "Add"),
+                ("Value", "0.00:0.10:0.00"),
+            ]),
+        ]);
+        let (geom, err) = eval(&root, 1);
+        let geom = geom.expect("Pos modify");
+        assert!(err.is_none(), "{err:?}");
+        for (v, b) in geom.vertices.iter().zip(&base.vertices) {
+            assert!((v.pos[1] - (b.pos[1] + 0.1)).abs() < 1e-5);
+        }
+
+        // A Group name restricts Create to the tagged vertices.
+        let root = root_with(vec![
+            instance(sphere_t, "s", "Sphere 1", &[]),
+            instance(group_t, "g", "Group 1", &[
+                ("Input", "Sphere 1"),
+                ("Center", "0.00:0.80:0.00"),
+                ("Size", "2.00:0.50:2.00"),
+            ]),
+            instance(attr_t, "a1", "Attr 1", &[
+                ("Input", "Group 1"),
+                ("Operation", "Create"),
+                ("Attribute Name", "mass"),
+                ("Value", "1.00"),
+                ("Group", "group1"),
+            ]),
+        ]);
+        let (geom, err) = eval(&root, 2);
+        let geom = geom.expect("grouped Create");
+        assert!(err.is_none(), "{err:?}");
+        let tagged = geom.vertices.iter().filter(|v| v.attributes.contains_key("mass")).count();
+        let members = geom.vertices.iter().filter(|v| v.attributes.contains_key("group:group1")).count();
+        assert!(tagged > 0 && tagged < geom.vertices.len());
+        assert_eq!(tagged, members, "Create must land exactly on the group");
+
+        // A bad Value surfaces an error and passes the geometry through.
+        let root = root_with(vec![
+            instance(sphere_t, "s", "Sphere 1", &[]),
+            instance(attr_t, "a1", "Attr 1", &[
+                ("Input", "Sphere 1"),
+                ("Operation", "Create"),
+                ("Attribute Name", "mass"),
+                ("Value", "abc"),
+            ]),
+        ]);
+        let (geom, err) = eval(&root, 1);
+        let geom = geom.expect("bad Value still passes geometry through");
+        assert!(err.is_some(), "bad Value must surface an error");
+        assert_eq!(geom.vertices.len(), base.vertices.len());
+        assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
+    }
+
     /// The Plane template mirrors the Sphere subnet (an opencl node feeding an
     /// output node); its kernel generates a divs x divs grid on XZ at y = 0,
     /// with the Size param as the side length.