diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index 84854413..b6039a52 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -1,54 +1,41 @@ -"use client"; +'use client' -import { - type CameraControlEvent, - emitter, - sceneRegistry, - useScene, -} from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -import { CameraControls, CameraControlsImpl } from "@react-three/drei"; -import { useEffect, useMemo, useRef } from "react"; -import { Vector3 } from "three"; +import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { CameraControls, CameraControlsImpl } from '@react-three/drei' +import { useEffect, useMemo, useRef } from 'react' +import { Vector3 } from 'three' -const currentTarget = new Vector3(); +const currentTarget = new Vector3() export const CustomCameraControls = () => { - const controls = useRef(null!); - const currentLevelId = useViewer((state) => state.selection.levelId); - const firstLoad = useRef(true); + const controls = useRef(null!) + const currentLevelId = useViewer((state) => state.selection.levelId) + const firstLoad = useRef(true) useEffect(() => { - let targetY = 0; + let targetY = 0 if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId); + const levelMesh = sceneRegistry.nodes.get(currentLevelId) if (levelMesh) { - targetY = levelMesh.position.y; + targetY = levelMesh.position.y } } if (firstLoad.current) { - firstLoad.current = false; - (controls.current as CameraControlsImpl).setLookAt( - 20, - 20, - 20, - 0, - 0, - 0, - true, - ); + firstLoad.current = false + ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) } - (controls.current as CameraControlsImpl).getTarget(currentTarget); - (controls.current as CameraControlsImpl).moveTo( + ;(controls.current as CameraControlsImpl).getTarget(currentTarget) + ;(controls.current as CameraControlsImpl).moveTo( currentTarget.x, targetY, currentTarget.z, true, - ); - }, [currentLevelId]); + ) + }, [currentLevelId]) // Configure mouse buttons based on control mode and camera mode - const cameraMode = useViewer((state) => state.cameraMode); + const cameraMode = useViewer((state) => state.cameraMode) const mouseButtons = useMemo(() => { // Use ZOOM for orthographic camera, DOLLY for perspective camera const wheelAction = @@ -62,19 +49,18 @@ export const CustomCameraControls = () => { right: CameraControlsImpl.ACTION.ROTATE, wheel: wheelAction, } - }, [cameraMode]); - + }, [cameraMode]) useEffect(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return; + if (!controls.current) return - const position = new Vector3(); - const target = new Vector3(); - controls.current.getPosition(position); - controls.current.getTarget(target); + const position = new Vector3() + const target = new Vector3() + controls.current.getPosition(position) + controls.current.getTarget(target) - const state = useScene.getState(); + const state = useScene.getState() state.updateNode(nodeId, { camera: { @@ -82,14 +68,14 @@ export const CustomCameraControls = () => { target: [target.x, target.y, target.z], mode: useViewer.getState().cameraMode, }, - }); - }; + }) + } const handleNodeView = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return; + if (!controls.current) return - const node = useScene.getState().nodes[nodeId]; - if (!node || !node.camera) return; - const { position, target } = node.camera; + const node = useScene.getState().nodes[nodeId] + if (!node || !node.camera) return + const { position, target } = node.camera controls.current.setLookAt( position[0], @@ -99,20 +85,69 @@ export const CustomCameraControls = () => { target[1], target[2], true, - ); - }; + ) + } - emitter.on("camera-controls:capture", handleNodeCapture); - emitter.on("camera-controls:view", handleNodeView); + const handleTopView = () => { + if (!controls.current) return + + const currentPolarAngle = controls.current.polarAngle + + // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45° + // Otherwise, go to top view (0°) + const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 + + controls.current.rotatePolarTo(targetAngle, true) + } + + const handleOrbitCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded - Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + const handleOrbitCCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° counter-clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded + Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + emitter.on('camera-controls:capture', handleNodeCapture) + emitter.on('camera-controls:view', handleNodeView) + emitter.on('camera-controls:top-view', handleTopView) + emitter.on('camera-controls:orbit-cw', handleOrbitCW) + emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) return () => { - emitter.off("camera-controls:capture", handleNodeCapture); - emitter.off("camera-controls:view", handleNodeView); - }; - }, []); + emitter.off('camera-controls:capture', handleNodeCapture) + emitter.off('camera-controls:view', handleNodeView) + emitter.off('camera-controls:top-view', handleTopView) + emitter.off('camera-controls:orbit-cw', handleOrbitCW) + emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) + } + }, []) - return ; -}; + minPolarAngle={0} + ref={controls} + mouseButtons={mouseButtons} + /> + ) +} diff --git a/apps/editor/components/tools/shared/polygon-editor.tsx b/apps/editor/components/tools/shared/polygon-editor.tsx new file mode 100644 index 00000000..e71f1266 --- /dev/null +++ b/apps/editor/components/tools/shared/polygon-editor.tsx @@ -0,0 +1,298 @@ +import { useThree } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + BufferGeometry, + Float32BufferAttribute, + type Mesh, + Plane, + Raycaster, + Vector2, + Vector3, +} from 'three' + +const Y_OFFSET = 0.02 + +type DragState = { + isDragging: boolean + vertexIndex: number + initialPosition: [number, number] + pointerId: number +} + +export interface PolygonEditorProps { + polygon: Array<[number, number]> + color?: string + onPolygonChange: (polygon: Array<[number, number]>) => void + minVertices?: number +} + +/** + * Generic polygon editor component for editing polygon vertices + * Used by zone and site boundary editors + */ +export const PolygonEditor: React.FC = ({ + polygon, + color = '#3b82f6', + onPolygonChange, + minVertices = 3, +}) => { + const { gl, camera } = useThree() + + // Local state for dragging + const [dragState, setDragState] = useState(null) + const [previewPolygon, setPreviewPolygon] = useState | null>(null) + const [hoveredVertex, setHoveredVertex] = useState(null) + const [hoveredMidpoint, setHoveredMidpoint] = useState(null) + + // Refs for raycasting during drag + const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET)) + const raycaster = useRef(new Raycaster()) + const lineRef = useRef(null!) + + // The polygon to display (preview during drag, or actual polygon) + const displayPolygon = previewPolygon ?? polygon + + // Calculate midpoints for adding new vertices + const midpoints = useMemo(() => { + if (displayPolygon.length < 2) return [] + return displayPolygon.map(([x1, z1], index) => { + const nextIndex = (index + 1) % displayPolygon.length + const [x2, z2] = displayPolygon[nextIndex]! + return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number] + }) + }, [displayPolygon]) + + // Handle vertex drag + const handleVertexDrag = useCallback( + (clientX: number, clientY: number, vertexIndex: number) => { + const canvas = gl.domElement + const rect = canvas.getBoundingClientRect() + const x = ((clientX - rect.left) / rect.width) * 2 - 1 + const y = -((clientY - rect.top) / rect.height) * 2 + 1 + + raycaster.current.setFromCamera(new Vector2(x, y), camera) + const intersection = new Vector3() + raycaster.current.ray.intersectPlane(dragPlane.current, intersection) + + if (intersection) { + // Snap to 0.5 grid + const gridX = Math.round(intersection.x * 2) / 2 + const gridZ = Math.round(intersection.z * 2) / 2 + + const basePolygon = previewPolygon ?? polygon + const newPolygon = [...basePolygon] + newPolygon[vertexIndex] = [gridX, gridZ] + setPreviewPolygon(newPolygon) + } + }, + [gl, camera, previewPolygon, polygon], + ) + + // Commit polygon changes + const commitPolygonChange = useCallback(() => { + if (previewPolygon) { + onPolygonChange(previewPolygon) + } + setPreviewPolygon(null) + setDragState(null) + }, [previewPolygon, onPolygonChange]) + + // Handle adding a new vertex at midpoint + const handleAddVertex = useCallback( + (afterIndex: number, position: [number, number]) => { + const basePolygon = previewPolygon ?? polygon + const newPolygon = [ + ...basePolygon.slice(0, afterIndex + 1), + position, + ...basePolygon.slice(afterIndex + 1), + ] + + setPreviewPolygon(newPolygon) + return afterIndex + 1 // Return new vertex index + }, + [polygon, previewPolygon], + ) + + // Handle deleting a vertex + const handleDeleteVertex = useCallback( + (index: number) => { + const basePolygon = previewPolygon ?? polygon + if (basePolygon.length <= minVertices) return // Need at least minVertices points + + const newPolygon = basePolygon.filter((_, i) => i !== index) + onPolygonChange(newPolygon) + setPreviewPolygon(null) + }, + [polygon, previewPolygon, onPolygonChange, minVertices], + ) + + // Set up pointer move/up listeners for dragging with pointer capture + useEffect(() => { + if (!dragState?.isDragging) return + + const canvas = gl.domElement + const pointerId = dragState.pointerId + + // Capture pointer to prevent R3F events from firing on other objects (like the grid) + canvas.setPointerCapture(pointerId) + + const handlePointerMove = (e: PointerEvent) => { + handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex) + } + + const handlePointerUp = (e: PointerEvent) => { + // Release pointer capture + if (canvas.hasPointerCapture(e.pointerId)) { + canvas.releasePointerCapture(e.pointerId) + } + commitPolygonChange() + } + + canvas.addEventListener('pointermove', handlePointerMove) + canvas.addEventListener('pointerup', handlePointerUp) + + return () => { + // Release capture on cleanup + if (canvas.hasPointerCapture(pointerId)) { + canvas.releasePointerCapture(pointerId) + } + canvas.removeEventListener('pointermove', handlePointerMove) + canvas.removeEventListener('pointerup', handlePointerUp) + } + }, [dragState, gl, handleVertexDrag, commitPolygonChange]) + + // Update line geometry when polygon changes + useEffect(() => { + if (!lineRef.current || displayPolygon.length < 2) return + + const positions: number[] = [] + for (const [x, z] of displayPolygon) { + positions.push(x!, Y_OFFSET + 0.01, z!) + } + // Close the loop + const first = displayPolygon[0]! + positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!) + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + + lineRef.current.geometry.dispose() + lineRef.current.geometry = geometry + }, [displayPolygon]) + + if (displayPolygon.length < minVertices) return null + + const canDelete = displayPolygon.length > minVertices + + return ( + + {/* Border line */} + {/* @ts-ignore */} + + + + + + {/* Vertex handles */} + {displayPolygon.map(([x, z], index) => { + const isHovered = hoveredVertex === index + const isDragging = dragState?.vertexIndex === index + + return ( + { + e.stopPropagation() + setHoveredVertex(index) + }} + onPointerLeave={(e) => { + e.stopPropagation() + setHoveredVertex(null) + }} + onPointerDown={(e) => { + e.stopPropagation() + setDragState({ + isDragging: true, + vertexIndex: index, + initialPosition: [x!, z!], + pointerId: e.nativeEvent.pointerId, + }) + }} + onClick={(e) => { + e.stopPropagation() + }} + onDoubleClick={(e) => { + e.stopPropagation() + if (canDelete) { + handleDeleteVertex(index) + } + }} + > + + + + ) + })} + + {/* Midpoint handles for adding vertices (hidden while dragging) */} + {!dragState && + midpoints.map(([x, z], index) => { + const isHovered = hoveredMidpoint === index + + return ( + { + e.stopPropagation() + setHoveredMidpoint(index) + }} + onPointerLeave={(e) => { + e.stopPropagation() + setHoveredMidpoint(null) + }} + onPointerDown={(e) => { + e.stopPropagation() + const newVertexIndex = handleAddVertex(index, [x!, z!]) + if (newVertexIndex >= 0) { + setDragState({ + isDragging: true, + vertexIndex: newVertexIndex, + initialPosition: [x!, z!], + pointerId: e.nativeEvent.pointerId, + }) + setHoveredMidpoint(null) + } + }} + onClick={(e) => { + e.stopPropagation() + }} + > + + + + ) + })} + + ) +} diff --git a/apps/editor/components/tools/site/site-boundary-editor.tsx b/apps/editor/components/tools/site/site-boundary-editor.tsx new file mode 100644 index 00000000..bf197fff --- /dev/null +++ b/apps/editor/components/tools/site/site-boundary-editor.tsx @@ -0,0 +1,42 @@ +import { useScene, type SiteNode } from '@pascal-app/core' +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' + +/** + * Site boundary editor - allows editing site polygon when in site phase + * Uses the generic PolygonEditor component + */ +export const SiteBoundaryEditor: React.FC = () => { + const nodes = useScene((state) => state.nodes) + const rootNodeIds = useScene((state) => state.rootNodeIds) + const updateNode = useScene((state) => state.updateNode) + + // Get the site node (first root node) + const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null + const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null + + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + if (site) { + updateNode(site.id, { + polygon: { + type: 'polygon', + points: newPolygon, + }, + }) + } + }, + [site, updateNode], + ) + + if (!site || !site.polygon?.points || site.polygon.points.length < 3) return null + + return ( + + ) +} diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index bd3b03eb..d7740e54 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -4,13 +4,16 @@ import { CeilingTool } from "./ceiling/ceiling-tool"; import { ItemTool } from "./item/item-tool"; import { MoveTool } from "./item/move-tool"; import { RoofTool } from "./roof/roof-tool"; +import { SiteBoundaryEditor } from "./site/site-boundary-editor"; import { SlabTool } from "./slab/slab-tool"; import { WallTool } from "./wall/wall-tool"; import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor"; import { ZoneTool } from "./zone/zone-tool"; const tools: Record>> = { - site: {}, + site: { + "property-line": SiteBoundaryEditor, + }, structure: { wall: WallTool, slab: SlabTool, @@ -31,6 +34,9 @@ export const ToolManager: React.FC = () => { const movingNode = useEditor((state) => state.movingNode); const selectedZoneId = useViewer((state) => state.selection.zoneId); + // Show site boundary editor when in site phase and edit mode + const showSiteBoundaryEditor = phase === "site" && mode === "edit"; + // Show zone boundary editor when in structure/select mode with a zone selected const showZoneBoundaryEditor = phase === "structure" && mode === "select" && selectedZoneId !== null; @@ -42,6 +48,7 @@ export const ToolManager: React.FC = () => { return ( <> + {showSiteBoundaryEditor && } {showZoneBoundaryEditor && } {movingNode && } {!movingNode && BuildToolComponent && } diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index 23998980..935dea97 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -1,7 +1,83 @@ import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' -import { type Line, type Mesh, Vector3 } from 'three' +import { useEffect, useRef, useMemo } from 'react' +import { DoubleSide, type Mesh, Vector3, Shape, ShapeGeometry } from 'three' + +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 @@ -16,36 +92,43 @@ const commitWallDrawing = (start: [number, number], end: [number, number]) => { export const WallTool: React.FC = () => { const cursorRef = useRef(null) - const drawingLineRef = 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) useEffect(() => { - let buildingState = 0 - const startingPoint = new Vector3(0, 0, 0) - const endingPoint = new Vector3(0, 0, 0) let gridPosition: [number, number] = [0, 0] - drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint]) - const onGridMove = (event: GridEvent) => { - if (!cursorRef.current) return + 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]) cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) - if (buildingState === 1) { - endingPoint.set(gridPosition[0], event.position[1], gridPosition[1]) + + if (buildingState.current === 1) { + // Snap to 45° angles + const snapped = snapTo45Degrees(startingPoint.current, cursorPosition) + endingPoint.current.copy(snapped) + + // Update wall preview geometry + updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) } - drawingLineRef.current.geometry.setFromPoints([startingPoint, endingPoint]) } + const onGridClick = (event: GridEvent) => { - if (buildingState === 0) { - startingPoint.set(gridPosition[0], event.position[1], gridPosition[1]) - buildingState = 1 - console.log('starting building at:', startingPoint) - drawingLineRef.current.visible = true - } else if (buildingState === 1) { - commitWallDrawing([startingPoint.x, startingPoint.z], [endingPoint.x, endingPoint.z]) - drawingLineRef.current.visible = false - buildingState = 0 + 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) { + commitWallDrawing( + [startingPoint.current.x, startingPoint.current.z], + [endingPoint.current.x, endingPoint.current.z] + ) + wallPreviewRef.current.visible = false + buildingState.current = 0 } } @@ -60,25 +143,24 @@ export const WallTool: React.FC = () => { return ( + {/* Cursor indicator */} - - {/* @ts-ignore */} - - - - - + + {/* Wall preview */} + + + + ) } diff --git a/apps/editor/components/tools/zone/zone-boundary-editor.tsx b/apps/editor/components/tools/zone/zone-boundary-editor.tsx index 8968f0a6..2e66eef9 100644 --- a/apps/editor/components/tools/zone/zone-boundary-editor.tsx +++ b/apps/editor/components/tools/zone/zone-boundary-editor.tsx @@ -1,299 +1,37 @@ import { useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useThree } from '@react-three/fiber' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - BufferGeometry, - Float32BufferAttribute, - type Mesh, - Plane, - Raycaster, - Vector2, - Vector3, -} from 'three' - -const Y_OFFSET = 0.02 - -type DragState = { - isDragging: boolean - vertexIndex: number - initialPosition: [number, number] - pointerId: number -} +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' /** * Zone boundary editor - allows editing zone polygon vertices when a zone is selected - * Uses the event emitter system for grid interactions + * Uses the generic PolygonEditor component */ export const ZoneBoundaryEditor: React.FC = () => { - const { gl, camera } = useThree() const selectedZoneId = useViewer((state) => state.selection.zoneId) const zoneNode = useScene((state) => (selectedZoneId ? state.nodes[selectedZoneId] : null)) const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null const updateNode = useScene((state) => state.updateNode) - // Local state for dragging - const [dragState, setDragState] = useState(null) - const [previewPolygon, setPreviewPolygon] = useState | null>(null) - const [hoveredVertex, setHoveredVertex] = useState(null) - const [hoveredMidpoint, setHoveredMidpoint] = useState(null) - - // Refs for raycasting during drag - const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET)) - const raycaster = useRef(new Raycaster()) - const lineRef = useRef(null!) - - // The polygon to display (preview during drag, or actual zone polygon) - const displayPolygon = previewPolygon ?? zone?.polygon ?? [] - - // Calculate midpoints for adding new vertices - const midpoints = useMemo(() => { - if (displayPolygon.length < 2) return [] - return displayPolygon.map(([x1, z1], index) => { - const nextIndex = (index + 1) % displayPolygon.length - const [x2, z2] = displayPolygon[nextIndex]! - return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number] - }) - }, [displayPolygon]) - - // Handle vertex drag - const handleVertexDrag = useCallback( - (clientX: number, clientY: number, vertexIndex: number) => { - if (!zone) return - - const canvas = gl.domElement - const rect = canvas.getBoundingClientRect() - const x = ((clientX - rect.left) / rect.width) * 2 - 1 - const y = -((clientY - rect.top) / rect.height) * 2 + 1 - - raycaster.current.setFromCamera(new Vector2(x, y), camera) - const intersection = new Vector3() - raycaster.current.ray.intersectPlane(dragPlane.current, intersection) - - if (intersection) { - // Snap to 0.5 grid - const gridX = Math.round(intersection.x * 2) / 2 - const gridZ = Math.round(intersection.z * 2) / 2 - - const basePolygon = previewPolygon ?? zone.polygon - const newPolygon = [...basePolygon] - newPolygon[vertexIndex] = [gridX, gridZ] - setPreviewPolygon(newPolygon) + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + if (selectedZoneId) { + updateNode(selectedZoneId, { polygon: newPolygon }) } }, - [zone, gl, camera, previewPolygon], + [selectedZoneId, updateNode], ) - // Commit polygon changes - const commitPolygonChange = useCallback(() => { - if (previewPolygon && selectedZoneId) { - updateNode(selectedZoneId, { polygon: previewPolygon }) - } - setPreviewPolygon(null) - setDragState(null) - }, [previewPolygon, selectedZoneId, updateNode]) + if (!zone || !zone.polygon || zone.polygon.length < 3) return null - // Handle adding a new vertex at midpoint - const handleAddVertex = useCallback( - (afterIndex: number, position: [number, number]) => { - if (!zone) return -1 - - const basePolygon = previewPolygon ?? zone.polygon - const newPolygon = [ - ...basePolygon.slice(0, afterIndex + 1), - position, - ...basePolygon.slice(afterIndex + 1), - ] - - setPreviewPolygon(newPolygon) - return afterIndex + 1 // Return new vertex index - }, - [zone, previewPolygon], - ) - - // Handle deleting a vertex - const handleDeleteVertex = useCallback( - (index: number) => { - if (!zone || !selectedZoneId) return - - const basePolygon = previewPolygon ?? zone.polygon - if (basePolygon.length <= 3) return // Need at least 3 points - - const newPolygon = basePolygon.filter((_, i) => i !== index) - updateNode(selectedZoneId, { polygon: newPolygon }) - setPreviewPolygon(null) - }, - [zone, selectedZoneId, previewPolygon, updateNode], - ) - - // Set up pointer move/up listeners for dragging with pointer capture - useEffect(() => { - if (!dragState?.isDragging) return - - const canvas = gl.domElement - const pointerId = dragState.pointerId - - // Capture pointer to prevent R3F events from firing on other objects (like the grid) - canvas.setPointerCapture(pointerId) - - const handlePointerMove = (e: PointerEvent) => { - handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex) - } - - const handlePointerUp = (e: PointerEvent) => { - // Release pointer capture - if (canvas.hasPointerCapture(e.pointerId)) { - canvas.releasePointerCapture(e.pointerId) - } - commitPolygonChange() - } - - canvas.addEventListener('pointermove', handlePointerMove) - canvas.addEventListener('pointerup', handlePointerUp) - - return () => { - // Release capture on cleanup - if (canvas.hasPointerCapture(pointerId)) { - canvas.releasePointerCapture(pointerId) - } - canvas.removeEventListener('pointermove', handlePointerMove) - canvas.removeEventListener('pointerup', handlePointerUp) - } - }, [dragState, gl, handleVertexDrag, commitPolygonChange]) - - // Update line geometry when polygon changes - useEffect(() => { - if (!lineRef.current || displayPolygon.length < 2) return - - const positions: number[] = [] - for (const [x, z] of displayPolygon) { - positions.push(x!, Y_OFFSET + 0.01, z!) - } - // Close the loop - const first = displayPolygon[0]! - positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!) - - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) - - lineRef.current.geometry.dispose() - lineRef.current.geometry = geometry - }, [displayPolygon]) - - if (!zone || displayPolygon.length < 3) return null - - const canDelete = displayPolygon.length > 3 const zoneColor = zone.color || '#3b82f6' return ( - - {/* Border line */} - {/* @ts-ignore */} - - - - - - {/* Vertex handles */} - {displayPolygon.map(([x, z], index) => { - const isHovered = hoveredVertex === index - const isDragging = dragState?.vertexIndex === index - - return ( - { - e.stopPropagation() - setHoveredVertex(index) - }} - onPointerLeave={(e) => { - e.stopPropagation() - setHoveredVertex(null) - }} - onPointerDown={(e) => { - e.stopPropagation() - setDragState({ - isDragging: true, - vertexIndex: index, - initialPosition: [x!, z!], - pointerId: e.nativeEvent.pointerId, - }) - }} - onClick={(e) => { - e.stopPropagation() - }} - onDoubleClick={(e) => { - e.stopPropagation() - if (canDelete) { - handleDeleteVertex(index) - } - }} - > - - - - ) - })} - - {/* Midpoint handles for adding vertices (hidden while dragging) */} - {!dragState && - midpoints.map(([x, z], index) => { - const isHovered = hoveredMidpoint === index - - return ( - { - e.stopPropagation() - setHoveredMidpoint(index) - }} - onPointerLeave={(e) => { - e.stopPropagation() - setHoveredMidpoint(null) - }} - onPointerDown={(e) => { - e.stopPropagation() - const newVertexIndex = handleAddVertex(index, [x!, z!]) - if (newVertexIndex >= 0) { - setDragState({ - isDragging: true, - vertexIndex: newVertexIndex, - initialPosition: [x!, z!], - pointerId: e.nativeEvent.pointerId, - }) - setHoveredMidpoint(null) - } - }} - onClick={(e) => { - e.stopPropagation() - }} - > - - - - ) - })} - + ) } diff --git a/apps/editor/components/ui/action-menu/camera-actions.tsx b/apps/editor/components/ui/action-menu/camera-actions.tsx new file mode 100644 index 00000000..929b557f --- /dev/null +++ b/apps/editor/components/ui/action-menu/camera-actions.tsx @@ -0,0 +1,79 @@ +'use client' + +import { emitter } from '@pascal-app/core' +import { RotateCcw, RotateCw, Rotate3D } from 'lucide-react' +import { Button } from '@/components/ui/primitives/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/primitives/tooltip' + +export function CameraActions() { + const goToTopView = () => { + emitter.emit('camera-controls:top-view') + } + + const orbitCW = () => { + emitter.emit('camera-controls:orbit-cw') + } + + const orbitCCW = () => { + emitter.emit('camera-controls:orbit-ccw') + } + + return ( +
+ {/* Orbit CCW */} + + + + + +

