Files
editor/packages/viewer/src/components/renderers/zone/zone-renderer.tsx
T
2026-01-29 14:52:15 +09:00

203 lines
5.9 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, uv } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
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
// Fade opacity from 0.6 at bottom to 0 at top
const opacity = float(0.6).mul(float(1).sub(gradientT))
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: baseColor,
opacityNode: opacity,
side: DoubleSide,
depthWrite: false,
depthTest: false,
})
}
/**
* Creates a floor material for zones using TSL
*/
const createFloorMaterial = (zoneColor: string) => {
const baseColor = color(new Color(zoneColor))
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: baseColor,
opacityNode: float(0.15),
side: DoubleSide,
depthWrite: false,
})
}
/**
* 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}>
<Html name="label" position={[centroid[0], 1, centroid[1]]}>
<div style={{
transform: 'translate3d(-50%, -50%, 0)',
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}`,
display: 'flex',
gap: '8px',
alignItems: 'center',
}}>
{/* <div style={{
height: '8px',
width: '8px',
borderRadius: "9999px",
backgroundColor: node.color
}}></div> */}
{node.name}</div>
</Html>
{/* Floor fill */}
<mesh position={[0, Y_OFFSET, 0]} rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial}>
<shapeGeometry args={[floorShape]} />
</mesh>
{/* Wall borders with gradient */}
<mesh geometry={wallGeometry} material={wallMaterial} />
</group>
)
}