import { emitter, type FenceNode, type GridEvent, type LevelNode, useScene, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' import { formatAngleRadians, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, } from '../shared/segment-angle' import { createFenceOnCurrentLevel, type FencePlanPoint, snapFenceDraftPoint, } from './fence-drafting' const FENCE_PREVIEW_HEIGHT = 1.8 const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 const DRAFT_ANGLE_LABEL_Y = 0.28 type DraftAngleLabel = { id: string label: string position: [number, number, number] } type DraftMeasurementState = { lengthLabel: string lengthPosition: [number, number, number] angleLabels: DraftAngleLabel[] } | null type SegmentLike = { id: string start: FencePlanPoint end: FencePlanPoint curveOffset?: number } function formatMeasurement(value: number, unit: 'metric' | 'imperial') { if (unit === 'imperial') { const feet = value * 3.280_84 const wholeFeet = Math.floor(feet) const inches = Math.round((feet - wholeFeet) * 12) if (inches === 12) return `${wholeFeet + 1}'0"` return `${wholeFeet}'${inches}"` } return `${Number.parseFloat(value.toFixed(2))}m` } function getDraftAngleLabels( start: FencePlanPoint, end: FencePlanPoint, segments: SegmentLike[], ): DraftAngleLabel[] { const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] const endpoints = [ { id: 'start', point: start, draftVector: draftFromStart }, { id: 'end', point: end, draftVector: draftFromEnd }, ] const labels: DraftAngleLabel[] = [] for (const endpoint of endpoints) { const connectedSegment = segments.find((segment) => Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)), ) if (!connectedSegment) continue const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) if (!connectedReference) continue const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) if (angle === null) continue labels.push({ id: endpoint.id, label: formatAngleRadians(angle), position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], }) } return labels } function getDraftMeasurementState( start: FencePlanPoint, end: FencePlanPoint, segments: SegmentLike[], unit: 'metric' | 'imperial', ): DraftMeasurementState { const dx = end[0] - start[0] const dz = end[1] - start[1] const length = Math.hypot(dx, dz) if (length < 0.01) return null return { lengthLabel: formatMeasurement(length, unit), lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], angleLabels: getDraftAngleLabels(start, end, segments), } } function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { return [ ...walls.map((wall) => ({ id: wall.id, start: wall.start, end: wall.end, curveOffset: wall.curveOffset, })), ...fences.map((fence) => ({ id: fence.id, start: fence.start, end: fence.end, curveOffset: fence.curveOffset, })), ] } const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => { 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() const shape = new Shape() shape.moveTo(0, 0) shape.lineTo(length, 0) shape.lineTo(length, FENCE_PREVIEW_HEIGHT) shape.lineTo(0, FENCE_PREVIEW_HEIGHT) shape.closePath() const geometry = new ShapeGeometry(shape) const angle = -Math.atan2(direction.z, direction.x) mesh.position.set(start.x, start.y, start.z) mesh.rotation.y = angle if (mesh.geometry) { mesh.geometry.dispose() } mesh.geometry = geometry } const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => { const currentLevelId = useViewer.getState().selection.levelId const { nodes } = useScene.getState() if (!currentLevelId) return { walls: [], fences: [] } const levelNode = nodes[currentLevelId] if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] } const children = (levelNode as LevelNode).children.map((childId) => nodes[childId]) return { walls: children.filter((node): node is WallNode => node?.type === 'wall'), fences: children.filter((node): node is FenceNode => node?.type === 'fence'), } } export const FenceTool: React.FC = () => { const unit = useViewer((state) => state.unit) const cursorRef = useRef(null) const previewRef = 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) const [draftMeasurement, setDraftMeasurement] = useState(null) useEffect(() => { let previousFenceEnd: [number, number] | null = null const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && previewRef.current)) return const { walls, fences } = getCurrentLevelElements() const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] if (buildingState.current === 1) { const snappedLocal = snapFenceDraftPoint({ point: localPoint, walls, fences, start: [startingPoint.current.x, startingPoint.current.z], angleSnap: !shiftPressed.current, }) endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) cursorRef.current.position.copy(endingPoint.current) const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] if ( previousFenceEnd && (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) ) { sfxEmitter.emit('sfx:grid-snap') } previousFenceEnd = currentFenceEnd updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) setDraftMeasurement( getDraftMeasurementState( [startingPoint.current.x, startingPoint.current.z], snappedLocal, getReferenceSegments(walls, fences), unit, ), ) } else { const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences }) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) setDraftMeasurement(null) } } const onGridClick = (event: GridEvent) => { const { walls, fences } = getCurrentLevelElements() const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] if (buildingState.current === 0) { const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences }) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) endingPoint.current.copy(startingPoint.current) buildingState.current = 1 previewRef.current.visible = true setDraftMeasurement(null) } else { const snappedEnd = snapFenceDraftPoint({ point: localClick, walls, fences, start: [startingPoint.current.x, startingPoint.current.z], angleSnap: !shiftPressed.current, }) const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) previewRef.current.visible = false buildingState.current = 0 setDraftMeasurement(null) } } 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) { markToolCancelConsumed() buildingState.current = 0 previewRef.current.visible = false setDraftMeasurement(null) } } 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) } }, [unit]) return ( {draftMeasurement && ( <> {draftMeasurement.angleLabels.map((angleLabel) => ( ))} )} ) } function DraftMeasurementLabel({ label, position, }: { label: string position: [number, number, number] }) { return (
{label}
) }