import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { DoubleSide, type Mesh, type Group, Shape, ShapeGeometry, Vector3 } from 'three' import { EDITOR_LAYER } from '@/lib/constants' import { sfxEmitter } from '@/lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' const WALL_HEIGHT = 2.5 const WALL_THICKNESS = 0.15 /** * Snap point to 45° angle increments relative to start point * Also snaps end point to 0.5 grid */ const snapTo45Degrees = (start: Vector3, cursor: Vector3): Vector3 => { const dx = cursor.x - start.x const dz = cursor.z - start.z // Calculate angle in radians const angle = Math.atan2(dz, dx) // Round to nearest 45° (π/4 radians) const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) // Calculate distance from start to cursor const distance = Math.sqrt(dx * dx + dz * dz) // Project end point along snapped angle let snappedX = start.x + Math.cos(snappedAngle) * distance let snappedZ = start.z + Math.sin(snappedAngle) * distance // Snap to 0.5 grid snappedX = Math.round(snappedX * 2) / 2 snappedZ = Math.round(snappedZ * 2) / 2 return new Vector3(snappedX, cursor.y, snappedZ) } /** * Update wall preview mesh geometry to create a vertical plane between two points */ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => { // Calculate direction and perpendicular for wall thickness const direction = new Vector3(end.x - start.x, 0, end.z - start.z) const length = direction.length() if (length < 0.01) { mesh.visible = false return } mesh.visible = true direction.normalize() // Perpendicular vector for thickness const perpendicular = new Vector3(-direction.z, 0, direction.x).multiplyScalar(WALL_THICKNESS / 2) // Create wall shape (vertical rectangle in XY plane) const shape = new Shape() shape.moveTo(0, 0) shape.lineTo(length, 0) shape.lineTo(length, WALL_HEIGHT) shape.lineTo(0, WALL_HEIGHT) shape.closePath() // Create geometry const geometry = new ShapeGeometry(shape) // Calculate rotation angle // Negate the angle to fix the opposite direction issue const angle = -Math.atan2(direction.z, direction.x) // Position at start point and rotate mesh.position.set(start.x, start.y, start.z) mesh.rotation.y = angle // Dispose old geometry and assign new one if (mesh.geometry) { mesh.geometry.dispose() } mesh.geometry = geometry } const commitWallDrawing = (start: [number, number], end: [number, number]) => { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() if (!currentLevelId) return const wallCount = Object.values(nodes).filter((n) => n.type === 'wall').length const name = `Wall ${wallCount + 1}` const wall = WallNode.parse({ name, start, end }) createNode(wall, currentLevelId) sfxEmitter.emit('sfx:structure-build') } export const WallTool: React.FC = () => { const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) useEffect(() => { let gridPosition: [number, number] = [0, 0] let previousWallEnd: [number, number] | null = null const onGridMove = (event: GridEvent) => { if (!cursorRef.current || !wallPreviewRef.current) return gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2] const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1]) if (buildingState.current === 1) { // Snap to 45° angles only if shift is not pressed const snapped = shiftPressed.current ? cursorPosition : snapTo45Degrees(startingPoint.current, cursorPosition) endingPoint.current.copy(snapped) // Position the cursor at the end of the wall being drawn cursorRef.current.position.set(snapped.x, snapped.y, snapped.z) // Play snap sound only when the actual wall end position changes const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z] if (previousWallEnd && (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])) { sfxEmitter.emit('sfx:grid-snap') } previousWallEnd = currentWallEnd // Update wall preview geometry updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) } else { // Not drawing a wall, just follow the grid position cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) } } const onGridClick = (event: GridEvent) => { if (buildingState.current === 0) { startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1]) buildingState.current = 1 wallPreviewRef.current.visible = true } else if (buildingState.current === 1) { const dx = endingPoint.current.x - startingPoint.current.x const dz = endingPoint.current.z - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return commitWallDrawing( [startingPoint.current.x, startingPoint.current.z], [endingPoint.current.x, endingPoint.current.z], ) wallPreviewRef.current.visible = false buildingState.current = 0 } } const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') { shiftPressed.current = true } } const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') { shiftPressed.current = false } } const onCancel = () => { if (buildingState.current === 1) { buildingState.current = 0 wallPreviewRef.current.visible = false } } emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } }, []) return ( {/* Cursor indicator */} {/* Wall preview */} ) }