Optimize elevator editing performance with live previews
This commit is contained in:
@@ -0,0 +1,811 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame, type ThreeEvent } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveElevatorLevels } from '../../../systems/elevator/elevator-utils'
|
||||
|
||||
const SHAFT_WALL_COLOR = '#d7dce4'
|
||||
const SHAFT_SIDE_COLOR = '#4b5563'
|
||||
const SHAFT_TRIM_COLOR = '#eef2f7'
|
||||
const CAB_COLOR = '#d7dde5'
|
||||
const GLASS_COLOR = '#f8fafc'
|
||||
const DOOR_COLOR = '#8e98a6'
|
||||
const PANEL_COLOR = '#1f2937'
|
||||
|
||||
type SegmentName =
|
||||
| 'bottom'
|
||||
| 'lowerLeft'
|
||||
| 'lowerRight'
|
||||
| 'middle'
|
||||
| 'top'
|
||||
| 'upperLeft'
|
||||
| 'upperRight'
|
||||
|
||||
const DIGIT_SEGMENTS: Record<string, readonly SegmentName[]> = {
|
||||
'0': ['top', 'upperLeft', 'upperRight', 'lowerLeft', 'lowerRight', 'bottom'],
|
||||
'1': ['upperRight', 'lowerRight'],
|
||||
'2': ['top', 'upperRight', 'middle', 'lowerLeft', 'bottom'],
|
||||
'3': ['top', 'upperRight', 'middle', 'lowerRight', 'bottom'],
|
||||
'4': ['upperLeft', 'upperRight', 'middle', 'lowerRight'],
|
||||
'5': ['top', 'upperLeft', 'middle', 'lowerRight', 'bottom'],
|
||||
'6': ['top', 'upperLeft', 'middle', 'lowerLeft', 'lowerRight', 'bottom'],
|
||||
'7': ['top', 'upperRight', 'lowerRight'],
|
||||
'8': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerLeft', 'lowerRight', 'bottom'],
|
||||
'9': ['top', 'upperLeft', 'upperRight', 'middle', 'lowerRight', 'bottom'],
|
||||
'-': ['middle'],
|
||||
}
|
||||
|
||||
const SEGMENT_PROPS: Record<
|
||||
SegmentName,
|
||||
{ position: [number, number, number]; size: [number, number, number] }
|
||||
> = {
|
||||
bottom: { position: [0, -0.44, 0], size: [0.56, 0.11, 0.018] },
|
||||
lowerLeft: { position: [-0.32, -0.22, 0], size: [0.11, 0.42, 0.018] },
|
||||
lowerRight: { position: [0.32, -0.22, 0], size: [0.11, 0.42, 0.018] },
|
||||
middle: { position: [0, 0, 0], size: [0.52, 0.1, 0.018] },
|
||||
top: { position: [0, 0.44, 0], size: [0.56, 0.11, 0.018] },
|
||||
upperLeft: { position: [-0.32, 0.22, 0], size: [0.11, 0.42, 0.018] },
|
||||
upperRight: { position: [0.32, 0.22, 0], size: [0.11, 0.42, 0.018] },
|
||||
}
|
||||
|
||||
function MeshButtonLabel({
|
||||
color,
|
||||
label,
|
||||
position,
|
||||
scale,
|
||||
}: {
|
||||
color: string
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
scale: number
|
||||
}) {
|
||||
const characters = label.split('').filter((character) => DIGIT_SEGMENTS[character])
|
||||
const spacing = 0.72 * scale
|
||||
const startX = -((characters.length - 1) * spacing) / 2
|
||||
|
||||
if (characters.length === 0) return null
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
{characters.map((character, charIndex) => (
|
||||
<group key={`${character}-${charIndex}`} position={[startX + charIndex * spacing, 0, 0]}>
|
||||
{(DIGIT_SEGMENTS[character] ?? []).map((segment) => {
|
||||
const props = SEGMENT_PROPS[segment]
|
||||
return (
|
||||
<mesh
|
||||
key={segment}
|
||||
position={[props.position[0] * scale, props.position[1] * scale, props.position[2]]}
|
||||
>
|
||||
<boxGeometry args={[props.size[0] * scale, props.size[1] * scale, props.size[2]]} />
|
||||
<meshStandardMaterial color={color} metalness={0.12} roughness={0.34} />
|
||||
</mesh>
|
||||
)
|
||||
})}
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function ElevatorDirectionGlyph({
|
||||
color,
|
||||
direction,
|
||||
position,
|
||||
scale,
|
||||
}: {
|
||||
color: string
|
||||
direction: 'down' | 'up' | null
|
||||
position: [number, number, number]
|
||||
scale: number
|
||||
}) {
|
||||
if (!direction) {
|
||||
return (
|
||||
<mesh position={position}>
|
||||
<boxGeometry args={[0.08 * scale, 0.08 * scale, 0.018]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.28}
|
||||
metalness={0.08}
|
||||
roughness={0.32}
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
const ySign = direction === 'up' ? 1 : -1
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh
|
||||
position={[-0.04 * scale, -0.02 * ySign * scale, 0]}
|
||||
rotation-z={(-ySign * Math.PI) / 4}
|
||||
>
|
||||
<boxGeometry args={[0.16 * scale, 0.035 * scale, 0.018]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.36}
|
||||
metalness={0.08}
|
||||
roughness={0.32}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0.04 * scale, -0.02 * ySign * scale, 0]} rotation-z={(ySign * Math.PI) / 4}>
|
||||
<boxGeometry args={[0.16 * scale, 0.035 * scale, 0.018]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={0.36}
|
||||
metalness={0.08}
|
||||
roughness={0.32}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function ElevatorFloorIndicator({
|
||||
active,
|
||||
direction,
|
||||
faceSign = -1,
|
||||
label,
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
active: boolean
|
||||
direction: 'down' | 'up' | null
|
||||
faceSign?: -1 | 1
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
scale?: number
|
||||
}) {
|
||||
const glowColor = active ? '#38bdf8' : '#94a3b8'
|
||||
const screenColor = active ? '#041f2f' : '#111827'
|
||||
const displayLabel = label || '-'
|
||||
const screenZ = faceSign * 0.026 * scale
|
||||
const glyphZ = faceSign * 0.041 * scale
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[0.42 * scale, 0.16 * scale, 0.045 * scale]} />
|
||||
<meshStandardMaterial color={PANEL_COLOR} metalness={0.36} roughness={0.34} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, screenZ]}>
|
||||
<boxGeometry args={[0.34 * scale, 0.095 * scale, 0.012 * scale]} />
|
||||
<meshStandardMaterial
|
||||
color={screenColor}
|
||||
emissive={active ? '#0ea5e9' : '#000000'}
|
||||
emissiveIntensity={active ? 0.16 : 0}
|
||||
metalness={0.12}
|
||||
roughness={0.38}
|
||||
/>
|
||||
</mesh>
|
||||
<ElevatorDirectionGlyph
|
||||
color={glowColor}
|
||||
direction={direction}
|
||||
position={[-0.115 * scale, 0, glyphZ]}
|
||||
scale={scale}
|
||||
/>
|
||||
<MeshButtonLabel
|
||||
color={glowColor}
|
||||
label={displayLabel}
|
||||
position={[0.075 * scale, 0, glyphZ]}
|
||||
scale={0.055 * scale}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function ElevatorMeshButton({
|
||||
active,
|
||||
buttonKind,
|
||||
elevatorId,
|
||||
faceSign = -1,
|
||||
label,
|
||||
levelId,
|
||||
onRequest,
|
||||
position,
|
||||
queued,
|
||||
radius = 0.055,
|
||||
}: {
|
||||
active: boolean
|
||||
buttonKind: 'cab' | 'landing'
|
||||
elevatorId: AnyNodeId
|
||||
faceSign?: -1 | 1
|
||||
label?: string
|
||||
levelId: AnyNodeId
|
||||
onRequest: () => void
|
||||
position: [number, number, number]
|
||||
queued: boolean
|
||||
radius?: number
|
||||
}) {
|
||||
const buttonColor = active ? '#38bdf8' : queued ? '#fbbf24' : '#d6dde7'
|
||||
const labelColor = active || queued ? '#111827' : '#334155'
|
||||
const ringColor = active ? '#0ea5e9' : queued ? '#f59e0b' : '#64748b'
|
||||
const depth = active ? 0.028 : 0.04
|
||||
const faceZ = faceSign * (depth / 2 + 0.004)
|
||||
const userData = useMemo(
|
||||
() => ({
|
||||
elevatorButton: {
|
||||
elevatorId,
|
||||
kind: buttonKind,
|
||||
levelId,
|
||||
},
|
||||
}),
|
||||
[buttonKind, elevatorId, levelId],
|
||||
)
|
||||
|
||||
const press = (event: ThreeEvent<PointerEvent>) => {
|
||||
if (event.button !== 0) return
|
||||
onRequest()
|
||||
}
|
||||
|
||||
return (
|
||||
<group onPointerDown={press} position={position} userData={userData}>
|
||||
{(active || queued) && (
|
||||
<mesh position={[0, 0, faceSign * (depth + 0.004)]} receiveShadow rotation-x={Math.PI / 2}>
|
||||
<cylinderGeometry args={[radius * 1.42, radius * 1.42, 0.012, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={buttonColor}
|
||||
depthWrite={false}
|
||||
emissive={buttonColor}
|
||||
emissiveIntensity={active ? 0.28 : 0.18}
|
||||
opacity={0.58}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
<mesh castShadow position={[0, 0, faceSign * (depth / 2 + 0.003)]} receiveShadow>
|
||||
<torusGeometry args={[radius * 1.12, radius * 0.12, 8, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={ringColor}
|
||||
emissive={active || queued ? ringColor : '#000000'}
|
||||
emissiveIntensity={active ? 0.16 : queued ? 0.1 : 0}
|
||||
metalness={0.48}
|
||||
roughness={0.28}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh castShadow receiveShadow rotation-x={Math.PI / 2}>
|
||||
<cylinderGeometry args={[radius, radius * 0.92, depth, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={buttonColor}
|
||||
emissive={active || queued ? buttonColor : '#000000'}
|
||||
emissiveIntensity={active ? 0.28 : queued ? 0.18 : 0}
|
||||
metalness={0.22}
|
||||
roughness={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
{label && (
|
||||
<MeshButtonLabel
|
||||
color={labelColor}
|
||||
label={label}
|
||||
position={[0, 0, faceZ]}
|
||||
scale={radius * 0.72}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function DoorLeaf({
|
||||
animated,
|
||||
doorOpen,
|
||||
height,
|
||||
side,
|
||||
width,
|
||||
y,
|
||||
z,
|
||||
}: {
|
||||
animated?:
|
||||
| {
|
||||
elevatorId: AnyNodeId
|
||||
kind: 'cab'
|
||||
}
|
||||
| {
|
||||
elevatorId: AnyNodeId
|
||||
kind: 'landing'
|
||||
levelId: AnyNodeId
|
||||
}
|
||||
doorOpen: number
|
||||
height: number
|
||||
side: 'left' | 'right'
|
||||
width: number
|
||||
y: number
|
||||
z: number
|
||||
}) {
|
||||
const ref = useRef<Group>(null)
|
||||
const direction = side === 'left' ? -1 : 1
|
||||
const getLeafX = (openAmount: number) => direction * (width / 4 + openAmount * width * 0.34)
|
||||
const leafWidth = Math.max(width / 2 - 0.018, 0.12)
|
||||
const railHeight = Math.min(0.09, Math.max(0.055, height * 0.04))
|
||||
const stileWidth = Math.min(0.07, Math.max(0.04, leafWidth * 0.18))
|
||||
const glassWidth = Math.max(leafWidth - stileWidth * 2.2, 0.03)
|
||||
const glassHeight = Math.max(height - railHeight * 3, 0.2)
|
||||
|
||||
useFrame(() => {
|
||||
if (!(animated && ref.current)) return
|
||||
const runtime = useInteractive.getState().elevators[animated.elevatorId]
|
||||
const nextDoorOpen =
|
||||
animated.kind === 'cab'
|
||||
? (runtime?.doorOpen ?? 0)
|
||||
: runtime?.currentLevelId === animated.levelId
|
||||
? (runtime?.doorOpen ?? 0)
|
||||
: 0
|
||||
ref.current.position.x = getLeafX(nextDoorOpen)
|
||||
}, 2.6)
|
||||
|
||||
return (
|
||||
<group ref={ref} position={[getLeafX(doorOpen), y + height / 2, z]}>
|
||||
<mesh castShadow position={[0, height / 2 - railHeight / 2, 0]} receiveShadow>
|
||||
<boxGeometry args={[leafWidth, railHeight, 0.05]} />
|
||||
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, -height / 2 + railHeight / 2, 0]} receiveShadow>
|
||||
<boxGeometry args={[leafWidth, railHeight, 0.05]} />
|
||||
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[-leafWidth / 2 + stileWidth / 2, 0, 0]} receiveShadow>
|
||||
<boxGeometry args={[stileWidth, height, 0.05]} />
|
||||
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[leafWidth / 2 - stileWidth / 2, 0, 0]} receiveShadow>
|
||||
<boxGeometry args={[stileWidth, height, 0.05]} />
|
||||
<meshStandardMaterial color={DOOR_COLOR} metalness={0.34} roughness={0.34} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0, -0.004]}>
|
||||
<boxGeometry args={[glassWidth, glassHeight, 0.012]} />
|
||||
<meshStandardMaterial
|
||||
color={GLASS_COLOR}
|
||||
depthWrite={false}
|
||||
metalness={0}
|
||||
opacity={0.2}
|
||||
roughness={0.08}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingDoorFrame({
|
||||
doorHeight,
|
||||
doorWidth,
|
||||
levelTopY,
|
||||
levelY,
|
||||
shaftWidth,
|
||||
z,
|
||||
}: {
|
||||
doorHeight: number
|
||||
doorWidth: number
|
||||
levelTopY: number
|
||||
levelY: number
|
||||
shaftWidth: number
|
||||
z: number
|
||||
}) {
|
||||
const wallDepth = 0.09
|
||||
const levelHeight = Math.max(levelTopY - levelY, doorHeight + 0.24)
|
||||
const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
|
||||
const jambCenterOffset = doorWidth / 2 + jambWidth / 2
|
||||
const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
|
||||
const trim = 0.055
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh castShadow position={[-jambCenterOffset, levelY + levelHeight / 2, z]} receiveShadow>
|
||||
<boxGeometry args={[jambWidth, levelHeight, wallDepth]} />
|
||||
<meshStandardMaterial color={SHAFT_WALL_COLOR} metalness={0.08} roughness={0.56} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[jambCenterOffset, levelY + levelHeight / 2, z]} receiveShadow>
|
||||
<boxGeometry args={[jambWidth, levelHeight, wallDepth]} />
|
||||
<meshStandardMaterial color={SHAFT_WALL_COLOR} metalness={0.08} roughness={0.56} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, levelY + doorHeight + headerHeight / 2, z]} receiveShadow>
|
||||
<boxGeometry args={[shaftWidth, headerHeight, wallDepth]} />
|
||||
<meshStandardMaterial color={SHAFT_WALL_COLOR} metalness={0.08} roughness={0.56} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, levelY + trim / 2, z - 0.006]} receiveShadow>
|
||||
<boxGeometry args={[doorWidth + trim * 2, trim, wallDepth * 1.12]} />
|
||||
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
position={[-doorWidth / 2 - trim / 2, levelY + doorHeight / 2, z - 0.006]}
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[trim, doorHeight, wallDepth * 1.12]} />
|
||||
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
position={[doorWidth / 2 + trim / 2, levelY + doorHeight / 2, z - 0.006]}
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[trim, doorHeight, wallDepth * 1.12]} />
|
||||
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, levelY + doorHeight + trim / 2, z - 0.006]} receiveShadow>
|
||||
<boxGeometry args={[doorWidth + trim * 2, trim, wallDepth * 1.12]} />
|
||||
<meshStandardMaterial color={SHAFT_TRIM_COLOR} metalness={0.2} roughness={0.38} />
|
||||
</mesh>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingDoor({
|
||||
animated,
|
||||
elevatorId,
|
||||
doorOpen,
|
||||
doorHeight,
|
||||
doorWidth,
|
||||
levelId,
|
||||
levelY,
|
||||
z,
|
||||
}: {
|
||||
animated: boolean
|
||||
elevatorId: AnyNodeId
|
||||
doorOpen: number
|
||||
doorHeight: number
|
||||
doorWidth: number
|
||||
levelId: AnyNodeId
|
||||
levelY: number
|
||||
z: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DoorLeaf
|
||||
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
|
||||
doorOpen={doorOpen}
|
||||
height={doorHeight}
|
||||
side="left"
|
||||
width={doorWidth}
|
||||
y={levelY}
|
||||
z={z}
|
||||
/>
|
||||
<DoorLeaf
|
||||
animated={animated ? { elevatorId, kind: 'landing', levelId } : undefined}
|
||||
doorOpen={doorOpen}
|
||||
height={doorHeight}
|
||||
side="right"
|
||||
width={doorWidth}
|
||||
y={levelY}
|
||||
z={z}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const cabRef = useRef<Group>(null)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const handlers = useNodeEvents(node, 'elevator')
|
||||
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
|
||||
const renderNode = useMemo(
|
||||
() => (liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node),
|
||||
[liveOverrides, node],
|
||||
)
|
||||
|
||||
useRegistry(node.id, 'elevator', ref)
|
||||
|
||||
const { entries, defaultEntry, shaftBaseY, shaftTopY, totalHeight } = useMemo(
|
||||
() => resolveElevatorLevels(renderNode, nodes),
|
||||
[renderNode, nodes],
|
||||
)
|
||||
const elevatorId = node.id as AnyNodeId
|
||||
const runtimeStatus = useInteractive(
|
||||
useShallow((state) => {
|
||||
const runtime = state.elevators[elevatorId]
|
||||
if (!runtime) return null
|
||||
return {
|
||||
currentLevelId: runtime.currentLevelId,
|
||||
phase: runtime.phase,
|
||||
queue: runtime.queue,
|
||||
targetLevelId: runtime.targetLevelId,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultEntry) return
|
||||
|
||||
const elevatorId = node.id as AnyNodeId
|
||||
const interactive = useInteractive.getState()
|
||||
const current = interactive.elevators[elevatorId]
|
||||
if (!current) {
|
||||
interactive.initElevator(elevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
|
||||
} else if (!entries.some((entry) => entry.id === current.currentLevelId)) {
|
||||
interactive.setElevatorState(elevatorId, {
|
||||
carY: defaultEntry.baseY,
|
||||
currentLevelId: defaultEntry.id as AnyNodeId,
|
||||
doorOpen: 0,
|
||||
phase: 'idle',
|
||||
phaseStartedAt: null,
|
||||
queue: [],
|
||||
targetLevelId: null,
|
||||
})
|
||||
}
|
||||
}, [defaultEntry, entries, node.id])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useInteractive.getState().removeElevator(elevatorId)
|
||||
}
|
||||
}, [elevatorId])
|
||||
|
||||
useFrame(() => {
|
||||
if (!cabRef.current) return
|
||||
const runtime = useInteractive.getState().elevators[elevatorId]
|
||||
if (!runtime) return
|
||||
cabRef.current.position.y = runtime.carY
|
||||
}, 2.6)
|
||||
|
||||
const shaftWidth = Math.max(renderNode.width, 0.8)
|
||||
const shaftDepth = Math.max(renderNode.depth, 0.8)
|
||||
const cabHeight = Math.max(renderNode.cabHeight, 1.4)
|
||||
const doorWidth = Math.min(Math.max(renderNode.doorWidth, 0.45), shaftWidth - 0.18)
|
||||
const doorHeight = Math.min(Math.max(renderNode.doorHeight, 1.2), cabHeight - 0.1)
|
||||
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
|
||||
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
|
||||
const shaftWallThickness = 0.09
|
||||
const runtimeSnapshot = useInteractive.getState().elevators[elevatorId]
|
||||
const cabBaseY = runtimeSnapshot?.carY ?? defaultEntry?.baseY ?? 0
|
||||
const activeLevelId =
|
||||
runtimeStatus?.currentLevelId ?? runtimeSnapshot?.currentLevelId ?? defaultEntry?.id ?? null
|
||||
const pendingLevelId =
|
||||
runtimeStatus?.targetLevelId ??
|
||||
runtimeSnapshot?.targetLevelId ??
|
||||
runtimeStatus?.queue[0] ??
|
||||
runtimeSnapshot?.queue[0] ??
|
||||
null
|
||||
const currentEntry =
|
||||
entries.find((entry) => entry.id === activeLevelId) ?? defaultEntry ?? entries[0] ?? null
|
||||
const pendingEntry = pendingLevelId ? entries.find((entry) => entry.id === pendingLevelId) : null
|
||||
const indicatorEntry = pendingEntry ?? currentEntry
|
||||
const indicatorDirection =
|
||||
currentEntry && pendingEntry && Math.abs(pendingEntry.baseY - currentEntry.baseY) > 0.001
|
||||
? pendingEntry.baseY > currentEntry.baseY
|
||||
? 'up'
|
||||
: 'down'
|
||||
: null
|
||||
const indicatorActive = Boolean(
|
||||
pendingEntry ||
|
||||
runtimeStatus?.phase === 'moving' ||
|
||||
runtimeSnapshot?.phase === 'moving' ||
|
||||
runtimeStatus?.phase === 'opening' ||
|
||||
runtimeSnapshot?.phase === 'opening',
|
||||
)
|
||||
const queuedLevelIds = new Set<string>()
|
||||
for (const levelId of runtimeStatus?.queue ?? runtimeSnapshot?.queue ?? [])
|
||||
queuedLevelIds.add(levelId)
|
||||
if (runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId) {
|
||||
queuedLevelIds.add((runtimeStatus?.targetLevelId ?? runtimeSnapshot?.targetLevelId)!)
|
||||
}
|
||||
const doorOpen = runtimeSnapshot?.doorOpen ?? 0
|
||||
const frontWallZ = -shaftDepth / 2 - shaftWallThickness / 2
|
||||
const frontZ = frontWallZ - shaftWallThickness / 2 - 0.018
|
||||
const landingPanelX = Math.min(shaftWidth / 2 - 0.16, doorWidth / 2 + 0.18)
|
||||
const cabPanelX = shaftWidth / 2 - 0.075
|
||||
const cabPanelZ = -shaftDepth / 2 + 0.36
|
||||
const cabButtonColumns = entries.length > 1 ? 2 : 1
|
||||
const cabButtonRows = Math.max(1, Math.ceil(entries.length / cabButtonColumns))
|
||||
const cabButtonSpacingX = 0.14
|
||||
const cabButtonSpacingY = 0.15
|
||||
const cabPanelWidth = cabButtonColumns * cabButtonSpacingX + 0.13
|
||||
const cabPanelHeight = cabButtonRows * cabButtonSpacingY + 0.12
|
||||
const panelRelativeY = Math.min(Math.max(doorHeight * 0.6, 0.95), cabHeight - 0.35)
|
||||
const cabPanelY = panelRelativeY
|
||||
const entrySpans = entries.map((entry, index) => {
|
||||
const nextEntry = entries[index + 1]
|
||||
return {
|
||||
entry,
|
||||
levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
|
||||
}
|
||||
})
|
||||
const requestLevel = (levelId: AnyNodeId) => {
|
||||
useInteractive.getState().requestElevator(elevatorId, levelId)
|
||||
}
|
||||
|
||||
return (
|
||||
<group
|
||||
position={renderNode.position}
|
||||
ref={ref}
|
||||
rotation-y={renderNode.rotation}
|
||||
visible={renderNode.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<mesh
|
||||
castShadow
|
||||
position={[0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + shaftWallThickness / 2]}
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry
|
||||
args={[shaftWidth + shaftWallThickness * 2, shaftHeight, shaftWallThickness]}
|
||||
/>
|
||||
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
position={[-shaftWidth / 2 - shaftWallThickness / 2, shaftBaseY + shaftHeight / 2, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry
|
||||
args={[shaftWallThickness, shaftHeight, shaftDepth + shaftWallThickness * 2]}
|
||||
/>
|
||||
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
position={[shaftWidth / 2 + shaftWallThickness / 2, shaftBaseY + shaftHeight / 2, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry
|
||||
args={[shaftWallThickness, shaftHeight, shaftDepth + shaftWallThickness * 2]}
|
||||
/>
|
||||
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
position={[0, shaftBaseY + shaftHeight - shaftWallThickness / 2, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry
|
||||
args={[
|
||||
shaftWidth + shaftWallThickness * 2,
|
||||
shaftWallThickness,
|
||||
shaftDepth + shaftWallThickness * 2,
|
||||
]}
|
||||
/>
|
||||
<meshStandardMaterial color={SHAFT_SIDE_COLOR} metalness={0.12} roughness={0.58} />
|
||||
</mesh>
|
||||
|
||||
<group ref={cabRef} position={[0, cabBaseY, 0]}>
|
||||
<mesh castShadow position={[0, 0.04, 0]} receiveShadow>
|
||||
<boxGeometry args={[shaftWidth, 0.08, shaftDepth]} />
|
||||
<meshStandardMaterial color={CAB_COLOR} metalness={0.18} roughness={0.45} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow position={[0, cabHeight - 0.04, 0]} receiveShadow>
|
||||
<boxGeometry args={[shaftWidth, 0.08, shaftDepth]} />
|
||||
<meshStandardMaterial color={CAB_COLOR} metalness={0.18} roughness={0.45} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow position={[0, cabHeight / 2, shaftDepth / 2 - 0.04]} receiveShadow>
|
||||
<boxGeometry args={[shaftWidth, cabHeight, 0.08]} />
|
||||
<meshStandardMaterial color={CAB_COLOR} metalness={0.2} roughness={0.48} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow position={[-shaftWidth / 2 + 0.04, cabHeight / 2, 0]} receiveShadow>
|
||||
<boxGeometry args={[0.08, cabHeight, shaftDepth]} />
|
||||
<meshStandardMaterial color={CAB_COLOR} metalness={0.2} roughness={0.48} />
|
||||
</mesh>
|
||||
|
||||
<mesh castShadow position={[shaftWidth / 2 - 0.04, cabHeight / 2, 0]} receiveShadow>
|
||||
<boxGeometry args={[0.08, cabHeight, shaftDepth]} />
|
||||
<meshStandardMaterial color={CAB_COLOR} metalness={0.2} roughness={0.48} />
|
||||
</mesh>
|
||||
|
||||
<DoorLeaf
|
||||
animated={{ elevatorId, kind: 'cab' }}
|
||||
doorOpen={doorOpen}
|
||||
height={doorHeight}
|
||||
side="left"
|
||||
width={doorWidth}
|
||||
y={0}
|
||||
z={frontZ}
|
||||
/>
|
||||
<DoorLeaf
|
||||
animated={{ elevatorId, kind: 'cab' }}
|
||||
doorOpen={doorOpen}
|
||||
height={doorHeight}
|
||||
side="right"
|
||||
width={doorWidth}
|
||||
y={0}
|
||||
z={frontZ}
|
||||
/>
|
||||
|
||||
<ElevatorFloorIndicator
|
||||
active={indicatorActive}
|
||||
direction={indicatorDirection}
|
||||
faceSign={1}
|
||||
label={indicatorEntry?.label ?? '-'}
|
||||
position={[0, doorHeight + 0.13, frontZ + 0.055]}
|
||||
scale={0.78}
|
||||
/>
|
||||
|
||||
<group position={[cabPanelX, cabPanelY, cabPanelZ]} rotation-y={-Math.PI / 2}>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[cabPanelWidth, cabPanelHeight, 0.045]} />
|
||||
<meshStandardMaterial color={PANEL_COLOR} metalness={0.32} roughness={0.36} />
|
||||
</mesh>
|
||||
|
||||
{entries.map((entry, index) => {
|
||||
const column = index % cabButtonColumns
|
||||
const row = Math.floor(index / cabButtonColumns)
|
||||
const x = (column - (cabButtonColumns - 1) / 2) * cabButtonSpacingX
|
||||
const y = ((cabButtonRows - 1) / 2 - row) * cabButtonSpacingY
|
||||
|
||||
return (
|
||||
<ElevatorMeshButton
|
||||
active={activeLevelId === entry.id}
|
||||
buttonKind="cab"
|
||||
elevatorId={elevatorId}
|
||||
faceSign={1}
|
||||
key={entry.id}
|
||||
label={entry.label}
|
||||
levelId={entry.id as AnyNodeId}
|
||||
onRequest={() => requestLevel(entry.id as AnyNodeId)}
|
||||
position={[x, y, 0.045]}
|
||||
queued={queuedLevelIds.has(entry.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</group>
|
||||
</group>
|
||||
|
||||
{entrySpans.map(({ entry, levelTopY }) => (
|
||||
<group key={entry.id}>
|
||||
<LandingDoorFrame
|
||||
doorHeight={doorHeight}
|
||||
doorWidth={doorWidth}
|
||||
levelTopY={levelTopY}
|
||||
levelY={entry.baseY}
|
||||
shaftWidth={shaftWidth}
|
||||
z={frontWallZ}
|
||||
/>
|
||||
<LandingDoor
|
||||
animated={activeLevelId === entry.id}
|
||||
elevatorId={elevatorId}
|
||||
doorHeight={doorHeight}
|
||||
doorOpen={activeLevelId === entry.id ? doorOpen : 0}
|
||||
doorWidth={doorWidth}
|
||||
levelId={entry.id as AnyNodeId}
|
||||
levelY={entry.baseY}
|
||||
z={frontZ - 0.02}
|
||||
/>
|
||||
<ElevatorFloorIndicator
|
||||
active={indicatorActive || activeLevelId === entry.id || queuedLevelIds.has(entry.id)}
|
||||
direction={indicatorDirection}
|
||||
label={indicatorEntry?.label ?? entry.label}
|
||||
position={[0, entry.baseY + doorHeight + 0.16, frontZ - 0.055]}
|
||||
scale={0.62}
|
||||
/>
|
||||
<group position={[landingPanelX, entry.baseY + panelRelativeY, frontZ - 0.035]}>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[0.18, 0.42, 0.04]} />
|
||||
<meshStandardMaterial color={PANEL_COLOR} metalness={0.25} roughness={0.4} />
|
||||
</mesh>
|
||||
<ElevatorMeshButton
|
||||
active={activeLevelId === entry.id && doorOpen > 0.5}
|
||||
buttonKind="landing"
|
||||
elevatorId={elevatorId}
|
||||
levelId={entry.id as AnyNodeId}
|
||||
onRequest={() => requestLevel(entry.id as AnyNodeId)}
|
||||
position={[0, 0.06, -0.045]}
|
||||
queued={queuedLevelIds.has(entry.id)}
|
||||
radius={0.045}
|
||||
/>
|
||||
<mesh position={[0, -0.12, -0.035]}>
|
||||
<boxGeometry args={[0.095, 0.025, 0.012]} />
|
||||
<meshStandardMaterial
|
||||
color={queuedLevelIds.has(entry.id) ? '#fbbf24' : '#64748b'}
|
||||
emissive={queuedLevelIds.has(entry.id) ? '#fbbf24' : '#000000'}
|
||||
emissiveIntensity={queuedLevelIds.has(entry.id) ? 0.16 : 0}
|
||||
metalness={0.18}
|
||||
roughness={0.42}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { BuildingRenderer } from './building/building-renderer'
|
||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||
import { ColumnRenderer } from './column/column-renderer'
|
||||
import { DoorRenderer } from './door/door-renderer'
|
||||
import { ElevatorRenderer } from './elevator/elevator-renderer'
|
||||
import { FenceRenderer } from './fence/fence-renderer'
|
||||
import { GuideRenderer } from './guide/guide-renderer'
|
||||
import { ItemRenderer } from './item/item-renderer'
|
||||
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
||||
{node.type === 'building' && <BuildingRenderer node={node} />}
|
||||
{node.type === 'ceiling' && <CeilingRenderer node={node} />}
|
||||
{node.type === 'column' && <ColumnRenderer node={node} />}
|
||||
{node.type === 'elevator' && <ElevatorRenderer node={node} />}
|
||||
{node.type === 'level' && <LevelRenderer node={node} />}
|
||||
{node.type === 'item' && <ItemRenderer node={node} />}
|
||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { unionPolygons } from '../../../lib/polygon-union'
|
||||
import useViewer from '../../../store/use-viewer'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
@@ -89,22 +89,12 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
shape.closePath()
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
const multiPolygons = slabPolygons.map((p) => [
|
||||
p.map((pt) => [pt[0], -pt[1]] as [number, number]),
|
||||
])
|
||||
const unioned = polygonClipping.union(
|
||||
multiPolygons[0] as polygonClipping.Polygon,
|
||||
...(multiPolygons.slice(1) as polygonClipping.Polygon[]),
|
||||
)
|
||||
for (const geom of unioned) {
|
||||
const ring = geom[0]
|
||||
if (ring && ring.length > 0) {
|
||||
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)
|
||||
}
|
||||
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type LevelNode, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { unionPolygons } from '../../lib/polygon-union'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
export const GroundOccluder = () => {
|
||||
@@ -64,31 +64,15 @@ export const GroundOccluder = () => {
|
||||
})
|
||||
|
||||
if (polygons.length > 0) {
|
||||
// Format for polygon-clipping: [[[x, y], [x, y], ...]]
|
||||
const multiPolygons = polygons.map((pts) => {
|
||||
const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z)
|
||||
return [ring]
|
||||
})
|
||||
for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) {
|
||||
const hole = new THREE.Path()
|
||||
|
||||
// Union all polygons together to prevent artifacts from overlapping
|
||||
const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1))
|
||||
|
||||
// Add each resulting unioned polygon as a hole
|
||||
for (const geom of unionedPolygons) {
|
||||
// First ring in each geometry is the exterior ring
|
||||
if (geom.length > 0) {
|
||||
const ring = geom[0]!
|
||||
const hole = new THREE.Path()
|
||||
|
||||
if (ring.length > 0) {
|
||||
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()
|
||||
s.holes.push(hole)
|
||||
}
|
||||
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()
|
||||
s.holes.push(hole)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import useViewer from '../../store/use-viewer'
|
||||
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
|
||||
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
|
||||
import { DoorSystem } from '../../systems/door/door-system'
|
||||
import { ElevatorAnimationSystem } from '../../systems/elevator/elevator-animation-system'
|
||||
import { ElevatorOpeningSystem } from '../../systems/elevator/elevator-opening-system'
|
||||
import { FenceSystem } from '../../systems/fence/fence-system'
|
||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
import { ItemSystem } from '../../systems/item/item-system'
|
||||
@@ -94,8 +96,6 @@ type WebGPUDeviceLossInfo = {
|
||||
|
||||
type WebGPUDeviceLike = {
|
||||
lost: Promise<WebGPUDeviceLossInfo>
|
||||
label?: string
|
||||
features?: Set<string>
|
||||
addEventListener?: (type: string, listener: EventListener) => void
|
||||
removeEventListener?: (type: string, listener: EventListener) => void
|
||||
}
|
||||
@@ -108,18 +108,9 @@ function GPUDeviceWatcher() {
|
||||
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||
|
||||
if (!device) {
|
||||
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
||||
backend: backend?.constructor?.name ?? 'unknown',
|
||||
rendererType: (gl as any).constructor?.name ?? 'unknown',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer] WebGPU device ready', {
|
||||
label: device.label,
|
||||
features: device.features ? Array.from(device.features) : [],
|
||||
})
|
||||
|
||||
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
||||
console.error(
|
||||
`[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
|
||||
@@ -167,24 +158,12 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
const canvas = props.canvas
|
||||
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
|
||||
if (cached) return cached
|
||||
// Surface the env we're about to ask WebGPU for — catches "no
|
||||
// navigator.gpu" / "adapter request failed" silently failing in
|
||||
// mobile WebViews where WebGPU is gated behind flags.
|
||||
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
|
||||
console.log('[viewer] Creating WebGPURenderer', {
|
||||
hasNavigatorGPU: hasGpu,
|
||||
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
|
||||
})
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 0.9
|
||||
await renderer.init()
|
||||
console.log('[viewer] WebGPURenderer ready', {
|
||||
backend: (renderer as any).backend?.constructor?.name,
|
||||
isWebGPU: (renderer as any).isWebGPURenderer === true,
|
||||
})
|
||||
return renderer
|
||||
} catch (err) {
|
||||
// Drop the failed promise from the cache so a future Canvas
|
||||
@@ -228,6 +207,8 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
{/* Core systems */}
|
||||
<CeilingSystem />
|
||||
<DoorAnimationSystem />
|
||||
<ElevatorAnimationSystem />
|
||||
<ElevatorOpeningSystem />
|
||||
<WindowAnimationSystem />
|
||||
<DoorSystem />
|
||||
<FenceSystem />
|
||||
|
||||
@@ -174,22 +174,9 @@ const PostProcessingPasses = ({
|
||||
void pipelineVersion
|
||||
|
||||
if (!(renderer && scene && camera)) {
|
||||
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
|
||||
hasRenderer: !!renderer,
|
||||
hasScene: !!scene,
|
||||
hasCamera: !!camera,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer/post-processing] Building pipeline', {
|
||||
version: pipelineVersion,
|
||||
ssgi: SSGI_PARAMS.enabled,
|
||||
hoverHighlightMode,
|
||||
projectId,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
})
|
||||
|
||||
hasPipelineErrorRef.current = false
|
||||
|
||||
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
||||
@@ -202,9 +189,6 @@ const PostProcessingPasses = ({
|
||||
// exclusively and never attempts the TSL pipeline.
|
||||
const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator
|
||||
if (!hasWebGPU) {
|
||||
console.warn(
|
||||
'[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).',
|
||||
)
|
||||
hasPipelineErrorRef.current = true
|
||||
renderPipelineRef.current = null
|
||||
return
|
||||
@@ -331,7 +315,6 @@ const PostProcessingPasses = ({
|
||||
renderPipeline.outputNode = finalOutput
|
||||
renderPipelineRef.current = renderPipeline
|
||||
retryCountRef.current = 0
|
||||
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
|
||||
} catch (error) {
|
||||
hasPipelineErrorRef.current = true
|
||||
console.error(
|
||||
@@ -410,9 +393,6 @@ const PostProcessingPasses = ({
|
||||
if (retryCountRef.current < MAX_PIPELINE_RETRIES) {
|
||||
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
||||
retryCountRef.current++
|
||||
console.warn(
|
||||
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
||||
)
|
||||
if (rebuildTimeoutRef.current !== null) {
|
||||
clearTimeout(rebuildTimeoutRef.current)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type ColumnNode,
|
||||
type DoorEvent,
|
||||
type DoorNode,
|
||||
type ElevatorEvent,
|
||||
type ElevatorNode,
|
||||
type EventSuffix,
|
||||
emitter,
|
||||
type FenceEvent,
|
||||
@@ -57,6 +59,7 @@ type NodeConfig = {
|
||||
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
|
||||
window: { node: WindowNode; event: WindowEvent }
|
||||
door: { node: DoorNode; event: DoorEvent }
|
||||
elevator: { node: ElevatorNode; event: ElevatorEvent }
|
||||
}
|
||||
|
||||
type NodeType = keyof NodeConfig
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type Point2D, unionPolygons } from './polygon-union'
|
||||
|
||||
function polygonArea(points: Point2D[]) {
|
||||
let area = 0
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const current = points[i]!
|
||||
const next = points[(i + 1) % points.length]!
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return Math.abs(area / 2)
|
||||
}
|
||||
|
||||
describe('unionPolygons', () => {
|
||||
test('collapses a contained polygon into the containing polygon', () => {
|
||||
const small: Point2D[] = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
]
|
||||
const large: Point2D[] = [
|
||||
[-1, -1],
|
||||
[2, -1],
|
||||
[2, 2],
|
||||
[-1, 2],
|
||||
]
|
||||
|
||||
const result = unionPolygons([small, large])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(polygonArea(result[0]!)).toBeCloseTo(9)
|
||||
})
|
||||
|
||||
test('combines overlapping rectangles into one boundary', () => {
|
||||
const left: Point2D[] = [
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
]
|
||||
const right: Point2D[] = [
|
||||
[1, 1],
|
||||
[3, 1],
|
||||
[3, 3],
|
||||
[1, 3],
|
||||
]
|
||||
|
||||
const result = unionPolygons([left, right])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toHaveLength(8)
|
||||
expect(polygonArea(result[0]!)).toBeCloseTo(7)
|
||||
})
|
||||
|
||||
test('keeps disjoint polygons as separate boundaries', () => {
|
||||
const left: Point2D[] = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
]
|
||||
const right: Point2D[] = [
|
||||
[2, 0],
|
||||
[3, 0],
|
||||
[3, 1],
|
||||
[2, 1],
|
||||
]
|
||||
|
||||
const result = unionPolygons([left, right])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map(polygonArea)).toEqual([1, 1])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,294 @@
|
||||
export type Point2D = [number, number]
|
||||
|
||||
const EPSILON = 1e-7
|
||||
const KEY_SCALE = 1e6
|
||||
|
||||
type Edge = {
|
||||
start: Point2D
|
||||
end: Point2D
|
||||
polygonIndex: number
|
||||
splits: number[]
|
||||
}
|
||||
|
||||
type Segment = {
|
||||
start: Point2D
|
||||
end: Point2D
|
||||
used: boolean
|
||||
}
|
||||
|
||||
function pointsEqual(a: Point2D, b: Point2D, tolerance = EPSILON) {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= tolerance
|
||||
}
|
||||
|
||||
function pointKey(point: Point2D) {
|
||||
return `${Math.round(point[0] * KEY_SCALE)}:${Math.round(point[1] * KEY_SCALE)}`
|
||||
}
|
||||
|
||||
function interpolate(a: Point2D, b: Point2D, t: number): Point2D {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
}
|
||||
|
||||
function cross(ax: number, ay: number, bx: number, by: number) {
|
||||
return ax * by - ay * bx
|
||||
}
|
||||
|
||||
function polygonArea(points: Point2D[]) {
|
||||
let area = 0
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const current = points[i]!
|
||||
const next = points[(i + 1) % points.length]!
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return area / 2
|
||||
}
|
||||
|
||||
function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const crossValue = cross(point[0] - start[0], point[1] - start[1], dx, dz)
|
||||
if (Math.abs(crossValue) > EPSILON) return false
|
||||
|
||||
const dot =
|
||||
(point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1])
|
||||
return dot <= EPSILON
|
||||
}
|
||||
|
||||
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
|
||||
let inside = false
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const pi = polygon[i]!
|
||||
const pj = polygon[j]!
|
||||
|
||||
if (pointOnSegment(point, pj, pi)) return false
|
||||
|
||||
const intersects =
|
||||
pi[1] > point[1] !== pj[1] > point[1] &&
|
||||
point[0] < ((pj[0] - pi[0]) * (point[1] - pi[1])) / (pj[1] - pi[1]) + pi[0]
|
||||
|
||||
if (intersects) inside = !inside
|
||||
}
|
||||
|
||||
return inside
|
||||
}
|
||||
|
||||
function normalizeRing(ring: Point2D[]) {
|
||||
const normalized: Point2D[] = []
|
||||
|
||||
for (const [x, z] of ring) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(z)) continue
|
||||
|
||||
const point: Point2D = [x, z]
|
||||
const previous = normalized[normalized.length - 1]
|
||||
if (!previous || !pointsEqual(previous, point)) {
|
||||
normalized.push(point)
|
||||
}
|
||||
}
|
||||
|
||||
const first = normalized[0]
|
||||
const last = normalized[normalized.length - 1]
|
||||
if (first && last && pointsEqual(first, last)) {
|
||||
normalized.pop()
|
||||
}
|
||||
|
||||
if (normalized.length < 3 || Math.abs(polygonArea(normalized)) <= EPSILON) return []
|
||||
return polygonArea(normalized) < 0 ? [...normalized].reverse() : normalized
|
||||
}
|
||||
|
||||
function addSplit(edge: Edge, t: number) {
|
||||
if (t < -EPSILON || t > 1 + EPSILON) return
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
if (edge.splits.some((split) => Math.abs(split - clamped) <= EPSILON)) return
|
||||
edge.splits.push(clamped)
|
||||
}
|
||||
|
||||
function parameterOnEdge(point: Point2D, edge: Edge) {
|
||||
const dx = edge.end[0] - edge.start[0]
|
||||
const dz = edge.end[1] - edge.start[1]
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared <= EPSILON) return 0
|
||||
return ((point[0] - edge.start[0]) * dx + (point[1] - edge.start[1]) * dz) / lengthSquared
|
||||
}
|
||||
|
||||
function addIntersectionSplits(left: Edge, right: Edge) {
|
||||
const rx = left.end[0] - left.start[0]
|
||||
const rz = left.end[1] - left.start[1]
|
||||
const sx = right.end[0] - right.start[0]
|
||||
const sz = right.end[1] - right.start[1]
|
||||
const qpx = right.start[0] - left.start[0]
|
||||
const qpz = right.start[1] - left.start[1]
|
||||
const denominator = cross(rx, rz, sx, sz)
|
||||
const numerator = cross(qpx, qpz, rx, rz)
|
||||
|
||||
if (Math.abs(denominator) <= EPSILON) {
|
||||
if (Math.abs(numerator) > EPSILON) return
|
||||
|
||||
for (const point of [left.start, left.end, right.start, right.end]) {
|
||||
if (
|
||||
pointOnSegment(point, left.start, left.end) &&
|
||||
pointOnSegment(point, right.start, right.end)
|
||||
) {
|
||||
addSplit(left, parameterOnEdge(point, left))
|
||||
addSplit(right, parameterOnEdge(point, right))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const t = cross(qpx, qpz, sx, sz) / denominator
|
||||
const u = cross(qpx, qpz, rx, rz) / denominator
|
||||
if (t < -EPSILON || t > 1 + EPSILON || u < -EPSILON || u > 1 + EPSILON) return
|
||||
|
||||
addSplit(left, t)
|
||||
addSplit(right, u)
|
||||
}
|
||||
|
||||
function buildEdges(polygons: Point2D[][]) {
|
||||
const edges: Edge[] = []
|
||||
|
||||
polygons.forEach((polygon, polygonIndex) => {
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
edges.push({
|
||||
start: polygon[i]!,
|
||||
end: polygon[(i + 1) % polygon.length]!,
|
||||
polygonIndex,
|
||||
splits: [0, 1],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
for (let j = i + 1; j < edges.length; j++) {
|
||||
const left = edges[i]!
|
||||
const right = edges[j]!
|
||||
if (left.polygonIndex === right.polygonIndex) continue
|
||||
addIntersectionSplits(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
function buildBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
|
||||
const segments: Segment[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
const splits = [...edge.splits].sort((a, b) => a - b)
|
||||
|
||||
for (let i = 0; i < splits.length - 1; i++) {
|
||||
const startT = splits[i]!
|
||||
const endT = splits[i + 1]!
|
||||
if (endT - startT <= EPSILON) continue
|
||||
|
||||
const start = interpolate(edge.start, edge.end, startT)
|
||||
const end = interpolate(edge.start, edge.end, endT)
|
||||
const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
|
||||
const insideAnother = polygons.some(
|
||||
(polygon, index) => index !== edge.polygonIndex && pointInPolygon(mid, polygon),
|
||||
)
|
||||
|
||||
if (!insideAnother) {
|
||||
segments.push({ start, end, used: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removeDuplicateInteriorSegments(segments)
|
||||
}
|
||||
|
||||
function segmentKey(segment: Segment) {
|
||||
const start = pointKey(segment.start)
|
||||
const end = pointKey(segment.end)
|
||||
return start < end ? `${start}|${end}` : `${end}|${start}`
|
||||
}
|
||||
|
||||
function removeDuplicateInteriorSegments(segments: Segment[]) {
|
||||
const groups = new Map<string, Segment[]>()
|
||||
for (const segment of segments) {
|
||||
const key = segmentKey(segment)
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.push(segment)
|
||||
} else {
|
||||
groups.set(key, [segment])
|
||||
}
|
||||
}
|
||||
|
||||
const result: Segment[] = []
|
||||
for (const group of groups.values()) {
|
||||
if (group.length === 1) {
|
||||
result.push(group[0]!)
|
||||
continue
|
||||
}
|
||||
|
||||
const firstStart = pointKey(group[0]!.start)
|
||||
const firstEnd = pointKey(group[0]!.end)
|
||||
const hasOppositeDirection = group.some(
|
||||
(segment) => pointKey(segment.start) === firstEnd && pointKey(segment.end) === firstStart,
|
||||
)
|
||||
|
||||
if (!hasOppositeDirection) {
|
||||
result.push(group[0]!)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function assembleRings(segments: Segment[]) {
|
||||
const byStart = new Map<string, Segment[]>()
|
||||
for (const segment of segments) {
|
||||
const key = pointKey(segment.start)
|
||||
const group = byStart.get(key)
|
||||
if (group) {
|
||||
group.push(segment)
|
||||
} else {
|
||||
byStart.set(key, [segment])
|
||||
}
|
||||
}
|
||||
|
||||
const rings: Point2D[][] = []
|
||||
|
||||
for (const firstSegment of segments) {
|
||||
if (firstSegment.used) continue
|
||||
|
||||
firstSegment.used = true
|
||||
const ring: Point2D[] = [firstSegment.start, firstSegment.end]
|
||||
const startKey = pointKey(firstSegment.start)
|
||||
let currentKey = pointKey(firstSegment.end)
|
||||
|
||||
while (currentKey !== startKey) {
|
||||
const next = byStart.get(currentKey)?.find((segment) => !segment.used)
|
||||
if (!next) break
|
||||
|
||||
next.used = true
|
||||
ring.push(next.end)
|
||||
currentKey = pointKey(next.end)
|
||||
}
|
||||
|
||||
if (currentKey !== startKey) continue
|
||||
|
||||
const last = ring[ring.length - 1]
|
||||
if (last && pointsEqual(ring[0]!, last)) {
|
||||
ring.pop()
|
||||
}
|
||||
|
||||
const normalized = normalizeRing(ring)
|
||||
if (normalized.length >= 3) {
|
||||
rings.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
return rings
|
||||
}
|
||||
|
||||
export function unionPolygons(polygons: Point2D[][]): Point2D[][] {
|
||||
const validPolygons = polygons.map(normalizeRing).filter((polygon) => polygon.length >= 3)
|
||||
if (validPolygons.length <= 1) return validPolygons
|
||||
|
||||
const edges = buildEdges(validPolygons)
|
||||
const segments = buildBoundarySegments(edges, validPolygons)
|
||||
const rings = assembleRings(segments)
|
||||
|
||||
return rings.length > 0 ? rings : validPolygons
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { type AnyNodeId, type CeilingNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
@@ -82,7 +83,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
|
||||
shape.closePath()
|
||||
|
||||
// Add holes to the shape
|
||||
const holes = ceilingNode.holes || []
|
||||
const holes = mergeSurfaceHolePolygons(ceilingNode.holes || [])
|
||||
for (const holePolygon of holes) {
|
||||
if (holePolygon.length < 3) continue
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
sceneRegistry,
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { resolveElevatorLevels } from './elevator-utils'
|
||||
|
||||
const EPSILON = 0.001
|
||||
|
||||
function moveToward(current: number, target: number, maxDelta: number) {
|
||||
const delta = target - current
|
||||
if (Math.abs(delta) <= maxDelta) return target
|
||||
return current + Math.sign(delta) * maxDelta
|
||||
}
|
||||
|
||||
export function ElevatorAnimationSystem() {
|
||||
useFrame(({ clock }, delta) => {
|
||||
const interactive = useInteractive.getState()
|
||||
const nodes = useScene.getState().nodes
|
||||
const now = clock.getElapsedTime() * 1000
|
||||
|
||||
for (const elevatorId of sceneRegistry.byType.elevator) {
|
||||
const typedElevatorId = elevatorId as AnyNodeId
|
||||
const node = nodes[typedElevatorId]
|
||||
if (node?.type !== 'elevator') {
|
||||
interactive.removeElevator(typedElevatorId)
|
||||
continue
|
||||
}
|
||||
|
||||
const elevator = node as ElevatorNode
|
||||
const { entries, defaultEntry } = resolveElevatorLevels(elevator, nodes)
|
||||
if (!defaultEntry) continue
|
||||
|
||||
const state = interactive.elevators[typedElevatorId]
|
||||
if (!state) {
|
||||
interactive.initElevator(typedElevatorId, defaultEntry.id as AnyNodeId, defaultEntry.baseY)
|
||||
continue
|
||||
}
|
||||
|
||||
const currentEntry =
|
||||
entries.find((entry) => entry.id === state.currentLevelId) ?? defaultEntry
|
||||
if (currentEntry.id !== state.currentLevelId) {
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
currentLevelId: currentEntry.id as AnyNodeId,
|
||||
carY: currentEntry.baseY,
|
||||
targetLevelId: null,
|
||||
phase: 'idle',
|
||||
phaseStartedAt: null,
|
||||
queue: [],
|
||||
doorOpen: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const targetEntry = state.targetLevelId
|
||||
? entries.find((entry) => entry.id === state.targetLevelId)
|
||||
: state.queue[0]
|
||||
? entries.find((entry) => entry.id === state.queue[0])
|
||||
: null
|
||||
|
||||
const doorDurationMs = Math.max(elevator.doorDurationMs ?? 900, 1)
|
||||
const doorStep = (delta * 1000) / doorDurationMs
|
||||
|
||||
switch (state.phase) {
|
||||
case 'idle': {
|
||||
const nextLevelId = state.queue[0] ?? null
|
||||
if (!nextLevelId) {
|
||||
if (state.doorOpen > EPSILON) {
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
doorOpen: Math.max(0, state.doorOpen - doorStep),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
targetLevelId: nextLevelId,
|
||||
phase:
|
||||
state.doorOpen > EPSILON
|
||||
? 'closing'
|
||||
: nextLevelId === state.currentLevelId
|
||||
? 'opening'
|
||||
: 'moving',
|
||||
phaseStartedAt: now,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'closing': {
|
||||
const doorOpen = Math.max(0, state.doorOpen - doorStep)
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
doorOpen,
|
||||
phase: doorOpen <= EPSILON ? (state.targetLevelId ? 'moving' : 'idle') : 'closing',
|
||||
phaseStartedAt: doorOpen <= EPSILON ? now : state.phaseStartedAt,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'moving': {
|
||||
if (!targetEntry) {
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
targetLevelId: null,
|
||||
phase: 'idle',
|
||||
queue: [],
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const speed = Math.max(elevator.speed ?? 2.2, 0.1)
|
||||
const nextY = moveToward(state.carY, targetEntry.baseY, speed * delta)
|
||||
const arrived = Math.abs(nextY - targetEntry.baseY) <= EPSILON
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
carY: nextY,
|
||||
currentLevelId: arrived ? (targetEntry.id as AnyNodeId) : state.currentLevelId,
|
||||
phase: arrived ? 'opening' : 'moving',
|
||||
phaseStartedAt: arrived ? now : state.phaseStartedAt,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'opening': {
|
||||
const doorOpen = Math.min(1, state.doorOpen + doorStep)
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
doorOpen,
|
||||
phase: doorOpen >= 1 - EPSILON ? 'open' : 'opening',
|
||||
phaseStartedAt: doorOpen >= 1 - EPSILON ? now : state.phaseStartedAt,
|
||||
targetLevelId: doorOpen >= 1 - EPSILON ? null : state.targetLevelId,
|
||||
queue:
|
||||
doorOpen >= 1 - EPSILON && state.queue[0] === state.currentLevelId
|
||||
? state.queue.slice(1)
|
||||
: state.queue,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'open': {
|
||||
const elapsed = now - (state.phaseStartedAt ?? now)
|
||||
if (elapsed < Math.max(elevator.dwellMs ?? 1400, 0)) break
|
||||
|
||||
interactive.setElevatorState(typedElevatorId, {
|
||||
phase: 'closing',
|
||||
phaseStartedAt: now,
|
||||
targetLevelId: state.queue[0] ?? null,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2)
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type AnyNode, syncAutoElevatorOpenings, useScene } from '@pascal-app/core'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
function isOpeningRelevantNode(node: AnyNode | undefined) {
|
||||
return (
|
||||
node?.type === 'building' ||
|
||||
node?.type === 'ceiling' ||
|
||||
node?.type === 'elevator' ||
|
||||
node?.type === 'level' ||
|
||||
node?.type === 'slab'
|
||||
)
|
||||
}
|
||||
|
||||
function hasOpeningRelevantNodeChange(
|
||||
nextNodes: Record<string, AnyNode>,
|
||||
prevNodes: Record<string, AnyNode>,
|
||||
) {
|
||||
if (nextNodes === prevNodes) return false
|
||||
|
||||
const ids = new Set([...Object.keys(nextNodes), ...Object.keys(prevNodes)])
|
||||
for (const id of ids) {
|
||||
const nextNode = nextNodes[id]
|
||||
const prevNode = prevNodes[id]
|
||||
if (nextNode === prevNode) continue
|
||||
if (isOpeningRelevantNode(nextNode) || isOpeningRelevantNode(prevNode)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const ElevatorOpeningSystem = () => {
|
||||
const syncingAutoOpeningsRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const applyUpdates = (updates: ReturnType<typeof syncAutoElevatorOpenings>) => {
|
||||
if (updates.length === 0) return
|
||||
syncingAutoOpeningsRef.current = true
|
||||
useScene.getState().updateNodes(updates)
|
||||
queueMicrotask(() => {
|
||||
syncingAutoOpeningsRef.current = false
|
||||
})
|
||||
}
|
||||
|
||||
applyUpdates(syncAutoElevatorOpenings(useScene.getState().nodes))
|
||||
|
||||
return useScene.subscribe((state, prevState) => {
|
||||
if (syncingAutoOpeningsRef.current) return
|
||||
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
|
||||
applyUpdates(syncAutoElevatorOpenings(state.nodes))
|
||||
})
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
resolveElevatorBuildingLevels,
|
||||
resolveElevatorServiceLevels,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
type LevelNode,
|
||||
} from '@pascal-app/core'
|
||||
import { getLevelHeight } from '../level/level-utils'
|
||||
|
||||
export type ElevatorLevelEntry = {
|
||||
id: LevelNode['id']
|
||||
label: string
|
||||
baseY: number
|
||||
}
|
||||
|
||||
export function resolveElevatorLevels(
|
||||
elevator: ElevatorNode,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): {
|
||||
entries: ElevatorLevelEntry[]
|
||||
defaultEntry: ElevatorLevelEntry | null
|
||||
shaftBaseY: number
|
||||
shaftTopY: number
|
||||
totalHeight: number
|
||||
} {
|
||||
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
|
||||
|
||||
const baseYByLevelId = new Map<string, number>()
|
||||
let cumulativeY = 0
|
||||
for (const level of allLevels) {
|
||||
baseYByLevelId.set(level.id, cumulativeY)
|
||||
cumulativeY += getLevelHeight(level.id, nodes)
|
||||
}
|
||||
|
||||
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
|
||||
const entries = serviceLevels.map((level) => ({
|
||||
id: level.id,
|
||||
label: String(level.level),
|
||||
baseY: baseYByLevelId.get(level.id) ?? 0,
|
||||
}))
|
||||
|
||||
const defaultEntry =
|
||||
entries.find((entry) => entry.id === elevator.defaultLevelId) ??
|
||||
entries.find((entry) => entry.id === elevator.fromLevelId) ??
|
||||
entries[0] ??
|
||||
null
|
||||
const firstServedLevel = serviceLevels[0] ?? null
|
||||
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
|
||||
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
|
||||
const lastServedIndex = lastServedLevel
|
||||
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
|
||||
: -1
|
||||
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
|
||||
const shaftTopY = nextLevel
|
||||
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
|
||||
: lastServedLevel
|
||||
? cumulativeY
|
||||
: elevator.cabHeight + 0.3
|
||||
|
||||
return {
|
||||
entries,
|
||||
defaultEntry,
|
||||
shaftBaseY,
|
||||
shaftTopY,
|
||||
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { mergeSurfaceHolePolygons } from '../surface-hole-geometry'
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
@@ -86,6 +87,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
const polygon = getRenderableSlabPolygon(slabNode)
|
||||
const elevation = slabNode.elevation ?? 0.05
|
||||
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
|
||||
|
||||
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||
|
||||
@@ -94,7 +96,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
|
||||
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
for (const holePolygon of slabNode.holes ?? []) {
|
||||
for (const holePolygon of holePolygons) {
|
||||
if (holePolygon.length < 3) continue
|
||||
const holePath = new THREE.Path()
|
||||
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
|
||||
@@ -122,6 +124,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
|
||||
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
const polygon = getRenderableSlabPolygon(slabNode)
|
||||
const depth = Math.abs(slabNode.elevation ?? 0.05)
|
||||
const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? [])
|
||||
|
||||
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||
|
||||
@@ -134,7 +137,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
for (const [x, z] of polygon) {
|
||||
bounds.expandByPoint(new THREE.Vector2(x, z))
|
||||
}
|
||||
for (const hole of slabNode.holes ?? []) {
|
||||
for (const hole of holePolygons) {
|
||||
for (const [x, z] of hole) {
|
||||
bounds.expandByPoint(new THREE.Vector2(x, z))
|
||||
}
|
||||
@@ -157,8 +160,8 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
|
||||
|
||||
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
|
||||
const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
|
||||
for (const hole of slabNode.holes ?? []) {
|
||||
const holesPts2d = holePolygons.map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
|
||||
for (const hole of holePolygons) {
|
||||
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type Point2D, unionPolygons } from '../lib/polygon-union'
|
||||
|
||||
export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] {
|
||||
return unionPolygons(holes)
|
||||
}
|
||||
Reference in New Issue
Block a user