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

commite6d4945d93d04acb20a104fb7c18608fce709104
parentc8530c7860
authorLucas Galante <[email protected]>
date2026-08-25 10:14
feat: Sphere construction controls — Rows, Columns, Center

The template kernel's hardcoded tessellation (16x24) and center (0, 0.55, 0)
become channels: Rows/Columns via chi() spinboxes (kernel-clamped to 2..128
and 3..128, so out-of-range MCP writes degrade instead of emitting nothing;
max tessellation stays under the 200k-vertex launch cap), Center as three
chf() sliders — scalar channels rather than one chv(), because the CPU
reference backend deliberately has no float3 and chv would break every
headless eval. Defaults reproduce the historical sphere byte-identically
(the 2304-vertex baselines still pass). Saved instances are self-contained
— they keep their stored kernel and params, so only newly added Sphere
nodes carry the controls.

 nodes/sphere.json | 54 ++++++++++++++++++++++++++++++++++++++++-----
 src/main.rs       | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 115 insertions(+), 5 deletions(-)

diff --git a/nodes/sphere.json b/nodes/sphere.json
index e92aa72..33ab8ae 100644
--- a/nodes/sphere.json
+++ b/nodes/sphere.json
@@ -4,7 +4,42 @@
   "inputs": 0,
   "outputs": 1,
   "params": [
-    { "name": "Radius", "default": "0.5", "type": "slider" }
+    {
+      "name": "Radius",
+      "default": "0.5",
+      "type": "slider"
+    },
+    {
+      "name": "Rows",
+      "default": "16",
+      "type": "spinbox",
+      "min": 2,
+      "max": 128,
+      "step": 1
+    },
+    {
+      "name": "Columns",
+      "default": "24",
+      "type": "spinbox",
+      "min": 3,
+      "max": 128,
+      "step": 1
+    },
+    {
+      "name": "Center X",
+      "default": "0.0",
+      "type": "slider:-2:2"
+    },
+    {
+      "name": "Center Y",
+      "default": "0.55",
+      "type": "slider:-2:2"
+    },
+    {
+      "name": "Center Z",
+      "default": "0.0",
+      "type": "slider:-2:2"
+    }
   ],
   "children": [
     {
@@ -13,18 +48,27 @@
       "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 radius = chf(\"Radius\", 0.5f);\n        float center_x = 0.0f;\n        float center_y = 0.55f;\n        float center_z = 0.0f;\n        int lat_steps = 16;\n        int lon_steps = 24;\n        int count = 0;\n        for (int lat = 0; lat < lat_steps; lat++) {\n            float theta0 = 3.14159265f * (float)lat / (float)lat_steps;\n            float theta1 = 3.14159265f * (float)(lat + 1) / (float)lat_steps;\n            for (int lon = 0; lon < lon_steps; lon++) {\n                float phi0 = 6.2831853f * (float)lon / (float)lon_steps;\n                float phi1 = 6.2831853f * (float)(lon + 1) / (float)lon_steps;\n                float x00 = radius * sin(theta0) * cos(phi0);\n                float y00 = radius * cos(theta0);\n                float z00 = radius * sin(theta0) * sin(phi0);\n                float x10 = radius * sin(theta1) * cos(phi0);\n                float y10 = radius * cos(theta1);\n                float z10 = radius * sin(theta1) * sin(phi0);\n                float x11 = radius * sin(theta1) * cos(phi1);\n                float y11 = radius * cos(theta1);\n                float z11 = radius * sin(theta1) * sin(phi1);\n                float x01 = radius * sin(theta0) * cos(phi1);\n                float y01 = radius * cos(theta0);\n                float z01 = radius * sin(theta0) * sin(phi1);\n                float px[6] = {x00, x10, x11, x00, x11, x01};\n                float py[6] = {y00, y10, y11, y00, y11, y01};\n                float pz[6] = {z00, z10, z11, z00, z11, z01};\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 + py[v];\n                        out_pos[idx * 3 + 2] = center_z + pz[v];\n                        float nx = px[v];\n                        float ny = py[v];\n                        float nz = pz[v];\n                        float len = sqrt(nx*nx + ny*ny + nz*nz);\n                        if (len > 0.0f) {\n                            nx /= len;\n                            ny /= len;\n                            nz /= len;\n                        }\n                        out_col[idx * 3 + 0] = 0.5f + nx * 0.5f;\n                        out_col[idx * 3 + 1] = 0.5f + ny * 0.5f;\n                        out_col[idx * 3 + 2] = 0.5f + nz * 0.5f;\n                    }\n                }\n            }\n        }\n        *out_count = count;\n    }\n}"
+          "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 radius = chf(\"Radius\", 0.5f);\n        float center_x = chf(\"Center X\", 0.0f);\n        float center_y = chf(\"Center Y\", 0.55f);\n        float center_z = chf(\"Center Z\", 0.0f);\n        int lat_steps = chi(\"Rows\", 16);\n        if (lat_steps < 2) { lat_steps = 2; }\n        if (lat_steps > 128) { lat_steps = 128; }\n        int lon_steps = chi(\"Columns\", 24);\n        if (lon_steps < 3) { lon_steps = 3; }\n        if (lon_steps > 128) { lon_steps = 128; }\n        int count = 0;\n        for (int lat = 0; lat < lat_steps; lat++) {\n            float theta0 = 3.14159265f * (float)lat / (float)lat_steps;\n            float theta1 = 3.14159265f * (float)(lat + 1) / (float)lat_steps;\n            for (int lon = 0; lon < lon_steps; lon++) {\n                float phi0 = 6.2831853f * (float)lon / (float)lon_steps;\n                float phi1 = 6.2831853f * (float)(lon + 1) / (float)lon_steps;\n                float x00 = radius * sin(theta0) * cos(phi0);\n                float y00 = radius * cos(theta0);\n                float z00 = radius * sin(theta0) * sin(phi0);\n                float x10 = radius * sin(theta1) * cos(phi0);\n                float y10 = radius * cos(theta1);\n                float z10 = radius * sin(theta1) * sin(phi0);\n                float x11 = radius * sin(theta1) * cos(phi1);\n                float y11 = radius * cos(theta1);\n                float z11 = radius * sin(theta1) * sin(phi1);\n                float x01 = radius * sin(theta0) * cos(phi1);\n                float y01 = radius * cos(theta0);\n                float z01 = radius * sin(theta0) * sin(phi1);\n                float px[6] = {x00, x10, x11, x00, x11, x01};\n                float py[6] = {y00, y10, y11, y00, y11, y01};\n                float pz[6] = {z00, z10, z11, z00, z11, z01};\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 + py[v];\n                        out_pos[idx * 3 + 2] = center_z + pz[v];\n                        float nx = px[v];\n                        float ny = py[v];\n                        float nz = pz[v];\n                        float len = sqrt(nx*nx + ny*ny + nz*nz);\n                        if (len > 0.0f) {\n                            nx /= len;\n                            ny /= len;\n                            nz /= len;\n                        }\n                        out_col[idx * 3 + 0] = 0.5f + nx * 0.5f;\n                        out_col[idx * 3 + 1] = 0.5f + ny * 0.5f;\n                        out_col[idx * 3 + 2] = 0.5f + nz * 0.5f;\n                    }\n                }\n            }\n        }\n        *out_count = count;\n    }\n}"
         }
       ],
