235 lines
8.4 KiB
TypeScript

'use client'
import {
type BuildingNode,
emitter,
type GridEvent,
sceneRegistry,
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import {
CursorSphere,
consumePlacementDragRelease,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
const Y_AXIS = new THREE.Vector3(0, 1, 0)
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
const previousGridPosRef = useRef<[number, number] | null>(null)
const dragAnchorRef = useRef<[number, number] | null>(null)
// Stable refs so the effect never needs node in its dependency array
const nodeIdRef = useRef(node.id)
const originalPositionRef = useRef<[number, number, number]>([...node.position] as [
number,
number,
number,
])
const originalRotationRef = useRef<number>(node.rotation[1] ?? 0)
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
// Local-space offset from the building's origin to its bbox center. The
// move preview preserves the first pointer-to-center delta, then uses this
// offset to write the origin while keeping rotation around the visual center.
const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3())
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(node.id)
if (obj) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
const originWorld = new THREE.Vector3()
obj.getWorldPosition(originWorld)
const originalRotation = node.rotation[1] ?? 0
centerOffsetLocalRef.current = center
.clone()
.sub(originWorld)
.applyAxisAngle(Y_AXIS, -originalRotation)
return [center.x, 0, center.z]
}
const pos = new THREE.Vector3()
obj.getWorldPosition(pos)
return [pos.x, pos.y, pos.z]
}
return [node.position[0], node.position[1], node.position[2]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalPosition = originalPositionRef.current
const offsetWork = new THREE.Vector3()
const offsetAt = (rotationY: number) =>
offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY)
const originalCenterOffset = offsetAt(originalRotationRef.current).clone()
const originalCenter: [number, number] = [
originalPosition[0] + originalCenterOffset.x,
originalPosition[2] + originalCenterOffset.z,
]
useScene.temporal.getState().pause()
dragAnchorRef.current = null
previousGridPosRef.current = null
// Publish the building's current pose to useLiveTransforms so the
// floor-plan (and any other live consumers) can follow per-frame
// without peeking into the Three.js mesh.
const publishLive = (posX: number, posZ: number, rotY: number) => {
useLiveTransforms.getState().set(nodeId, {
position: [posX, originalPosition[1], posZ],
rotation: rotY,
})
}
let wasCommitted = false
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
triggerSFX('sfx:item-rotate')
pendingRotationRef.current += rotationDelta
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
mesh.rotation.y = pendingRotationRef.current
// Keep the bbox center pinned to the cursor through rotation.
if (previousGridPosRef.current) {
const [gridX, gridZ] = previousGridPosRef.current
const off = offsetAt(pendingRotationRef.current)
mesh.position.x = gridX - off.x
mesh.position.z = gridZ - off.z
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
} else {
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
}
}
}
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const rawX = bypassSnap ? event.position[0] : Math.round(event.position[0] * 2) / 2
const rawZ = bypassSnap ? event.position[2] : Math.round(event.position[2] * 2) / 2
const anchor = dragAnchorRef.current ?? [rawX, rawZ]
dragAnchorRef.current = anchor
const gridX = originalCenter[0] + (rawX - anchor[0])
const gridZ = originalCenter[1] + (rawZ - anchor[1])
if (
!bypassSnap &&
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
setCursorWorldPos([gridX, 0, gridZ])
// Directly update the Three.js group — no store update during drag
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
const off = offsetAt(pendingRotationRef.current)
mesh.position.x = gridX - off.x
mesh.position.z = gridZ - off.z
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
}
}
const onGridClick = (event: GridEvent) => {
if (wasCommitted) return
const [gridX, gridZ] = previousGridPosRef.current ?? originalCenter
wasCommitted = true
const off = offsetAt(pendingRotationRef.current)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, {
position: [gridX - off.x, originalPosition[1], gridZ - off.z],
rotation: [0, pendingRotationRef.current, 0],
})
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
onGridClick({ nativeEvent: event } as unknown as GridEvent)
}
const onCancel = () => {
// Revert mesh position and rotation immediately
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
mesh.position.x = originalPosition[0]
mesh.position.z = originalPosition[2]
mesh.rotation.y = originalRotationRef.current
}
pendingRotationRef.current = originalRotationRef.current
// Restore building selection
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
useScene.temporal.getState().resume()
// Tell the keyboard handler we handled this, so it doesn't also clear the selection
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
if (!wasCommitted) {
useScene.getState().updateNode(nodeId, {
position: originalPosition,
rotation: [0, originalRotationRef.current, 0],
})
}
// Drop the live transform — committed positions are now in the scene
// store, so the floor-plan should read those instead of the stale
// drag overlay.
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
}
}, [exitMoveMode]) // stable — node values captured via refs at mount
return (
<group>
<CursorSphere position={cursorWorldPos} showTooltip={false} />
</group>
)
}
export default MoveBuildingContent