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

commitba0e56e12689c03177a736bf7e9c00dddc84c380
parent6694eb677d
authorLucas Galante <[email protected]>
date2026-08-03 15:28
feat: Group node — box-select prims, points, or edges into a named group

A native filter node (transform/scatter family): passes its input
through, tagging elements inside an axis-aligned Center/Size box with a
per-vertex 'group:<name>' Float attribute. Element Type picks the
selection unit over the triangle soup — Points (per vertex), Primitives
(centroid; the triangle tags whole), Edges (both endpoints inside).
Invert flips the selection; Highlight (default on) tints members toward
a warm accent so the group reads in the viewport. Membership rides
GVertex::attributes, so it survives merges and native filters and gives
downstream nodes a group input to consume by name.

Co-Authored-By: Claude Fable 5 <[email protected]>

 nodes/group.json |  47 +++++++++++++++++++++++++
 src/geometry.rs  | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 152 insertions(+)

diff --git a/nodes/group.json b/nodes/group.json
new file mode 100644
index 0000000..45fb932
--- /dev/null
+++ b/nodes/group.json
@@ -0,0 +1,47 @@
+{
+  "name": "Group",
+  "type": "group",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    {
+      "name": "Input",
+      "type": "text",
+      "default": ""
+    },
+    {
+      "name": "Group Name",
+      "type": "text",
+      "default": "group1"
+    },
+    {
+      "name": "Element Type",
+      "type": "choice:Points,Primitives,Edges",
+      "default": "Points"
+    },
+    {
+      "name": "Center",
+      "type": "float3",
+      "default": "0.00:0.00:0.00",
+      "min": -10.0,
+      "max": 10.0
+    },
+    {
+      "name": "Size",
+      "type": "float3",
+      "default": "1.00:1.00:1.00",
+      "min": 0.0,
+      "max": 20.0
+    },
+    {
+      "name": "Invert",
+      "type": "toggle",
+      "default": "false"
+    },
+    {
+      "name": "Highlight",
+      "type": "toggle",
+      "default": "true"
+    }
+  ]
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 4d23b51..b24050c 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -398,6 +398,8 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_transform_geometry_with_errors(root, target, visited, ocl_error)
     } else if target.node_type.eq_ignore_ascii_case("scatter") {
         resolve_scatter_geometry_with_errors(root, target, visited, ocl_error)
+    } else if target.node_type.eq_ignore_ascii_case("group") {
+        resolve_group_geometry_with_errors(root, target, visited, ocl_error)
     } else if target.node_type.eq_ignore_ascii_case("opencl") {
         resolve_opencl_geometry_with_errors(root, target, visited, ocl_error)
     } else if target.node_type.eq_ignore_ascii_case("node") {
@@ -478,6 +480,99 @@ pub fn resolve_scatter_geometry(root: &FsNode, target: &FsNode, visited: &mut Ve
     resolve_scatter_geometry_with_errors(root, target, visited, &mut err)
 }
 
+/// The Group node: pass the input geometry through, tagging the elements
+/// selected by an axis-aligned box (Center/Size) with a per-vertex membership
+/// attribute `group:<name>` = Float(1.0). Element Type picks the selection
+/// unit over the triangle soup — Points (per vertex), Primitives (a
+/// triangle's centroid; all three vertices tag together), Edges (a triangle
+/// edge with both endpoints inside; its two vertices tag). Membership rides
+/// GVertex::attributes so it survives merges and native filters — downstream
+/// nodes consume it by reading the same attribute. Highlight tints members
+/// toward a warm accent so the group reads in the viewport.
+pub fn resolve_group_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+) -> Option<Geometry> {
+    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)?;
+
+    let group_name = node_param_str(target, "Group Name", "group1");
+    let attr = format!("group:{}", group_name.trim());
+    let etype = node_param_str(target, "Element Type", "Points").to_lowercase();
+    let center = node_param_vec3(target, "Center", Vec3::ZERO);
+    let half = node_param_vec3(target, "Size", Vec3::ONE) * 0.5;
+    let invert = node_param_str(target, "Invert", "false") == "true";
+    let highlight = node_param_str(target, "Highlight", "true") == "true";
+
+    let inside = |p: &[f32; 3]| -> bool {
+        (p[0] - center.x).abs() <= half.x
+            && (p[1] - center.y).abs() <= half.y
+            && (p[2] - center.z).abs() <= half.z
+    };
+
+    let n = geom.vertices.len();
+    let mut member = vec![false; n];
+    match etype.as_str() {
+        "primitives" => {
+            for tri in 0..n / 3 {
+                let b = tri * 3;
+                let centroid = [
+                    (geom.vertices[b].pos[0] + geom.vertices[b + 1].pos[0] + geom.vertices[b + 2].pos[0]) / 3.0,
+                    (geom.vertices[b].pos[1] + geom.vertices[b + 1].pos[1] + geom.vertices[b + 2].pos[1]) / 3.0,
+                    (geom.vertices[b].pos[2] + geom.vertices[b + 1].pos[2] + geom.vertices[b + 2].pos[2]) / 3.0,
+                ];
+                if inside(&centroid) {
+                    member[b] = true;
+                    member[b + 1] = true;
+                    member[b + 2] = true;
+                }
+            }
+        }
+        "edges" => {
+            for tri in 0..n / 3 {
+                let b = tri * 3;
+                for (a, c) in [(0usize, 1usize), (1, 2), (2, 0)] {
+                    if inside(&geom.vertices[b + a].pos) && inside(&geom.vertices[b + c].pos) {
+                        member[b + a] = true;
+                        member[b + c] = true;
+                    }
+                }
+            }
+        }
+        _ => {
+            for (i, v) in geom.vertices.iter().enumerate() {
+                if inside(&v.pos) {
+                    member[i] = true;
+                }
+            }
+        }
+    }
+    if invert {
+        for m in member.iter_mut() {
+            *m = !*m;
+        }
+    }
+
+    for (i, v) in geom.vertices.iter_mut().enumerate() {
+        if member[i] {
+            v.attributes.insert(attr.clone(), GAttribute::Float(1.0));
+            if highlight {
+                let acc = [1.0, 0.78, 0.20];
+                for k in 0..3 {
+                    v.col[k] = v.col[k] * 0.35 + acc[k] * 0.65;
+                }
+            }
+        }
+    }
+    Some(geom)
+}
+
 struct PrecomputedTriangle {
     v0: Vec3,
     edge1: Vec3,
@@ -1155,6 +1250,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "input"
         || nt == "output"
         || nt == "scatter"
+        || nt == "group"
 }
 
 pub fn network_sphere_vertices(root: &FsNode) -> Geometry {
@@ -1217,6 +1313,15 @@ pub fn network_sphere_vertices_with_errors(root: &FsNode, ocl_error: &mut Option
                     out.merge(geom);
                 }
             }
+        } else if node.node_type.eq_ignore_ascii_case("group") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_group_geometry_with_errors(root, node, &mut visited, ocl_error) {
+                    out.merge(geom);
+                }
+            }
         } else if node.node_type.eq_ignore_ascii_case("opencl") {
             let _idx = *count;
             *count += 1;