From cef3f24dad763494d5b5cf9edf6cdb4a75b78357 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 7 May 2026 10:11:25 +0530 Subject: [PATCH] Move door animations into viewer system --- packages/core/src/events/bus.ts | 8 ++ packages/core/src/index.ts | 1 + packages/core/src/store/use-interactive.ts | 33 +++++ .../editor/first-person-controls.tsx | 39 ++---- packages/editor/src/hooks/use-keyboard.ts | 30 +---- packages/editor/src/lib/door-interaction.ts | 118 ++++++++++-------- .../viewer/src/components/viewer/index.tsx | 2 + .../systems/door/door-animation-system.tsx | 59 +++++++++ 8 files changed, 181 insertions(+), 109 deletions(-) create mode 100644 packages/viewer/src/systems/door/door-animation-system.tsx diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7530b30e..388de61f 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -137,6 +137,13 @@ type GuideEvents = { 'guide:deleted': { guideId: GuideNode['id'] } } +type DoorAnimationEvents = { + 'door:animation-completed': { + doorId: DoorNode['id'] + field: 'operationState' | 'swingAngle' + } +} + type PresetEvents = { 'preset:generate-thumbnail': { presetId: string; nodeId: string } 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } @@ -178,6 +185,7 @@ type EditorEvents = GridEvents & CameraControlEvents & ToolEvents & GuideEvents & + DoorAnimationEvents & PresetEvents & ThumbnailEvents & SnapshotEvents & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1f3bfecc..43ee7204 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,7 @@ export { } from './store/history-control' export { type ControlValue, + type DoorAnimationState, type DoorInteractiveState, type ItemInteractiveState, useInteractive, diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index cb2da899..19a1c78d 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -17,9 +17,19 @@ export type DoorInteractiveState = { swingAngle?: number } +export type DoorAnimationState = { + field: keyof DoorInteractiveState + from: number + to: number + startedAt: number | null + durationMs: number + persist: boolean +} + type InteractiveStore = { items: Record doors: Record + doorAnimations: Record /** Initialize a node's interactive state from its asset definition (idempotent) */ initItem: (itemId: AnyNodeId, interactive: Interactive) => void @@ -35,6 +45,12 @@ type InteractiveStore = { /** Clear transient door open state */ removeDoorOpenState: (doorId: AnyNodeId) => void + + /** Queue a door animation for the viewer frame loop */ + startDoorAnimation: (doorId: AnyNodeId, value: DoorAnimationState) => void + + /** Cancel a queued door animation */ + cancelDoorAnimation: (doorId: AnyNodeId) => void } const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { @@ -53,6 +69,7 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa export const useInteractive = create((set, get) => ({ items: {}, doors: {}, + doorAnimations: {}, initItem: (itemId, interactive) => { const { controls } = interactive @@ -106,4 +123,20 @@ export const useInteractive = create((set, get) => ({ return { doors: rest } }) }, + + startDoorAnimation: (doorId, value) => { + set((state) => ({ + doorAnimations: { + ...state.doorAnimations, + [doorId]: value, + }, + })) + }, + + cancelDoorAnimation: (doorId) => { + set((state) => { + const { [doorId]: _, ...rest } = state.doorAnimations + return { doorAnimations: rest } + }) + }, })) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index cbf1d07e..f3597c20 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,16 +1,16 @@ 'use client' import '../../three-types' -import { type AnyNodeId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' +import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three' import { - animateDoorOpenState, DOOR_SWING_OPEN_ANGLE, isOperationDoorType, + toggleDoorOpenState, } from '../../lib/door-interaction' import useEditor from '../../store/use-editor' import { @@ -159,7 +159,9 @@ export const FirstPersonControls = () => { const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 - const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, node.swingAngle ?? 0)) + const currentSwingAngle = + useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0 + const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle)) const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign doorLeafMatrix @@ -194,30 +196,8 @@ export const FirstPersonControls = () => { const node = useScene.getState().nodes[doorId] if (node?.type !== 'door' || node.openingKind === 'opening') return - if (isOperationDoorType(node.doorType)) { - const currentOpenAmount = - useInteractive.getState().doors[doorId]?.operationState ?? node.operationState ?? 0 - animateDoorOpenState( - doorId, - 'operationState', - currentOpenAmount, - currentOpenAmount >= 0.5 ? 0 : 1, - rebuildColliderWorld, - { persist: false }, - ) - } else { - const currentSwingAngle = - useInteractive.getState().doors[doorId]?.swingAngle ?? node.swingAngle ?? 0 - animateDoorOpenState( - doorId, - 'swingAngle', - currentSwingAngle, - currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - rebuildColliderWorld, - { persist: false }, - ) - } - }, [rebuildColliderWorld, resolveInteractableDoorId]) + toggleDoorOpenState(doorId, { persist: false }) + }, [resolveInteractableDoorId]) const placedSpawn = useMemo(() => { if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null @@ -258,6 +238,11 @@ export const FirstPersonControls = () => { } }, [rebuildColliderWorld]) + useEffect(() => { + emitter.on('door:animation-completed', rebuildColliderWorld) + return () => emitter.off('door:animation-completed', rebuildColliderWorld) + }, [rebuildColliderWorld]) + useEffect(() => { if (!world) return if (controllerStart) return diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 9c7c9f6a..9d5383a7 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,12 +1,7 @@ import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' -import { - animateDoorOpenState, - DOOR_SWING_OPEN_ANGLE, - isOperationDoorType, - updateDoorOpenState, -} from '../lib/door-interaction' +import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction' import { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' import useEditor from '../store/use-editor' @@ -158,23 +153,7 @@ export const useKeyboard = ({ if (node?.type === 'door') { e.preventDefault() if (node.openingKind !== 'opening') { - if (isOperationDoorType(node.doorType)) { - const currentOpenAmount = node.operationState ?? 0 - animateDoorOpenState( - node.id, - 'operationState', - currentOpenAmount, - currentOpenAmount >= 0.5 ? 0 : 1, - ) - } else { - const currentSwingAngle = node.swingAngle ?? 0 - animateDoorOpenState( - node.id, - 'swingAngle', - currentSwingAngle, - currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - ) - } + toggleDoorOpenState(node.id) sfxEmitter.emit('sfx:item-rotate') } } else if (node && 'rotation' in node) { @@ -200,10 +179,7 @@ export const useKeyboard = ({ if (node?.type === 'door') { e.preventDefault() if (node.openingKind !== 'opening') { - updateDoorOpenState( - node.id, - isOperationDoorType(node.doorType) ? { operationState: 0 } : { swingAngle: 0 }, - ) + closeDoorOpenState(node.id) sfxEmitter.emit('sfx:item-rotate') } } else if (node && 'rotation' in node) { diff --git a/packages/editor/src/lib/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts index a37026bb..7d9efc48 100644 --- a/packages/editor/src/lib/door-interaction.ts +++ b/packages/editor/src/lib/door-interaction.ts @@ -1,9 +1,13 @@ -import { type AnyNodeId, isOperationDoorType, useInteractive, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + type DoorInteractiveState, + isOperationDoorType, + useInteractive, + useScene, +} from '@pascal-app/core' export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 - -const DOOR_TOGGLE_ANIMATION_MS = 520 -const activeDoorAnimations = new Map() +export const DOOR_TOGGLE_ANIMATION_MS = 520 export { isOperationDoorType } @@ -11,70 +15,74 @@ type DoorOpenAnimationOptions = { persist?: boolean } -export function updateDoorOpenState( +function getDisplayedDoorValue( doorId: AnyNodeId, - data: { operationState?: number; swingAngle?: number }, + field: keyof DoorInteractiveState, + nodeValue: number | undefined, ) { - const scene = useScene.getState() - const node = scene.nodes[doorId] - scene.updateNode(doorId, data) - scene.dirtyNodes.add(doorId) - if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) + const interactive = useInteractive.getState() + const runtimeValue = interactive.doors[doorId]?.[field] + if (runtimeValue !== undefined) return runtimeValue + + const queuedValue = interactive.doorAnimations[doorId]?.from + if (queuedValue !== undefined) return queuedValue + + return nodeValue ?? 0 } -function setRuntimeDoorOpenState( +function startDoorOpenAnimation( doorId: AnyNodeId, - data: { operationState?: number; swingAngle?: number }, -) { - const scene = useScene.getState() - const node = scene.nodes[doorId] - useInteractive.getState().setDoorOpenState(doorId, data) - scene.dirtyNodes.add(doorId) - if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) -} - -function clearRuntimeDoorOpenState(doorId: AnyNodeId) { - const scene = useScene.getState() - const node = scene.nodes[doorId] - useInteractive.getState().removeDoorOpenState(doorId) - scene.dirtyNodes.add(doorId) - if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) -} - -export function animateDoorOpenState( - doorId: AnyNodeId, - field: 'operationState' | 'swingAngle', + field: keyof DoorInteractiveState, from: number, to: number, - onComplete?: () => void, options?: DoorOpenAnimationOptions, ) { - const existingFrame = activeDoorAnimations.get(doorId) - if (existingFrame !== undefined) { - window.cancelAnimationFrame(existingFrame) + useInteractive.getState().startDoorAnimation(doorId, { + field, + from, + to, + startedAt: null, + durationMs: DOOR_TOGGLE_ANIMATION_MS, + persist: options?.persist ?? true, + }) +} + +export function toggleDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) { + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return + + if (isOperationDoorType(node.doorType)) { + const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState) + startDoorOpenAnimation( + doorId, + 'operationState', + currentOpenAmount, + currentOpenAmount >= 0.5 ? 0 : 1, + options, + ) + return } - const startedAt = performance.now() - const ease = (value: number) => value * value * (3 - 2 * value) + const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle) + startDoorOpenAnimation( + doorId, + 'swingAngle', + currentSwingAngle, + currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + options, + ) +} - const tick = (now: number) => { - const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS) - const value = from + (to - from) * ease(progress) - setRuntimeDoorOpenState(doorId, { [field]: value }) +export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) { + const node = useScene.getState().nodes[doorId] + if (node?.type !== 'door' || node.openingKind === 'opening') return - if (progress < 1) { - activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick)) - } else { - activeDoorAnimations.delete(doorId) - if (options?.persist ?? true) { - updateDoorOpenState(doorId, { [field]: to }) - clearRuntimeDoorOpenState(doorId) - } else { - setRuntimeDoorOpenState(doorId, { [field]: to }) - } - onComplete?.() - } + if (isOperationDoorType(node.doorType)) { + const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState) + startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 0, options) + return } - activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick)) + const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle) + startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, 0, options) } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 192e3c93..6d6c6156 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' 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 { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' @@ -225,6 +226,7 @@ const Viewer: React.FC = ({ {/* Core systems */} + diff --git a/packages/viewer/src/systems/door/door-animation-system.tsx b/packages/viewer/src/systems/door/door-animation-system.tsx new file mode 100644 index 00000000..2c2f380a --- /dev/null +++ b/packages/viewer/src/systems/door/door-animation-system.tsx @@ -0,0 +1,59 @@ +import { type AnyNodeId, type DoorNode, emitter, useInteractive, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' + +const easeDoorAnimation = (value: number) => value * value * (3 - 2 * value) + +function markDoorDirty(doorId: AnyNodeId) { + const scene = useScene.getState() + const node = scene.nodes[doorId] + scene.dirtyNodes.add(doorId) + if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) +} + +export const DoorAnimationSystem = () => { + useFrame(({ clock }) => { + const interactive = useInteractive.getState() + const entries = Object.entries(interactive.doorAnimations) + if (entries.length === 0) return + + const now = clock.getElapsedTime() * 1000 + + for (const [doorId, animation] of entries) { + const typedDoorId = doorId as AnyNodeId + const scene = useScene.getState() + const node = scene.nodes[typedDoorId] + if (node?.type !== 'door') { + interactive.cancelDoorAnimation(typedDoorId) + interactive.removeDoorOpenState(typedDoorId) + continue + } + + const startedAt = animation.startedAt ?? now + if (animation.startedAt === null) { + interactive.startDoorAnimation(typedDoorId, { ...animation, startedAt }) + } + + const progress = Math.min(1, (now - startedAt) / animation.durationMs) + const value = animation.from + (animation.to - animation.from) * easeDoorAnimation(progress) + interactive.setDoorOpenState(typedDoorId, { [animation.field]: value }) + markDoorDirty(typedDoorId) + + if (progress < 1) continue + + interactive.cancelDoorAnimation(typedDoorId) + if (animation.persist) { + scene.updateNode(typedDoorId, { [animation.field]: animation.to }) + interactive.removeDoorOpenState(typedDoorId) + markDoorDirty(typedDoorId) + } else { + interactive.setDoorOpenState(typedDoorId, { [animation.field]: animation.to }) + } + emitter.emit('door:animation-completed', { + doorId: typedDoorId as DoorNode['id'], + field: animation.field, + }) + } + }, 2) + + return null +}