Fix first-person door toggles and colliders

This commit is contained in:
sudhir
2026-05-05 17:14:28 +05:30
parent 6df4b4895c
commit 0ef2254e6b
5 changed files with 183 additions and 102 deletions
@@ -7,6 +7,11 @@ import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three' import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
import {
animateDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
isOperationDoorType,
} from '../../lib/door-interaction'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { import {
buildFirstPersonColliderWorldFromRegistry, buildFirstPersonColliderWorldFromRegistry,
@@ -22,7 +27,6 @@ const CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002 const LOOK_SENSITIVITY = 0.002
const CONTROLLER_CENTER_FROM_EYE = 0.85 const CONTROLLER_CENTER_FROM_EYE = 0.85
const DOOR_INTERACTION_DISTANCE = 2.5 const DOOR_INTERACTION_DISTANCE = 2.5
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const DOOR_LEAF_INTERACTION_DEPTH = 0.08
const keyboardMap = [ const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
@@ -43,6 +47,12 @@ const doorLeafLocalHit = new Vector3()
const doorLeafLocalRay = new Ray() const doorLeafLocalRay = new Ray()
const doorLeafMatrix = new Matrix4() const doorLeafMatrix = new Matrix4()
const doorLeafWorldHit = new Vector3() const doorLeafWorldHit = new Vector3()
const doorOpeningBox = new Box3()
const doorOpeningInverseMatrix = new Matrix4()
const doorOpeningLocalHit = new Vector3()
const doorOpeningLocalRay = new Ray()
const doorOpeningMatrix = new Matrix4()
const doorOpeningWorldHit = new Vector3()
const spawnWorldPosition = new Vector3() const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
@@ -56,17 +66,6 @@ const resolvePlacedSpawnNode = (
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
} }
function updateDoorOpenState(
doorId: AnyNodeId,
data: { operationState?: number; swingAngle?: number },
) {
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)
}
export const FirstPersonControls = () => { export const FirstPersonControls = () => {
const { camera, gl } = useThree() const { camera, gl } = useThree()
const selectedLevelId = useViewer((state) => state.selection.levelId) const selectedLevelId = useViewer((state) => state.selection.levelId)
@@ -124,6 +123,39 @@ export const FirstPersonControls = () => {
if (leafW <= 0 || leafH <= 0) continue if (leafW <= 0 || leafH <= 0) continue
const leafCenterY = -node.frameThickness / 2 const leafCenterY = -node.frameThickness / 2
if (isOperationDoorType(node.doorType)) {
doorOpeningMatrix
.copy(object.matrixWorld)
.multiply(new Matrix4().makeTranslation(0, leafCenterY, 0))
doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert()
doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
doorOpeningLocalRay
.copy(doorInteractionRaycaster.ray)
.applyMatrix4(doorOpeningInverseMatrix)
const localOpeningHit = doorOpeningLocalRay.intersectBox(
doorOpeningBox,
doorOpeningLocalHit,
)
if (!localOpeningHit) continue
doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix)
const openingHitDistance = doorOpeningWorldHit.distanceTo(
doorInteractionRaycaster.ray.origin,
)
if (
openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
openingHitDistance < closestDistance
) {
closestDoorId = doorId as AnyNodeId
closestDistance = openingHitDistance
}
continue
}
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
@@ -162,27 +194,25 @@ export const FirstPersonControls = () => {
const node = useScene.getState().nodes[doorId] const node = useScene.getState().nodes[doorId]
if (node?.type !== 'door' || node.openingKind === 'opening') return if (node?.type !== 'door' || node.openingKind === 'opening') return
if ( if (isOperationDoorType(node.doorType)) {
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 currentOpenAmount = node.operationState ?? 0 const currentOpenAmount = node.operationState ?? 0
updateDoorOpenState(doorId, { animateDoorOpenState(
operationState: currentOpenAmount >= 0.5 ? 0 : 1, doorId,
}) 'operationState',
currentOpenAmount,
currentOpenAmount >= 0.5 ? 0 : 1,
rebuildColliderWorld,
)
} else { } else {
const currentSwingAngle = node.swingAngle ?? 0 const currentSwingAngle = node.swingAngle ?? 0
updateDoorOpenState(doorId, { animateDoorOpenState(
swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, doorId,
}) 'swingAngle',
currentSwingAngle,
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
rebuildColliderWorld,
)
} }
requestAnimationFrame(rebuildColliderWorld)
}, [rebuildColliderWorld, resolveInteractableDoorId]) }, [rebuildColliderWorld, resolveInteractableDoorId])
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => { const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
@@ -23,6 +23,7 @@ const UP = new THREE.Vector3(0, 1, 0)
const SPAWN_EYE_HEIGHT = 1.65 const SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25 const RAYCAST_CLEARANCE = 25
const DOOR_LEAF_COLLIDER_DEPTH = 0.06 const DOOR_LEAF_COLLIDER_DEPTH = 0.06
const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
@@ -99,19 +100,56 @@ 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
const leafCenterY = -node.frameThickness / 2 const leafCenterY = -node.frameThickness / 2
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 visibleHeight = leafH * (1 - openAmount)
if (visibleHeight <= 0.12) return null
const sourceGeometry = new THREE.BoxGeometry(
leafW,
visibleHeight,
DOOR_LEAF_COLLIDER_DEPTH,
).toNonIndexed()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
sourceGeometry.dispose()
const visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
geometry.applyMatrix4(
root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
)
return geometry
}
if (isOperationDoor && (node.operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD) {
return null
}
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2 const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
root.updateWorldMatrix(true, false)
const sourceGeometry = new THREE.BoxGeometry( const sourceGeometry = new THREE.BoxGeometry(
leafW, leafW,
leafH, leafH,
@@ -98,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
} }
const getPlacementOrientation = (event: WallEvent) => {
const faceSide = getSideFromNormal(event.normal)
const side = movingDoorNode.side ?? faceSide
const rotationOffset = side !== faceSide ? Math.PI : 0
return {
side,
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
cursorRotation:
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
}
}
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) { if (isCurvedWall(event.node)) {
@@ -106,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
} }
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0]) const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
@@ -167,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
} }
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const localX = snapToHalf(event.localPosition[0]) const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
@@ -234,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
if (isCurvedWall(event.node)) return if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const { side, itemRotation } = getPlacementOrientation(event)
const itemRotation = calculateItemRotation(event.normal)
const localX = snapToHalf(event.localPosition[0]) const localX = snapToHalf(event.localPosition[0])
const { clampedX, clampedY } = clampToWall( const { clampedX, clampedY } = clampToWall(
+8 -63
View File
@@ -1,55 +1,16 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import {
animateDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
isOperationDoorType,
updateDoorOpenState,
} from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_TOGGLE_ANIMATION_MS = 520
const activeDoorAnimations = new Map<AnyNodeId, number>()
function updateDoorOpenState(
doorId: AnyNodeId,
data: { operationState?: number; swingAngle?: number },
) {
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)
}
function animateDoorOpenState(
doorId: AnyNodeId,
field: 'operationState' | 'swingAngle',
from: number,
to: number,
) {
const existingFrame = activeDoorAnimations.get(doorId)
if (existingFrame !== undefined) {
window.cancelAnimationFrame(existingFrame)
}
const startedAt = performance.now()
const ease = (value: number) => value * value * (3 - 2 * value)
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 })
if (progress < 1) {
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
} else {
activeDoorAnimations.delete(doorId)
updateDoorOpenState(doorId, { [field]: to })
}
}
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
}
// Tools call this in their onCancel handler when they have an active mid-action to cancel, // Tools call this in their onCancel handler when they have an active mid-action to cancel,
// so that the global Escape handler knows not to also switch to select mode. // so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false let _toolCancelConsumed = false
@@ -197,15 +158,7 @@ export const useKeyboard = ({
if (node?.type === 'door') { if (node?.type === 'door') {
e.preventDefault() e.preventDefault()
if (node.openingKind !== 'opening') { if (node.openingKind !== 'opening') {
if ( if (isOperationDoorType(node.doorType)) {
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 currentOpenAmount = node.operationState ?? 0 const currentOpenAmount = node.operationState ?? 0
animateDoorOpenState( animateDoorOpenState(
node.id, node.id,
@@ -249,15 +202,7 @@ export const useKeyboard = ({
if (node.openingKind !== 'opening') { if (node.openingKind !== 'opening') {
updateDoorOpenState( updateDoorOpenState(
node.id, node.id,
node.doorType === 'folding' || isOperationDoorType(node.doorType) ? { operationState: 0 } : { swingAngle: 0 },
node.doorType === 'pocket' ||
node.doorType === 'barn' ||
node.doorType === 'sliding' ||
node.doorType === 'garage-sectional' ||
node.doorType === 'garage-rollup' ||
node.doorType === 'garage-tiltup'
? { operationState: 0 }
: { swingAngle: 0 },
) )
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} }
@@ -0,0 +1,61 @@
import { type AnyNodeId, useScene } from '@pascal-app/core'
export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_TOGGLE_ANIMATION_MS = 520
const activeDoorAnimations = new Map<AnyNodeId, number>()
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 function updateDoorOpenState(
doorId: AnyNodeId,
data: { operationState?: number; swingAngle?: number },
) {
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)
}
export function animateDoorOpenState(
doorId: AnyNodeId,
field: 'operationState' | 'swingAngle',
from: number,
to: number,
onComplete?: () => void,
) {
const existingFrame = activeDoorAnimations.get(doorId)
if (existingFrame !== undefined) {
window.cancelAnimationFrame(existingFrame)
}
const startedAt = performance.now()
const ease = (value: number) => value * value * (3 - 2 * value)
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 })
if (progress < 1) {
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
} else {
activeDoorAnimations.delete(doorId)
updateDoorOpenState(doorId, { [field]: to })
onComplete?.()
}
}
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
}