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

commit035a33f5e0f2a1c18d83f769065aa0fe8327121a
parent7a67dadad8
authorLucas Galante <[email protected]>
date2026-09-24 11:08
feat: Sphere, Box, Plane and Extrude are native nodes, not kernel subnets

Phase 7 step 3 of shapeshifter.md, first half. Each was an
input → opencl → output subnet whose kernel ran under `if (id == 0)` —
one work item doing loops — then a weld by position that threw away the
shared points the loop had known. src/shapes.rs builds each with welded
points and real primitives: the Sphere's three methods (Cube as quads,
welded by a quantized key), Box about a Center with vertex normals and
its Wireframe frame, Plane with its gradient, and Extrude AS A WHOLE —
points along their normals, walls on boundary edges only, Keep Base
closing the slab — where the kernel walled every interior edge.

Templates are plain native nodes; the Embryo is the one subnet template
left, its sphere1 resolving to the native Sphere. Saved kernel subnets
migrate on load through nativize_kernel_subnets (id, name, position,
values kept; children gone; a parameter the native template lacks goes
with them), and the bundled project files are converted in place. A
bare `sphere` node with no Center parameters is still placed by index,
which is what the hand-built test nodes are.

The opencl node, kernel_cpu.rs, the launcher and opencl3 still ship;
the kernel_cpu suite now exercises them on an inline generator and the
OpenCL node's own default. Retiring them is the rest of the step.

Verified beyond the suite: the pre-port default_project.json exports
and thumbnails through the migration with the same welded sphere.

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

 CLAUDE.md            |  99 +++++++----
 default_project.json |  69 +-------
 nodes/box.json       |  37 ++---
 nodes/extrude.json   |  83 +++-------
 nodes/plane.json     |  32 +---
 nodes/sphere.json    | 168 ++++++++-----------
 project.json         |  69 +-------
 shapeshifter.md      |   9 +
 src/app.rs           |  34 ++++
 src/geometry.rs      |  92 +++++++++--
 src/kernel_cpu.rs    | 120 +++++++-------
 src/main.rs          | 388 ++++++++++++++++++-------------------------
 src/shapes.rs        | 458 +++++++++++++++++++++++++++++++++++++++++++++++++++
 13 files changed, 974 insertions(+), 684 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 30610e2..e8233bf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -630,29 +630,67 @@ way — both in `src/geometry.rs`:
   clamped; an empty slot passes nothing. Only `Input` draws a wire, the
   limit every second operand has (Boolean's With, Copy's target).
 
-### The Sphere's construction methods
-
-`nodes/sphere.json` carries a **Method** dropdown — `UV`, `Icosphere`,
-`Cube` — and one kernel that builds all three: a single loop over triangle
-INDICES, each decoded by method into three corners on the unit sphere, then
-one shared block that turns it outward, places it, shades it and writes it.
-UV is Rows x Columns, the sphere this node always built, corner for corner
-(its rows skip the outward turn so the pole slivers keep their order).
-Icosphere splits each of the icosahedron's 20 faces into Frequency^2
-triangles by integer barycentric weights summed in ONE fixed expression, so
-a corner on an edge two faces share lands on the same bits from either side
-and the 1e-4 weld joins them — the welded count is `10f^2 + 2`, which
-`sphere_method_builds_a_uv_ico_or_cube_sphere` asserts along with
-closedness. Cube lays a Resolution x Resolution grid on each face and pushes
-it out through the spherified-cube map rather than a bare normalize, which
-crowds the corners; `6r^2 + 2` points. Rows/Columns, Frequency and
-Resolution each show only under their method (`show_when`).
-
-**A choice reaches a kernel as its option INDEX** (`geometry::param_number`,
-the one conversion behind both `chi("Method")` in a kernel and a
-`chi("Method")` parameter reference). The kernel path used to parse the
-option's TEXT, so every dropdown read as 0 from inside a kernel — a
-dropdown on a kernel node was simply not possible before this.
+### Sphere, Box, Plane and Extrude are native (2026-09-24)
+
+`src/shapes.rs` holds the four shapes that were kernel subnets — Phase 7
+step 3 of `shapeshifter.md`. Each was `input → opencl → output` with a
+kernel that ran under `if (id == 0)`: one work item doing loops, then a
+weld by position on the way back that threw away every shared point the
+loop had known. Native, each builds welded points and real primitives —
+a quad stays a quad — costs no JIT compile and needs no OpenCL at all.
+The parameter surfaces are the templates' own, so a saved instance keeps
+its values. The templates are plain native nodes now (`"type": "sphere"`
+and so on, no children); the Embryo is the one subnet template left, and
+its `sphere1` child resolves to the native Sphere with the template's
+whole surface under the Embryo's overrides.
+
+**The Sphere's Method** — `UV`, `Icosphere`, `Cube` — survives as it was:
+UV is Rows x Columns through `sphere_detail`; Icosphere splits each of the
+icosahedron's 20 faces into Frequency^2 triangles by integer barycentric
+weights; Cube lays a Resolution x Resolution grid on each face and pushes
+it out through the spherified-cube map, and builds QUADS where the kernel
+fanned them. Welded counts are `2 + (rows - 1) * cols`, `10 f^2 + 2` and
+`6 r^2 + 2`, which `sphere_method_builds_a_uv_ico_or_cube_sphere` asserts
+along with closedness. Welding is by a QUANTIZED position key (1e-5)
+rather than by trusting bit-identical arithmetic across faces: the kernel
+summed weights in one fixed expression so shared corners landed on the
+same bits, then welded at 1e-4 anyway; a quantized key is the same
+guarantee stated once. Colour is the kernel's — the SIGNED normal folded
+into 0..1, world-anchored — with Color on, `DEFAULT_COLOR` off.
+
+**A bare `sphere` node with no Center parameters is placed by index**
+(`index_center`), the way Line and Points still are: that is the tests'
+hand-built `ref_node("sphere", [Radius])`, and every node that came through
+a template or a load carries Center X/Y/Z and sits where they say.
+
+**Box** is eight corners and six quads about a float3 Center (new; the
+kernel hard-coded (0, 0.55, 0)), normals on the VERTICES like `box_detail`;
+Wireframe draws the twelve edges as bars and the corners as small cubes,
+as the kernel did. Its unused `Input` is gone. **Plane** is the kernel's
+sheet with its colour gradient; Grid is the same sheet with a float3 Center
+and no gradient, two nodes for history's sake.
+
+**Extrude extrudes AS A WHOLE**, which is the one semantic change: every
+point moves along its point normal, the input's primitives become the
+top, one quad wall rises from each BOUNDARY edge, and Keep Base keeps the
+originals wound the other way — a sheet becomes a closed slab, a closed
+surface a two-skinned shell. The kernel extruded every triangle on its
+own and welded the prisms back together, which put a wall along every
+interior edge. Point attributes and groups ride to the top copies; the
+kernel's 15% darker walls were a per-corner colour a soup could hold and
+shared points cannot, and are gone.
+
+**Saved kernel subnets migrate on load.** `nativize_kernel_subnets` in
+`merge_template_defs` turns a `node` whose children include an `opencl`
+child and whose base name is one of the four into the native node: id,
+name, position, flag and values stay, the children go, and a parameter the
+native template lacks goes with them. Only when the `opencl` child is
+actually there, so a subnet someone built by hand and called "sphere2"
+keeps what is inside it. The bundled `default_project.json` and
+`project.json` were converted in place. The `opencl` node itself still
+ships and still runs; retiring it and both kernel backends is the rest of
+step 3, and `test_loader_merges_new_template_params` is the migration's
+test.
 
 ### The Embryo node is a template of nodes
 
@@ -693,8 +731,8 @@ unmoved), where the HDA runs Catmull-Clark — same parameter, one operation
 rather than two under one name. **The second input is the first**: the HDA
 read its Source from input 2, and this app's nodes name one Input.
 
-**Exactly one child of the template draws, `normal1`**, the last real node
-— as the Sphere's kernel node is its one drawing child. A subnet viewed from
+**Exactly one child of the template draws, `normal1`**, the last real
+node. A subnet viewed from
 OUTSIDE shows its internals by their own flags (output children draw only
 at the displayed level), so with every chain node visible the hull drew
 five times over, each draw re-evaluating the pipeline: 2.4 s per edit on a
@@ -1540,11 +1578,12 @@ project deserialization including thumbnails): missing params are inserted
 where the template puts them (after the last template param the instance
 already has — so the Sphere's Method lands above Radius in an old save, not
 below Color), existing ones keep their value but take the template's UI
-metadata, and subnet
-templates (Sphere/Plane/Extrude) refresh their children's `Code` outright —
-**the template owns the surface and implementation, the instance owns its
-values.** A kernel hand-edited inside a template instance reverts on load;
-custom kernels belong in bare OpenCL nodes, which the merge never touches.
+metadata, and a subnet template (the Embryo, since the four kernel subnets
+went native) refreshes its children's params and any kernel child's `Code`
+outright — **the template owns the surface and implementation, the
+instance owns its values.** A kernel hand-edited inside a template
+instance reverts on load; custom kernels belong in bare OpenCL nodes,
+which the merge never touches.
 Native nodes match their template by type, subnet instances by name
 ("sphere3" → "Sphere", case-insensitively) plus a full child name/type match; the merge never
 injects or deletes children and never rewrites files on disk.
diff --git a/default_project.json b/default_project.json
index eac2d71..268a359 100644
--- a/default_project.json
+++ b/default_project.json
@@ -47,7 +47,7 @@
       },
       {
         "name": "sphere1",
-        "type": "node",
+        "type": "sphere",
         "position": [
           4.0,
           2.0
@@ -64,70 +64,7 @@
             "step": null
           }
         ],
-        "children": [
-          {
-            "name": "opencl1",
-            "type": "opencl",
-            "position": [
-              4.0,
-              2.0
-            ],
-            "params": [
-              {
-                "name": "Input",
-                "label": "",
-                "type": "text",
-                "default": "",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              },
-              {
-                "name": "Code",
-                "label": "",
-                "type": "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}",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              },
-              {
-                "name": "Update Parameters",
-                "label": "",
-                "type": "button",
-                "default": "",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              }
-            ],
-            "children": []
-          },
-          {
-            "name": "output1",
-            "type": "output",
-            "position": [
-              4.0,
-              3.0
-            ],
-            "params": [
-              {
-                "name": "Input",
-                "label": "",
-                "type": "text",
-                "default": "opencl1",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              }
-            ],
-            "children": []
-          }
-        ]
+        "children": []
       }
     ],
     "params": [],
@@ -145,4 +82,4 @@
     "current_path": [],
     "selected_node": null
   }
