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

commit8c69329d3b2290a064d66b589d3755d35ba1c7fa
parent8453875305
authorLucas Galante <[email protected]>
date2026-09-01 20:54
feat: Curve node — cubic Bézier strip from four control points

A new subnet template (nodes/curve.json, opencl -> output like Sphere/Box):
the kernel samples a cubic Bézier through the Point 1-4 params and emits
each of the Segments spans as an oriented 36-vertex box strip, colored by
tangent. Covered by the CPU template test, the subnet evaluation test, and
the CPU/OpenCL cross-validation list.

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

 nodes/curve.json  | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/kernel_cpu.rs |  22 +++++++++++
 src/main.rs       |  89 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 222 insertions(+)

diff --git a/nodes/curve.json b/nodes/curve.json
new file mode 100644
index 0000000..60a469e
--- /dev/null
+++ b/nodes/curve.json
@@ -0,0 +1,111 @@
+{
+ "name": "Curve",
+ "type": "node",
+ "inputs": 0,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Point 1 X",
+   "default": "-0.75",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 1 Y",
+   "default": "0.05",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 1 Z",
+   "default": "0.0",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 2 X",
+   "default": "-0.25",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 2 Y",
+   "default": "1.05",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 2 Z",
+   "default": "0.0",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 3 X",
+   "default": "0.25",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 3 Y",
+   "default": "0.05",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 3 Z",
+   "default": "0.0",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 4 X",
+   "default": "0.75",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 4 Y",
+   "default": "1.05",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Point 4 Z",
+   "default": "0.0",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Segments",
+   "default": "24",
+   "type": "spinbox",
+   "min": 1,
+   "max": 256,
+   "step": 1
+  },
+  {
+   "name": "Thickness",
+   "default": "0.02",
+   "type": "slider"
+  }
+ ],
+ "children": [
+  {
+   "name": "opencl1",
+   "type": "opencl",
+   "params": [
+    {
+     "name": "Code",
+     "default": "void add_seg(float x0, float y0, float z0, float x1, float y1, float z1, float thickness, __global float* out_pos, __global float* out_col, int* count, int max_vertices) {\n    float dx = x1 - x0;\n    float dy = y1 - y0;\n    float dz = z1 - z0;\n    float len = sqrt(dx*dx + dy*dy + dz*dz);\n    if (len < 0.00001f) { return; }\n    dx /= len;\n    dy /= len;\n    dz /= len;\n    float upx = 1.0f;\n    float upy = 0.0f;\n    float upz = 0.0f;\n    if (fabs(dx) > 0.9f) {\n        upx = 0.0f;\n        upy = 1.0f;\n    }\n    float ux = dy * upz - dz * upy;\n    float uy = dz * upx - dx * upz;\n    float uz = dx * upy - dy * upx;\n    float ulen = sqrt(ux*ux + uy*uy + uz*uz);\n    if (ulen < 0.00001f) { return; }\n    ux /= ulen;\n    uy /= ulen;\n    uz /= ulen;\n    float vx = dy * uz - dz * uy;\n    float vy = dz * ux - dx * uz;\n    float vz = dx * uy - dy * ux;\n    float h = thickness * 0.5f;\n    float su[4] = {-1.0f, 1.0f, 1.0f, -1.0f};\n    float sv[4] = {-1.0f, -1.0f, 1.0f, 1.0f};\n    float cx[8];\n    float cy[8];\n    float cz[8];\n    for (int i = 0; i < 4; i++) {\n        float ox = h * (su[i] * ux + sv[i] * vx);\n        float oy = h * (su[i] * uy + sv[i] * vy);\n        float oz = h * (su[i] * uz + sv[i] * vz);\n        cx[i] = x0 + ox;\n        cy[i] = y0 + oy;\n        cz[i] = z0 + oz;\n        cx[i + 4] = x1 + ox;\n        cy[i + 4] = y1 + oy;\n        cz[i + 4] = z1 + oz;\n    }\n    int tri[36] = {\n        0, 2, 1, 0, 3, 2,\n        4, 5, 6, 4, 6, 7,\n        0, 1, 5, 0, 5, 4,\n        1, 2, 6, 1, 6, 5,\n        2, 3, 7, 2, 7, 6,\n        3, 0, 4, 3, 4, 7\n    };\n    float r = 0.5f + dx * 0.5f;\n    float g = 0.5f + dy * 0.5f;\n    float b = 0.5f + dz * 0.5f;\n    int start = *count;\n    for (int i = 0; i < 36; i++) {\n        int idx = start + i;\n        if (idx < max_vertices) {\n            int c = tri[i];\n            out_pos[idx * 3 + 0] = cx[c];\n            out_pos[idx * 3 + 1] = cy[c];\n            out_pos[idx * 3 + 2] = cz[c];\n            out_col[idx * 3 + 0] = r;\n            out_col[idx * 3 + 1] = g;\n            out_col[idx * 3 + 2] = b;\n        }\n    }\n    *count = start + 36;\n}\n\n__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 p0x = chf(\"Point 1 X\", -0.75f);\n        float p0y = chf(\"Point 1 Y\", 0.05f);\n        float p0z = chf(\"Point 1 Z\", 0.0f);\n        float p1x = chf(\"Point 2 X\", -0.25f);\n        float p1y = chf(\"Point 2 Y\", 1.05f);\n        float p1z = chf(\"Point 2 Z\", 0.0f);\n        float p2x = chf(\"Point 3 X\", 0.25f);\n        float p2y = chf(\"Point 3 Y\", 0.05f);\n        float p2z = chf(\"Point 3 Z\", 0.0f);\n        float p3x = chf(\"Point 4 X\", 0.75f);\n        float p3y = chf(\"Point 4 Y\", 1.05f);\n        float p3z = chf(\"Point 4 Z\", 0.0f);\n        int segments = chi(\"Segments\", 24);\n        if (segments < 1) { segments = 1; }\n        if (segments > 256) { segments = 256; }\n        float thickness = chf(\"Thickness\", 0.02f);\n        int count = 0;\n        float prev_x = p0x;\n        float prev_y = p0y;\n        float prev_z = p0z;\n        for (int i = 0; i < segments; i++) {\n            float t = (float)(i + 1) / (float)segments;\n            float s = 1.0f - t;\n            float b0 = s * s * s;\n            float b1 = 3.0f * s * s * t;\n            float b2 = 3.0f * s * t * t;\n            float b3 = t * t * t;\n            float x = b0 * p0x + b1 * p1x + b2 * p2x + b3 * p3x;\n            float y = b0 * p0y + b1 * p1y + b2 * p2y + b3 * p3y;\n            float z = b0 * p0z + b1 * p1z + b2 * p2z + b3 * p3z;\n            add_seg(prev_x, prev_y, prev_z, x, y, z, thickness, out_pos, out_col, &count, max_vertices);\n            prev_x = x;\n            prev_y = y;\n            prev_z = z;\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/kernel_cpu.rs b/src/kernel_cpu.rs
index a4f3bb7..64642b8 100644
--- a/src/kernel_cpu.rs
+++ b/src/kernel_cpu.rs
@@ -1644,6 +1644,27 @@ mod template_tests {
         }
     }
 
+    #[test]
+    fn cpu_runs_the_curve_template() {
+        let (code, params) = template_kernel("Curve");
+        let mut g = Geometry::new();
+        run_kernel_cpu(&code, &mut g, &params).expect("curve kernel");
+        // 24 segments, each a 36-vertex box: the default Bézier degenerates
+        // nowhere, so every segment emits.
+        assert_eq!(g.vertices.len(), 864);
+        for v in &g.vertices {
+            assert!(v.pos.iter().all(|c| c.is_finite()), "curve produced non-finite positions");
+        }
+        // The strip starts at Point 1 and ends at Point 4 (within a half
+        // thickness of the sampled centerline).
+        let near = |v: &GVertex, p: [f32; 3]| {
+            let d = (0..3).map(|k| (v.pos[k] - p[k]).powi(2)).sum::<f32>().sqrt();
+            d < 0.1
+        };
+        assert!(g.vertices.iter().any(|v| near(v, [-0.75, 0.05, 0.0])), "curve does not reach Point 1");
+        assert!(g.vertices.iter().any(|v| near(v, [0.75, 1.05, 0.0])), "curve does not reach Point 4");
+    }
+
     /// The reference test proper: byte-level agreement with OpenCL on every
     /// shipped kernel. Skips silently where no platform exists — the absolute
     /// tests above still cover the CPU side there.
@@ -1654,6 +1675,7 @@ mod template_tests {
             ("Plane", Geometry::new()),
             ("Box", Geometry::new()),
             ("Extrude", triangle()),
+            ("Curve", Geometry::new()),
         ] {
             let (code, params) = template_kernel(name);
 
diff --git a/src/main.rs b/src/main.rs
index 25425a2..0dd80ee 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1218,6 +1218,95 @@ mod tests {
         assert!((max_dist_2 - 1.0).abs() < 0.01, "Expected radius around 1.0, got {}", max_dist_2);
     }
 
+    /// The Curve template: a subnet (opencl -> output) whose kernel samples a
+    /// cubic Bézier through the four control-point params and emits each of
+    /// the "Segments" spans as a 36-vertex oriented box strip.
+    #[test]
+    fn test_curve_subnet_geometry_generation() {
+        let templates_root = crate::app::load_fs_tree();
+        let curve_template = templates_root
+            .children
+            .iter()
+            .find(|t| t.name == "Curve")
+            .expect("Curve template should be loaded");
+
+        assert_eq!(curve_template.children.len(), 2);
+        let opencl1 = curve_template.children.iter().find(|c| c.name == "opencl1").unwrap();
+        assert_eq!(opencl1.node_type, "opencl");
+        let output1 = curve_template.children.iter().find(|c| c.name == "output1").unwrap();
+        assert_eq!(output1.node_type, "output");
+
+        let mut curve_instance = curve_template.clone();
+        curve_instance.id = "curve_inst".to_string();
+        for child in &mut curve_instance.children {
+            child.id = format!("{}_{}", curve_instance.id, child.name);
+        }
+
+        let root = FsNode {
+            id: "root".to_string(),
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![curve_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[0],
+            &mut visited,
+            &mut ocl_err,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
+        ).expect("Geometry generation failed");
+
+        assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
+        // 24 default segments x 36 vertices per segment box.
+        assert_eq!(geom.vertices.len(), 864);
+        for v in &geom.vertices {
+            assert!(v.pos.iter().all(|c| c.is_finite()), "curve produced non-finite positions");
+        }
+
+        // Halving Segments halves the strip.
+        let mut curve_instance_2 = curve_template.clone();
+        curve_instance_2.id = "curve_inst_2".to_string();
+        for child in &mut curve_instance_2.children {
+            child.id = format!("{}_{}", curve_instance_2.id, child.name);
+        }
+        if let Some(seg_param) = curve_instance_2.params.iter_mut().find(|p| p.name == "Segments") {
+            seg_param.default = "12".to_string();
+        }
+
+        let root_2 = FsNode {
+            id: "root".to_string(),
+            name: "root".to_string(),
+            node_type: "node".to_string(),
+            children: vec![curve_instance_2],
+            params: vec![],
+            geometry_visible: true,
+            position: (0.0, 0.0),
+            inputs: 0,
+            outputs: 0,
+        };
+
+        let mut visited_2 = Vec::new();
+        let mut ocl_err_2 = None;
+        let geom_2 = crate::geometry::generate_single_node_geometry_with_errors(
+            &root_2,
+            &root_2.children[0],
+            &mut visited_2,
+            &mut ocl_err_2,
+            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
+        ).expect("Geometry generation failed");
+
+        assert!(ocl_err_2.is_none(), "OpenCL compilation error: {:?}", ocl_err_2);
+        assert_eq!(geom_2.vertices.len(), 432);
+    }
+
     /// 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) =