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