256 lines
7.3 KiB
TypeScript
256 lines
7.3 KiB
TypeScript
import { useRegistry, type ZoneNode } from '@pascal-app/core'
|
|
import { Html } from '@react-three/drei'
|
|
import { useMemo, useRef } from 'react'
|
|
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
|
import { color, float, uniform, uv } from 'three/tsl'
|
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
|
import { ZONE_LAYER } from '../../../lib/layers'
|
|
|
|
const Y_OFFSET = 0.01
|
|
const WALL_HEIGHT = 2.3
|
|
|
|
/**
|
|
* Creates a gradient material for zone walls using TSL
|
|
* Gradient goes from zone color at bottom to transparent at top
|
|
*/
|
|
const createWallGradientMaterial = (zoneColor: string) => {
|
|
const baseColor = color(new Color(zoneColor))
|
|
|
|
// Use UV y coordinate for vertical gradient (0 at bottom, 1 at top)
|
|
const gradientT = uv().y
|
|
|
|
const opacity = uniform(0)
|
|
// Fade opacity from 0.6 at bottom to 0 at top
|
|
const finalOpacity = float(0.6).mul(float(1).sub(gradientT)).mul(opacity)
|
|
|
|
return new MeshBasicNodeMaterial({
|
|
transparent: true,
|
|
colorNode: baseColor,
|
|
opacityNode: finalOpacity,
|
|
side: DoubleSide,
|
|
depthWrite: true,
|
|
depthTest: false,
|
|
userData: {
|
|
uOpacity: opacity,
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates a floor material for zones using TSL
|
|
*/
|
|
const createFloorMaterial = (zoneColor: string) => {
|
|
const baseColor = color(new Color(zoneColor))
|
|
const opacity = uniform(0)
|
|
return new MeshBasicNodeMaterial({
|
|
transparent: true,
|
|
colorNode: baseColor,
|
|
opacityNode: float(0.25).mul(opacity),
|
|
side: DoubleSide,
|
|
depthWrite: false,
|
|
depthTest: false,
|
|
userData: { uOpacity: opacity },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates wall geometry for zone borders
|
|
* Each wall segment is a vertical quad from one polygon point to the next
|
|
*/
|
|
const createWallGeometry = (polygon: Array<[number, number]>): BufferGeometry => {
|
|
const geometry = new BufferGeometry()
|
|
|
|
if (polygon.length < 2) return geometry
|
|
|
|
const positions: number[] = []
|
|
const uvs: number[] = []
|
|
const indices: number[] = []
|
|
|
|
// Create a wall segment for each edge of the polygon
|
|
for (let i = 0; i < polygon.length; i++) {
|
|
const current = polygon[i]!
|
|
const next = polygon[(i + 1) % polygon.length]!
|
|
|
|
const baseIndex = i * 4
|
|
|
|
// Four vertices per wall segment (two triangles forming a quad)
|
|
// Bottom-left
|
|
positions.push(current[0]!, Y_OFFSET, current[1]!)
|
|
uvs.push(0, 0)
|
|
|
|
// Bottom-right
|
|
positions.push(next[0]!, Y_OFFSET, next[1]!)
|
|
uvs.push(1, 0)
|
|
|
|
// Top-right
|
|
positions.push(next[0]!, Y_OFFSET + WALL_HEIGHT, next[1]!)
|
|
uvs.push(1, 1)
|
|
|
|
// Top-left
|
|
positions.push(current[0]!, Y_OFFSET + WALL_HEIGHT, current[1]!)
|
|
uvs.push(0, 1)
|
|
|
|
// Two triangles for the quad
|
|
indices.push(baseIndex, baseIndex + 1, baseIndex + 2, baseIndex, baseIndex + 2, baseIndex + 3)
|
|
}
|
|
|
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
|
geometry.setIndex(indices)
|
|
geometry.computeVertexNormals()
|
|
|
|
return geometry
|
|
}
|
|
|
|
export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|
const ref = useRef<Group>(null!)
|
|
|
|
useRegistry(node.id, 'zone', ref)
|
|
|
|
// Create floor shape from polygon
|
|
const floorShape = useMemo(() => {
|
|
if (!node?.polygon || node.polygon.length < 3) return null
|
|
const shape = new Shape()
|
|
const firstPt = node.polygon[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.length; i++) {
|
|
const pt = node.polygon[i]!
|
|
shape.lineTo(pt[0]!, -pt[1]!)
|
|
}
|
|
shape.closePath()
|
|
|
|
return shape
|
|
}, [node?.polygon])
|
|
|
|
// Create wall geometry from polygon
|
|
const wallGeometry = useMemo(() => {
|
|
if (!node?.polygon || node.polygon.length < 2) return null
|
|
return createWallGeometry(node.polygon)
|
|
}, [node?.polygon])
|
|
|
|
// Calculate polygon centroid for label positioning using the geometric centroid formula
|
|
// This correctly handles polygons regardless of vertex distribution along edges
|
|
const centroid = useMemo(() => {
|
|
if (!node?.polygon || node.polygon.length < 3) return [0, 0] as [number, number]
|
|
|
|
const polygon = node.polygon
|
|
let signedArea = 0
|
|
let cx = 0
|
|
let cz = 0
|
|
|
|
for (let i = 0; i < polygon.length; i++) {
|
|
const [x0, z0] = polygon[i]!
|
|
const [x1, z1] = polygon[(i + 1) % polygon.length]!
|
|
|
|
// Cross product for signed area
|
|
const cross = x0 * z1 - x1 * z0
|
|
signedArea += cross
|
|
cx += (x0 + x1) * cross
|
|
cz += (z0 + z1) * cross
|
|
}
|
|
|
|
signedArea /= 2
|
|
const factor = 1 / (6 * signedArea)
|
|
|
|
return [cx * factor, cz * factor] as [number, number]
|
|
}, [node?.polygon])
|
|
|
|
// Create materials
|
|
const floorMaterial = useMemo(() => {
|
|
if (!node?.color) return null
|
|
return createFloorMaterial(node.color)
|
|
}, [node?.color])
|
|
|
|
const wallMaterial = useMemo(() => {
|
|
if (!node?.color) return null
|
|
return createWallGradientMaterial(node.color)
|
|
}, [node?.color])
|
|
|
|
const handlers = useNodeEvents(node, 'zone')
|
|
|
|
if (!(node && floorShape && wallGeometry && floorMaterial && wallMaterial)) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
|
|
<Html
|
|
name="label"
|
|
position={[centroid[0], 1, centroid[1]]}
|
|
style={{ pointerEvents: 'none' }}
|
|
zIndexRange={[10, 0]}
|
|
>
|
|
<div
|
|
id={`${node.id}-label`}
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
transform: 'translate3d(-50%, -50%, 0)',
|
|
opacity: 0,
|
|
transition: 'opacity 0.3s ease-in-out',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 'max-content',
|
|
color: 'white',
|
|
textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<span>{node.name}</span>
|
|
</div>
|
|
<div
|
|
className="label-pin"
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
marginTop: '2px',
|
|
opacity: 0,
|
|
transition: 'opacity 0.5s ease-in-out',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: '2px',
|
|
height: '40px',
|
|
backgroundColor: node.color,
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: '10px',
|
|
height: '10px',
|
|
borderRadius: '50%',
|
|
backgroundColor: node.color,
|
|
border: '1px solid white',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Html>
|
|
|
|
{/* Floor fill */}
|
|
<mesh
|
|
layers={ZONE_LAYER}
|
|
material={floorMaterial}
|
|
name="floor"
|
|
position={[0, Y_OFFSET, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
>
|
|
<shapeGeometry args={[floorShape]} />
|
|
</mesh>
|
|
|
|
{/* Wall borders with gradient */}
|
|
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
|
|
</group>
|
|
)
|
|
}
|