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

commit1165c52fb9db2313988e6e8ca82c18511c195375
parenta1883d8069
authorLucas Galante <[email protected]>
date2026-08-26 15:03
fix: the sphere template winds CCW-outward — the raster ball was inside-out

The raster pipeline culls back faces with CCW fronts (wgpu convention;
the negative-viewport-height Y flip keeps model-space CCW = front). The
sphere kernel wound its quads {00,10,11}/{00,11,01} — 97% CW seen from
outside — so the live viewport drew the ball's INTERIOR: near faces
culled, far faces shown. A closed symmetric mesh disguises that
completely; the tissue sim exposed it (a far-side pull spike rendered
visibly THROUGH the ball).

Both triangles now wind CCW-outward. The two compensations built on the
old winding flip with it: extrude's negated extrusion cross is a plain
cross again, and the meta Point Normals overlay drops its negation.
Saved scenes heal on load (merge_template_defs refreshes template
kernels). test_template_meshes_wind_ccw_outward guards every closed
generator template plus the plane's up-face — RT intersects both sides
and can never catch this class.

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

 nodes/extrude.json | 124 +++++++++++++++++++++++------------------------
 nodes/sphere.json  | 138 ++++++++++++++++++++++++++---------------------------
 src/main.rs        | 114 +++++++++++++++++++++++++++++++++++++++++++
 src/render.rs      |   9 ++--
 4 files changed, 250 insertions(+), 135 deletions(-)

