Optimize elevator editing performance with live previews
This commit is contained in:
@@ -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