Centralize door animation state and operation mapping
This commit is contained in:
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user