diff --git a/nodes/extrude.json b/nodes/extrude.json
index 0fbfae5..3d18087 100644
--- a/nodes/extrude.json
+++ b/nodes/extrude.json
@@ -1,69 +1,69 @@
 {
-  "name": "Extrude",
-  "type": "node",
-  "inputs": 1,
-  "outputs": 1,
-  "params": [
+ "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": "Input",
-      "default": "",
-      "type": "text"
+     "name": "Input",
+     "default": "input1"
     },
     {
-      "name": "Distance",
-      "default": "0.2",
-      "type": "slider",
-      "min": -1.0,
-      "max": 1.0,
-      "step": 0.01
-    },
-    {
-      "name": "Keep Base",
-      "default": "true",
-      "type": "toggle"
+     "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}"
     }
-  ],
-  "children": [
-    {
-      "name": "input1",
-      "type": "input",
-      "params": [],
-      "position": [
-        4.0,
-        1.0
-      ]
-    },
-    {
-      "name": "opencl1",
-      "type": "opencl",
-      "params": [
-        {
-          "name": "Input",
-          "default": "input1"
-        },
-        {
-          "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            // Negated cross: the templates' triangles wind clockwise seen\n            // from outside under this renderer's convention, so the plain\n            // cross(B-A, C-A) points inward.\n            float nx = uz * vy - uy * vz;\n            float ny = ux * vz - uz * vx;\n            float nz = uy * vx - ux * vy;\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
-      ]
-    },
+   ],
+   "position": [
+    4.0,
+    2.0
+   ]
+  },
+  {
+   "name": "output1",
+   "type": "output",
+   "params": [
     {
-      "name": "output1",
-      "type": "output",
-      "params": [
-        {
-          "name": "Input",
-          "default": "opencl1"
-        }
-      ],
-      "position": [
-        4.0,
-        3.0
-      ]
+     "name": "Input",
+     "default": "opencl1"
     }
-  ]
-}
+   ],
+   "position": [
+    4.0,
+    3.0
+   ]
+  }
+ ]
+}
\ No newline at end of file
diff --git a/nodes/sphere.json b/nodes/sphere.json
index 33ab8ae..c028188 100644
--- a/nodes/sphere.json
+++ b/nodes/sphere.json
@@ -1,74 +1,74 @@
 {
-  "name": "Sphere",
-  "type": "node",
-  "inputs": 0,
-  "outputs": 1,
-  "params": [
+ "name": "Sphere",
+ "type": "node",
+ "inputs": 0,
+ "outputs": 1,
+ "params": [
+  {
+   "name": "Radius",
+   "default": "0.5",
+   "type": "slider"
+  },
+  {
+   "name": "Rows",
+   "default": "16",
+   "type": "spinbox",
+   "min": 2,
+   "max": 128,
+   "step": 1
+  },
+  {
+   "name": "Columns",
+   "default": "24",
+   "type": "spinbox",
+   "min": 3,
+   "max": 128,
+   "step": 1
+  },
+  {
+   "name": "Center X",
+   "default": "0.0",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Center Y",
+   "default": "0.55",
+   "type": "slider:-2:2"
+  },
+  {
+   "name": "Center Z",
+   "default": "0.0",
+   "type": "slider:-2:2"
+  }
+ ],
+ "children": [
+  {
+   "name": "opencl1",
+   "type": "opencl",
+   "params": [
     {
-      "name": "Radius",
-      "default": "0.5",
-      "type": "slider"
-    },
-    {
-      "name": "Rows",
-      "default": "16",
-      "type": "spinbox",
-      "min": 2,
-      "max": 128,
-      "step": 1
-    },
-    {
-      "name": "Columns",
-      "default": "24",
-      "type": "spinbox",
-      "min": 3,
-      "max": 128,
-      "step": 1
-    },
-    {
-      "name": "Center X",
-      "default": "0.0",
-      "type": "slider:-2:2"
-    },
-    {
-      "name": "Center Y",
-      "default": "0.55",
-      "type": "slider:-2:2"
-    },
-    {
-      "name": "Center Z",
-      "default": "0.0",
-      "type": "slider:-2:2"
+     "name": "Code",
+     "default": "__kernel void process(__global const float* in_pos, __global const float* in_col, int in_count, __global float* out_pos, __global float* out_col, __global int* out_count, int max_vertices) {\n    int id = get_global_id(0);\n    if (id == 0) {\n        float radius = chf(\"Radius\", 0.5f);\n        float center_x = chf(\"Center X\", 0.0f);\n        float center_y = chf(\"Center Y\", 0.55f);\n        float center_z = chf(\"Center Z\", 0.0f);\n        int lat_steps = chi(\"Rows\", 16);\n        if (lat_steps < 2) { lat_steps = 2; }\n        if (lat_steps > 128) { lat_steps = 128; }\n        int lon_steps = chi(\"Columns\", 24);\n        if (lon_steps < 3) { lon_steps = 3; }\n        if (lon_steps > 128) { lon_steps = 128; }\n        int count = 0;\n        for (int lat = 0; lat < lat_steps; lat++) {\n            float theta0 = 3.14159265f * (float)lat / (float)lat_steps;\n            float theta1 = 3.14159265f * (float)(lat + 1) / (float)lat_steps;\n            for (int lon = 0; lon < lon_steps; lon++) {\n                float phi0 = 6.2831853f * (float)lon / (float)lon_steps;\n                float phi1 = 6.2831853f * (float)(lon + 1) / (float)lon_steps;\n                float x00 = radius * sin(theta0) * cos(phi0);\n                float y00 = radius * cos(theta0);\n                float z00 = radius * sin(theta0) * sin(phi0);\n                float x10 = radius * sin(theta1) * cos(phi0);\n                float y10 = radius * cos(theta1);\n                float z10 = radius * sin(theta1) * sin(phi0);\n                float x11 = radius * sin(theta1) * cos(phi1);\n                float y11 = radius * cos(theta1);\n                float z11 = radius * sin(theta1) * sin(phi1);\n                float x01 = radius * sin(theta0) * cos(phi1);\n                float y01 = radius * cos(theta0);\n                float z01 = radius * sin(theta0) * sin(phi1);\n                float px[6] = {x00, x11, x10, x00, x01, x11};\n                float py[6] = {y00, y11, y10, y00, y01, y11};\n                float pz[6] = {z00, z11, z10, z00, z01, z11};\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}"
     }
-  ],
-  "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 radius = chf(\"Radius\", 0.5f);\n        float center_x = chf(\"Center X\", 0.0f);\n        float center_y = chf(\"Center Y\", 0.55f);\n        float center_z = chf(\"Center Z\", 0.0f);\n        int lat_steps = chi(\"Rows\", 16);\n        if (lat_steps < 2) { lat_steps = 2; }\n        if (lat_steps > 128) { lat_steps = 128; }\n        int lon_steps = chi(\"Columns\", 24);\n        if (lon_steps < 3) { lon_steps = 3; }\n        if (lon_steps > 128) { lon_steps = 128; }\n        int count = 0;\n        for (int lat = 0; lat < lat_steps; lat++) {\n            float theta0 = 3.14159265f * (float)lat / (float)lat_steps;\n            float theta1 = 3.14159265f * (float)(lat + 1) / (float)lat_steps;\n            for (int lon = 0; lon < lon_steps; lon++) {\n                float phi0 = 6.2831853f * (float)lon / (float)lon_steps;\n                float phi1 = 6.2831853f * (float)(lon + 1) / (float)lon_steps;\n                float x00 = radius * sin(theta0) * cos(phi0);\n                float y00 = radius * cos(theta0);\n                float z00 = radius * sin(theta0) * sin(phi0);\n                float x10 = radius * sin(theta1) * cos(phi0);\n                float y10 = radius * cos(theta1);\n                float z10 = radius * sin(theta1) * sin(phi0);\n                float x11 = radius * sin(theta1) * cos(phi1);\n                float y11 = radius * cos(theta1);\n                float z11 = radius * sin(theta1) * sin(phi1);\n                float x01 = radius * sin(theta0) * cos(phi1);\n                float y01 = radius * cos(theta0);\n                float z01 = radius * sin(theta0) * sin(phi1);\n                float px[6] = {x00, x10, x11, x00, x11, x01};\n                float py[6] = {y00, y10, y11, y00, y11, y01};\n                float pz[6] = {z00, z10, z11, z00, z11, z01};\n                for (int v = 0; v < 6; v++) {\n                    int idx = count++;\n                    if (idx < max_vertices) {\n                        out_pos[idx * 3 + 0] = center_x + px[v];\n                        out_pos[idx * 3 + 1] = center_y + py[v];\n                        out_pos[idx * 3 + 2] = center_z + pz[v];\n                        float nx = px[v];\n                        float ny = py[v];\n                        float nz = pz[v];\n                        float len = sqrt(nx*nx + ny*ny + nz*nz);\n                        if (len > 0.0f) {\n                            nx /= len;\n                            ny /= len;\n                            nz /= len;\n                        }\n                        out_col[idx * 3 + 0] = 0.5f + nx * 0.5f;\n                        out_col[idx * 3 + 1] = 0.5f + ny * 0.5f;\n                        out_col[idx * 3 + 2] = 0.5f + nz * 0.5f;\n                    }\n                }\n            }\n        }\n        *out_count = count;\n    }\n}"
-        }
-      ],
-      "position": [
-        4.0,
-        2.0
-      ]
-    },
+   ],
+   "position": [
+    4.0,
+    2.0
+   ]
+  },
+  {
+   "name": "output1",
+   "type": "output",
+   "params": [
     {
-      "name": "output1",
-      "type": "output",
-      "params": [
-        {
-          "name": "Input",
-          "default": "opencl1"
-        }
-      ],
-      "position": [
-        4.0,
-        3.0
-      ]
+     "name": "Input",
+     "default": "opencl1"
     }
-  ]
-}
+   ],
+   "position": [
+    4.0,
+    3.0
+   ]
+  }
+ ]
+}
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
index 36ca896..47ccf41 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -767,6 +767,120 @@ mod tests {
         assert_eq!(output_input.default, "opencl1");
     }
 
