Files
editor/packages/viewer/src/systems/ceiling/ceiling-system.tsx
T

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
}