185 lines
6.3 KiB
TypeScript
185 lines
6.3 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
type CeilingNode,
|
|
getEffectiveNode,
|
|
nodeRegistry,
|
|
sceneRegistry,
|
|
useLiveTransforms,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { useFrame } from '@react-three/fiber'
|
|
import * as THREE from 'three'
|
|
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
|
|
|
|
type SceneNodes = ReturnType<typeof useScene.getState>['nodes']
|
|
|
|
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
|
const uv = geometry.getAttribute('uv')
|
|
if (!uv) return
|
|
|
|
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
|
}
|
|
|
|
// ============================================================================
|
|
// CEILING SYSTEM
|
|
// ============================================================================
|
|
|
|
export const CeilingSystem = () => {
|
|
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 ceilings
|
|
dirtyNodes.forEach((id) => {
|
|
const node = nodes[id]
|
|
if (!node || node.type !== 'ceiling') return
|
|
|
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
|
if (mesh) {
|
|
// Merge any live drag override so the polygon / height resize
|
|
// arrow rebuilds the mesh at pointer rate — zustand only learns
|
|
// the final value on commit. Mirrors WallSystem / GeometrySystem.
|
|
const effective = getEffectiveNode(node as CeilingNode)
|
|
const itemHoles = collectCeilingHoles(effective, nodes)
|
|
updateCeilingGeometry(effective, mesh, itemHoles)
|
|
clearDirty(id as AnyNodeId)
|
|
}
|
|
// If mesh not found, keep it dirty for next frame
|
|
})
|
|
})
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Collects ceiling-hole polygons from child nodes that declare the `ceilingCut`
|
|
* capability. Each child's `buildCeilingHole` returns a rotated-rectangle
|
|
* footprint in ceiling-local [x, z] space (or `null` to opt out), which is
|
|
* merged as an extra hole before triangulation.
|
|
*
|
|
* The viewer never branches on `child.type` — the dispatch goes through
|
|
* `nodeRegistry`, so any future kind (a heat lamp, a skylight panel, …) can
|
|
* participate just by declaring `capabilities.ceilingCut` on its definition.
|
|
*/
|
|
function collectCeilingHoles(
|
|
ceiling: CeilingNode,
|
|
nodes: SceneNodes,
|
|
): Array<Array<[number, number]>> {
|
|
const holes: Array<Array<[number, number]>> = []
|
|
|
|
for (const childId of ceiling.children ?? []) {
|
|
const child = nodes[childId as AnyNodeId]
|
|
if (!child) continue
|
|
const def = nodeRegistry.get(child.type)
|
|
const hole = def?.capabilities?.ceilingCut?.buildCeilingHole(child)
|
|
if (hole) holes.push(hole)
|
|
}
|
|
|
|
return holes
|
|
}
|
|
|
|
/**
|
|
* Updates the geometry for a single ceiling
|
|
*/
|
|
function updateCeilingGeometry(
|
|
node: CeilingNode,
|
|
mesh: THREE.Mesh,
|
|
extraHoles: Array<Array<[number, number]>> = [],
|
|
) {
|
|
const newGeo = generateCeilingGeometry(node, extraHoles)
|
|
|
|
mesh.geometry.dispose()
|
|
mesh.geometry = newGeo
|
|
|
|
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
|
|
if (gridMesh) {
|
|
gridMesh.geometry.dispose()
|
|
gridMesh.geometry = newGeo.clone()
|
|
}
|
|
|
|
// Position at the ceiling height and reset X/Z so live-drag mesh
|
|
// offsets (set by move tools during the drag) don't leak into the
|
|
// canonical position after the rebuild. Matches the pattern used by
|
|
// FenceSystem.updateFenceGeometry / GeometrySystem (both fully reset
|
|
// position+rotation after rebuild).
|
|
const liveTransform = useLiveTransforms.getState().get(node.id)
|
|
mesh.position.x = liveTransform?.position[0] ?? 0
|
|
mesh.position.z = liveTransform?.position[2] ?? 0
|
|
mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) // Slight offset to avoid z-fighting with upper-level slabs
|
|
}
|
|
|
|
/**
|
|
* Generates flat ceiling geometry from polygon (no extrusion).
|
|
*
|
|
* `extraHoles` are transient, derived cutouts (e.g. recessed-fixture
|
|
* footprints) that are cut alongside the node's persisted `holes` but never
|
|
* stored on the node — they are recomputed on every rebuild.
|
|
*/
|
|
export function generateCeilingGeometry(
|
|
ceilingNode: CeilingNode,
|
|
extraHoles: Array<Array<[number, number]>> = [],
|
|
): THREE.BufferGeometry {
|
|
const polygon = ceilingNode.polygon
|
|
|
|
if (polygon.length < 3) {
|
|
// A degenerate ceiling (fewer than 3 points, e.g. mid-edit) still gets a
|
|
// non-empty position buffer — three zero-vertices forming one invisible
|
|
// triangle. An empty attribute (count 0) would leave WebGPU vertex buffer
|
|
// slot 0 unbound when this mesh (and its cloned grid overlay) is drawn,
|
|
// which the validator rejects ("slot 0 … was not set") and which poisons
|
|
// the whole command encoder.
|
|
const degenerate = new THREE.BufferGeometry()
|
|
degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
|
degenerate.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
|
|
degenerate.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
|
|
return degenerate
|
|
}
|
|
|
|
// Create shape from polygon
|
|
// Shape is in X-Y plane, we'll rotate to X-Z plane
|
|
const shape = new THREE.Shape()
|
|
const firstPt = polygon[0]!
|
|
|
|
// Negate Y (which becomes Z) to get correct orientation after rotation
|
|
shape.moveTo(firstPt[0], -firstPt[1])
|
|
|
|
for (let i = 1; i < polygon.length; i++) {
|
|
const pt = polygon[i]!
|
|
shape.lineTo(pt[0], -pt[1])
|
|
}
|
|
shape.closePath()
|
|
|
|
// Add holes to the shape: persisted structural openings (stair/elevator/
|
|
// manual, merged to dissolve overlaps) plus transient recessed-fixture
|
|
// cutouts. Both are in the same ceiling-local [x, z] space.
|
|
const holes = [...mergeSurfaceHolePolygons(ceilingNode.holes || []), ...extraHoles]
|
|
for (const holePolygon of holes) {
|
|
if (holePolygon.length < 3) continue
|
|
|
|
const holePath = new THREE.Path()
|
|
const holeFirstPt = holePolygon[0]!
|
|
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])
|
|
|
|
for (let i = 1; i < holePolygon.length; i++) {
|
|
const pt = holePolygon[i]!
|
|
holePath.lineTo(pt[0], -pt[1])
|
|
}
|
|
holePath.closePath()
|
|
|
|
shape.holes.push(holePath)
|
|
}
|
|
|
|
// Create flat shape geometry (no extrusion)
|
|
const geometry = new THREE.ShapeGeometry(shape)
|
|
|
|
// Rotate so the shape lies flat in X-Z plane
|
|
geometry.rotateX(-Math.PI / 2)
|
|
geometry.computeVertexNormals()
|
|
ensureUv2Attribute(geometry)
|
|
|
|
return geometry
|
|
}
|