304 lines
9.4 KiB
TypeScript
304 lines
9.4 KiB
TypeScript
import {
|
|
type CeilingEvent,
|
|
emitter,
|
|
type GridEvent,
|
|
sceneRegistry,
|
|
useScene,
|
|
useSpatialQuery,
|
|
type WallEvent,
|
|
} from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useFrame } from '@react-three/fiber'
|
|
import { useEffect, useRef } from 'react'
|
|
import { BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three'
|
|
import useEditor from '@/store/use-editor'
|
|
import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync'
|
|
import {
|
|
ceilingStrategy,
|
|
checkCanPlace,
|
|
floorStrategy,
|
|
wallStrategy,
|
|
} from './placement-strategies'
|
|
import type { PlacementState, TransitionResult } from './placement-types'
|
|
import { useDraftNode } from './use-draft-node'
|
|
|
|
export const ItemTool: React.FC = () => {
|
|
const cursorRef = useRef<Mesh>(null!)
|
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
|
const placementState = useRef<PlacementState>({
|
|
surface: 'floor',
|
|
wallId: null,
|
|
ceilingId: null,
|
|
})
|
|
|
|
const selectedItem = useEditor((state) => state.selectedItem)
|
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
|
const draftNode = useDraftNode()
|
|
|
|
useEffect(() => {
|
|
if (!selectedItem) return
|
|
|
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
|
|
|
// Reset placement state for new item
|
|
placementState.current = { surface: 'floor', wallId: null, ceilingId: null }
|
|
|
|
// ---- Helpers ----
|
|
|
|
const getContext = () => ({
|
|
asset: selectedItem,
|
|
levelId: useViewer.getState().selection.levelId,
|
|
draftItem: draftNode.current,
|
|
gridPosition: gridPosition.current,
|
|
state: { ...placementState.current },
|
|
})
|
|
|
|
const revalidate = (): boolean => {
|
|
const placeable = checkCanPlace(getContext(), validators)
|
|
;(cursorRef.current.material as MeshStandardMaterial).color.set(
|
|
placeable ? 'green' : 'red',
|
|
)
|
|
return placeable
|
|
}
|
|
|
|
const applyTransition = (result: TransitionResult) => {
|
|
Object.assign(placementState.current, result.stateUpdate)
|
|
gridPosition.current.set(...result.gridPosition)
|
|
cursorRef.current.position.set(...result.cursorPosition)
|
|
cursorRef.current.rotation.y = result.cursorRotationY
|
|
|
|
const draft = draftNode.current
|
|
if (draft) {
|
|
Object.assign(draft, result.nodeUpdate)
|
|
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
|
}
|
|
revalidate()
|
|
}
|
|
|
|
// ---- Create initial draft ----
|
|
|
|
draftNode.create(gridPosition.current, selectedItem)
|
|
revalidate()
|
|
|
|
// ---- Floor Handlers ----
|
|
|
|
const onGridMove = (event: GridEvent) => {
|
|
const result = floorStrategy.move(getContext(), event)
|
|
if (!result) return
|
|
|
|
gridPosition.current.set(...result.gridPosition)
|
|
cursorRef.current.position.set(...result.cursorPosition)
|
|
|
|
const draft = draftNode.current
|
|
if (draft) draft.position = result.gridPosition
|
|
|
|
revalidate()
|
|
}
|
|
|
|
const onGridClick = (event: GridEvent) => {
|
|
const result = floorStrategy.click(getContext(), event, validators)
|
|
if (!result) return
|
|
|
|
draftNode.commit(result.nodeUpdate)
|
|
draftNode.create(gridPosition.current, selectedItem)
|
|
revalidate()
|
|
}
|
|
|
|
// ---- Wall Handlers ----
|
|
|
|
const onWallEnter = (event: WallEvent) => {
|
|
const nodes = useScene.getState().nodes
|
|
const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
applyTransition(result)
|
|
}
|
|
|
|
const onWallMove = (event: WallEvent) => {
|
|
const result = wallStrategy.move(getContext(), event)
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
gridPosition.current.set(...result.gridPosition)
|
|
cursorRef.current.position.set(...result.cursorPosition)
|
|
cursorRef.current.rotation.y = result.cursorRotationY
|
|
|
|
// Sync side/rotation on draft ref (needed by checkCanPlace)
|
|
const draft = draftNode.current
|
|
if (draft && result.nodeUpdate) {
|
|
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
|
|
if ('rotation' in result.nodeUpdate)
|
|
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
|
|
}
|
|
|
|
const placeable = revalidate()
|
|
|
|
// Only update mesh + store when placement is valid
|
|
if (draft && placeable) {
|
|
draft.position = result.gridPosition
|
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
if (mesh) {
|
|
mesh.position.copy(gridPosition.current)
|
|
const rot = result.nodeUpdate?.rotation
|
|
if (rot) mesh.rotation.y = rot[1]
|
|
}
|
|
if (result.nodeUpdate) {
|
|
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
|
}
|
|
if (result.dirtyNodeId) {
|
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
}
|
|
}
|
|
}
|
|
|
|
const onWallClick = (event: WallEvent) => {
|
|
const result = wallStrategy.click(getContext(), event, validators)
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
draftNode.commit(result.nodeUpdate)
|
|
if (result.dirtyNodeId) {
|
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
|
}
|
|
draftNode.create(gridPosition.current, selectedItem)
|
|
revalidate()
|
|
}
|
|
|
|
const onWallLeave = (event: WallEvent) => {
|
|
const result = wallStrategy.leave(getContext())
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
applyTransition(result)
|
|
}
|
|
|
|
// ---- Ceiling Handlers ----
|
|
|
|
const onCeilingEnter = (event: CeilingEvent) => {
|
|
const nodes = useScene.getState().nodes
|
|
const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes)
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
applyTransition(result)
|
|
}
|
|
|
|
const onCeilingMove = (event: CeilingEvent) => {
|
|
const result = ceilingStrategy.move(getContext(), event)
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
gridPosition.current.set(...result.gridPosition)
|
|
cursorRef.current.position.set(...result.cursorPosition)
|
|
|
|
revalidate()
|
|
|
|
const draft = draftNode.current
|
|
if (draft) {
|
|
draft.position = result.gridPosition
|
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
|
if (mesh) mesh.position.copy(gridPosition.current)
|
|
}
|
|
}
|
|
|
|
const onCeilingClick = (event: CeilingEvent) => {
|
|
const result = ceilingStrategy.click(getContext(), event, validators)
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
draftNode.commit(result.nodeUpdate)
|
|
draftNode.create(gridPosition.current, selectedItem)
|
|
revalidate()
|
|
}
|
|
|
|
const onCeilingLeave = (event: CeilingEvent) => {
|
|
const result = ceilingStrategy.leave(getContext())
|
|
if (!result) return
|
|
|
|
event.stopPropagation()
|
|
applyTransition(result)
|
|
}
|
|
|
|
// ---- Keyboard rotation ----
|
|
|
|
const ROTATION_STEP = Math.PI / 2
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
const draft = draftNode.current
|
|
if (!draft) return
|
|
|
|
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()
|
|
const currentRotation = draft.rotation
|
|
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
|
|
|
useScene.getState().updateNode(draft.id, { rotation: draft.rotation })
|
|
cursorRef.current.rotation.y = newRotationY
|
|
revalidate()
|
|
}
|
|
}
|
|
window.addEventListener('keydown', onKeyDown)
|
|
|
|
// ---- Bounding box geometry ----
|
|
|
|
const boxGeometry = new BoxGeometry(
|
|
selectedItem.dimensions[0],
|
|
selectedItem.dimensions[1],
|
|
selectedItem.dimensions[2],
|
|
)
|
|
boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0)
|
|
cursorRef.current.geometry = boxGeometry
|
|
|
|
// ---- Subscribe ----
|
|
|
|
emitter.on('grid:move', onGridMove)
|
|
emitter.on('grid:click', onGridClick)
|
|
emitter.on('wall:enter', onWallEnter)
|
|
emitter.on('wall:move', onWallMove)
|
|
emitter.on('wall:click', onWallClick)
|
|
emitter.on('wall:leave', onWallLeave)
|
|
emitter.on('ceiling:enter', onCeilingEnter)
|
|
emitter.on('ceiling:move', onCeilingMove)
|
|
emitter.on('ceiling:click', onCeilingClick)
|
|
emitter.on('ceiling:leave', onCeilingLeave)
|
|
|
|
return () => {
|
|
draftNode.destroy()
|
|
emitter.off('grid:move', onGridMove)
|
|
emitter.off('grid:click', onGridClick)
|
|
emitter.off('wall:enter', onWallEnter)
|
|
emitter.off('wall:move', onWallMove)
|
|
emitter.off('wall:click', onWallClick)
|
|
emitter.off('wall:leave', onWallLeave)
|
|
emitter.off('ceiling:enter', onCeilingEnter)
|
|
emitter.off('ceiling:move', onCeilingMove)
|
|
emitter.off('ceiling:click', onCeilingClick)
|
|
emitter.off('ceiling:leave', onCeilingLeave)
|
|
window.removeEventListener('keydown', onKeyDown)
|
|
}
|
|
}, [selectedItem, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
|
|
|
|
useFrame((_, delta) => {
|
|
if (draftNode.current && placementState.current.surface === 'floor') {
|
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
if (mesh) {
|
|
mesh.position.lerp(gridPosition.current, delta * 20)
|
|
}
|
|
}
|
|
})
|
|
|
|
return (
|
|
<group>
|
|
<mesh ref={cursorRef}>
|
|
<boxGeometry args={[0.1, 0.1, 0.1]} />
|
|
<meshStandardMaterial color="red" wireframe />
|
|
</mesh>
|
|
</group>
|
|
)
|
|
}
|