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

commitdcfc28f7b7348f2efda037712085ec80ffa70e04
parent1ae6c179cb
authorLucas Galante <[email protected]>
date2026-08-25 10:25
feat: Plane Center controls + resolution-control coverage

The Plane already had Rows/Columns resolution spinboxes (the precedent the
Sphere's controls copied); what it lacked for parity was placement — the
kernel hardcoded center (0, 0, 0). Center X/Y/Z become chf() channels like
the Sphere's (scalar, CPU-backend-safe). The new test also covers the
long-standing Rows/Columns tessellation math, which had no coverage.
Existing saved planes gain the controls on load via the template merge.

 nodes/plane.json | 17 ++++++++++++++-
 src/main.rs      | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 80 insertions(+), 1 deletion(-)

diff --git a/nodes/plane.json b/nodes/plane.json
index f5d068c..6c5d35f 100644
--- a/nodes/plane.json
+++ b/nodes/plane.json
@@ -29,6 +29,21 @@
       "min": 1,
       "max": 128,
       "step": 1
+    },
+    {
+      "name": "Center X",
+      "default": "0.0",
+      "type": "slider:-2:2"
+    },
+    {
+      "name": "Center Y",
+      "default": "0.0",
+      "type": "slider:-2:2"
+    },
+    {
+      "name": "Center Z",
+      "default": "0.0",
+      "type": "slider:-2:2"
     }
   ],
   "children": [
@@ -38,7 +53,7 @@
       "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_x = chf(\"Width\", 1.0f);\n        float size_z = chf(\"Length\", 1.0f);\n        int cols = chi(\"Columns\", 16);\n        int rows = chi(\"Rows\", 16);\n        if (cols < 1) cols = 1;\n        if (cols > 128) cols = 128;\n        if (rows < 1) rows = 1;\n        if (rows > 128) rows = 128;\n        float center_x = 0.0f;\n        float center_y = 0.0f;\n        float center_z = 0.0f;\n        float half_x = size_x * 0.5f;\n        float half_z = size_z * 0.5f;\n        float step_x = size_x / (float)cols;\n        float step_z = size_z / (float)rows;\n        float inv_x = size_x > 0.0f ? 1.0f / size_x : 0.0f;\n        float inv_z = size_z > 0.0f ? 1.0f / size_z : 0.0f;\n        int count = 0;\n        for (int i = 0; i < cols; i++) {\n            float x0 = -half_x + (float)i * step_x;\n            float x1 = x0 + step_x;\n            for (int j = 0; j < rows; j++) {\n                float z0 = -half_z + (float)j * step_z;\n                float z1 = z0 + step_z;\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_x) * inv_x;\n                        float fz = (pz[v] + half_z) * inv_z;\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}"
+          "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_x = chf(\"Width\", 1.0f);\n        float size_z = chf(\"Length\", 1.0f);\n        int cols = chi(\"Columns\", 16);\n        int rows = chi(\"Rows\", 16);\n        if (cols < 1) cols = 1;\n        if (cols > 128) cols = 128;\n        if (rows < 1) rows = 1;\n        if (rows > 128) rows = 128;\n        float center_x = chf(\"Center X\", 0.0f);\n        float center_y = chf(\"Center Y\", 0.0f);\n        float center_z = chf(\"Center Z\", 0.0f);\n        float half_x = size_x * 0.5f;\n        float half_z = size_z * 0.5f;\n        float step_x = size_x / (float)cols;\n        float step_z = size_z / (float)rows;\n        float inv_x = size_x > 0.0f ? 1.0f / size_x : 0.0f;\n        float inv_z = size_z > 0.0f ? 1.0f / size_z : 0.0f;\n        int count = 0;\n        for (int i = 0; i < cols; i++) {\n            float x0 = -half_x + (float)i * step_x;\n            float x1 = x0 + step_x;\n            for (int j = 0; j < rows; j++) {\n                float z0 = -half_z + (float)j * step_z;\n                float z1 = z0 + step_z;\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_x) * inv_x;\n                        float fz = (pz[v] + half_z) * inv_z;\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": [
diff --git a/src/main.rs b/src/main.rs
index bdefc2c..e4bacb4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1131,6 +1131,70 @@ mod tests {
         assert!(geom.vertices.iter().all(|v| !v.attributes.contains_key("mass")));
     }
 
+    /// The Plane template's construction controls: Rows/Columns set the grid
+    /// tessellation (vertex count = rows * columns * 6 — coverage the
+    /// long-standing spinboxes never had), and the Center X/Y/Z channels
+    /// place the plane like the Sphere's do.
+    #[test]
+    fn test_plane_construction_controls() {
+        let templates_root = crate::app::load_fs_tree();
+        let plane_t = templates_root.children.iter().find(|t| t.name == "Plane").unwrap();
+        let build = |params: &[(&str, &str)]| {
+            let mut inst = plane_t.clone();
+            inst.id = "p".to_string();
+            inst.name = "Plane 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("plane generation failed");
+            assert!(err.is_none(), "{err:?}");
+            geom
+        };
+
+        // Defaults: a 16x16 grid at the origin, flat on y = 0.
+        let base = build(&[]);
+        assert_eq!(base.vertices.len(), 16 * 16 * 6);
+        assert!(base.vertices.iter().all(|v| v.pos[1].abs() < 1e-6));
+
+        // Resolution: 3 columns x 2 rows.
+        assert_eq!(build(&[("Rows", "2"), ("Columns", "3")]).vertices.len(), 3 * 2 * 6);
+
+        // Center: lifts to y = 0.3 and shifts x by 1 (span [0.5, 1.5]).
+        let moved = build(&[("Center X", "1.0"), ("Center Y", "0.3")]);
+        let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
+        for v in &moved.vertices {
+            assert!((v.pos[1] - 0.3).abs() < 1e-5);
+            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}");
+    }
+
     /// The loader's template merge: saved instances gain params their
     /// template grew after the save (values they already hold are kept), a
     /// subnet instance's kernel refreshes to the template's (so the new