diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index b146d533..de4297a3 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -7,6 +7,11 @@ 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, +} from '../../lib/door-interaction' import useEditor from '../../store/use-editor' import { buildFirstPersonColliderWorldFromRegistry, @@ -22,7 +27,6 @@ const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 const CONTROLLER_CENTER_FROM_EYE = 0.85 const DOOR_INTERACTION_DISTANCE = 2.5 -const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 const DOOR_LEAF_INTERACTION_DEPTH = 0.08 const keyboardMap = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, @@ -43,6 +47,12 @@ const doorLeafLocalHit = new Vector3() const doorLeafLocalRay = new Ray() const doorLeafMatrix = new Matrix4() 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 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 } -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 = () => { const { camera, gl } = useThree() const selectedLevelId = useViewer((state) => state.selection.levelId) @@ -124,6 +123,39 @@ export const FirstPersonControls = () => { if (leafW <= 0 || leafH <= 0) continue 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 swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 @@ -162,27 +194,25 @@ export const FirstPersonControls = () => { const node = useScene.getState().nodes[doorId] if (node?.type !== 'door' || node.openingKind === 'opening') return - if ( - node.doorType === 'folding' || - node.doorType === 'pocket' || - node.doorType === 'barn' || - node.doorType === 'sliding' || - node.doorType === 'garage-sectional' || - node.doorType === 'garage-rollup' || - node.doorType === 'garage-tiltup' - ) { + if (isOperationDoorType(node.doorType)) { const currentOpenAmount = node.operationState ?? 0 - updateDoorOpenState(doorId, { - operationState: currentOpenAmount >= 0.5 ? 0 : 1, - }) + animateDoorOpenState( + doorId, + 'operationState', + currentOpenAmount, + currentOpenAmount >= 0.5 ? 0 : 1, + rebuildColliderWorld, + ) } else { const currentSwingAngle = node.swingAngle ?? 0 - updateDoorOpenState(doorId, { - swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - }) + animateDoorOpenState( + doorId, + 'swingAngle', + currentSwingAngle, + currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + rebuildColliderWorld, + ) } - - requestAnimationFrame(rebuildColliderWorld) }, [rebuildColliderWorld, resolveInteractableDoorId]) const placedSpawn = useMemo(() => { 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 c373d6da..d7cd8c23 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 @@ -23,6 +23,7 @@ const UP = new THREE.Vector3(0, 1, 0) const SPAWN_EYE_HEIGHT = 1.65 const RAYCAST_CLEARANCE = 25 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 @@ -99,19 +100,56 @@ 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 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 swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1 const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1 const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0)) const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - root.updateWorldMatrix(true, false) - const sourceGeometry = new THREE.BoxGeometry( leafW, leafH, diff --git a/packages/editor/src/components/tools/door/move-door-tool.tsx b/packages/editor/src/components/tools/door/move-door-tool.tsx index 79972369..ee1a80c3 100644 --- a/packages/editor/src/components/tools/door/move-door-tool.tsx +++ b/packages/editor/src/components/tools/door/move-door-tool.tsx @@ -98,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod 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) => { if (!isValidWallSideFace(event.normal)) return if (isCurvedWall(event.node)) { @@ -106,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod } if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) const localX = snapToHalf(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( @@ -167,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod } if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) const localX = snapToHalf(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( @@ -234,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod if (isCurvedWall(event.node)) return if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) + const { side, itemRotation } = getPlacementOrientation(event) const localX = snapToHalf(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index f1186bc0..9c7c9f6a 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,55 +1,16 @@ 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 { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' import useEditor from '../store/use-editor' -const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 -const DOOR_TOGGLE_ANIMATION_MS = 520 -const activeDoorAnimations = new Map() - -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, // so that the global Escape handler knows not to also switch to select mode. let _toolCancelConsumed = false @@ -197,15 +158,7 @@ export const useKeyboard = ({ if (node?.type === 'door') { e.preventDefault() if (node.openingKind !== 'opening') { - if ( - node.doorType === 'folding' || - node.doorType === 'pocket' || - node.doorType === 'barn' || - node.doorType === 'sliding' || - node.doorType === 'garage-sectional' || - node.doorType === 'garage-rollup' || - node.doorType === 'garage-tiltup' - ) { + if (isOperationDoorType(node.doorType)) { const currentOpenAmount = node.operationState ?? 0 animateDoorOpenState( node.id, @@ -249,15 +202,7 @@ export const useKeyboard = ({ if (node.openingKind !== 'opening') { updateDoorOpenState( node.id, - node.doorType === 'folding' || - 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 }, + isOperationDoorType(node.doorType) ? { operationState: 0 } : { swingAngle: 0 }, ) sfxEmitter.emit('sfx:item-rotate') } diff --git a/packages/editor/src/lib/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts new file mode 100644 index 00000000..98335b4b --- /dev/null +++ b/packages/editor/src/lib/door-interaction.ts @@ -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() + +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)) +}