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) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-05 17:37:23 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4707c5b4f8
commit f01c443f2a
@@ -81,6 +81,20 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode) 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. * 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. * because exactly one faces the camera under FrontSide culling.
*/ */
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = getRenderableSlabPolygon(slabNode) const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode))
const elevation = slabNode.elevation ?? 0.05 const elevation = slabNode.elevation ?? 0.05
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) 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) * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
*/ */
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = getRenderableSlabPolygon(slabNode) const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode))
const depth = Math.abs(slabNode.elevation ?? 0.05) const depth = Math.abs(slabNode.elevation ?? 0.05)
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])