-      "position": [4.0, 2.0]
+      "position": [
+        4.0,
+        2.0
+      ]
     },
     {
       "name": "output1",
       "type": "output",
       "params": [
-        { "name": "Input", "default": "opencl1" }
+        {
+          "name": "Input",
+          "default": "opencl1"
+        }
       ],
-      "position": [4.0, 3.0]
+      "position": [
+        4.0,
+        3.0
+      ]
     }
   ]
 }
diff --git a/src/main.rs b/src/main.rs
index 4c96c09..6205b8a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1131,6 +1131,72 @@ mod tests {
         assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
     }
 
+    /// The Sphere template's construction controls: Rows/Columns set the
+    /// lat/lon tessellation (vertex count = rows * columns * 6), Center X/Y/Z
+    /// place the sphere, and the defaults keep the historical 16x24 sphere at
+    /// (0, 0.55, 0) byte-identical (the extrude test's 2304-vertex baseline).
+    #[test]
+    fn test_sphere_construction_controls() {
+        let templates_root = crate::app::load_fs_tree();
+        let sphere_t = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
+        let build = |params: &[(&str, &str)]| {
+            let mut inst = sphere_t.clone();
+            inst.id = "s".to_string();
+            inst.name = "Sphere 1".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();
+            }
+            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 err = None;
+            let mut cache = crate::geometry::SimCache::default();
+            let geom = crate::geometry::generate_single_node_geometry_with_errors(
+                &root,
+                &root.children[0],
+                &mut visited,
+                &mut err,
+                &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
+            ).expect("sphere generation failed");
+            assert!(err.is_none(), "{err:?}");
+            geom
+        };
+
+        // Defaults: the historical 16x24 sphere.
+        assert_eq!(build(&[]).vertices.len(), 16 * 24 * 6);
+
+        // A coarse 4x6 tessellation.
+        let coarse = build(&[("Rows", "4"), ("Columns", "6")]);
+        assert_eq!(coarse.vertices.len(), 4 * 6 * 6);
+
+        // Center X shifts the whole sphere: default spans x in [-0.5, 0.5],
+        // shifted spans [0.5, 1.5].
+        let shifted = build(&[("Center X", "1.0")]);
+        let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
+        for v in &shifted.vertices {
+            min_x = min_x.min(v.pos[0]);
+            max_x = max_x.max(v.pos[0]);
+        }
+        assert!((min_x - 0.5).abs() < 0.01, "min x {min_x}");
+        assert!((max_x - 1.5).abs() < 0.01, "max x {max_x}");
+
+        // Degenerate resolutions clamp instead of emitting nothing.
+        assert_eq!(build(&[("Rows", "0"), ("Columns", "0")]).vertices.len(), 2 * 3 * 6);
+    }
+
     /// A Scatter consumed downstream must still evaluate: the dispatch pushes
     /// the target id before dispatching, so a resolver-local visited guard
     /// sees it and refuses every dispatched call — scatter geometry silently