Orbit Left

+
+
+ + {/* Orbit CW */} + + + + + +

Orbit Right

+
+
+ + {/* Top View */} + + + + + +

Top View

+
+
+
+ ) +} diff --git a/apps/editor/components/ui/action-menu/index.tsx b/apps/editor/components/ui/action-menu/index.tsx index 6c5b16c8..cc08a43c 100644 --- a/apps/editor/components/ui/action-menu/index.tsx +++ b/apps/editor/components/ui/action-menu/index.tsx @@ -3,6 +3,7 @@ import { TooltipProvider } from "@/components/ui/primitives/tooltip"; import { cn } from "@/lib/utils"; +import { CameraActions } from "./camera-actions"; import { ControlModes } from "./control-modes"; import { PhaseSwitcher } from "./phase-switcher"; import { StructureTools } from "./structure-tools"; @@ -140,6 +141,8 @@ export function ActionMenu({ className }: { className?: string }) {
+
+
diff --git a/apps/editor/components/ui/action-menu/view-toggles.tsx b/apps/editor/components/ui/action-menu/view-toggles.tsx index 4985e405..5b12263a 100644 --- a/apps/editor/components/ui/action-menu/view-toggles.tsx +++ b/apps/editor/components/ui/action-menu/view-toggles.tsx @@ -88,7 +88,7 @@ export function ViewToggles() { 'h-8 w-8 text-zinc-400 transition-all', cameraMode === 'orthographic' ? 'bg-violet-500/20 text-violet-400' - : 'hover:bg-zinc-800', + : 'hover:text-violet-400', )} onClick={toggleCameraMode} size="icon" @@ -110,7 +110,7 @@ export function ViewToggles() { 'h-8 w-8 text-zinc-400 transition-all', levelMode !== 'stacked' ? 'bg-amber-500/20 text-amber-400' - : 'hover:bg-zinc-800', + : 'hover:text-amber-400', )} onClick={cycleLevelMode} size="icon" diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx index a6924faf..04952346 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx @@ -49,8 +49,10 @@ function SitePhaseView() { const selectedBuildingId = useViewer((state) => state.selection.buildingId); const setSelection = useViewer((state) => state.setSelection); - const buildings = rootNodeIds - .map((id) => nodes[id]) + // Get site node and its building children + const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; + const buildings = (siteNode?.type === 'site' ? siteNode.children : []) + .map((child) => typeof child === 'string' ? nodes[child] : child) .filter((node): node is BuildingNode => node?.type === "building"); if (buildings.length === 0) { @@ -92,8 +94,10 @@ function BuildingSelector() { const selectedBuildingId = useViewer((state) => state.selection.buildingId); const setSelection = useViewer((state) => state.setSelection); - const buildings = rootNodeIds - .map((id) => nodes[id]) + // Get site node and its building children + const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null; + const buildings = (siteNode?.type === 'site' ? siteNode.children : []) + .map((child) => typeof child === 'string' ? nodes[child] : child) .filter((node): node is BuildingNode => node?.type === "building"); const selectedBuilding = selectedBuildingId diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx index 245fb06d..033bce12 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/references-dialog.tsx @@ -59,8 +59,11 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial }) createNode(node, levelId as AnyNodeId) e.target.value = '' + // Auto-select and close dialog + setSelectedReferenceId(node.id) + onOpenChange(false) }, - [levelId, createNode], + [levelId, createNode, setSelectedReferenceId, onOpenChange], ) const handleAddGuide = useCallback( @@ -75,8 +78,11 @@ export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDial }) createNode(node, levelId as AnyNodeId) e.target.value = '' + // Auto-select and close dialog + setSelectedReferenceId(node.id) + onOpenChange(false) }, - [levelId, createNode], + [levelId, createNode, setSelectedReferenceId, onOpenChange], ) const handleEdit = useCallback( diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index ee4ece60..bd8dc6f1 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -83,15 +83,17 @@ const useEditor = create()((set, get) => ({ const selectBuildingAndLevel0 = () => { let buildingId = viewer.selection.buildingId - // If no building selected, find the first one + // If no building selected, find the first one from site's children if (!buildingId) { - const firstBuildingId = scene.rootNodeIds.find((id) => { - const node = scene.nodes[id] - return node?.type === 'building' - }) - if (firstBuildingId) { - buildingId = firstBuildingId as BuildingNode['id'] - viewer.setSelection({ buildingId }) + const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null + if (siteNode?.type === 'site') { + const firstBuilding = siteNode.children + .map((child) => (typeof child === 'string' ? scene.nodes[child] : child)) + .find((node) => node?.type === 'building') + if (firstBuilding) { + buildingId = firstBuilding.id as BuildingNode['id'] + viewer.setSelection({ buildingId }) + } } } diff --git a/bun.lock b/bun.lock index d9c9ff13..eba03d97 100644 --- a/bun.lock +++ b/bun.lock @@ -62,7 +62,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.1.3", + "version": "0.1.10", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -126,7 +126,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.1.3", + "version": "0.1.10", "dependencies": { "zustand": "^5", }, @@ -137,7 +137,7 @@ "typescript": "5.9.2", }, "peerDependencies": { - "@pascal-app/core": "^0.1.3", + "@pascal-app/core": "^0.1.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 79590943..60966b9b 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' -import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, ZoneNode } from '../schema' import type { AnyNode } from '../schema/types' // Base event interfaces @@ -20,6 +20,7 @@ export interface NodeEvent { export type WallEvent = NodeEvent export type ItemEvent = NodeEvent +export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent @@ -56,11 +57,15 @@ export interface CameraControlEvent { type CameraControlEvents = { 'camera-controls:view': CameraControlEvent 'camera-controls:capture': CameraControlEvent + 'camera-controls:top-view': undefined + 'camera-controls:orbit-cw': undefined + 'camera-controls:orbit-ccw': undefined } type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'item', ItemEvent> & + NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & NodeEvents<'level', LevelEvent> & NodeEvents<'zone', ZoneEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 3f13c69d..0e0b7a49 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -8,6 +8,7 @@ export const sceneRegistry = { // Categorized lookups: Type -> Set of IDs // Using a Set is faster for adding/deleting than an Array byType: { + site: new Set(), building: new Set(), ceiling: new Set(), level: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dbad2144..d86a0ffd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export type { ItemEvent, LevelEvent, NodeEvent, + SiteEvent, SlabEvent, WallEvent, ZoneEvent, diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts index b621c741..d52410e9 100644 --- a/packages/core/src/schema/nodes/site.ts +++ b/packages/core/src/schema/nodes/site.ts @@ -24,12 +24,12 @@ export const SiteNode = BaseNode.extend({ // Specific props polygon: PropertyLineData.optional().default({ type: 'polygon', - // Default 30x30 square matching GRID_SIZE + // Default 30x30 square centered at origin points: [ - [0, 0], - [30, 0], - [30, 30], - [0, 30], + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], ], }), // terrain: TerrainData, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 64ad6f7c..3708f460 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -6,6 +6,7 @@ import { create, type StoreApi, type UseBoundStore } from 'zustand' import { persist } from 'zustand/middleware' import { BuildingNode } from '../schema' import { LevelNode } from '../schema/nodes/level' +import { SiteNode } from '../schema/nodes/site' import type { AnyNode, AnyNodeId } from '../schema/types' import { isObject } from '../utils/types' import * as nodeActions from './actions/node-actions' @@ -87,25 +88,29 @@ const useScene: UseSceneStore = create()( return // Scene already loaded } - const building = BuildingNode.parse({ - children: [], - }) - + // Create hierarchy: Site → Building → Level const level0 = LevelNode.parse({ level: 0, children: [], }) - building.children.push(level0.id) + const building = BuildingNode.parse({ + children: [level0.id], + }) + + const site = SiteNode.parse({ + children: [building], + }) // Define all nodes flat const nodes: Record = { + [site.id]: site, [building.id]: building, [level0.id]: level0, } - // Root nodes are the levels - const rootNodeIds = [building.id] + // Site is the root + const rootNodeIds = [site.id] set({ nodes, rootNodeIds }) }, @@ -155,13 +160,44 @@ const useScene: UseSceneStore = create()( onRehydrateStorage: (state) => { console.log('hydrating...') - // optional return (state, error) => { if (error) { console.log('an error happened during hydration', error) - } else { - console.log('hydration finished') + return } + + if (!state) { + console.log('hydration finished - no state') + return + } + + // Migration: Wrap old scenes (where root is not a SiteNode) in a SiteNode + const rootId = state.rootNodeIds?.[0] + const rootNode = rootId ? state.nodes[rootId] : null + + if (rootNode && rootNode.type !== 'site') { + console.log('Migrating old scene: wrapping in SiteNode') + + // Collect existing root nodes (should be BuildingNode or ItemNode) + const existingRoots = (state.rootNodeIds || []) + .map(id => state.nodes[id]) + .filter(node => node?.type === 'building' || node?.type === 'item') + + // Create a new SiteNode with existing roots as children + const site = SiteNode.parse({ + children: existingRoots, + }) + + // Add site to nodes + state.nodes[site.id] = site + + // Update root to be the site + state.rootNodeIds = [site.id] + + console.log('Migration complete: scene now has SiteNode as root') + } + + console.log('hydration finished') } }, }, diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 8224bcc8..b3aacaea 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -8,6 +8,7 @@ import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' import { RoofRenderer } from './roof/roof-renderer' import { ScanRenderer } from './scan/scan-renderer' +import { SiteRenderer } from './site/site-renderer' import { SlabRenderer } from './slab/slab-renderer' import { WallRenderer } from './wall/wall-renderer' import { ZoneRenderer } from './zone/zone-renderer' @@ -19,6 +20,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { return ( <> + {node.type === 'site' && } {node.type === 'building' && } {node.type === 'ceiling' && } {node.type === 'level' && } diff --git a/packages/viewer/src/components/renderers/scan/scan-renderer.tsx b/packages/viewer/src/components/renderers/scan/scan-renderer.tsx index 67e593a7..d186c9b4 100644 --- a/packages/viewer/src/components/renderers/scan/scan-renderer.tsx +++ b/packages/viewer/src/components/renderers/scan/scan-renderer.tsx @@ -38,9 +38,11 @@ const ScanModel = ({ url, opacity }: { url: string; opacity: number }) => { if (isTransparent) { material.transparent = true material.opacity = normalizedOpacity + material.depthWrite = false; } else { material.transparent = false material.opacity = 1 + material.depthWrite = true; } material.needsUpdate = true } diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx new file mode 100644 index 00000000..58e3d222 --- /dev/null +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -0,0 +1,103 @@ +import { type SiteNode, useRegistry } from '@pascal-app/core' +import { useMemo, useRef } from 'react' +import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { NodeRenderer } from '../node-renderer' + +const Y_OFFSET = 0.01 +const LINE_HEIGHT = 0.5 + +/** + * Creates simple line geometry for site boundary + * Single horizontal line at ground level + */ +const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => { + const geometry = new BufferGeometry() + + if (points.length < 2) return geometry + + const positions: number[] = [] + + // Create a simple line loop at ground level + for (const [x, z] of points) { + positions.push(x!, Y_OFFSET, z!) + } + // Close the loop + positions.push(points[0]![0]!, Y_OFFSET, points[0]![1]!) + + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + + return geometry +} + +export const SiteRenderer = ({ node }: { node: SiteNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'site', ref) + + // Create floor shape from polygon points + const floorShape = useMemo(() => { + if (!node?.polygon?.points || node.polygon.points.length < 3) return null + const shape = new Shape() + const firstPt = node.polygon.points[0]! + + // Shape is in X-Y plane, we rotate it to X-Z plane + // Negate Y (which becomes Z) to get correct orientation + shape.moveTo(firstPt[0]!, -firstPt[1]!) + + for (let i = 1; i < node.polygon.points.length; i++) { + const pt = node.polygon.points[i]! + shape.lineTo(pt[0]!, -pt[1]!) + } + shape.closePath() + + return shape + }, [node?.polygon?.points]) + + // Create boundary line geometry + const lineGeometry = useMemo(() => { + if (!node?.polygon?.points || node.polygon.points.length < 2) return null + return createBoundaryLineGeometry(node.polygon.points) + }, [node?.polygon?.points]) + + const handlers = useNodeEvents(node, 'site') + + if (!node || !floorShape || !lineGeometry) { + return null + } + + return ( + + {/* Render children (buildings and items) */} + {node.children.map((child) => ( + + ))} + + {/* Transparent floor fill */} + + + + + + {/* Simple boundary line */} + {/* @ts-ignore */} + + + + + ) +} diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 64042113..c350edb7 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -11,6 +11,8 @@ import { type LevelNode, type RoofEvent, type RoofNode, + type SiteEvent, + type SiteNode, type SlabEvent, type SlabNode, type WallEvent, @@ -21,6 +23,7 @@ import { import type { ThreeEvent } from '@react-three/fiber' type NodeConfig = { + site: { node: SiteNode; event: SiteEvent } item: { node: ItemNode; event: ItemEvent } wall: { node: WallNode; event: WallEvent } building: { node: BuildingNode; event: BuildingEvent }