import { useFrame } from '@react-three/fiber' import { type AnyNodeId, getRenderableSlabPolygon, sceneRegistry, type SlabNode, useScene, } from '@pascal-app/core' import * as THREE from 'three' function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') if (!uv) return geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) } // ============================================================================ // SLAB SYSTEM // ============================================================================ export const SlabSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) useFrame(() => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes // Process dirty slabs dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'slab') return const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (mesh) { updateSlabGeometry(node as SlabNode, mesh) clearDirty(id as AnyNodeId) } // If mesh not found, keep it dirty for next frame }) }, 1) return null } /** * Updates the geometry for a single slab */ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { const newGeo = generateSlabGeometry(node) ensureUv2Attribute(newGeo) mesh.geometry.dispose() mesh.geometry = newGeo // For negative elevation, shift the mesh down so the top face sits at Y=elevation // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level). const elevation = node.elevation ?? 0.05 mesh.position.y = elevation < 0 ? elevation : 0 } /** * Generates extruded slab geometry from polygon */ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { const elevation = slabNode.elevation ?? 0.05 return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode) } /** * Standard slab: flat extrusion upward from Y=0 by elevation thickness. */ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { const polygon = getRenderableSlabPolygon(slabNode) const elevation = slabNode.elevation ?? 0.05 if (polygon.length < 3) return new THREE.BufferGeometry() const shape = new THREE.Shape() shape.moveTo(polygon[0]![0], -polygon[0]![1]) for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1]) shape.closePath() for (const holePolygon of slabNode.holes ?? []) { if (holePolygon.length < 3) continue const holePath = new THREE.Path() holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1]) for (let i = 1; i < holePolygon.length; i++) holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1]) holePath.closePath() shape.holes.push(holePath) } const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false }) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() return geometry } /** * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|. * No top cap — the opening at ground level is handled by the ground occluder hole. * mesh.position.y must be set to elevation so the floor sits at the correct world Y. * * Geometry is built directly in 3D (Y-up) to avoid rotation confusion: * - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool) * - 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 depth = Math.abs(slabNode.elevation ?? 0.05) if (polygon.length < 3) return new THREE.BufferGeometry() const positions: number[] = [] const uvs: number[] = [] const indices: number[] = [] const n = polygon.length const bounds = new THREE.Box2() for (const [x, z] of polygon) { bounds.expandByPoint(new THREE.Vector2(x, z)) } for (const hole of slabNode.holes ?? []) { for (const [x, z] of hole) { bounds.expandByPoint(new THREE.Vector2(x, z)) } } const floorWidth = Math.max(bounds.max.x - bounds.min.x, 0.001) const floorHeight = Math.max(bounds.max.y - bounds.min.y, 0.001) const pushFloorVertex = (x: number, y: number, z: number) => { positions.push(x, y, z) uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight) } const pushWallVertex = (x: number, y: number, z: number, u: number, v: number) => { positions.push(x, y, z) uvs.push(u, v) } // --- Floor at Y=0 --- for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!) const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) for (const hole of slabNode.holes ?? []) { for (const [x, z] of hole) pushFloorVertex(x!, 0, z!) } const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d) for (const tri of floorTris) { // Reversed winding → normals point +Y (upward) in XZ plane indices.push(tri[0]!, tri[2]!, tri[1]!) } // --- Inner walls (no top cap at Y=depth) --- // Standard winding on a CCW polygon in XZ gives inward-facing normals. for (let i = 0; i < n; i++) { const j = (i + 1) % n const [x0, z0] = polygon[i]! const [x1, z1] = polygon[j]! const vBase = positions.length / 3 const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001) pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level indices.push(vBase, vBase + 1, vBase + 2) indices.push(vBase, vBase + 2, vBase + 3) } const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geo.setIndex(indices) geo.computeVertexNormals() return geo }