graphic design tool
git clone https://git.lucas.co/cce-designer.git
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;
}