+    /// The raster pipeline culls back faces with CCW fronts (the wgpu
+    /// convention: negative-viewport-height Y flip keeps model-space CCW =
+    /// front). A template mesh must wind CCW as seen from OUTSIDE, or the
+    /// live viewport silently shows its interior — near faces culled, far
+    /// faces drawn — which a closed symmetric mesh disguises until a
+    /// deformation makes it obvious. RT intersects both sides and never
+    /// catches this; this test is the raster-side guard.
+    #[test]
+    fn test_template_meshes_wind_ccw_outward() {
+        let templates_root = crate::app::load_fs_tree();
+        let eval_template = |name: &str| -> crate::geometry::Geometry {
+            let t = templates_root
+                .children
+                .iter()
+                .find(|t| t.name == name)
+                .unwrap_or_else(|| panic!("{name} template should be loaded"));
+            let mut inst = t.clone();
+            inst.id = format!("{name}-winding-inst");
+            for child in &mut inst.children {
+                child.id = format!("{}_{}", inst.id, child.name);
+            }
+            let root = FsNode {
+                id: "root".to_string(),
+                name: "root".to_string(),
+                node_type: "node".to_string(),
+                children: vec![inst],
+                params: vec![],
+                geometry_visible: true,
+                position: (0.0, 0.0),
+                inputs: 0,
+                outputs: 0,
+            };
+            let mut visited = Vec::new();
+            let mut err = None;
+            let g = crate::geometry::generate_single_node_geometry_with_errors(
+                &root,
+                &root.children[0],
+                &mut visited,
+                &mut err,
+                &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
+            )
+            .expect("geometry");
+            assert!(err.is_none(), "{name}: {err:?}");
+            g
+        };
+        let tri_cross = |g: &crate::geometry::Geometry, tri: usize| -> [f32; 3] {
+            let a = g.vertices[tri * 3].pos;
+            let b = g.vertices[tri * 3 + 1].pos;
+            let d = g.vertices[tri * 3 + 2].pos;
+            let e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
+            let e2 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
+            [
+                e1[1] * e2[2] - e1[2] * e2[1],
+                e1[2] * e2[0] - e1[0] * e2[2],
+                e1[0] * e2[1] - e1[1] * e2[0],
+            ]
+        };
+
+        // Closed generators: the winding cross must point OUTWARD (away from
+        // the mesh center) on effectively every non-degenerate triangle.
+        for name in ["Sphere", "Box"] {
+            let g = eval_template(name);
+            let n = g.vertices.len() as f32;
+            let mut c = [0.0f32; 3];
+            for v in &g.vertices {
+                for k in 0..3 {
+                    c[k] += v.pos[k] / n;
+                }
+            }
+            let (mut outward, mut total) = (0usize, 0usize);
+            for tri in 0..g.vertices.len() / 3 {
+                let nrm = tri_cross(&g, tri);
+                let a = g.vertices[tri * 3].pos;
+                let b = g.vertices[tri * 3 + 1].pos;
+                let d = g.vertices[tri * 3 + 2].pos;
+                let cen = [
+                    (a[0] + b[0] + d[0]) / 3.0 - c[0],
+                    (a[1] + b[1] + d[1]) / 3.0 - c[1],
+                    (a[2] + b[2] + d[2]) / 3.0 - c[2],
+                ];
+                let dot = nrm[0] * cen[0] + nrm[1] * cen[1] + nrm[2] * cen[2];
+                if dot.abs() > 1e-12 {
+                    total += 1;
+                    if dot > 0.0 {
+                        outward += 1;
+                    }
+                }
+            }
+            let f = outward as f32 / total.max(1) as f32;
+            assert!(
+                f > 0.95,
+                "{name}: only {:.0}% of triangles wind CCW-outward — the raster viewport shows this mesh inside-out",
+                f * 100.0
+            );
+        }
+
+        // The plane's visible face is UP: the winding cross must point +Y.
+        let g = eval_template("Plane");
+        let (mut up, mut total) = (0usize, 0usize);
+        for tri in 0..g.vertices.len() / 3 {
+            let nrm = tri_cross(&g, tri);
+            if nrm[1].abs() > 1e-12 {
+                total += 1;
+                if nrm[1] > 0.0 {
+                    up += 1;
+                }
+            }
+        }
+        assert!(
+            up as f32 / total.max(1) as f32 > 0.95,
+            "Plane: winding faces down — invisible from above in the raster viewport"
+        );
+    }
+
     #[test]
     fn test_sphere_subnet_geometry_generation() {
         let templates_root = crate::app::load_fs_tree();
diff --git a/src/render.rs b/src/render.rs
index f9f08ec..a14a2f0 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -950,9 +950,10 @@ pub(crate) fn collect_meta_overlays(
                 if want_normals {
                     // Smooth vertex normals from topology: per distinct
                     // position, the normalized sum of touching triangles'
-                    // face normals. On this repo's meshes cross(B-A, C-A)
-                    // points INWARD (see the node-template kernel notes), so
-                    // it is negated for outward whiskers. The kernel outputs'
+                    // face normals. Template meshes wind CCW seen from
+                    // outside (the raster culling convention — the sphere's
+                    // historical CW winding is fixed), so the plain
+                    // cross(B-A, C-A) points outward. The kernel outputs'
                     // Norm attribute is a default up-vector — useless here.
                     use glam::Vec3;
                     let quant = |p: &[f32; 3]| {
@@ -968,7 +969,7 @@ pub(crate) fn collect_meta_overlays(
                         let a = Vec3::from_array(tri[0].pos);
                         let b = Vec3::from_array(tri[1].pos);
                         let c = Vec3::from_array(tri[2].pos);
-                        let n = -(b - a).cross(c - a);
+                        let n = (b - a).cross(c - a);
                         if n.length_squared() <= 1e-12 {
                             continue;
                         }