-}
\ No newline at end of file
+}
diff --git a/nodes/box.json b/nodes/box.json
index fd45e8e..472dd5d 100644
--- a/nodes/box.json
+++ b/nodes/box.json
@@ -1,38 +1,23 @@
 {
   "name": "Box",
-  "type": "node",
-  "inputs": 1,
+  "type": "box",
+  "inputs": 0,
   "outputs": 1,
   "params": [
-    { "name": "Input", "default": "", "type": "text" },
-    { "name": "Scale", "default": "1.0", "type": "slider" },
-    { "name": "Wireframe", "default": "false", "type": "toggle" }
-  ],
-  "children": [
     {
-      "name": "input1",
-      "type": "input",
-      "position": [4.0, 1.0]
+      "name": "Scale",
+      "default": "1.0",
+      "type": "slider"
     },
     {
-      "name": "opencl1",
-      "type": "opencl",
-      "params": [
-        { "name": "Input", "default": "input1" },
-        {
-          "name": "Code",
-          "default": "void add_box(float cx, float cy, float cz, float hx, float hy, float hz, __global float* out_pos, __global float* out_col, int* count, int max_vertices) {\n    float temp_pos[108] = {\n        -hx, -hy, hz,   hx, -hy, hz,   hx,  hy, hz,\n        -hx, -hy, hz,   hx,  hy, hz,  -hx,  hy, hz,\n        -hx, -hy, -hz, -hx,  hy, -hz,  hx,  hy, -hz,\n        -hx, -hy, -hz,  hx,  hy, -hz,  hx, -hy, -hz,\n        -hx, -hy, -hz, -hx, -hy, hz,  -hx,  hy, hz,\n        -hx, -hy, -hz, -hx,  hy, hz,  -hx,  hy, -hz,\n        hx, -hy, -hz,  hx,  hy, -hz,  hx,  hy, hz,\n        hx, -hy, -hz,  hx,  hy, hz,   hx, -hy, hz,\n        -hx,  hy, -hz, -hx,  hy, hz,   hx,  hy, hz,\n        -hx,  hy, -hz,  hx,  hy, hz,   hx,  hy, -hz,\n        -hx, -hy, -hz,  hx, -hy, -hz,  hx, -hy, hz,\n        -hx, -hy, -hz,  hx, -hy, hz,  -hx, -hy, hz\n    };\n    int start = *count;\n    for (int i = 0; i < 36; i++) {\n        int idx = start + i;\n        if (idx < max_vertices) {\n            out_pos[idx * 3 + 0] = cx + temp_pos[i * 3 + 0];\n            out_pos[idx * 3 + 1] = cy + temp_pos[i * 3 + 1];\n            out_pos[idx * 3 + 2] = cz + temp_pos[i * 3 + 2];\n            out_col[idx * 3 + 0] = 0.8f;\n            out_col[idx * 3 + 1] = 0.2f;\n            out_col[idx * 3 + 2] = 0.2f;\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 scale = chf(\"Scale\", 1.0f);\n        int wireframe = chb(\"Wireframe\", 0);\n        float dx = 0.5f * scale;\n        float dy = 0.5f * scale;\n        float dz = 0.5f * scale;\n        float center_x = 0.0f;\n        float center_y = 0.55f;\n        float center_z = 0.0f;\n        int count = 0;\n        if (wireframe) {\n            float t_line = 0.008f * scale;\n            float t_corner = 0.012f * scale;\n            for (int ix = 0; ix < 2; ix++) {\n                float cx = center_x + (ix == 0 ? -dx : dx);\n                for (int iy = 0; iy < 2; iy++) {\n                    float cy = center_y + (iy == 0 ? -dy : dy);\n                    for (int iz = 0; iz < 2; iz++) {\n                        float cz = center_z + (iz == 0 ? -dz : dz);\n                        add_box(cx, cy, cz, t_corner, t_corner, t_corner, out_pos, out_col, &count, max_vertices);\n                    }\n                }\n            }\n            for (int iy = 0; iy < 2; iy++) {\n                float cy = center_y + (iy == 0 ? -dy : dy);\n                for (int iz = 0; iz < 2; iz++) {\n                    float cz = center_z + (iz == 0 ? -dz : dz);\n                    add_box(center_x, cy, cz, dx, t_line, t_line, out_pos, out_col, &count, max_vertices);\n                }\n            }\n            for (int ix = 0; ix < 2; ix++) {\n                float cx = center_x + (ix == 0 ? -dx : dx);\n                for (int iz = 0; iz < 2; iz++) {\n                    float cz = center_z + (iz == 0 ? -dz : dz);\n                    add_box(cx, center_y, cz, t_line, dy, t_line, out_pos, out_col, &count, max_vertices);\n                }\n            }\n            for (int ix = 0; ix < 2; ix++) {\n                float cx = center_x + (ix == 0 ? -dx : dx);\n                for (int iy = 0; iy < 2; iy++) {\n                    float cy = center_y + (iy == 0 ? -dy : dy);\n                    add_box(cx, cy, center_z, t_line, t_line, dz, out_pos, out_col, &count, max_vertices);\n                }\n            }\n        } else {\n            add_box(center_x, center_y, center_z, dx, dy, dz, out_pos, out_col, &count, max_vertices);\n        }\n        *out_count = count;\n    }\n}"
-        }
-      ],
-      "position": [4.0, 2.0]
+      "name": "Wireframe",
+      "default": "false",
+      "type": "toggle"
     },
     {
-      "name": "output1",
-      "type": "output",
-      "params": [
-        { "name": "Input", "default": "opencl1" }
-      ],
-      "position": [4.0, 3.0]
+      "name": "Center",
+      "type": "float3",
+      "default": "0.00:0.55:0.00"
     }
   ]
 }
