From f01c443f2af53eab3e49eb845d9c5e32ab6cfa97 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 5 Jun 2026 17:37:23 -0400 Subject: [PATCH] fix(editor): render slab side walls solid regardless of polygon winding (#377) Slab and pool side walls assumed a CCW contour (the unflipped quad's right-hand normal is outward only for CCW), but outsetPolygon and the slab tool preserve the drawn winding. A CW-drawn slab therefore got inward-facing side-wall normals that FrontSide culling dropped, so the slab read as see-through from the camera-facing side (~half the time, depending on draw direction). Normalize the contour to CCW (reverse when signed area < 0) before building both the positive-slab and pool geometry. Caps are unaffected (Earcut normalizes the outer ring); holes are already double-emitted + winding-normalized; ceilings use flat ShapeGeometry (no extruded walls); autoFromWalls slabs are already CCW so this is a no-op for them. Co-authored-by: Claude Opus 4.8 (1M context) --- .../viewer/src/systems/slab/slab-system.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 0e7fc0ab..81427c1a 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -81,6 +81,20 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode) } +// Earcut normalizes cap triangulation regardless of input winding, but the side +// walls below assume a CCW contour (the unflipped quad's right-hand normal faces +// outward only for CCW). outsetPolygon and the slab tool preserve the drawn +// winding, so a CW-drawn slab gets inward-facing walls that FrontSide culls and +// the slab reads as see-through from the front. Normalize to CCW first. +function ensureCounterClockwisePolygon(polygon: Array<[number, number]>): Array<[number, number]> { + let area2 = 0 + for (let i = 0; i < polygon.length; i++) { + const j = (i + 1) % polygon.length + area2 += polygon[i]![0] * polygon[j]![1] - polygon[j]![0] * polygon[i]![1] + } + return area2 < 0 ? [...polygon].reverse() : polygon +} + /** * Standard slab: flat extrusion upward from Y=0 by elevation thickness. * @@ -94,7 +108,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { * because exactly one faces the camera under FrontSide culling. */ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { - const polygon = getRenderableSlabPolygon(slabNode) + const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode)) const elevation = slabNode.elevation ?? 0.05 const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) @@ -187,7 +201,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool) */ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { - const polygon = getRenderableSlabPolygon(slabNode) + const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode)) const depth = Math.abs(slabNode.elevation ?? 0.05) const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])