diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 59e84f2b..1f3bfecc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,13 @@ export { } from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' 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 { detectSpacesForLevel, @@ -61,6 +68,7 @@ export { } from './store/history-control' export { type ControlValue, + type DoorInteractiveState, type ItemInteractiveState, useInteractive, } from './store/use-interactive' diff --git a/packages/core/src/lib/door-operation.ts b/packages/core/src/lib/door-operation.ts new file mode 100644 index 00000000..cdfb93da --- /dev/null +++ b/packages/core/src/lib/door-operation.ts @@ -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) +} diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts index 8b165559..cb2da899 100644 --- a/packages/core/src/store/use-interactive.ts +++ b/packages/core/src/store/use-interactive.ts @@ -12,8 +12,14 @@ export type ItemInteractiveState = { controlValues: ControlValue[] } +export type DoorInteractiveState = { + operationState?: number + swingAngle?: number +} + type InteractiveStore = { items: Record + doors: Record /** Initialize a node's interactive state from its asset definition (idempotent) */ initItem: (itemId: AnyNodeId, interactive: Interactive) => void @@ -23,6 +29,12 @@ type InteractiveStore = { /** Remove a node's state (e.g. on unmount) */ 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 => { @@ -40,6 +52,7 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa export const useInteractive = create((set, get) => ({ items: {}, + doors: {}, initItem: (itemId, interactive) => { const { controls } = interactive @@ -74,4 +87,23 @@ export const useInteractive = create((set, get) => ({ 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 } + }) + }, })) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index d7cd8c23..9fa57b91 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -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 { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 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') 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 leafH = node.height - node.frameThickness if (leafW <= 0 || leafH <= 0) return null @@ -117,10 +116,7 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { root.updateWorldMatrix(true, false) if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') { - const openAmount = - node.doorType === 'garage-sectional' - ? Math.min(1, (node.operationState ?? 0) / 0.88) - : Math.max(0, Math.min(1, node.operationState ?? 0)) + const openAmount = getGarageVisibleOpeningRatio(node.doorType, node.operationState) const visibleHeight = leafH * (1 - openAmount) if (visibleHeight <= 0.12) return null @@ -140,7 +136,10 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) { 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 } diff --git a/packages/editor/src/lib/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts index 98335b4b..00c162e7 100644 --- a/packages/editor/src/lib/door-interaction.ts +++ b/packages/editor/src/lib/door-interaction.ts @@ -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 const DOOR_TOGGLE_ANIMATION_MS = 520 const activeDoorAnimations = new Map() -export function isOperationDoorType(doorType: string | undefined) { - return ( - doorType === 'folding' || - doorType === 'pocket' || - doorType === 'barn' || - doorType === 'sliding' || - doorType === 'garage-sectional' || - doorType === 'garage-rollup' || - doorType === 'garage-tiltup' - ) -} +export { isOperationDoorType } export function updateDoorOpenState( doorId: AnyNodeId, @@ -28,6 +18,25 @@ export function updateDoorOpenState( 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( doorId: AnyNodeId, field: 'operationState' | 'swingAngle', @@ -46,13 +55,14 @@ export function animateDoorOpenState( const tick = (now: number) => { const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS) const value = from + (to - from) * ease(progress) - updateDoorOpenState(doorId, { [field]: value }) + setRuntimeDoorOpenState(doorId, { [field]: value }) if (progress < 1) { activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick)) } else { activeDoorAnimations.delete(doorId) updateDoorOpenState(doorId, { [field]: to }) + clearRuntimeDoorOpenState(doorId) onComplete?.() } } diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 7be54319..cba46835 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,5 +1,13 @@ 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 { baseMaterial, glassMaterial } from '../../lib/materials' @@ -370,7 +378,7 @@ function addFoldingDoor( }, ) { const panelCount = leafCount === 2 ? 2 : 4 - const foldAmount = Math.max(0, Math.min(1, operationState)) + const foldAmount = clampDoorOperationState(operationState) const panelLength = insideWidth / panelCount const foldAngle = Math.PI * 0.44 * foldAmount @@ -501,7 +509,7 @@ function addPocketDoor( 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 leafWidth = insideWidth const leafCenterX = slideSign * insideWidth * openAmount @@ -594,7 +602,7 @@ function addBarnDoor( contentPadding: DoorNode['contentPadding'] }, ) { - const openAmount = Math.max(0, Math.min(1, operationState)) + const openAmount = clampDoorOperationState(operationState) const slideSign = slideDirection === 'right' ? 1 : -1 const leafWidth = insideWidth * 1.06 const leafCenterX = slideSign * insideWidth * openAmount @@ -711,7 +719,7 @@ function addSlidingDoor( contentPadding: DoorNode['contentPadding'] }, ) { - const openAmount = Math.max(0, Math.min(1, operationState)) + const openAmount = clampDoorOperationState(operationState) const activeOnRight = slideDirection === 'left' const fixedSign = activeOnRight ? -1 : 1 const activeSign = activeOnRight ? 1 : -1 @@ -814,7 +822,7 @@ function addGarageSectionalDoor( 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 panelHeight = leafHeight / panelCount const panelGap = Math.min(0.012, panelHeight * 0.08) @@ -920,7 +928,7 @@ function addGarageRollupDoor( 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 visibleHeight = leafHeight * (1 - openAmount) const visibleSlatCount = Math.ceil(visibleHeight / slatHeight) @@ -1011,7 +1019,7 @@ function addGarageTiltupDoor( operationState: number }, ) { - const openAmount = Math.max(0, Math.min(1, operationState)) + const openAmount = clampDoorOperationState(operationState) const angle = (Math.PI / 2) * openAmount const hingeY = leafCenterY + leafHeight / 2 const panelCenterY = hingeY - Math.cos(angle) * (leafHeight / 2) @@ -1114,13 +1122,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { contentPadding, hingesSide, swingDirection, - swingAngle = 0, + swingAngle: nodeSwingAngle = 0, doorType = 'hinged', - operationState = 0, + operationState: nodeOperationState = 0, leafCount = 1, slideDirection = 'left', garagePanelCount = 4, } = 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)) if (openingKind === 'opening') {