diff --git a/nodes/extrude.json b/nodes/extrude.json
index 3d18087..8f69fc0 100644
--- a/nodes/extrude.json
+++ b/nodes/extrude.json
@@ -1,69 +1,26 @@
 {
- "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": "Extrude",
+  "type": "extrude",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
     {
-     "name": "Input",
-     "default": "input1"
+      "name": "Input",
+      "default": "",
+      "type": "text"
     },
     {
-     "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            // Plain cross(B-A, C-A): template meshes wind CCW seen from\n            // outside (the raster culling convention), so this points\n            // outward. (Historically the sphere wound CW and this cross was\n            // negated to compensate \u2014 both fixed together.)\n            float nx = uy * vz - uz * vy;\n            float ny = uz * vx - ux * vz;\n            float nz = ux * vy - uy * vx;\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": "Distance",
+      "default": "0.2",
+      "type": "slider",
+      "min": -1.0,
+      "max": 1.0,
+      "step": 0.01
+    },
     {
-     "name": "Input",
-     "default": "opencl1"
+      "name": "Keep Base",
+      "default": "true",
+      "type": "toggle"
     }
-   ],
-   "position": [
-    4.0,
-    3.0
-   ]
-  }
- ]
-}
\ No newline at end of file
+  ]
+}
diff --git a/nodes/plane.json b/nodes/plane.json
index bea95b0..7145267 100644
--- a/nodes/plane.json
+++ b/nodes/plane.json
@@ -1,6 +1,6 @@
 {
   "name": "Plane",
-  "type": "node",
+  "type": "plane",
   "inputs": 0,
   "outputs": 1,
   "params": [
@@ -50,35 +50,5 @@
       "default": "true",
       "type": "toggle"
     }
-  ],
-  "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_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        int colored = chb(\"Color\", true) > 0.5f ? 1 : 0;\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                        if (colored) {\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                        } else {\n                            out_col[idx * 3 + 0] = 0.8f;\n                            out_col[idx * 3 + 1] = 0.8f;\n                            out_col[idx * 3 + 2] = 0.8f;\n                        }\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/nodes/sphere.json b/nodes/sphere.json
index 0191467..2df581d 100644
--- a/nodes/sphere.json
+++ b/nodes/sphere.json
@@ -1,104 +1,74 @@
 {
- "name": "Sphere",
- "type": "node",
- "inputs": 0,
- "outputs": 1,
- "params": [
-  {
-   "name": "Method",
-   "default": "UV",
-   "type": "choice:UV,Icosphere,Cube"
-  },
-  {
-   "name": "Radius",
-   "default": "0.5",
-   "type": "slider"
-  },
-  {
-   "name": "Rows",
-   "default": "16",
-   "type": "spinbox",
-   "min": 2,
-   "max": 128,
-   "step": 1,
-   "show_when": "Method == UV"
-  },
-  {
-   "name": "Columns",
-   "default": "24",
-   "type": "spinbox",
-   "min": 3,
-   "max": 128,
-   "step": 1,
-   "show_when": "Method == UV"
-  },
-  {
-   "name": "Frequency",
-   "default": "4",
-   "type": "spinbox",
-   "min": 1,
-   "max": 16,
-   "step": 1,
-   "show_when": "Method == Icosphere"
-  },
-  {
-   "name": "Resolution",
-   "default": "8",
-   "type": "spinbox",
-   "min": 1,
-   "max": 64,
-   "step": 1,
-   "show_when": "Method == Cube"
-  },
-  {
-   "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"
-  },
-  {
-   "name": "Color",
-   "default": "true",
-   "type": "toggle"
-  }
- ],
- "children": [
-  {
-   "name": "opencl1",
-   "type": "opencl",
-   "params": [
+  "name": "Sphere",
+  "type": "sphere",
+  "inputs": 0,
+  "outputs": 1,
+  "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        int method = chi(\"Method\", 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 freq = chi(\"Frequency\", 4);\n        if (freq < 1) { freq = 1; }\n        if (freq > 16) { freq = 16; }\n        int res = chi(\"Resolution\", 8);\n        if (res < 1) { res = 1; }\n        if (res > 64) { res = 64; }\n        int colored = chb(\"Color\", true) > 0.5f ? 1 : 0;\n        /* The icosahedron the icosphere subdivides: 12 corners on three\n           orthogonal golden rectangles, 20 faces. Orientation does not\n           matter here; every triangle is turned outward below. */\n        float t = 1.6180340f;\n        float ivx[12] = {-1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, t, t, -t, -t};\n        float ivy[12] = {t, t, -t, -t, -1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f};\n        float ivz[12] = {0.0f, 0.0f, 0.0f, 0.0f, t, t, -t, -t, -1.0f, 1.0f, -1.0f, 1.0f};\n        int ifa[20] = {0, 0, 0, 0, 0, 1, 5, 11, 10, 7, 3, 3, 3, 3, 3, 4, 2, 6, 8, 9};\n        int ifb[20] = {11, 5, 1, 7, 10, 5, 11, 10, 7, 1, 9, 4, 2, 6, 8, 9, 4, 2, 6, 8};\n        int ifc[20] = {5, 1, 7, 10, 11, 9, 4, 2, 6, 8, 4, 2, 6, 8, 9, 5, 11, 10, 7, 1};\n        /* One loop over triangles, whatever builds them: triangle t is\n           decoded by method into three corners on the unit sphere, and one\n           block below places, orients, shades and writes it. */\n        int total = lat_steps * lon_steps * 2;\n        if (method == 1) { total = 20 * freq * freq; }\n        if (method == 2) { total = 6 * res * res * 2; }\n        int count = 0;\n        for (int tri = 0; tri < total; tri++) {\n            float tx[3] = {0.0f, 0.0f, 0.0f};\n            float ty[3] = {0.0f, 0.0f, 0.0f};\n            float tz[3] = {0.0f, 0.0f, 0.0f};\n            if (method == 1) {\n                /* Icosphere: each face split into freq*freq triangles by\n                   barycentric weights, the corners pushed onto the sphere.\n                   Row i of a face holds freq-i upright triangles and\n                   freq-i-1 inverted ones, interleaved. The weights are\n                   integers summed in one fixed expression, so a corner on\n                   an edge two faces share lands on the same bits from\n                   either side and the weld joins them. */\n                int face = tri / (freq * freq);\n                int k = tri - face * freq * freq;\n                int i = 0;\n                int row_len = 2 * freq - 1;\n                while (k >= row_len) {\n                    k -= row_len;\n                    i++;\n                    row_len = 2 * (freq - i) - 1;\n                }\n                int j = k / 2;\n                int upright = (k - j * 2) == 0 ? 1 : 0;\n                int wa[3];\n                int wb[3];\n                int wc[3];\n                if (upright) {\n                    wb[0] = i;     wc[0] = j;\n                    wb[1] = i + 1; wc[1] = j;\n                    wb[2] = i;     wc[2] = j + 1;\n                } else {\n                    wb[0] = i + 1; wc[0] = j;\n                    wb[1] = i + 1; wc[1] = j + 1;\n                    wb[2] = i;     wc[2] = j + 1;\n                }\n                int a = ifa[face];\n                int b = ifb[face];\n                int c = ifc[face];\n                for (int v = 0; v < 3; v++) {\n                    wa[v] = freq - wb[v] - wc[v];\n                    float x = ((float)wa[v] * ivx[a] + (float)wb[v] * ivx[b] + (float)wc[v] * ivx[c]) / (float)freq;\n                    float y = ((float)wa[v] * ivy[a] + (float)wb[v] * ivy[b] + (float)wc[v] * ivy[c]) / (float)freq;\n                    float z = ((float)wa[v] * ivz[a] + (float)wb[v] * ivz[b] + (float)wc[v] * ivz[c]) / (float)freq;\n                    float len = sqrt(x * x + y * y + z * z);\n                    tx[v] = x / len;\n                    ty[v] = y / len;\n                    tz[v] = z / len;\n                }\n            } else if (method == 2) {\n                /* Cube sphere: a res*res grid on each face of the cube\n                   spanning -1..1, every point pushed onto the sphere by the\n                   spherified-cube map (not a bare normalize, which crowds\n                   the corners and stretches the face centres). */\n                int per_face = res * res * 2;\n                int face = tri / per_face;\n                int q = (tri - face * per_face) / 2;\n                int side = tri - face * per_face - q * 2;\n                int i = q / res;\n                int j = q - i * res;\n                float u0 = -1.0f + 2.0f * (float)i / (float)res;\n                float u1 = -1.0f + 2.0f * (float)(i + 1) / (float)res;\n                float v0 = -1.0f + 2.0f * (float)j / (float)res;\n                float v1 = -1.0f + 2.0f * (float)(j + 1) / (float)res;\n                float qu[4] = {u0, u1, u1, u0};\n                float qv[4] = {v0, v0, v1, v1};\n                for (int v = 0; v < 3; v++) {\n                    int corner = side == 0 ? v : (v == 0 ? 0 : v + 1);\n                    float u = qu[corner];\n                    float w = qv[corner];\n                    float x = 0.0f;\n                    float y = 0.0f;\n                    float z = 0.0f;\n                    int axis = face / 2;\n                    float sign = (face - axis * 2) == 0 ? 1.0f : -1.0f;\n                    if (axis == 0) { x = sign; y = u; z = w; }\n                    if (axis == 1) { y = sign; z = u; x = w; }\n                    if (axis == 2) { z = sign; x = u; y = w; }\n                    float x2 = x * x;\n                    float y2 = y * y;\n                    float z2 = z * z;\n                    tx[v] = x * sqrt(1.0f - y2 * 0.5f - z2 * 0.5f + y2 * z2 / 3.0f);\n                    ty[v] = y * sqrt(1.0f - z2 * 0.5f - x2 * 0.5f + z2 * x2 / 3.0f);\n                    tz[v] = z * sqrt(1.0f - x2 * 0.5f - y2 * 0.5f + x2 * y2 / 3.0f);\n                }\n            } else {\n                /* UV: rows of latitude, columns of longitude, two triangles\n                   a cell \u2014 the sphere this node has always built, corner\n                   for corner. */\n                int cell = tri / 2;\n                int side = tri - cell * 2;\n                int lat = cell / lon_steps;\n                int lon = cell - lat * lon_steps;\n                float theta0 = 3.14159265f * (float)lat / (float)lat_steps;\n                float theta1 = 3.14159265f * (float)(lat + 1) / (float)lat_steps;\n                float phi0 = 6.2831853f * (float)lon / (float)lon_steps;\n                float phi1 = 6.2831853f * (float)(lon + 1) / (float)lon_steps;\n                float x00 = sin(theta0) * cos(phi0);\n                float y00 = cos(theta0);\n                float z00 = sin(theta0) * sin(phi0);\n                float x10 = sin(theta1) * cos(phi0);\n                float y10 = cos(theta1);\n                float z10 = sin(theta1) * sin(phi0);\n                float x11 = sin(theta1) * cos(phi1);\n                float y11 = cos(theta1);\n                float z11 = sin(theta1) * sin(phi1);\n                float x01 = sin(theta0) * cos(phi1);\n                float y01 = cos(theta0);\n                float z01 = sin(theta0) * sin(phi1);\n                if (side == 0) {\n                    tx[0] = x00; ty[0] = y00; tz[0] = z00;\n                    tx[1] = x11; ty[1] = y11; tz[1] = z11;\n                    tx[2] = x10; ty[2] = y10; tz[2] = z10;\n                } else {\n                    tx[0] = x00; ty[0] = y00; tz[0] = z00;\n                    tx[1] = x01; ty[1] = y01; tz[1] = z01;\n                    tx[2] = x11; ty[2] = y11; tz[2] = z11;\n                }\n            }\n            /* Turn the triangle outward: the sphere is convex about the\n               origin, so a face normal pointing away from its own centroid\n               is inside out, whatever table it came from. The UV rows are\n               already outward and skip this, so their pole slivers (whose\n               normal is rounding noise) keep the order they always had. */\n            if (method != 0) {\n                float ex = tx[1] - tx[0];\n                float ey = ty[1] - ty[0];\n                float ez = tz[1] - tz[0];\n                float fx = tx[2] - tx[0];\n                float fy = ty[2] - ty[0];\n                float fz = tz[2] - tz[0];\n                float nx = ey * fz - ez * fy;\n                float ny = ez * fx - ex * fz;\n                float nz = ex * fy - ey * fx;\n                float d = nx * (tx[0] + tx[1] + tx[2]) + ny * (ty[0] + ty[1] + ty[2]) + nz * (tz[0] + tz[1] + tz[2]);\n                if (d < 0.0f) {\n                    float sx = tx[1]; tx[1] = tx[2]; tx[2] = sx;\n                    float sy = ty[1]; ty[1] = ty[2]; ty[2] = sy;\n                    float sz = tz[1]; tz[1] = tz[2]; tz[2] = sz;\n                }\n            }\n            for (int v = 0; v < 3; v++) {\n                int idx = count++;\n                if (idx < max_vertices) {\n                    out_pos[idx * 3 + 0] = center_x + radius * tx[v];\n                    out_pos[idx * 3 + 1] = center_y + radius * ty[v];\n                    out_pos[idx * 3 + 2] = center_z + radius * tz[v];\n                    float nx = tx[v];\n                    float ny = ty[v];\n                    float nz = tz[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                    if (colored) {\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                    } else {\n                        out_col[idx * 3 + 0] = 0.8f;\n                        out_col[idx * 3 + 1] = 0.8f;\n                        out_col[idx * 3 + 2] = 0.8f;\n                    }\n                }\n            }\n        }\n        *out_count = count;\n    }\n}"
-    }
-   ],
-   "position": [
-    4.0,
-    2.0
-   ]
-  },
-  {
-   "name": "output1",
-   "type": "output",
-   "params": [
+      "name": "Method",
+      "default": "UV",
+      "type": "choice:UV,Icosphere,Cube"
+    },
+    {
+      "name": "Radius",
+      "default": "0.5",
+      "type": "slider"
+    },
+    {
+      "name": "Rows",
+      "default": "16",
+      "type": "spinbox",
+      "min": 2,
+      "max": 128,
+      "step": 1,
+      "show_when": "Method == UV"
+    },
+    {
+      "name": "Columns",
+      "default": "24",
+      "type": "spinbox",
+      "min": 3,
+      "max": 128,
+      "step": 1,
+      "show_when": "Method == UV"
+    },
+    {
+      "name": "Frequency",
+      "default": "4",
+      "type": "spinbox",
+      "min": 1,
+      "max": 16,
+      "step": 1,
+      "show_when": "Method == Icosphere"
+    },
+    {
+      "name": "Resolution",
+      "default": "8",
+      "type": "spinbox",
+      "min": 1,
+      "max": 64,
+      "step": 1,
+      "show_when": "Method == Cube"
+    },
+    {
+      "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"
+    },
     {
-     "name": "Input",
-     "default": "opencl1"
+      "name": "Color",
+      "default": "true",
+      "type": "toggle"
     }
-   ],
-   "position": [
-    4.0,
-    3.0
-   ]
-  }
- ]
+  ]
 }
diff --git a/project.json b/project.json
index c89250c..68dc677 100644
--- a/project.json
+++ b/project.json
@@ -47,7 +47,7 @@
       },
       {
         "name": "sphere1",
-        "type": "node",
+        "type": "sphere",
         "position": [
           4.0,
           2.0
@@ -64,70 +64,7 @@
             "step": null
           }
         ],
-        "children": [
-          {
-            "name": "opencl1",
-            "type": "opencl",
-            "position": [
-              4.0,
-              2.0
-            ],
-            "params": [
-              {
-                "name": "Input",
-                "label": "",
-                "type": "text",
-                "default": "",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              },
-              {
-                "name": "Code",
-                "label": "",
-                "type": "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)(lat + 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.35f + fabs(nx) * 0.35f;\n                        out_col[idx * 3 + 1] = 0.45f + fabs(ny) * 0.35f;\n                        out_col[idx * 3 + 2] = 0.85f;\n                    }\n                }\n            }\n        }\n        *out_count = count;\n    }\n}",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              },
-              {
-                "name": "Update Parameters",
-                "label": "",
-                "type": "button",
-                "default": "",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              }
-            ],
-            "children": []
-          },
-          {
-            "name": "output1",
-            "type": "output",
-            "position": [
-              4.0,
-              3.0
-            ],
-            "params": [
-              {
-                "name": "Input",
-                "label": "",
-                "type": "text",
-                "default": "opencl1",
-                "options": [],
-                "min": null,
-                "max": null,
-                "step": null
-              }
-            ],
-            "children": []
-          }
-        ]
+        "children": []
       }
     ],
     "params": [],
@@ -145,4 +82,4 @@
     "current_path": [],
     "selected_node": null
   }
-}
\ No newline at end of file
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index d3d4d6a..0733ae1 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -660,6 +660,15 @@ step 1 resolves through `expr.rs`'s scope, so a referenced parameter that is
 itself an expression evaluates before the wrangle sees it, and neither
 language has to know the other exists.
 
+> **Started (2026-09-24): the four are native.** `src/shapes.rs` — Sphere
+> (all three methods, Cube as quads), Box (with a Center, without its dead
+> Input), Plane, and Extrude as a WHOLE (walls on boundary edges only,
+> where the kernel walled every interior edge). Saved kernel subnets
+> migrate on load through `nativize_kernel_subnets`; the bundled project
+> files were converted in place. The `opencl` node, `kernel_cpu.rs`, the
+> launcher and `opencl3` still ship — the retirement is the half of this
+> step still to do.
+
 **Step 3 — port the four kernel templates native, then retire OpenCL.**
 Sphere, Box, Plane and Extrude are the only kernels that ship. A native
 `sphere_detail` and `grid_detail` already exist (Plane IS the Grid); Box is
diff --git a/src/app.rs b/src/app.rs
index 6f04064..04e0629 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -835,6 +835,40 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
     }
     recompose_native_embryo(root, templates);
 
