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

commit718fa4e3a068dd19708bfa4aadfcfdc6e680000e
parentdebc206aa3
authorLucas Galante <[email protected]>
date2026-07-22 15:41
feat: Extrude node

A new subnet template (input -> opencl -> output): the kernel offsets
each input triangle along its face normal by Distance and stitches
side walls, with a Keep Base toggle for the original face (reversed
winding). Wound for the raster pass's CCW-front back-face culling —
the templates' triangles read clockwise from outside, so the normal is
the negated cross.

Two engine fixes it flushed out:
- The geometry cycle guard keyed on node NAME, so a subnet consuming
  another subnet's geometry died at the second "output1" — now by id,
  like the wire-walk guard (test fixtures gain unique ids).
- resolve_opencl_geometry resolved its Input globally by name; two
  subnet instances both containing "input1" would wire to the first.
  Siblings now win, exactly like the output type's lookup.

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

 nodes/extrude.json | 69 +++++++++++++++++++++++++++++++++++++++++++++
 src/geometry.rs    | 43 +++++++++++++++++-----------
 src/main.rs        | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 179 insertions(+), 16 deletions(-)

diff --git a/nodes/extrude.json b/nodes/extrude.json
new file mode 100644
index 0000000..0fbfae5
--- /dev/null
+++ b/nodes/extrude.json
@@ -0,0 +1,69 @@
+{
+  "name": "Extrude",
+  "type": "node",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    {
+      "name": "Input",
+      "default": "",
+      "type": "text"
+    },
+    {
+      "name": "Distance",
+      "default": "0.2",
+      "type": "slider",
+      "min": -1.0,
+      "max": 1.0,
+      "step": 0.01
+    },
+    {
+      "name": "Keep Base",
+      "default": "true",
+      "type": "toggle"
+    }
+  ],
+  "children": [
+    {
+      "name": "input1",
+      "type": "input",
+      "params": [],
+      "position": [
+        4.0,
+        1.0
+      ]
+    },
+    {
+      "name": "opencl1",
+      "type": "opencl",
+      "params": [
+        {
+          "name": "Input",
+          "default": "input1"
+        },
+        {
+          "name": "Code",
+          "default": "__kernel void process(__global const float* in_pos, __global const float* in_col, int in_count, __global float* out_pos, __global float* out_col, __global int* out_count, int max_vertices) {\n    int id = get_global_id(0);\n    if (id == 0) {\n        float dist = chf(\"Distance\", 0.2f);\n        int keep_base = chb(\"Keep Base\", true) > 0.5f ? 1 : 0;\n        int tri_count = in_count / 3;\n        int count = 0;\n        for (int t = 0; t < tri_count; t++) {\n            int i0 = (t * 3 + 0) * 3;\n            int i1 = (t * 3 + 1) * 3;\n            int i2 = (t * 3 + 2) * 3;\n            float px[3] = {in_pos[i0], in_pos[i1], in_pos[i2]};\n            float py[3] = {in_pos[i0 + 1], in_pos[i1 + 1], in_pos[i2 + 1]};\n            float pz[3] = {in_pos[i0 + 2], in_pos[i1 + 2], in_pos[i2 + 2]};\n            float cr[3] = {in_col[i0], in_col[i1], in_col[i2]};\n            float cg[3] = {in_col[i0 + 1], in_col[i1 + 1], in_col[i2 + 1]};\n            float cb[3] = {in_col[i0 + 2], in_col[i1 + 2], in_col[i2 + 2]};\n            // Face normal from the winding (CCW front): extrusion direction.\n            float ux = px[1] - px[0], uy = py[1] - py[0], uz = pz[1] - pz[0];\n            float vx = px[2] - px[0], vy = py[2] - py[0], vz = pz[2] - pz[0];\n            // Negated cross: the templates' triangles wind clockwise seen\n            // from outside under this renderer's convention, so the plain\n            // cross(B-A, C-A) points inward.\n            float nx = uz * vy - uy * vz;\n            float ny = ux * vz - uz * vx;\n            float nz = uy * vx - ux * vy;\n            float len = sqrt(nx * nx + ny * ny + nz * nz);\n            if (len > 1e-8f) { nx /= len; ny /= len; nz /= len; }\n            float ox = nx * dist, oy = ny * dist, oz = nz * dist;\n            // Top face: the input triangle offset along its normal, same winding.\n            for (int v = 0; v < 3; v++) {\n                int idx = count++;\n                if (idx < max_vertices) {\n                    out_pos[idx * 3 + 0] = px[v] + ox;\n                    out_pos[idx * 3 + 1] = py[v] + oy;\n                    out_pos[idx * 3 + 2] = pz[v] + oz;\n                    out_col[idx * 3 + 0] = cr[v];\n                    out_col[idx * 3 + 1] = cg[v];\n                    out_col[idx * 3 + 2] = cb[v];\n                }\n            }\n            // Side walls: one quad per edge, wound so the outside faces out\n            // (CCW front) for a CCW input triangle and positive distance.\n            for (int e = 0; e < 3; e++) {\n                int s0 = e;\n                int s1 = (e + 1) % 3;\n                float wx[6] = {px[s0], px[s1], px[s1] + ox, px[s0], px[s1] + ox, px[s0] + ox};\n                float wy[6] = {py[s0], py[s1], py[s1] + oy, py[s0], py[s1] + oy, py[s0] + oy};\n                float wz[6] = {pz[s0], pz[s1], pz[s1] + oz, pz[s0], pz[s1] + oz, pz[s0] + oz};\n                int wc[6] = {s0, s1, s1, s0, s1, s0};\n                for (int v = 0; v < 6; v++) {\n                    int idx = count++;\n                    if (idx < max_vertices) {\n                        out_pos[idx * 3 + 0] = wx[v];\n                        out_pos[idx * 3 + 1] = wy[v];\n                        out_pos[idx * 3 + 2] = wz[v];\n                        out_col[idx * 3 + 0] = cr[wc[v]] * 0.85f;\n                        out_col[idx * 3 + 1] = cg[wc[v]] * 0.85f;\n                        out_col[idx * 3 + 2] = cb[wc[v]] * 0.85f;\n                    }\n                }\n            }\n            // Base: the original triangle, winding reversed so it faces away\n            // from the extrusion.\n            if (keep_base) {\n                int ord[3] = {0, 2, 1};\n                for (int v = 0; v < 3; v++) {\n                    int s = ord[v];\n                    int idx = count++;\n                    if (idx < max_vertices) {\n                        out_pos[idx * 3 + 0] = px[s];\n                        out_pos[idx * 3 + 1] = py[s];\n                        out_pos[idx * 3 + 2] = pz[s];\n                        out_col[idx * 3 + 0] = cr[s];\n                        out_col[idx * 3 + 1] = cg[s];\n                        out_col[idx * 3 + 2] = cb[s];\n                    }\n                }\n            }\n        }\n        *out_count = count > max_vertices ? max_vertices : count;\n    }\n}"
+        }
+      ],
+      "position": [
+        4.0,
+        2.0
+      ]
+    },
+    {
+      "name": "output1",
+      "type": "output",
+      "params": [
+        {
+          "name": "Input",
+          "default": "opencl1"
+        }
+      ],
+      "position": [
+        4.0,
+        3.0
+      ]
+    }
+  ]
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 074f1d5..62a50fb 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -357,10 +357,15 @@ pub fn generate_single_node_geometry_with_errors(
     visited: &mut Vec<String>,
     ocl_error: &mut Option<String>,
 ) -> Option<Geometry> {
-    if visited.contains(&target.name) {
+    // Cycle guard by ID, not name: subnet instances share child names
+    // ("output1", "opencl1"), so a name guard falsely blocks a subnet that
+    // consumes another subnet's geometry (Extrude eating a Sphere never
+    // reaches the sphere's own output1). The wire-walk guard below (line
+    // ~490) already keys on id.
+    if visited.contains(&target.id) {
         return None;
     }
-    visited.push(target.name.clone());
+    visited.push(target.id.clone());
 
     let res = if target.node_type.eq_ignore_ascii_case("sphere") {
         let idx = find_sphere_index(root, target)?;
@@ -807,7 +812,13 @@ pub fn resolve_opencl_geometry_with_errors(
 ) -> Option<Geometry> {
     let input_name = node_param_str(target, "Input", "");
     let mut geom = if !input_name.is_empty() {
-        if let Some(input_node) = find_node_by_name(root, &input_name) {
+        // Siblings first, exactly like the output type's lookup: subnet
+        // templates (Extrude) wire their inner opencl to a child named
+        // "input1", and a global-first search would resolve to the FIRST
+        // subnet's child once two instances exist.
+        let sibling = find_parent_node(root, &target.id)
+            .and_then(|p| p.children.iter().find(|c| c.name == input_name || c.id == input_name));
+        if let Some(input_node) = sibling.or_else(|| find_node_by_name(root, &input_name)) {
             generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error).unwrap_or_default()
         } else {
             Geometry::default()
@@ -1561,7 +1572,7 @@ mod tests {
     #[test]
     fn test_add_node_points() {
         let add_node = FsNode {
-            id: String::new(),
+            id: "id Add points test".to_string(),
             inputs: 1,
             outputs: 1,
             name: "Add points test".to_string(),
@@ -1583,7 +1594,7 @@ mod tests {
             position: (0.0, 0.0),
         };
         let root = FsNode {
-            id: String::new(),
+            id: "id root".to_string(),
             inputs: 1,
             outputs: 1,
             name: "root".to_string(),
@@ -1600,7 +1611,7 @@ mod tests {
     #[test]
     fn test_transform_node() {
         let sphere = FsNode {
-            id: String::new(),
+            id: "id Sphere 1".to_string(),
             inputs: 1,
             outputs: 1,
             name: "Sphere 1".to_string(),
@@ -1622,7 +1633,7 @@ mod tests {
             position: (0.0, 0.0),
         };
         let transform1 = FsNode {
-            id: String::new(),
+            id: "id Transform 1".to_string(),
             inputs: 1,
             outputs: 1,
             name: "Transform 1".to_string(),
@@ -1654,7 +1665,7 @@ mod tests {
             position: (0.0, 0.0),
         };
         let root = FsNode {
-            id: String::new(),
+            id: "id root".to_string(),
             inputs: 1,
             outputs: 1,
             name: "root".to_string(),
@@ -1678,7 +1689,7 @@ mod tests {
 
         // Test chained transform
         let transform2 = FsNode {
-            id: String::new(),
+            id: "id Transform 2".to_string(),
             inputs: 1,
             outputs: 1,
             name: "Transform 2".to_string(),
@@ -1710,7 +1721,7 @@ mod tests {
             position: (0.0, 0.0),
         };
         let root_chained = FsNode {
-            id: String::new(),
+            id: "id root".to_string(),
             inputs: 1,
             outputs: 1,
             name: "root".to_string(),
@@ -1731,7 +1742,7 @@ mod tests {
 
         // Test loop detection
         let transform_loop = FsNode {
-            id: String::new(),
+            id: "id Transform Loop".to_string(),
             inputs: 1,
             outputs: 1,
             name: "Transform Loop".to_string(),
@@ -1763,7 +1774,7 @@ mod tests {
             position: (0.0, 0.0),
         };
         let root_loop = FsNode {
-            id: String::new(),
+            id: "id root".to_string(),
             inputs: 1,
             outputs: 1,
             name: "root".to_string(),
@@ -1786,7 +1797,7 @@ mod tests {
         }
 
         let sphere = FsNode {
-            id: String::new(),
+            id: "id Sphere 1".to_string(),
             inputs: 1,
             outputs: 1,
             name: "Sphere 1".to_string(),
@@ -1809,7 +1820,7 @@ mod tests {
         };
 
         let opencl_node = FsNode {
-            id: String::new(),
+            id: "id OpenCL 1".to_string(),
             inputs: 1,
             outputs: 1,
             name: "OpenCL 1".to_string(),
@@ -1849,7 +1860,7 @@ mod tests {
         };
 
         let root = FsNode {
-            id: String::new(),
+            id: "id root".to_string(),
             inputs: 1,
             outputs: 1,
             name: "root".to_string(),
@@ -1940,7 +1951,7 @@ mod tests {
         };
 
         let root = FsNode {
-            id: String::new(),
+            id: "id root".to_string(),
             inputs: 1,
             outputs: 1,
             name: "root".to_string(),
diff --git a/src/main.rs b/src/main.rs
index fe24536..afdaae6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -226,6 +226,89 @@ mod tests {
         assert!((max_dist_2 - 1.0).abs() < 0.01, "Expected radius around 1.0, got {}", max_dist_2);
     }
 
+    /// The Extrude template: a subnet (input -> opencl -> output) whose kernel
+    /// offsets each input triangle along its face normal and stitches side
+    /// walls. Per input triangle it emits top (3) + walls (18) + base (3) =
+    /// 24 vertices, or 21 with Keep Base off.
+    #[test]
+    fn test_extrude_subnet_geometry_generation() {
+        let templates_root = crate::app::load_fs_tree();
+        let sphere_template = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
+        let extrude_template = templates_root
+            .children
+            .iter()
+            .find(|t| t.name == "Extrude")
+            .expect("Extrude template should be loaded");
+        assert_eq!(extrude_template.children.len(), 3);
+        assert_eq!(extrude_template.inputs, 1);
+
+        let mut sphere_instance = sphere_template.clone();
+        sphere_instance.id = "sphere_inst".to_string();
+        sphere_instance.name = "Sphere 1".to_string();
+        for child in &mut sphere_instance.children {
+            child.id = format!("{}_{}", sphere_instance.id, child.name);
+        }
+
+        let mut extrude_instance = extrude_template.clone();
+        extrude_instance.id = "extrude_inst".to_string();
+        extrude_instance.name = "Extrude 1".to_string();
+        for child in &mut extrude_instance.children {
+            child.id = format!("{}_{}", extrude_instance.id, child.name);
+        }
+        extrude_instance.params.iter_mut().find(|p| p.name == "Input").unwrap().default =
+            "Sphere 1".to_string();
+
+        let root = FsNode {
+            id: "root".to_string(),
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![sphere_instance, extrude_instance],
+            params: vec![],
+            geometry_visible: true,
+            position: (0.0, 0.0),
+            inputs: 0,
+            outputs: 0,
+        };
+
+        let mut visited = Vec::new();
+        let mut ocl_err = None;
+        let geom = crate::geometry::generate_single_node_geometry_with_errors(
+            &root,
+            &root.children[1],
+            &mut visited,
+            &mut ocl_err,
+        ).expect("Extrude geometry generation failed");
+        assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
+        // 2304 sphere vertices = 768 triangles; 768 * 24 = 18432.
+        assert_eq!(geom.vertices.len(), 18432);
+
+        // Extruding a radius-0.5 sphere outward by the default 0.2 pushes the
+        // farthest vertices to ~0.7 from its center.
+        let mut max_dist: f32 = 0.0;
+        for v in &geom.vertices {
+            let dx = v.pos[0];
+            let dy = v.pos[1] - 0.55;
+            let dz = v.pos[2];
+            max_dist = max_dist.max((dx * dx + dy * dy + dz * dz).sqrt());
+        }
+        assert!((max_dist - 0.7).abs() < 0.02, "Expected max extent ~0.7, got {}", max_dist);
+
+        // Keep Base off drops the 3 base vertices per triangle: 768 * 21.
+        let mut root2 = root.clone();
+        root2.children[1].params.iter_mut().find(|p| p.name == "Keep Base").unwrap().default =
+            "false".to_string();
+        let mut visited2 = Vec::new();
+        let mut ocl_err2 = None;
+        let geom2 = crate::geometry::generate_single_node_geometry_with_errors(
+            &root2,
+            &root2.children[1],
+            &mut visited2,
+            &mut ocl_err2,
+        ).expect("Extrude geometry generation failed (no base)");
+        assert!(ocl_err2.is_none(), "OpenCL compilation error: {:?}", ocl_err2);
+        assert_eq!(geom2.vertices.len(), 16128);
+    }
+
     /// 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.