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

commitd58961eaebb0e9943b29b86dc42d1f047e07163e
parentf919e880a0
authorLucas Galante <[email protected]>
date2026-07-21 12:39
feat: Plane node — grid-geometry subnet template

Mirrors the Sphere subnet (an opencl node feeding an output node): the
kernel generates a 16x16 two-triangle-cell grid on XZ at y = 0, side
length from the Size param (chf), gradient-colored in the sphere's
palette. Triangles wind CCW viewed from +y — the raster scene pipeline
culls back faces (the RT path doesn't), so a backward winding renders
path-traced but vanishes in the viewport.

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

 nodes/plane.json | 43 ++++++++++++++++++++++++++++++++++
 src/main.rs      | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 113 insertions(+)

diff --git a/nodes/plane.json b/nodes/plane.json
new file mode 100644
index 0000000..9abd095
--- /dev/null
+++ b/nodes/plane.json
@@ -0,0 +1,43 @@
+{
+  "name": "Plane",
+  "type": "node",
+  "inputs": 0,
+  "outputs": 1,
+  "params": [
+    {
+      "name": "Size",
+      "default": "1.0",
+      "type": "slider"
+    }
+  ],
+  "children": [
+    {
+      "name": "opencl1",
+      "type": "opencl",
+      "params": [
+        {
+          "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 size = chf(\"Size\", 1.0f);\n        float center_x = 0.0f;\n        float center_y = 0.0f;\n        float center_z = 0.0f;\n        int divs = 16;\n        float half_size = size * 0.5f;\n        float step = size / (float)divs;\n        float inv = size > 0.0f ? 1.0f / size : 0.0f;\n        int count = 0;\n        for (int i = 0; i < divs; i++) {\n            float x0 = -half_size + (float)i * step;\n            float x1 = x0 + step;\n            for (int j = 0; j < divs; j++) {\n                float z0 = -half_size + (float)j * step;\n                float z1 = z0 + step;\n                float px[6] = {x0, x1, x1, x0, x0, x1};\n                float pz[6] = {z0, z1, z0, z0, z1, z1};\n                for (int v = 0; v < 6; v++) {\n                    int idx = count++;\n                    if (idx < max_vertices) {\n                        out_pos[idx * 3 + 0] = center_x + px[v];\n                        out_pos[idx * 3 + 1] = center_y;\n                        out_pos[idx * 3 + 2] = center_z + pz[v];\n                        float fx = (px[v] + half_size) * inv;\n                        float fz = (pz[v] + half_size) * inv;\n                        out_col[idx * 3 + 0] = 0.35f + fx * 0.35f;\n                        out_col[idx * 3 + 1] = 0.45f + fz * 0.35f;\n                        out_col[idx * 3 + 2] = 0.85f;\n                    }\n                }\n            }\n        }\n        *out_count = 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/main.rs b/src/main.rs
index 43e012c..4f7449b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -226,6 +226,76 @@ mod tests {
         assert!((max_dist_2 - 1.0).abs() < 0.01, "Expected radius around 1.0, got {}", max_dist_2);
     }
 
+    /// 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.
+    #[test]
+    fn test_plane_subnet_geometry_generation() {
+        let templates_root = crate::app::load_fs_tree();
+        let plane_template = templates_root
+            .children
+            .iter()
+            .find(|t| t.name == "Plane")
+            .expect("Plane template should be loaded");
+
+        assert_eq!(plane_template.children.len(), 2);
+        let opencl1 = plane_template.children.iter().find(|c| c.name == "opencl1").unwrap();
+        assert_eq!(opencl1.node_type, "opencl");
+        let output1 = plane_template.children.iter().find(|c| c.name == "output1").unwrap();
+        assert_eq!(output1.node_type, "output");
+
+        let generate = |size: Option<&str>, id: &str| {
+            let mut inst = plane_template.clone();
+            inst.id = id.to_string();
+            for child in &mut inst.children {
+                child.id = format!("{}_{}", inst.id, child.name);
+            }
+            if let Some(size) = size {
+                inst.params.iter_mut().find(|p| p.name == "Size").unwrap().default = size.to_string();
+            }
+            let root = FsNode {
+                id: "root".to_string(),
+                name: "root".to_string(),
+                node_type: "node".to_string(),
+                children: vec![inst],
+                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[0],
+                &mut visited,
+                &mut ocl_err,
+            ).expect("Geometry generation failed");
+            assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
+            geom
+        };
+
+        // Default Size 1.0: a 16x16 grid of two-triangle cells, flat at y = 0,
+        // spanning [-0.5, 0.5] on X and Z.
+        let geom = generate(None, "plane_inst");
+        assert_eq!(geom.vertices.len(), 16 * 16 * 6);
+        let mut max_x: f32 = 0.0;
+        let mut max_z: f32 = 0.0;
+        for v in &geom.vertices {
+            assert!(v.pos[1].abs() < 1e-6, "Expected flat plane at y=0, got y={}", v.pos[1]);
+            max_x = max_x.max(v.pos[0].abs());
+            max_z = max_z.max(v.pos[2].abs());
+        }
+        assert!((max_x - 0.5).abs() < 0.01, "Expected half-size 0.5 on X, got {}", max_x);
+        assert!((max_z - 0.5).abs() < 0.01, "Expected half-size 0.5 on Z, got {}", max_z);
+
+        // Size override 2.0 spans [-1, 1].
+        let geom_2 = generate(Some("2.0"), "plane_inst_2");
+        let max_x_2 = geom_2.vertices.iter().map(|v| v.pos[0].abs()).fold(0.0f32, f32::max);
+        assert!((max_x_2 - 1.0).abs() < 0.01, "Expected half-size 1.0 on X, got {}", max_x_2);
+    }
+
     #[test]
     fn test_dynamic_parameters_parsing_and_preprocessing() {
         let code = r#"