123 lines
3.8 KiB
TypeScript
123 lines
3.8 KiB
TypeScript
import { type SiteNode, useRegistry } from '@pascal-app/core'
|
|
import { Html } from '@react-three/drei'
|
|
import { useMemo, useRef } from 'react'
|
|
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
|
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
|
import { NodeRenderer } from '../node-renderer'
|
|
|
|
const Y_OFFSET = 0.01
|
|
const LINE_HEIGHT = 0.5
|
|
|
|
/**
|
|
* 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!, Y_OFFSET, z!)
|
|
}
|
|
// Close the loop
|
|
positions.push(points[0]![0]!, Y_OFFSET, points[0]![1]!)
|
|
|
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
|
|
return geometry
|
|
}
|
|
|
|
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|
const ref = useRef<Group>(null!)
|
|
|
|
useRegistry(node.id, 'site', ref)
|
|
|
|
// Create floor shape from polygon points
|
|
const floorShape = useMemo(() => {
|
|
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
|
|
const shape = new Shape()
|
|
const firstPt = node.polygon.points[0]!
|
|
|
|
// Shape is in X-Y plane, we rotate it to X-Z plane
|
|
// Negate Y (which becomes Z) to get correct orientation
|
|
shape.moveTo(firstPt[0]!, -firstPt[1]!)
|
|
|
|
for (let i = 1; i < node.polygon.points.length; i++) {
|
|
const pt = node.polygon.points[i]!
|
|
shape.lineTo(pt[0]!, -pt[1]!)
|
|
}
|
|
shape.closePath()
|
|
|
|
return shape
|
|
}, [node?.polygon?.points])
|
|
|
|
// 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])
|
|
|
|
// Edge distances for labels
|
|
const edges = useMemo(() => {
|
|
const polygon = node?.polygon?.points ?? []
|
|
if (polygon.length < 2) return []
|
|
return polygon.map(([x1, z1], i) => {
|
|
const [x2, z2] = polygon[(i + 1) % polygon.length]!
|
|
const midX = (x1! + x2) / 2
|
|
const midZ = (z1! + z2) / 2
|
|
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
|
|
return { midX, midZ, dist }
|
|
})
|
|
}, [node?.polygon?.points])
|
|
|
|
const handlers = useNodeEvents(node, 'site')
|
|
|
|
if (!node || !floorShape || !lineGeometry) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<group ref={ref} {...handlers}>
|
|
{/* Render children (buildings and items) */}
|
|
{node.children.map((child) => (
|
|
<NodeRenderer
|
|
key={typeof child === 'string' ? child : child.id}
|
|
nodeId={typeof child === 'string' ? child : child.id}
|
|
/>
|
|
))}
|
|
|
|
{/* Transparent floor fill */}
|
|
<mesh position={[0, Y_OFFSET - 0.005, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
|
<shapeGeometry args={[floorShape]} />
|
|
<shadowMaterial transparent opacity={0.75} />
|
|
</mesh>
|
|
|
|
{/* Simple boundary line */}
|
|
{/* @ts-ignore */}
|
|
<line geometry={lineGeometry} frustumCulled={false} renderOrder={9}>
|
|
<lineBasicMaterial color="#f59e0b" linewidth={2} transparent opacity={0.6} />
|
|
</line>
|
|
|
|
{/* Edge distance labels */}
|
|
{edges.map((edge, i) => (
|
|
<Html
|
|
center
|
|
key={`edge-${i}`}
|
|
position={[edge.midX, 0.5, edge.midZ]}
|
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
zIndexRange={[10, 0]}
|
|
occlude
|
|
>
|
|
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
|
|
{edge.dist.toFixed(2)}m
|
|
</div>
|
|
</Html>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|