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(null!) const gridPosition = useRef(new Vector3(0, 0, 0)) const placementState = useRef({ 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 ( ) }