Centralize door animation state and operation mapping

This commit is contained in:
sudhir
2026-05-05 17:23:48 +05:30
parent 0ef2254e6b
commit f5f80d0f3f
6 changed files with 139 additions and 37 deletions
+8
View File
@@ -33,6 +33,13 @@ export {
} from './hooks/spatial-grid/spatial-grid-sync' } from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export {
clampDoorOperationState,
getDoorRenderOpenAmount,
getGarageVisibleOpeningRatio,
isOperationDoorType,
SECTIONAL_GARAGE_RENDER_OPEN_SCALE,
} from './lib/door-operation'
export { getRenderableSlabPolygon } from './lib/slab-polygon' export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
detectSpacesForLevel, detectSpacesForLevel,
@@ -61,6 +68,7 @@ export {
} from './store/history-control' } from './store/history-control'
export { export {
type ControlValue, type ControlValue,
type DoorInteractiveState,
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
} from './store/use-interactive' } from './store/use-interactive'
+42
View File
@@ -0,0 +1,42 @@
import type { DoorNode, DoorType } from '../schema/nodes/door'
export const SECTIONAL_GARAGE_RENDER_OPEN_SCALE = 0.88
export function clampDoorOperationState(value: number | undefined) {
return Math.max(0, Math.min(1, value ?? 0))
}
export function isOperationDoorType(
doorType: DoorType | DoorNode['doorType'] | string | undefined,
) {
return (
doorType === 'folding' ||
doorType === 'pocket' ||
doorType === 'barn' ||
doorType === 'sliding' ||
doorType === 'garage-sectional' ||
doorType === 'garage-rollup' ||
doorType === 'garage-tiltup'
)
}
export function getDoorRenderOpenAmount(
doorType: DoorType | DoorNode['doorType'],
operationState: number | undefined,
) {
const openAmount = clampDoorOperationState(operationState)
return doorType === 'garage-sectional'
? openAmount * SECTIONAL_GARAGE_RENDER_OPEN_SCALE
: openAmount
}
export function getGarageVisibleOpeningRatio(
doorType: DoorType | DoorNode['doorType'],
operationState: number | undefined,
) {
if (doorType === 'garage-sectional') {
return Math.min(1, clampDoorOperationState(operationState) / SECTIONAL_GARAGE_RENDER_OPEN_SCALE)
}
return clampDoorOperationState(operationState)
}
@@ -12,8 +12,14 @@ export type ItemInteractiveState = {
controlValues: ControlValue[] controlValues: ControlValue[]
} }
export type DoorInteractiveState = {
operationState?: number
swingAngle?: number
}
type InteractiveStore = { type InteractiveStore = {
items: Record<AnyNodeId, ItemInteractiveState> items: Record<AnyNodeId, ItemInteractiveState>
doors: Record<AnyNodeId, DoorInteractiveState>
/** Initialize a node's interactive state from its asset definition (idempotent) */ /** Initialize a node's interactive state from its asset definition (idempotent) */
initItem: (itemId: AnyNodeId, interactive: Interactive) => void initItem: (itemId: AnyNodeId, interactive: Interactive) => void
@@ -23,6 +29,12 @@ type InteractiveStore = {
/** Remove a node's state (e.g. on unmount) */ /** Remove a node's state (e.g. on unmount) */
removeItem: (itemId: AnyNodeId) => void removeItem: (itemId: AnyNodeId) => void
/** Set transient door open state without committing it to the scene node */
setDoorOpenState: (doorId: AnyNodeId, value: DoorInteractiveState) => void
/** Clear transient door open state */
removeDoorOpenState: (doorId: AnyNodeId) => void
} }
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => { const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
@@ -40,6 +52,7 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa
export const useInteractive = create<InteractiveStore>((set, get) => ({ export const useInteractive = create<InteractiveStore>((set, get) => ({
items: {}, items: {},
doors: {},
initItem: (itemId, interactive) => { initItem: (itemId, interactive) => {
const { controls } = interactive const { controls } = interactive
@@ -74,4 +87,23 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
return { items: rest } return { items: rest }
}) })
}, },
setDoorOpenState: (doorId, value) => {
set((state) => ({
doors: {
...state.doors,
[doorId]: {
...state.doors[doorId],
...value,
},
},
}))
},
removeDoorOpenState: (doorId) => {
set((state) => {
const { [doorId]: _, ...rest } = state.doors
return { doors: rest }
})
},
})) }))
@@ -1,4 +1,11 @@
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' import {
getGarageVisibleOpeningRatio,
type AnyNodeId,
type DoorNode,
isOperationDoorType,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
@@ -100,14 +107,6 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty') const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
if (!hasLeafContent) return null if (!hasLeafContent) return null
const isOperationDoor =
node.doorType === 'folding' ||
node.doorType === 'pocket' ||
node.doorType === 'barn' ||
node.doorType === 'sliding' ||
node.doorType === 'garage-sectional' ||
node.doorType === 'garage-rollup' ||
node.doorType === 'garage-tiltup'
const leafW = node.width - 2 * node.frameThickness const leafW = node.width - 2 * node.frameThickness
const leafH = node.height - node.frameThickness const leafH = node.height - node.frameThickness
if (leafW <= 0 || leafH <= 0) return null if (leafW <= 0 || leafH <= 0) return null
@@ -117,10 +116,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
root.updateWorldMatrix(true, false) root.updateWorldMatrix(true, false)
if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') { if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') {
const openAmount = const openAmount = getGarageVisibleOpeningRatio(node.doorType, node.operationState)
node.doorType === 'garage-sectional'
? Math.min(1, (node.operationState ?? 0) / 0.88)
: Math.max(0, Math.min(1, node.operationState ?? 0))
const visibleHeight = leafH * (1 - openAmount) const visibleHeight = leafH * (1 - openAmount)
if (visibleHeight <= 0.12) return null if (visibleHeight <= 0.12) return null
@@ -140,7 +136,10 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
return geometry return geometry
} }
if (isOperationDoor && (node.operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD) { if (
isOperationDoorType(node.doorType) &&
(node.operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD
) {
return null return null
} }
+23 -13
View File
@@ -1,21 +1,11 @@
import { type AnyNodeId, useScene } from '@pascal-app/core' import { type AnyNodeId, isOperationDoorType, useInteractive, useScene } from '@pascal-app/core'
export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_TOGGLE_ANIMATION_MS = 520 const DOOR_TOGGLE_ANIMATION_MS = 520
const activeDoorAnimations = new Map<AnyNodeId, number>() const activeDoorAnimations = new Map<AnyNodeId, number>()
export function isOperationDoorType(doorType: string | undefined) { export { isOperationDoorType }
return (
doorType === 'folding' ||
doorType === 'pocket' ||
doorType === 'barn' ||
doorType === 'sliding' ||
doorType === 'garage-sectional' ||
doorType === 'garage-rollup' ||
doorType === 'garage-tiltup'
)
}
export function updateDoorOpenState( export function updateDoorOpenState(
doorId: AnyNodeId, doorId: AnyNodeId,
@@ -28,6 +18,25 @@ export function updateDoorOpenState(
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
} }
function setRuntimeDoorOpenState(
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( export function animateDoorOpenState(
doorId: AnyNodeId, doorId: AnyNodeId,
field: 'operationState' | 'swingAngle', field: 'operationState' | 'swingAngle',
@@ -46,13 +55,14 @@ export function animateDoorOpenState(
const tick = (now: number) => { const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS) const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS)
const value = from + (to - from) * ease(progress) const value = from + (to - from) * ease(progress)
updateDoorOpenState(doorId, { [field]: value }) setRuntimeDoorOpenState(doorId, { [field]: value })
if (progress < 1) { if (progress < 1) {
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick)) activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
} else { } else {
activeDoorAnimations.delete(doorId) activeDoorAnimations.delete(doorId)
updateDoorOpenState(doorId, { [field]: to }) updateDoorOpenState(doorId, { [field]: to })
clearRuntimeDoorOpenState(doorId)
onComplete?.() onComplete?.()
} }
} }
@@ -1,5 +1,13 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' import {
clampDoorOperationState,
type AnyNodeId,
type DoorNode,
getDoorRenderOpenAmount,
sceneRegistry,
useInteractive,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three' import * as THREE from 'three'
import { baseMaterial, glassMaterial } from '../../lib/materials' import { baseMaterial, glassMaterial } from '../../lib/materials'
@@ -370,7 +378,7 @@ function addFoldingDoor(
}, },
) { ) {
const panelCount = leafCount === 2 ? 2 : 4 const panelCount = leafCount === 2 ? 2 : 4
const foldAmount = Math.max(0, Math.min(1, operationState)) const foldAmount = clampDoorOperationState(operationState)
const panelLength = insideWidth / panelCount const panelLength = insideWidth / panelCount
const foldAngle = Math.PI * 0.44 * foldAmount const foldAngle = Math.PI * 0.44 * foldAmount
@@ -501,7 +509,7 @@ function addPocketDoor(
contentPadding: DoorNode['contentPadding'] contentPadding: DoorNode['contentPadding']
}, },
) { ) {
const openAmount = Math.max(0, Math.min(1, operationState)) * 0.88 const openAmount = clampDoorOperationState(operationState)
const slideSign = slideDirection === 'right' ? 1 : -1 const slideSign = slideDirection === 'right' ? 1 : -1
const leafWidth = insideWidth const leafWidth = insideWidth
const leafCenterX = slideSign * insideWidth * openAmount const leafCenterX = slideSign * insideWidth * openAmount
@@ -594,7 +602,7 @@ function addBarnDoor(
contentPadding: DoorNode['contentPadding'] contentPadding: DoorNode['contentPadding']
}, },
) { ) {
const openAmount = Math.max(0, Math.min(1, operationState)) const openAmount = clampDoorOperationState(operationState)
const slideSign = slideDirection === 'right' ? 1 : -1 const slideSign = slideDirection === 'right' ? 1 : -1
const leafWidth = insideWidth * 1.06 const leafWidth = insideWidth * 1.06
const leafCenterX = slideSign * insideWidth * openAmount const leafCenterX = slideSign * insideWidth * openAmount
@@ -711,7 +719,7 @@ function addSlidingDoor(
contentPadding: DoorNode['contentPadding'] contentPadding: DoorNode['contentPadding']
}, },
) { ) {
const openAmount = Math.max(0, Math.min(1, operationState)) const openAmount = clampDoorOperationState(operationState)
const activeOnRight = slideDirection === 'left' const activeOnRight = slideDirection === 'left'
const fixedSign = activeOnRight ? -1 : 1 const fixedSign = activeOnRight ? -1 : 1
const activeSign = activeOnRight ? 1 : -1 const activeSign = activeOnRight ? 1 : -1
@@ -814,7 +822,7 @@ function addGarageSectionalDoor(
garagePanelCount: number garagePanelCount: number
}, },
) { ) {
const openAmount = Math.max(0, Math.min(1, operationState)) const openAmount = getDoorRenderOpenAmount('garage-sectional', operationState)
const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount))) const panelCount = Math.max(3, Math.min(12, Math.round(garagePanelCount)))
const panelHeight = leafHeight / panelCount const panelHeight = leafHeight / panelCount
const panelGap = Math.min(0.012, panelHeight * 0.08) const panelGap = Math.min(0.012, panelHeight * 0.08)
@@ -920,7 +928,7 @@ function addGarageRollupDoor(
operationState: number operationState: number
}, },
) { ) {
const openAmount = Math.max(0, Math.min(1, operationState)) const openAmount = clampDoorOperationState(operationState)
const slatHeight = Math.max(0.055, Math.min(0.11, leafHeight / 22)) const slatHeight = Math.max(0.055, Math.min(0.11, leafHeight / 22))
const visibleHeight = leafHeight * (1 - openAmount) const visibleHeight = leafHeight * (1 - openAmount)
const visibleSlatCount = Math.ceil(visibleHeight / slatHeight) const visibleSlatCount = Math.ceil(visibleHeight / slatHeight)
@@ -1011,7 +1019,7 @@ function addGarageTiltupDoor(
operationState: number operationState: number
}, },
) { ) {
const openAmount = Math.max(0, Math.min(1, operationState)) const openAmount = clampDoorOperationState(operationState)
const angle = (Math.PI / 2) * openAmount const angle = (Math.PI / 2) * openAmount
const hingeY = leafCenterY + leafHeight / 2 const hingeY = leafCenterY + leafHeight / 2
const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2) const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2)
@@ -1114,13 +1122,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
contentPadding, contentPadding,
hingesSide, hingesSide,
swingDirection, swingDirection,
swingAngle = 0, swingAngle: nodeSwingAngle = 0,
doorType = 'hinged', doorType = 'hinged',
operationState = 0, operationState: nodeOperationState = 0,
leafCount = 1, leafCount = 1,
slideDirection = 'left', slideDirection = 'left',
garagePanelCount = 4, garagePanelCount = 4,
} = node } = node
const runtimeDoorState = useInteractive.getState().doors[node.id]
const swingAngle = runtimeDoorState?.swingAngle ?? nodeSwingAngle
const operationState = runtimeDoorState?.operationState ?? nodeOperationState
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle)) const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
if (openingKind === 'opening') { if (openingKind === 'opening') {