+    // A KERNEL SUBNET — a Sphere, Box, Plane or Extrude instance saved while
+    // those templates were `input → opencl → output` subnets (until
+    // 2026-09-24) — becomes the native node of that type: id, name,
+    // position, flag and parameter values stay, the children go, and a
+    // parameter the native template does not have (the Box's unused Input)
+    // goes with them. Matched by the same base-name rule `template_for`
+    // used to match them to their templates, and only when an `opencl`
+    // child is actually there, so a subnet someone built by hand and
+    // happened to call "sphere2" keeps whatever is inside it.
+    fn nativize_kernel_subnets(node: &mut FsNode, templates: &[NodeTemplate]) {
+        for c in &mut node.children {
+            if c.node_type.eq_ignore_ascii_case("node")
+                && c.children.iter().any(|k| k.node_type.eq_ignore_ascii_case("opencl"))
+            {
+                let base = c
+                    .name
+                    .trim_end_matches(|ch: char| ch.is_ascii_digit())
+                    .trim_end_matches(|ch: char| ch == '_' || ch.is_whitespace())
+                    .to_lowercase();
+                if ["sphere", "box", "plane", "extrude"].contains(&base.as_str()) {
+                    c.node_type = base.clone();
+                    c.children.clear();
+                    if let Some(t) = templates.iter().find(|t| t.node.node_type.eq_ignore_ascii_case(&base)) {
+                        c.params.retain(|p| t.node.params.iter().any(|tp| tp.name == p.name));
+                        c.inputs = t.node.inputs;
+                        c.outputs = t.node.outputs;
+                    }
+                }
+            }
+            nativize_kernel_subnets(c, templates);
+        }
+    }
+    nativize_kernel_subnets(root, templates);
+
     fn template_for<'a>(node: &FsNode, templates: &'a [NodeTemplate]) -> Option<&'a FsNode> {
         if node.node_type.eq_ignore_ascii_case("node") {
             let base = node
diff --git a/src/geometry.rs b/src/geometry.rs
index 74c4620..37cdd51 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1105,9 +1105,20 @@ pub fn generate_single_node_geometry_with_errors(
     let target = resolved.as_ref().unwrap_or(target);
 
     let res = if target.node_type.eq_ignore_ascii_case("sphere") {
-        let idx = find_sphere_index(root, target)?;
-        let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-        Some(sphere_detail(center, node_param_f32(target, "Radius", 0.5).max(0.05), 16, 24))
+        // A sphere with no Center parameters is a bare hand-built node,
+        // placed by index as Line and Points still are.
+        let legacy = if crate::shapes::sphere_has_center(target) {
+            None
+        } else {
+            Some(index_center(find_sphere_index(root, target)?))
+        };
+        Some(crate::shapes::sphere_node_detail(target, legacy))
+    } else if target.node_type.eq_ignore_ascii_case("box") {
+        Some(crate::shapes::box_node_detail(target))
+    } else if target.node_type.eq_ignore_ascii_case("plane") {
+        Some(crate::shapes::plane_node_detail(target))
+    } else if target.node_type.eq_ignore_ascii_case("extrude") {
+        resolve_extrude_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("line") {
         let idx = find_sphere_index(root, target)?;
         let start = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
@@ -2298,6 +2309,22 @@ pub fn resolve_wrangle_geometry_with_errors(
     }
 }
 
+/// The Extrude node: the input extruded as a whole along its point normals
+/// (`crate::shapes::extrude_detail`).
+pub fn resolve_extrude_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let input_node = find_input_node(root, target, &node_param_str(target, "Input", ""))?;
+    let input = generate_single_node_geometry_with_errors(root, input_node, visited, ocl_error, sim)?;
+    let distance = node_param_f32(target, "Distance", 0.2);
+    let keep_base = node_param_str(target, "Keep Base", "true") != "false";
+    Some(crate::shapes::extrude_detail(&input, distance, keep_base))
+}
+
 /// The Switch node: one of up to four inputs, chosen by Index.
 ///
 /// What a composed subnet puts behind a choice: the Embryo's Source is a
@@ -2357,11 +2384,10 @@ pub fn export_settings(target: &FsNode) -> (crate::export::Format, f32) {
 
 /// The Grid node: a flat sheet of quads in the XZ plane.
 ///
-/// Native, where Plane is an OpenCL subnet. Both make a grid; this one costs
-/// no kernel compile, produces welded points rather than a corner list that
-/// has to be welded on the way back, and can therefore be the input to a
-/// remesh or a diffusion without a round trip. Plane stays because a kernel
-/// generator is a useful thing to have an example of.
+/// Native from the start, where Plane was an OpenCL subnet until 2026-09-24
+/// (it is native too now, in `crate::shapes`). Both make a sheet of quads;
+/// Plane keeps its three Center sliders and its colour gradient, this one a
+/// float3 Center and no colour. Two nodes for history's sake.
 ///
 /// Placed at Center rather than by its position in the graph. The older
 /// generators offset themselves by an index so several of them do not stack,
@@ -5557,14 +5583,14 @@ pub fn run_kernel_on_detail(
 /// The NATIVE geometry types — the ones
 /// [`generate_single_node_geometry_with_errors`] dispatches on directly.
 ///
-/// Subnet templates (Box, Sphere, Plane, Extrude) are NOT here: they
-/// instantiate as type `node` and resolve through their `output` child, so
-/// their type never reaches this list. `"box"` sat here from 2026-06-13 to
-/// 2026-09-19 for that reason — `nodes/box.json` has always been a subnet,
-/// no node ever carried the type, and no resolver ever matched it.
-/// `test_every_listed_geometry_type_has_a_resolver` now keeps that from
-/// recurring: an entry here with no dispatch arm is a node type that would
-/// resolve to nothing, silently.
+/// Subnet templates (the Embryo) are NOT here: they instantiate as type
+/// `node` and resolve through their `output` child, so their type never
+/// reaches this list. `"box"` sat here from 2026-06-13 to 2026-09-19 while
+/// `nodes/box.json` was a kernel subnet no node ever carried the type of —
+/// it is a native type now (2026-09-24, with sphere, plane and extrude), and
+/// `test_every_listed_geometry_type_has_a_resolver` keeps an entry with no
+/// dispatch arm from recurring: that is a node type that would resolve to
+/// nothing, silently.
 pub fn is_geometry_node_type(node_type: &str) -> bool {
     let nt = node_type.to_lowercase();
     nt == "sphere"
@@ -5595,6 +5621,9 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "mold_shell"
         || nt == "hull"
         || nt == "wrangle"
+        || nt == "box"
+        || nt == "plane"
+        || nt == "extrude"
         || nt == "switch"
         || nt == "volume"
         || nt == "deform"
@@ -5698,8 +5727,29 @@ pub fn network_sphere_vertices_with_errors(
             let idx = *count;
             *count += 1;
             if is_visible {
-                let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
-                out.merge(&sphere_detail(center, node_param_f32(node, "Radius", 0.5).max(0.05), 16, 24));
+                let legacy = (!crate::shapes::sphere_has_center(node)).then(|| index_center(idx));
+                out.merge(&crate::shapes::sphere_node_detail(node, legacy));
+            }
+        } else if node.node_type.eq_ignore_ascii_case("box") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                out.merge(&crate::shapes::box_node_detail(node));
+            }
+        } else if node.node_type.eq_ignore_ascii_case("plane") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                out.merge(&crate::shapes::plane_node_detail(node));
+            }
+        } else if node.node_type.eq_ignore_ascii_case("extrude") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_extrude_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
             }
         } else if node.node_type.eq_ignore_ascii_case("line") {
             let idx = *count;
@@ -6132,6 +6182,12 @@ pub fn points_detail(node: &FsNode, center: Vec3) -> Detail {
     d
 }
 
+/// Where the index-placed generators (a bare sphere, Line, Points) stand:
+/// a 4-wide row of cells 1.25 apart, so several of them do not stack.
+pub fn index_center(idx: usize) -> Vec3 {
+    Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25)
+}
+
 pub fn find_sphere_index(root: &FsNode, target: &FsNode) -> Option<usize> {
     fn visit(node: &FsNode, target: &FsNode, count: &mut usize) -> Option<usize> {
         // By id, not by pointer: a node evaluated with its parameter
diff --git a/src/kernel_cpu.rs b/src/kernel_cpu.rs
index 6b914f3..80609c2 100644
--- a/src/kernel_cpu.rs
+++ b/src/kernel_cpu.rs
@@ -1675,29 +1675,48 @@ mod template_tests {
     use super::*;
     use crate::geometry::{parse_dynamic_params, preprocess_opencl_code, run_opencl_kernel_with_params};
 
-    /// A template's kernel Code + the default param values, exactly as the
-    /// resolve path would flatten them.
-    fn template_kernel(template_name: &str) -> (String, Vec<f32>) {
-        let root = crate::app::load_fs_tree();
-        let tpl = root
-            .children
-            .iter()
-            .find(|t| t.name == template_name)
-            .unwrap_or_else(|| panic!("{template_name} template"));
-        let opencl = tpl
-            .children
-            .iter()
-            .find(|c| c.node_type == "opencl")
-            .expect("template has an opencl child");
-        let code = opencl
-            .params
-            .iter()
-            .find(|p| p.name == "Code")
-            .expect("Code param")
-            .default
-            .clone();
+    /// A small generator in the kernel language: `Count` triangles of
+    /// `Size`, one per unit along x. The four template kernels this suite
+    /// used to run are native nodes now (2026-09-24), so the launcher
+    /// contract is exercised on this and on the OpenCL node's own default.
+    const GENERATOR: &str = r#"
+__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) {
+    int id = get_global_id(0);
+    if (id == 0) {
+        float s = chf("Size", 0.5f);
+        int n = chi("Count", 3);
+        int count = 0;
+        for (int t = 0; t < n; t++) {
+            float px[3] = {0.0f, s, 0.0f};
+            float py[3] = {0.0f, 0.0f, s};
+            for (int v = 0; v < 3; v++) {
+                int idx = count++;
+                if (idx < max_vertices) {
+                    out_pos[idx * 3 + 0] = px[v] + (float)t;
+                    out_pos[idx * 3 + 1] = py[v];
+                    out_pos[idx * 3 + 2] = 0.0f;
+                    out_col[idx * 3 + 0] = 0.5f + 0.5f * px[v];
+                    out_col[idx * 3 + 1] = 0.2f;
+                    out_col[idx * 3 + 2] = (float)t / (float)n;
+                }
+            }
+        }
+        *out_count = count;
+    }
+}"#;
+
+    /// The OpenCL node's shipped default: the one kernel still in a template.
+    fn opencl_node_default() -> String {
+        let templates = crate::app::load_fs_tree();
+        let node = templates.children.iter().find(|t| t.node_type == "opencl").expect("the OpenCL node template");
+        node.params.iter().find(|p| p.name == "Code").expect("Code param").default.clone()
+    }
+
+    /// A kernel prepared as the launcher prepares it: preprocessed, with its
+    /// parameters flattened in declaration order at their defaults.
+    fn prepared(code: &str) -> (String, Vec<f32>) {
         let mut flat = Vec::new();
-        for p in parse_dynamic_params(&code) {
+        for p in parse_dynamic_params(code) {
             if p.param_type == "float3" {
                 let parts: Vec<f32> = p.default.split(':').filter_map(|s| s.parse().ok()).collect();
                 flat.extend_from_slice(&[
@@ -1713,7 +1732,7 @@ mod template_tests {
                 flat.push(p.default.parse().unwrap_or(0.0));
             }
         }
-        (preprocess_opencl_code(&code), flat)
+        (preprocess_opencl_code(code), flat)
     }
 
     fn triangle() -> Geometry {
@@ -1725,39 +1744,25 @@ mod template_tests {
     }
 
     #[test]
-    fn cpu_runs_the_sphere_template() {
-        let (code, params) = template_kernel("Sphere");
+    fn cpu_runs_a_generator_kernel() {
+        let (code, params) = prepared(GENERATOR);
+        assert_eq!(params, vec![0.5, 3.0], "Size then Count, in declaration order");
         let mut g = Geometry::new();
-        run_kernel_cpu(&code, &mut g, &params).expect("sphere kernel");
-        // Same asserts as the OpenCL-side test: 16*24*6 vertices on a 0.5
-        // sphere centred at (0, 0.55, 0).
-        assert_eq!(g.vertices.len(), 2304);
-        let max_dist = g
-            .vertices
-            .iter()
-            .map(|v| {
-                let (dx, dy, dz) = (v.pos[0], v.pos[1] - 0.55, v.pos[2]);
-                (dx * dx + dy * dy + dz * dz).sqrt()
-            })
-            .fold(0.0f32, f32::max);
-        assert!((max_dist - 0.5).abs() < 0.01, "radius {max_dist}");
+        run_kernel_cpu(&code, &mut g, &params).expect("generator kernel");
+        assert_eq!(g.vertices.len(), 9, "three triangles of three corners");
+        assert_eq!(g.vertices[3].pos, [1.0, 0.0, 0.0], "the second triangle starts one unit along");
+        assert!((g.vertices[4].col[0] - 0.75).abs() < 1e-6);
     }
 
     #[test]
-    fn cpu_runs_the_plane_box_and_extrude_templates() {
-        for (name, input, expect_nonempty) in [
-            ("Plane", Geometry::new(), true),
-            ("Box", Geometry::new(), true),
-            ("Extrude", triangle(), true),
-        ] {
-            let (code, params) = template_kernel(name);
-            let mut g = input;
-            run_kernel_cpu(&code, &mut g, &params).unwrap_or_else(|e| panic!("{name} kernel: {e}"));
-            assert_eq!(!g.vertices.is_empty(), expect_nonempty, "{name} produced no geometry");
-            for v in &g.vertices {
-                assert!(v.pos.iter().all(|c| c.is_finite()), "{name} produced non-finite positions");
-            }
-        }
+    fn cpu_runs_the_opencl_nodes_default_deformer() {
+        let (code, params) = prepared(&opencl_node_default());
+        let mut g = triangle();
+        run_kernel_cpu(&code, &mut g, &params).expect("default deformer");
+        assert_eq!(g.vertices.len(), 3, "a deformer keeps its vertex count");
+        // y += sin(x * 4) * 0.15 at x = 1: the second corner rises.
+        assert!((g.vertices[1].pos[1] - (4.0f32).sin() * 0.15).abs() < 1e-5, "{:?}", g.vertices[1].pos);
+        assert_eq!(g.vertices[0].pos[1], 0.0, "and x = 0 does not move");
     }
 
     /// The reference test proper: byte-level agreement with OpenCL on every
@@ -1765,13 +1770,12 @@ mod template_tests {
     /// tests above still cover the CPU side there.
     #[test]
     fn cpu_matches_opencl_on_every_shipped_kernel() {
-        for (name, input) in [
-            ("Sphere", Geometry::new()),
-            ("Plane", Geometry::new()),
-            ("Box", Geometry::new()),
-            ("Extrude", triangle()),
+        let default = opencl_node_default();
+        for (name, source, input) in [
+            ("generator", GENERATOR.to_string(), Geometry::new()),
+            ("opencl default", default, triangle()),
         ] {
-            let (code, params) = template_kernel(name);
+            let (code, params) = prepared(&source);
 
             let mut gpu = input.clone();
             match run_opencl_kernel_with_params(&code, &mut gpu, &params) {
diff --git a/src/main.rs b/src/main.rs
index 429202d..7dc3b5d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,6 +11,7 @@ pub mod remesh;
 pub mod spatial;
 pub mod volume;
 pub mod wrangle;
+pub mod shapes;
 
 // Root-level aliases some modules import via `crate::` paths.
 #[allow(unused_imports)]
@@ -282,10 +283,10 @@ mod tests {
         let mut redraw = false;
         state
             .apply_action(
-                McpAction::AddNode { template_name: "Sphere".to_string(), name: None, x: 5.0, y: 5.0 },
+                McpAction::AddNode { template_name: "Embryo".to_string(), name: None, x: 5.0, y: 5.0 },
                 &mut redraw,
             )
-            .expect("add sphere node");
+            .expect("add embryo node");
         let added = state.current_dir().children.last().unwrap();
         assert!(!added.geometry_visible, "added node must start hidden");
         assert!(
@@ -575,8 +576,8 @@ mod tests {
         assert_eq!(by_name("my_region").params[0].default, "sphere1_2", "the wire followed the rename");
         assert_eq!(by_name("sphere1").params[0].default, "camera1");
         assert_eq!(proj.view_state.active_camera, "camera1");
-        // The template children inside the sphere were never spaced and are untouched.
-        assert!(by_name("sphere1_2").children.iter().any(|c| c.name == "opencl1"));
+        // The sphere is the native node the bundled file now holds.
+        assert_eq!(by_name("sphere1_2").node_type, "sphere");
 
         // A clean file is left exactly alone.
         let before = serde_json::to_string(&proj).unwrap();
@@ -1569,33 +1570,38 @@ mod tests {
         }
     }
 
+    /// A subnet template's children resolve against their own templates: a
+    /// child names a base by type, takes its params, and lays its overrides
+    /// on top. The Embryo is the shipped example (the four kernel subnets it
+    /// used to share this test with are native nodes since 2026-09-24).
     #[test]
     fn test_subnet_template_child_resolution() {
         let templates_root = crate::app::load_fs_tree();
-        let box_template = templates_root
+        let embryo = templates_root
             .children
             .iter()
-            .find(|t| t.name == "Box")
-            .expect("Box template should be loaded");
-        
-        assert_eq!(box_template.children.len(), 3);
-        
-        let input1 = box_template.children.iter().find(|c| c.name == "input1").unwrap();
+            .find(|t| t.name == "Embryo")
+            .expect("Embryo template should be loaded");
+        assert_eq!(embryo.node_type, "node");
+        assert_eq!(embryo.children.len(), 10);
+
+        let input1 = embryo.children.iter().find(|c| c.name == "input1").unwrap();
         assert_eq!(input1.node_type, "input");
-        
-        let opencl1 = box_template.children.iter().find(|c| c.name == "opencl1").unwrap();
-        assert_eq!(opencl1.node_type, "opencl");
-        
-        let input_param = opencl1.params.iter().find(|p| p.name == "Input").unwrap();
-        assert_eq!(input_param.default, "input1");
-        
-        let update_param = opencl1.params.iter().find(|p| p.name == "Update Parameters").unwrap();
-        assert_eq!(update_param.param_type, "button");
-        
-        let output1 = box_template.children.iter().find(|c| c.name == "output1").unwrap();
+
+        // The nested sphere is the native Sphere with the template's whole
+        // surface, the Embryo's overrides on top of it.
+        let sphere1 = embryo.children.iter().find(|c| c.name == "sphere1").unwrap();
+        assert_eq!(sphere1.node_type, "sphere");
+        assert!(sphere1.children.is_empty(), "a native node has no children");
+        assert!(sphere1.params.iter().any(|p| p.name == "Method"), "the base template's params arrive");
+        let radius = sphere1.params.iter().find(|p| p.name == "Radius").unwrap();
+        assert!(radius.expr && radius.default.contains("Radius"), "the override is the reference: {} (expr {})", radius.default, radius.expr);
+        assert_eq!(sphere1.params.iter().find(|p| p.name == "Center Y").unwrap().default, "0.0");
+
+        let output1 = embryo.children.iter().find(|c| c.name == "output1").unwrap();
         assert_eq!(output1.node_type, "output");
         let output_input = output1.params.iter().find(|p| p.name == "Input").unwrap();
-        assert_eq!(output_input.default, "opencl1");
+        assert_eq!(output_input.default, "normal1");
     }
 
     /// The raster pipeline culls back faces with CCW fronts (the wgpu
@@ -1785,29 +1791,20 @@ mod tests {
         assert_color_toggle_switches_between_gradient_and_default("Plane");
     }
 
+    /// The Sphere is a native node: no children, welded points, closed.
     #[test]
-    fn test_sphere_subnet_geometry_generation() {
+    fn test_sphere_node_geometry_generation() {
         let templates_root = crate::app::load_fs_tree();
         let sphere_template = templates_root
             .children
             .iter()
             .find(|t| t.name == "Sphere")
             .expect("Sphere template should be loaded");
-        
-        assert_eq!(sphere_template.children.len(), 2);
-        
-        let opencl1 = sphere_template.children.iter().find(|c| c.name == "opencl1").unwrap();
-        assert_eq!(opencl1.node_type, "opencl");
-        
-        let output1 = sphere_template.children.iter().find(|c| c.name == "output1").unwrap();
-        assert_eq!(output1.node_type, "output");
-        
+        assert_eq!(sphere_template.node_type, "sphere");
+        assert!(sphere_template.children.is_empty());
+
         let mut sphere_instance = sphere_template.clone();
         sphere_instance.id = "sphere_inst".to_string();
-        for child in &mut sphere_instance.children {
-            child.id = format!("{}_{}", sphere_instance.id, child.name);
-        }
-        
         let root = FsNode {
             id: "root".to_string(),
             name: "root".to_string(),
@@ -1819,77 +1816,25 @@ mod tests {
             inputs: 0,
             outputs: 0,
         };
-        
         let mut visited = Vec::new();
-        let mut ocl_err = None;
+        let mut err = None;
         let geom = crate::geometry::generate_single_node_geometry_with_errors(
             &root,
             &root.children[0],
             &mut visited,
-            &mut ocl_err,
+            &mut 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);
+        )
+        .expect("sphere generation failed");
+        assert!(err.is_none(), "{err:?}");
         assert_eq!(geom.num_points(), crate::geometry::sphere_point_len(16, 24));
-        
-        let mut max_dist: f32 = 0.0;
+        assert_eq!(geom.num_prims(), 24 * 2 + 24 * 14, "two pole fans and fourteen bands of quads");
+        assert!(geom.is_closed());
         for pos in geom.positions() {
-            let dx = pos[0] - 0.0;
-            let dy = pos[1] - 0.55;
-            let dz = pos[2] - 0.0;
-            let dist = (dx*dx + dy*dy + dz*dz).sqrt();
-            if dist > max_dist {
-                max_dist = dist;
-            }
-        }
-        assert!((max_dist - 0.5).abs() < 0.01, "Expected radius around 0.5, got {}", max_dist);
-        
-        let mut sphere_instance_2 = sphere_template.clone();
-        sphere_instance_2.id = "sphere_inst_2".to_string();
-        for child in &mut sphere_instance_2.children {
-            child.id = format!("{}_{}", sphere_instance_2.id, child.name);
-        }
-        if let Some(radius_param) = sphere_instance_2.params.iter_mut().find(|p| p.name == "Radius") {
-            radius_param.default = "1.0".to_string();
-        }
-        
-        let root_2 = FsNode {
-            id: "root".to_string(),
-            name: "root".to_string(),
-            node_type: "node".to_string(),
-            children: vec![sphere_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.num_points(), crate::geometry::sphere_point_len(16, 24));
-        
-        let mut max_dist_2: f32 = 0.0;
-        for pos in geom_2.positions() {
-            let dx = pos[0] - 0.0;
-            let dy = pos[1] - 0.55;
-            let dz = pos[2] - 0.0;
-            let dist = (dx*dx + dy*dy + dz*dz).sqrt();
-            if dist > max_dist_2 {
-                max_dist_2 = dist;
-            }
+            let r = (pos[0].powi(2) + (pos[1] - 0.55).powi(2) + pos[2].powi(2)).sqrt();
+            assert!((r - 0.5).abs() < 1e-4, "point {pos:?} is {r} from the centre");
         }
-        assert!((max_dist_2 - 1.0).abs() < 0.01, "Expected radius around 1.0, got {}", max_dist_2);
+        assert!(geom.points().has("Norm") && geom.points().has("UV") && geom.points().has("Cd"));
     }
 
     /// The native curve node: a Catmull-Rom strip through the "Points"
@@ -2252,99 +2197,66 @@ mod tests {
         assert!(!state.viewer_tool_undo());
     }
 
-    /// 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.
+    /// The Extrude node extrudes the input AS A WHOLE: points move along
+    /// their normals, boundary edges grow walls, and Keep Base closes the
+    /// bottom. A 2 x 2 plane becomes a closed slab of 18 points and 16
+    /// primitives; without the base it is open and 4 primitives lighter. A
+    /// closed sphere grows no walls at all — it becomes a two-skinned shell.
     #[test]
-    fn test_extrude_subnet_geometry_generation() {
+    fn test_extrude_node_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.node_type, "extrude");
+        assert!(extrude_template.children.is_empty());
         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 plane = ref_node("p", "plane1", "plane", vec![("Rows", "spinbox", "2"), ("Columns", "spinbox", "2"), ("Width", "slider", "1"), ("Length", "slider", "1")], vec![]);
+        let extrude = |keep: &str| {
+            ref_node("e", "extrude1", "extrude", vec![("Input", "text", "plane1"), ("Distance", "slider", "0.2"), ("Keep Base", "toggle", keep)], vec![])
         };
-
-        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,
-            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
-        ).expect("Extrude geometry generation failed");
-        assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
-        // The extrude kernel builds a wall per input triangle, so its output
-        // is a soup of loose shells; welding it is what 1850 counts.
-        assert_eq!(geom.num_points(), 1850);
-
-        // 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 pos in geom.positions() {
-            let dx = pos[0];
-            let dy = pos[1] - 0.55;
-            let dz = 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,
-            &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
-        ).expect("Extrude geometry generation failed (no base)");
-        assert!(ocl_err2.is_none(), "OpenCL compilation error: {:?}", ocl_err2);
-        // Same POINTS as the based variant: the base cap's corners are the
-        // wall corners, so dropping the cap removes primitives, not places.
-        // The primitive count is where the two variants actually differ.
-        assert_eq!(geom2.num_points(), geom.num_points());
-        assert!(
-            geom2.num_prims() < geom.num_prims(),
-            "no-base extrude should have fewer prims: {} vs {}",
-            geom2.num_prims(),
-            geom.num_prims()
-        );
+        let root = ref_node("root", "root", "node", vec![], vec![plane.clone(), extrude("true")]);
+        let (g, err) = eval(&root, &root.children[1]);
+        assert!(err.is_none(), "{err:?}");
+        let g = g.unwrap();
+        assert_eq!(g.num_points(), 18, "every point once, and once lifted");
+        assert_eq!(g.num_prims(), 4 + 8 + 4, "top, eight boundary walls, base");
+        assert!(g.is_closed(), "with the base kept the slab is watertight");
+        for p in 0..9 {
+            assert!((g.pos(p + 9).y - g.pos(p).y - 0.2).abs() < 1e-5, "top point {p} sits Distance above its base");
+        }
+        // Outward: every face normal points away from the slab's centre.
+        let centre = (0..18).map(|p| g.pos(p)).sum::<Vec3>() / 18.0;
+        for pr in 0..g.num_prims() {
+            let pts = g.prim_points(pr);
+            let (a, b, c) = (g.pos(pts[0] as usize), g.pos(pts[1] as usize), g.pos(pts[2] as usize));
+            let n = (b - a).cross(c - a);
+            let mid = pts.iter().map(|&q| g.pos(q as usize)).sum::<Vec3>() / pts.len() as f32;
+            assert!(n.dot(mid - centre) > 0.0, "primitive {pr} faces inward");
+        }
+        assert!(g.points().has("Cd") && g.points().has("UV"), "point attributes ride to the top");
+
+        let root2 = ref_node("root", "root", "node", vec![], vec![plane, extrude("false")]);
+        let g2 = eval(&root2, &root2.children[1]).0.unwrap();
+        assert_eq!(g2.num_points(), 18, "the base's corners are the walls' corners: same places, fewer faces");
+        assert_eq!(g2.num_prims(), 4 + 8);
+        assert!(!g2.is_closed(), "no base, open bottom");
+
+        // A closed input: no boundary, so no walls — an outer and an inner
+        // skin, the farthest points Distance beyond the sphere.
+        let sphere = ref_node("s", "sphere1", "sphere", vec![("Radius", "slider", "0.5"), ("Center X", "slider", "0"), ("Center Y", "slider", "0.55"), ("Center Z", "slider", "0")], vec![]);
+        let ext = ref_node("e", "extrude1", "extrude", vec![("Input", "text", "sphere1"), ("Distance", "slider", "0.2"), ("Keep Base", "toggle", "true")], vec![]);
+        let root3 = ref_node("root", "root", "node", vec![], vec![sphere, ext]);
+        let g3 = eval(&root3, &root3.children[1]).0.unwrap();
+        let base = crate::geometry::sphere_point_len(16, 24);
+        assert_eq!(g3.num_points(), 2 * base);
+        assert_eq!(g3.num_prims(), 2 * (24 * 2 + 24 * 14), "no walls on a closed surface");
+        assert!(g3.is_closed());
+        let max_dist = g3.positions().iter().map(|p| (p[0].powi(2) + (p[1] - 0.55).powi(2) + p[2].powi(2)).sqrt()).fold(0.0f32, f32::max);
+        assert!((max_dist - 0.7).abs() < 1e-3, "expected max extent ~0.7, got {max_dist}");
     }
 
     /// Group membership → viewport markers: the `group:<name>` tags a Group
@@ -2719,11 +2631,25 @@ mod tests {
             inputs: 0,
             outputs: 0,
         };
-        // A tree as an older save carries it: a meta child on the sphere and
-        // on its internal stages, plus the ROOT meta node beside them.
+        // A tree as an older save carries it: a meta child on the sphere, one
+        // on a node INSIDE a subnet, plus the ROOT meta node beside them.
         sphere.children.push(meta_child("s_meta"));
-        let opencl_idx = sphere.children.iter().position(|c| c.name == "opencl1").unwrap();
-        sphere.children[opencl_idx].children.push(meta_child("s_ocl_meta"));
+        let mut inner = camera_t.clone();
+        inner.id = "inner".to_string();
+        inner.name = "inner1".to_string();
+        inner.children.push(meta_child("inner_meta"));
+        let mut sub = FsNode {
+            id: "sub".to_string(),
+            name: "sub1".to_string(),
+            node_type: "node".to_string(),
+            children: vec![inner],
+            params: vec![],
+            geometry_visible: false,
+            position: (2.0, 0.0),
+            inputs: 0,
+            outputs: 0,
+        };
+        sub.children[0].children.push(meta_child("inner_meta2"));
         let mut session = meta_child("root_meta");
         session.geometry_visible = true;
 
@@ -2731,7 +2657,7 @@ mod tests {
             id: "root".to_string(),
             name: "root".to_string(),
             node_type: "node".to_string(),
-            children: vec![sphere, camera, session],
+            children: vec![sphere, camera, session, sub],
             params: vec![],
             geometry_visible: true,
             position: (0.0, 0.0),
@@ -2743,8 +2669,8 @@ mod tests {
 
         // Gone from the placed nodes, at every depth…
         assert!(!root.children[0].children.iter().any(|c| c.node_type == "meta"));
-        let opencl = root.children[0].children.iter().find(|c| c.name == "opencl1").unwrap();
-        assert!(!opencl.children.iter().any(|c| c.node_type == "meta"));
+        let inner = root.children[3].children.iter().find(|c| c.name == "inner1").unwrap();
+        assert!(!inner.children.iter().any(|c| c.node_type == "meta"));
         // …and the root meta node, which is the SESSION container and not a
         // per-node child at all, is still standing.
         assert!(
@@ -2886,28 +2812,42 @@ mod tests {
 
     /// 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
-    /// params actually work), and non-template lookalikes are left alone.
+    /// KERNEL SUBNET saved while Sphere was one becomes the native node with
+    /// its values intact and its children gone, and non-template lookalikes
+    /// are left alone.
     #[test]
     fn test_loader_merges_new_template_params() {
         let templates_root = crate::app::load_fs_tree();
         let templates = crate::app::flatten_node_templates(&templates_root);
-        let sphere_t = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
         let group_t = templates_root.children.iter().find(|t| t.name == "Group").unwrap();
+        let opencl_t = templates_root.children.iter().find(|t| t.node_type == "opencl").unwrap();
+        let output_t = templates_root.children.iter().find(|t| t.node_type == "output").unwrap();
 
         // An "old save": a Sphere instance from before the construction
-        // controls — only Radius (with a user value), and a stale kernel.
-        let mut old_sphere = sphere_t.clone();
-        old_sphere.id = "s".to_string();
-        old_sphere.name = "Sphere 3".to_string();
-        for child in &mut old_sphere.children {
-            child.id = format!("{}_{}", old_sphere.id, child.name);
-        }
-        old_sphere.params.retain(|p| p.name == "Radius");
-        old_sphere.params[0].default = "0.70".to_string();
-        let opencl = old_sphere.children.iter_mut().find(|c| c.name == "opencl1").unwrap();
-        opencl.params.iter_mut().find(|p| p.name == "Code").unwrap().default =
-            "OLD KERNEL".to_string();
+        // controls AND from before the port — a subnet of opencl1 → output1
+        // holding only Radius, with a user value, and a stale kernel.
+        let mut opencl1 = opencl_t.clone();
+        opencl1.id = "s_opencl1".to_string();
+        opencl1.name = "opencl1".to_string();
+        opencl1.params.iter_mut().find(|p| p.name == "Code").unwrap().default = "OLD KERNEL".to_string();
+        let mut output1 = output_t.clone();
+        output1.id = "s_output1".to_string();
+        output1.name = "output1".to_string();
+        output1.params.iter_mut().find(|p| p.name == "Input").unwrap().default = "opencl1".to_string();
+        let old_sphere = FsNode {
+            id: "s".to_string(),
+            name: "Sphere 3".to_string(),
+            node_type: "node".to_string(),
+            children: vec![opencl1, output1],
+            params: vec![crate::app::ParamDef {
+                name: "Radius".into(), label: String::new(), param_type: "slider".into(), default: "0.70".into(),
+                options: vec![], min: None, max: None, step: None, show_when: String::new(), expr: false,
+            }],
+            geometry_visible: true,
+            position: (3.0, 1.0),
+            inputs: 0,
+            outputs: 1,
+        };
 
         // An old Group missing a later-added param, with a kept value.
         let mut old_group = group_t.clone();
@@ -2943,16 +2883,16 @@ mod tests {
         };
         crate::app::merge_template_defs(&mut root, &templates);
 
-        // Sphere: new params inserted where the template puts them — Method
-        // ABOVE the Radius the instance already had, the rest after it —
-        // with template defaults, value kept, kernel refreshed.
+        // Sphere: the kernel subnet is the native node now — same id, name
+        // and position, no children — and new params are inserted where the
+        // template puts them: Method ABOVE the Radius the instance already
+        // had, the rest after it, with template defaults and the value kept.
         let s = &root.children[0];
+        assert_eq!((s.node_type.as_str(), s.id.as_str(), s.name.as_str(), s.position), ("sphere", "s", "Sphere 3", (3.0, 1.0)));
+        assert!(s.children.is_empty(), "the opencl and output children go");
         let names: Vec<&str> = s.params.iter().map(|p| p.name.as_str()).collect();
         assert_eq!(names, ["Method", "Radius", "Rows", "Columns", "Frequency", "Resolution", "Center X", "Center Y", "Center Z", "Color"]);
         assert_eq!(s.params.iter().find(|p| p.name == "Radius").unwrap().default, "0.70", "instance value survives");
-        let code = &s.children.iter().find(|c| c.name == "opencl1").unwrap()
-            .params.iter().find(|p| p.name == "Code").unwrap().default;
-        assert!(code.contains("chi(\"Rows\""), "kernel refreshed from template");
 
         // And the merged instance evaluates with the new controls live.
         let mut merged_sphere_root = root.clone();
@@ -3061,9 +3001,8 @@ mod tests {
     /// onto the sphere). The welded point counts are the closed forms —
     /// 10f^2 + 2 and 6r^2 + 2 — which hold only if every corner two faces
     /// share lands on the same point, and closedness says the winding came
-    /// out consistent after the outward turn. Method reaches the kernel as
-    /// an option INDEX: the choice's text used to parse as 0, so a kernel
-    /// could not read a dropdown at all.
+    /// out consistent after the outward turn. Native since 2026-09-24
+    /// (`src/shapes.rs`); it was a kernel reading Method as an option index.
     #[test]
     fn sphere_method_builds_a_uv_ico_or_cube_sphere() {
         let templates_root = crate::app::load_fs_tree();
@@ -3133,7 +3072,7 @@ mod tests {
         for (res, expect) in [("1", 8), ("3", 56), ("8", 386)] {
             let cube = build(&[("Method", "Cube"), ("Resolution", res), ("Radius", "0.8")]);
             assert_eq!(cube.num_points(), expect, "cube sphere at resolution {res}");
-            assert_eq!(cube.num_prims(), 6 * res.parse::<usize>().unwrap().pow(2) * 2);
+            assert_eq!(cube.num_prims(), 6 * res.parse::<usize>().unwrap().pow(2), "one quad per cell — the kernel fanned them");
             assert!(cube.is_closed(), "cube sphere at resolution {res} is not closed");
             on_sphere(&cube, 0.8);
         }
@@ -3234,23 +3173,18 @@ mod tests {
         assert!(chained.points().has("mass"));
     }
 
-    /// 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.
+    /// The Plane node: a Columns x Rows sheet of quads on XZ at y = 0, Width
+    /// and Length its sides. Native since 2026-09-24; it was a kernel subnet.
     #[test]
-    fn test_plane_subnet_geometry_generation() {
+    fn test_plane_node_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");
+        assert_eq!(plane_template.node_type, "plane");
+        assert!(plane_template.children.is_empty());
 
         let generate = |overrides: &[(&str, &str)], id: &str| {
             let mut inst = plane_template.clone();
@@ -6602,9 +6536,9 @@ mod tests {
         // Nested template resolution: the sphere inside carries its own
         // resolved children, kernel node params included.
         let sphere1 = t.children.iter().find(|c| c.name == "sphere1").unwrap();
-        let opencl1 = sphere1.children.iter().find(|c| c.name == "opencl1").expect("the nested sphere's kernel node");
-        assert!(opencl1.params.iter().any(|p| p.name == "Input"), "the nested kernel node has its template's params");
-        assert!(opencl1.params.iter().find(|p| p.name == "Code").unwrap().default.contains("chf(\"Radius\""));
+        assert_eq!(sphere1.node_type, "sphere", "the nested sphere is the native Sphere");
+        assert!(sphere1.params.iter().any(|p| p.name == "Method"), "with its template's whole surface");
+        assert!(sphere1.params.iter().find(|p| p.name == "Radius").unwrap().expr, "and the Embryo's reference on its Radius");
 
         let instance = |overrides: &[(&str, &str)], extra: Vec<FsNode>| {
             let mut inst = t.clone();
diff --git a/src/shapes.rs b/src/shapes.rs
new file mode 100644
index 0000000..e5968db
--- /dev/null
+++ b/src/shapes.rs
@@ -0,0 +1,458 @@
+//! The four shapes that were kernel subnets — Sphere, Box, Plane, Extrude —
+//! as native generators. Phase 7 step 3 of `shapeshifter.md`.
+//!
+//! Each was a subnet of `input → opencl → output` whose kernel ran under
+//! `if (id == 0)`: one work item doing loops, then a weld by position on the
+//! way back that threw away every shared point the loop had known about.
+//! Native, each builds welded points and real primitives directly — a quad
+//! stays a quad — costs no JIT compile, and needs no OpenCL runtime at all.
+//! The parameter surfaces are the templates' own, so a saved instance keeps
+//! its values through `nativize_kernel_subnets` in `merge_template_defs`.
+//!
+//! The Sphere's three methods (UV, Icosphere, Cube) weld by a QUANTIZED
+//! position key rather than by trusting bit-identical arithmetic across
+//! faces: the kernel summed barycentric weights in one fixed expression so
+//! shared corners landed on the same bits, then welded at 1e-4 anyway. A
+//! quantized key is the same guarantee stated once.
+
+use crate::app::FsNode;
+use crate::detail::{AttribData, Detail, CD, DEFAULT_COLOR};
+use crate::geometry::{node_param_f32, node_param_str, node_param_vec3, point_normals, sphere_detail};
+use glam::Vec3;
+use std::collections::HashMap;
+
+/// The golden ratio: the icosahedron's corners sit on three orthogonal
+/// golden rectangles.
+const T: f32 = 1.618_034;
+
+/// Points welded by quantized position, so a corner two faces share is one
+/// point whichever face names it first.
+struct Welder {
+    map: HashMap<[i64; 3], u32>,
+    d: Detail,
+}
+
+impl Welder {
+    fn new() -> Self {
+        Welder { map: HashMap::new(), d: Detail::new() }
+    }
+
+    fn point(&mut self, p: Vec3) -> u32 {
+        let key = [
+            (p.x as f64 * 1e5).round() as i64,
+            (p.y as f64 * 1e5).round() as i64,
+            (p.z as f64 * 1e5).round() as i64,
+        ];
+        if let Some(&i) = self.map.get(&key) {
+            return i;
+        }
+        let i = self.d.add_point(p);
+        self.map.insert(key, i);
+        i
+    }
+
+    /// A primitive turned OUTWARD about the origin: the shapes here are
+    /// convex about their centre, so a face whose normal points toward its
+    /// own centroid is inside out, whatever table it came from.
+    fn outward_prim(&mut self, pts: &[u32]) {
+        let a = self.d.pos(pts[0] as usize);
+        let b = self.d.pos(pts[1] as usize);
+        let c = self.d.pos(pts[2] as usize);
+        let n = (b - a).cross(c - a);
+        let centroid: Vec3 = pts.iter().map(|&p| self.d.pos(p as usize)).sum();
+        if n.dot(centroid) < 0.0 {
+            let rev: Vec<u32> = pts.iter().rev().copied().collect();
+            self.d.add_prim(&rev);
+        } else {
+            self.d.add_prim(pts);
+        }
+    }
+}
+
+/// Whether a sphere node carries its own Center parameters. A bare `sphere`
+/// node built by hand (the tests' `ref_node`) has none and is placed by
+/// index, the way Line and Points still are; every node that came through a
+/// template or a load does.
+pub fn sphere_has_center(target: &FsNode) -> bool {
+    target.params.iter().any(|p| p.name == "Center X")
+}
+
+/// The Sphere node, by Method: UV (Rows x Columns), Icosphere (20 faces
+/// each split into Frequency^2 triangles), Cube (a Resolution x Resolution
+/// grid on each face, pushed out through the spherified-cube map). Welded
+/// point counts are the closed forms: `2 + (rows - 1) * cols`,
+/// `10 f^2 + 2` and `6 r^2 + 2`. Cube builds QUADS — the kernel fanned them.
+pub fn sphere_node_detail(target: &FsNode, legacy_center: Option<Vec3>) -> Detail {
+    let method = node_param_str(target, "Method", "UV").to_lowercase();
+    let radius = node_param_f32(target, "Radius", 0.5).max(1e-4);
+    let center = legacy_center.unwrap_or_else(|| {
+        Vec3::new(
+            node_param_f32(target, "Center X", 0.0),
+            node_param_f32(target, "Center Y", 0.55),
+            node_param_f32(target, "Center Z", 0.0),
+        )
+    });
+    let colored = node_param_str(target, "Color", "true") != "false";
+    let unit = match method.as_str() {
+        "icosphere" => icosphere_unit(node_param_f32(target, "Frequency", 4.0).round().clamp(1.0, 16.0) as usize),
+        "cube" => cube_sphere_unit(node_param_f32(target, "Resolution", 8.0).round().clamp(1.0, 64.0) as usize),
+        _ => {
+            let rows = node_param_f32(target, "Rows", 16.0).round().clamp(2.0, 128.0) as usize;
+            let cols = node_param_f32(target, "Columns", 24.0).round().clamp(3.0, 128.0) as usize;
+            let mut d = sphere_detail(center, radius, rows, cols);
+            finish_sphere(&mut d, center, colored);
+            return d;
+        }
+    };
+    let mut d = unit;
+    for p in 0..d.num_points() {
+        let u = d.pos(p);
+        d.set_pos(p, center + u * radius);
+    }
+    finish_sphere(&mut d, center, colored);
+    d
+}
+
+/// `Norm`, `UV` and `Cd` from the surface normal, as the sphere has always
+/// carried them. Colour is the kernel's: the SIGNED normal folded into
+/// 0..1, world-anchored so a point keeps its colour as the sphere turns.
+fn finish_sphere(d: &mut Detail, center: Vec3, colored: bool) {
+    let n: Vec<Vec3> = (0..d.num_points()).map(|p| (d.pos(p) - center).normalize_or_zero()).collect();
+    let norms = n.iter().map(|n| n.to_array()).collect();
+    let uvs = n
+        .iter()
+        .map(|n| [0.5 + n.z.atan2(n.x) / std::f32::consts::TAU, 0.5 - n.y.clamp(-1.0, 1.0).asin() / std::f32::consts::PI])
+        .collect();
+    let cds = n
+        .iter()
+        .map(|n| if colored { [0.5 + n.x * 0.5, 0.5 + n.y * 0.5, 0.5 + n.z * 0.5] } else { DEFAULT_COLOR })
+        .collect();
+    let points = d.points_mut();
+    let _ = points.insert("Norm", AttribData::Float3(norms));
+    let _ = points.insert("UV", AttribData::Float2(uvs));
+    let _ = points.insert(CD, AttribData::Float3(cds));
+}
+
+/// The unit icosphere: the icosahedron's 20 faces, each split into
+/// `freq^2` triangles by integer barycentric weights, every corner pushed
+/// onto the sphere. Row i of a face holds `freq - i` upright triangles and
+/// `freq - i - 1` inverted ones.
+fn icosphere_unit(freq: usize) -> Detail {
+    let v: [Vec3; 12] = [
+        Vec3::new(-1.0, T, 0.0),
+        Vec3::new(1.0, T, 0.0),
+        Vec3::new(-1.0, -T, 0.0),
+        Vec3::new(1.0, -T, 0.0),
+        Vec3::new(0.0, -1.0, T),
+        Vec3::new(0.0, 1.0, T),
+        Vec3::new(0.0, -1.0, -T),
+        Vec3::new(0.0, 1.0, -T),
+        Vec3::new(T, 0.0, -1.0),
+        Vec3::new(T, 0.0, 1.0),
+        Vec3::new(-T, 0.0, -1.0),
+        Vec3::new(-T, 0.0, 1.0),
+    ];
+    let faces: [[usize; 3]; 20] = [
+        [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
+        [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
+        [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
+        [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
+    ];
+    let f = freq.max(1);
+    let mut w = Welder::new();
+    for [a, b, c] in faces {
+        let corner = |w: &mut Welder, wb: usize, wc: usize| {
+            let wa = f - wb - wc;
+            let p = (v[a] * wa as f32 + v[b] * wb as f32 + v[c] * wc as f32) / f as f32;
+            w.point(p.normalize())
+        };
+        for i in 0..f {
+            for j in 0..(f - i) {
+                let p0 = corner(&mut w, i, j);
+                let p1 = corner(&mut w, i + 1, j);
+                let p2 = corner(&mut w, i, j + 1);
+                w.outward_prim(&[p0, p1, p2]);
+                if j + 1 < f - i {
+                    let q0 = corner(&mut w, i + 1, j);
+                    let q1 = corner(&mut w, i + 1, j + 1);
+                    let q2 = corner(&mut w, i, j + 1);
+                    w.outward_prim(&[q0, q1, q2]);
+                }
+            }
+        }
+    }
+    w.d
+}
+
+/// The unit cube sphere: a `res x res` grid on each face of the cube
+/// spanning -1..1, every point pushed onto the sphere by the spherified-cube
+/// map — not a bare normalize, which crowds the corners and stretches the
+/// face centres. One quad per cell.
+fn cube_sphere_unit(res: usize) -> Detail {
+    let r = res.max(1);
+    let mut w = Welder::new();
+    for face in 0..6 {
+        let axis = face / 2;
+        let sign = if face % 2 == 0 { 1.0 } else { -1.0 };
+        let at = |w: &mut Welder, i: usize, j: usize| {
+            let u = -1.0 + 2.0 * i as f32 / r as f32;
+            let v = -1.0 + 2.0 * j as f32 / r as f32;
+            let (x, y, z) = match axis {
+                0 => (sign, u, v),
+                1 => (v, sign, u),
+                _ => (u, v, sign),
+            };
+            let (x2, y2, z2) = (x * x, y * y, z * z);
+            w.point(Vec3::new(
+                x * (1.0 - y2 * 0.5 - z2 * 0.5 + y2 * z2 / 3.0).sqrt(),
+                y * (1.0 - z2 * 0.5 - x2 * 0.5 + z2 * x2 / 3.0).sqrt(),
+                z * (1.0 - x2 * 0.5 - y2 * 0.5 + x2 * y2 / 3.0).sqrt(),
+            ))
+        };
+        for i in 0..r {
+            for j in 0..r {
+                let q = [at(&mut w, i, j), at(&mut w, i + 1, j), at(&mut w, i + 1, j + 1), at(&mut w, i, j + 1)];
+                w.outward_prim(&q);
+            }
+        }
+    }
+    w.d
+}
+
+/// An axis-aligned box: eight shared corners, six quads wound
+/// counter-clockwise seen from outside, normals on the VERTICES (three faces
+/// meet at a corner with three different normals), one colour.
+pub fn cuboid_detail(center: Vec3, half: Vec3, color: [f32; 3]) -> Detail {
+    let mut d = Detail::new();
+    let (x, y, z) = (half.x, half.y, half.z);
+    let corners = [
+        center + Vec3::new(-x, -y, -z),
+        center + Vec3::new(x, -y, -z),
+        center + Vec3::new(x, y, -z),
+        center + Vec3::new(-x, y, -z),
+        center + Vec3::new(-x, -y, z),
+        center + Vec3::new(x, -y, z),
+        center + Vec3::new(x, y, z),
+        center + Vec3::new(-x, y, z),
+    ];
+    for c in corners {
+        d.add_point(c);
+    }
+    let faces: [([u32; 4], Vec3); 6] = [
+        ([3, 2, 1, 0], Vec3::NEG_Z),
+        ([6, 7, 4, 5], Vec3::Z),
+        ([7, 3, 0, 4], Vec3::NEG_X),
+        ([2, 6, 5, 1], Vec3::X),
+        ([7, 6, 2, 3], Vec3::Y),
+        ([1, 5, 4, 0], Vec3::NEG_Y),
+    ];
+    let mut norms = Vec::with_capacity(24);
+    for (quad, normal) in &faces {
+        d.add_prim(quad);
+        norms.extend(std::iter::repeat(normal.to_array()).take(4));
+    }
+    let _ = d.verts_mut().insert("Norm", AttribData::Float3(norms));
+    let _ = d.verts_mut().insert("UV", AttribData::Float2(vec![[0.0, 0.0]; 24]));
+    let _ = d.points_mut().insert(CD, AttribData::Float3(vec![color; 8]));
+    d
+}
+
+/// The Box node: a cube of Scale about Center — or, with Wireframe on, its
+/// twelve edges as thin bars and its eight corners as small cubes, which is
+/// what the kernel drew and what a reference frame wants.
+pub fn box_node_detail(target: &FsNode) -> Detail {
+    let scale = node_param_f32(target, "Scale", 1.0).max(1e-4);
+    let wireframe = node_param_str(target, "Wireframe", "false") != "false";
+    let center = node_param_vec3(target, "Center", Vec3::new(0.0, 0.55, 0.0));
+    let color = [0.8, 0.2, 0.2];
+    let h = 0.5 * scale;
+    if !wireframe {
+        return cuboid_detail(center, Vec3::splat(h), color);
+    }
+    let (t_line, t_corner) = (0.008 * scale, 0.012 * scale);
+    let signs = [-1.0f32, 1.0];
+    let mut out = Detail::new();
+    for &sx in &signs {
+        for &sy in &signs {
+            for &sz in &signs {
+                out.merge(&cuboid_detail(center + Vec3::new(sx * h, sy * h, sz * h), Vec3::splat(t_corner), color));
+            }
+        }
+    }
+    for &sa in &signs {
+        for &sb in &signs {
+            out.merge(&cuboid_detail(center + Vec3::new(0.0, sa * h, sb * h), Vec3::new(h, t_line, t_line), color));
+            out.merge(&cuboid_detail(center + Vec3::new(sa * h, 0.0, sb * h), Vec3::new(t_line, h, t_line), color));
+            out.merge(&cuboid_detail(center + Vec3::new(sa * h, sb * h, 0.0), Vec3::new(t_line, t_line, h), color));
+        }
+    }
+    out
+}
+
+/// The Plane node: a Width x Length sheet of Columns x Rows quads in the XZ
+/// plane about Center, wound counter-clockwise seen from +Y. Colour is the
+/// kernel's gradient across the sheet, or the default grey with Color off.
+/// Grid is the same sheet with a float3 Center and no gradient; two nodes
+/// for history's sake, and this one is the older.
+pub fn plane_node_detail(target: &FsNode) -> Detail {
+    let width = node_param_f32(target, "Width", 1.0).max(1e-4);
+    let length = node_param_f32(target, "Length", 1.0).max(1e-4);
+    let cols = node_param_f32(target, "Columns", 16.0).round().clamp(1.0, 500.0) as usize;
+    let rows = node_param_f32(target, "Rows", 16.0).round().clamp(1.0, 500.0) as usize;
+    let center = Vec3::new(
+        node_param_f32(target, "Center X", 0.0),
+        node_param_f32(target, "Center Y", 0.0),
+        node_param_f32(target, "Center Z", 0.0),
+    );
+    let colored = node_param_str(target, "Color", "true") != "false";
+
+    let mut d = Detail::new();
+    let mut cds = Vec::with_capacity((rows + 1) * (cols + 1));
+    let mut uvs = Vec::with_capacity((rows + 1) * (cols + 1));
+    for r in 0..=rows {
+        for c in 0..=cols {
+            let fx = c as f32 / cols as f32;
+            let fz = r as f32 / rows as f32;
+            d.add_point(center + Vec3::new((fx - 0.5) * width, 0.0, (fz - 0.5) * length));
+            uvs.push([fx, fz]);
+            cds.push(if colored { [0.35 + fx * 0.35, 0.45 + fz * 0.35, 0.85] } else { DEFAULT_COLOR });
+        }
+    }
+    let at = |r: usize, c: usize| (r * (cols + 1) + c) as u32;
+    for r in 0..rows {
+        for c in 0..cols {
+            d.add_prim(&[at(r, c), at(r + 1, c), at(r + 1, c + 1), at(r, c + 1)]);
+        }
+    }
+    let n = d.num_points();
+    let points = d.points_mut();
+    let _ = points.insert("Norm", AttribData::Float3(vec![[0.0, 1.0, 0.0]; n]));
+    let _ = points.insert("UV", AttribData::Float2(uvs));
+    let _ = points.insert(CD, AttribData::Float3(cds));
+    d
+}
+
+/// Extrude, as a WHOLE: every point moves along its point normal by
+/// `distance`, the input's primitives become the top, and one quad wall
+/// rises from each BOUNDARY edge — an edge one primitive uses. With Keep
+/// Base the original primitives stay, wound the other way, so a sheet
+/// becomes a closed slab and a closed surface a two-skinned shell.
+///
+/// The kernel extruded each triangle on its own and welded the pieces back
+/// together afterwards, which put a wall along every interior edge too; a
+/// surface extruded that way was a bed of prisms. A point shared by two
+/// faces has one top here, which is what "extrude the surface" means, and
+/// what makes the result closed when the input was a closed sheet.
+///
+/// Point attributes and groups carry to the top copies, primitive
+/// attributes to the top and base copies; walls are fresh. Wall corners
+/// share points with the top and base, so the kernel's 15% darker walls —
+/// a per-corner colour a soup could hold — have no place to live and are
+/// gone.
+pub fn extrude_detail(d: &Detail, distance: f32, keep_base: bool) -> Detail {
+    let n = d.num_points();
+    if n == 0 || d.num_prims() == 0 {
+        return d.clone();
+    }
+    let normals = point_normals(d);
+    let mut out = Detail::new();
+    let base: Vec<[f32; 3]> = d.positions().to_vec();
+    let top: Vec<[f32; 3]> = (0..n).map(|p| (d.pos(p) + normals[p] * distance).to_array()).collect();
+    out.add_points(&base);
+    out.add_points(&top);
+    // Base points keep their identities; the tops are new.
+    let mut ids = d.ids().to_vec();
+    let next = ids.iter().max().map_or(0, |m| m + 1);
+    ids.extend((0..n as u64).map(|i| next + i));
+    let _ = out.set_ids(ids, next + n as u64);
+
+    let n32 = n as u32;
+    let mut src: Vec<Option<usize>> = Vec::new();
+    let mut directed: Vec<(u32, u32)> = Vec::new();
+    let mut uses: HashMap<(u32, u32), usize> = HashMap::new();
+    for pr in 0..d.num_prims() {
+        let pts = d.prim_points(pr);
+        if pts.len() < 3 {
+            continue;
+        }
+        let lifted: Vec<u32> = pts.iter().map(|&p| p + n32).collect();
+        out.add_prim(&lifted);
+        src.push(Some(pr));
+        for k in 0..pts.len() {
+            let (a, b) = (pts[k], pts[(k + 1) % pts.len()]);
+            let key = (a.min(b), a.max(b));
+            let count = uses.entry(key).or_insert(0);
+            if *count == 0 {
+                directed.push((a, b));
+            }
+            *count += 1;
+        }
+    }
+    // Walls on the boundary, wound so that for a counter-clockwise top and
+    // a positive distance the outside faces out: `(b - a) x N` is the
+    // outward direction of edge a→b on a counter-clockwise face.
+    for (a, b) in directed {
+        if uses[&(a.min(b), a.max(b))] == 1 {
+            out.add_prim(&[a, b, b + n32, a + n32]);
+            src.push(None);
+        }
+    }
+    if keep_base {
+        for pr in 0..d.num_prims() {
+            let pts = d.prim_points(pr);
+            if pts.len() < 3 {
+                continue;
+            }
+            let rev: Vec<u32> = pts.iter().rev().copied().collect();
+            out.add_prim(&rev);
+            src.push(Some(pr));
+        }
+    }
+
+    // Attributes: points doubled, primitives by source.
+    for name in d.points().names() {
+        let data = d.points().get(name).unwrap();
+        let mut nd = AttribData::zeroed(data.ty(), 2 * n);
+        for p in 0..n {
+            if let Some(v) = data.get(p) {
+                let _ = nd.set(p, v);
+                let _ = nd.set(p + n, v);
+            }
+        }
+        let kind = d.points().kind(name);
+        let _ = out.points_mut().insert(name, nd);
+        out.points_mut().set_kind(name, kind);
+    }
+    for g in d.points().group_names() {
+        out.points_mut().create_group(g);
+        for m in d.points().group_members(g) {
+            out.points_mut().add_to_group(g, m as usize);
+            out.points_mut().add_to_group(g, m as usize + n);
+        }
+    }
+    let np = out.num_prims();
+    for name in d.prims().names() {
+        let data = d.prims().get(name).unwrap();
+        let mut nd = AttribData::zeroed(data.ty(), np);
+        for (i, s) in src.iter().enumerate() {
+            if let Some(v) = s.and_then(|pr| data.get(pr)) {
+                let _ = nd.set(i, v);
+            }
+        }
+        let kind = d.prims().kind(name);
+        let _ = out.prims_mut().insert(name, nd);
+        out.prims_mut().set_kind(name, kind);
+    }
+    for g in d.prims().group_names() {
+        out.prims_mut().create_group(g);
+        for (i, s) in src.iter().enumerate() {
+            if s.is_some_and(|pr| d.prims().in_group(g, pr)) {
+                out.prims_mut().add_to_group(g, i);
+            }
+        }
+    }
+    for name in d.detail().names() {
+        let _ = out.detail_mut().insert(name, d.detail().get(name).unwrap().clone());
+    }
+    out
+}