Wassim SAMAD ffc497c67a editor: gate Load Build behind a verification dialog
Loading JSON previously called setScene blindly and crashed when
the file held schema-invalid nodes (e.g. items missing `asset`).
The new dialog parses the file, runs validateBuildJson in core,
and surfaces structure counts (site/building/levels/walls/doors/
windows/items/slabs/ceilings/zones/scans), floor area, and a
per-node Schema Details list grouped by type. Import is blocked
when any hard error exists.

Two schema bugs the validator surfaced are fixed here too:

- SiteNode.children is now an id array like every other node
  (was a discriminatedUnion of full objects; three readers carried
  a string-or-object ternary that's now dropped). migrateNodes
  flattens legacy nested-object children on load. Default-scene
  seed and photo-to-scene MCP builder updated to pass `building.id`.
- LevelNode.children now includes shelf — the editor allowed it
  but the schema didn't. The schema-vs-registry-as-source-of-truth
  discussion is captured in plans/editor-node-registry.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:31:02 -04:00

146 lines
4.7 KiB
TypeScript

'use client'
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import { NodeRenderer, unionPolygons, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
const Y_OFFSET = 0.01
/**
* Creates simple line geometry for site boundary
* Single horizontal line at ground level
*/
const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => {
const geometry = new BufferGeometry()
if (points.length < 2) return geometry
const positions: number[] = []
// Create a simple line loop at ground level
for (const [x, z] of points) {
positions.push(x ?? 0, Y_OFFSET, z ?? 0)
}
// Close the loop
positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0)
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
return geometry
}
type S = ReturnType<typeof useScene.getState>
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, 'site', ref)
const theme = useViewer((state) => state.theme)
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
// Cache slab polygon references to keep the selector stable across unrelated store updates
const slabPolygonsCache = useRef<[number, number][][]>([])
const slabPolygons = useScene((state: S) => {
const nodeList = Object.values(state.nodes)
const levelIndexById = new Map<string, number>()
let lowestLevelIndex = Number.POSITIVE_INFINITY
nodeList.forEach((n) => {
if (n.type !== 'level') return
levelIndexById.set(n.id, n.level)
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
})
const next = nodeList
.filter(
(n): n is SlabNode =>
n.type === 'slab' &&
n.visible &&
n.polygon.length >= 3 &&
// Only recessed slabs should punch through the site ground.
// Positive slabs are real floor geometry and should not create a
// ghost footprint in the background ground fill.
(n.elevation ?? 0.05) < 0,
)
.filter((n) => {
if (!Number.isFinite(lowestLevelIndex)) return true
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
return parentLevel === lowestLevelIndex
})
.map((n) => n.polygon as [number, number][])
const prev = slabPolygonsCache.current
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
slabPolygonsCache.current = next
return next
})
// Ground shape: site polygon with slab footprints punched as holes
const groundShape = useMemo(() => {
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
const pts = node.polygon.points
const shape = new Shape()
shape.moveTo(pts[0]![0], -pts[0]![1])
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
shape.closePath()
if (slabPolygons.length > 0) {
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
if (ring.length < 3) continue
const hole = new Path()
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
hole.closePath()
shape.holes.push(hole)
}
}
return shape
}, [node?.polygon?.points, slabPolygons])
// Create boundary line geometry
const lineGeometry = useMemo(() => {
if (!node?.polygon?.points || node.polygon.points.length < 2) return null
return createBoundaryLineGeometry(node.polygon.points)
}, [node?.polygon?.points])
const handlers = useNodeEvents(node, 'site')
if (!(node && lineGeometry)) {
return null
}
return (
<group ref={ref} {...handlers}>
{/* Render children (buildings and items) */}
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && (
<mesh position={[0, -0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<shapeGeometry args={[groundShape]} />
<meshBasicMaterial
color={bgColor}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh>
)}
{/* Simple boundary line */}
{/* @ts-ignore */}
<line frustumCulled={false} geometry={lineGeometry} renderOrder={9}>
<lineBasicMaterial color="#f59e0b" linewidth={2} opacity={0.6} transparent />
</line>
</group>
)
}
export default SiteRenderer