diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx new file mode 100644 index 00000000..65da1879 --- /dev/null +++ b/apps/editor/components/tools/slab/slab-tool.tsx @@ -0,0 +1,364 @@ +import { emitter, type GridEvent, useScene, SlabNode, type LevelNode } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three"; +import useEditor from "@/store/use-editor"; + +const Y_OFFSET = 0.02; + +/** + * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point + */ +const calculateSnapPoint = ( + lastPoint: [number, number], + currentPoint: [number, number] +): [number, number] => { + const [x1, y1] = lastPoint; + const [x, y] = currentPoint; + + const dx = x - x1; + const dy = y - y1; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + + // Calculate distances to horizontal, vertical, and diagonal lines + const horizontalDist = absDy; + const verticalDist = absDx; + const diagonalDist = Math.abs(absDx - absDy); + + // Find the minimum distance to determine which axis to snap to + const minDist = Math.min(horizontalDist, verticalDist, diagonalDist); + + if (minDist === diagonalDist) { + // Snap to 45° diagonal + const diagonalLength = Math.min(absDx, absDy); + return [ + x1 + Math.sign(dx) * diagonalLength, + y1 + Math.sign(dy) * diagonalLength, + ]; + } else if (minDist === horizontalDist) { + // Snap to horizontal + return [x, y1]; + } else { + // Snap to vertical + return [x1, y]; + } +}; + +/** + * Creates a slab with the given polygon points + */ +const commitSlabDrawing = ( + levelId: LevelNode["id"], + points: Array<[number, number]> +) => { + const { createNode, nodes } = useScene.getState(); + + // Count existing slabs for naming + const slabCount = Object.values(nodes).filter((n) => n.type === "slab").length; + const name = `Slab ${slabCount + 1}`; + + const slab = SlabNode.parse({ + name, + polygon: points, + }); + + createNode(slab, levelId); +}; + +type PreviewState = { + points: Array<[number, number]>; + cursorPoint: [number, number] | null; +}; + +// Helper to validate point values (no NaN or Infinity) +const isValidPoint = ( + pt: [number, number] | null | undefined +): pt is [number, number] => { + if (!pt) return false; + return Number.isFinite(pt[0]) && Number.isFinite(pt[1]); +}; + +export const SlabTool: React.FC = () => { + const cursorRef = useRef(null); + const mainLineRef = useRef(null!); + const closingLineRef = useRef(null!); + const pointsRef = useRef>([]); + const currentLevelId = useViewer((state) => state.selection.levelId); + const setTool = useEditor((state) => state.setTool); + + // Preview state for reactive rendering (for shape and point markers) + const [preview, setPreview] = useState({ + points: [], + cursorPoint: null, + }); + + useEffect(() => { + if (!currentLevelId) return; + + let cursorPosition: [number, number] = [0, 0]; + + // Initialize line geometries + mainLineRef.current.geometry = new BufferGeometry(); + closingLineRef.current.geometry = new BufferGeometry(); + + const updateLines = () => { + const points = pointsRef.current; + + if (points.length === 0) { + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + return; + } + + // Build main line points + const linePoints: Vector3[] = points.map( + ([x, z]) => new Vector3(x, Y_OFFSET, z) + ); + + // Add cursor point + const lastPoint = points[points.length - 1]; + if (lastPoint) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + if (isValidPoint(snapped)) { + linePoints.push(new Vector3(snapped[0], Y_OFFSET, snapped[1])); + } + } + + // Update main line geometry + if (linePoints.length >= 2) { + mainLineRef.current.geometry.dispose(); + mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints); + mainLineRef.current.visible = true; + } else { + mainLineRef.current.visible = false; + } + + // Update closing line (from cursor back to first point) + const firstPoint = points[0]; + if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + if (isValidPoint(snapped)) { + const closingPoints = [ + new Vector3(snapped[0], Y_OFFSET, snapped[1]), + new Vector3(firstPoint[0], Y_OFFSET, firstPoint[1]), + ]; + closingLineRef.current.geometry.dispose(); + closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints); + closingLineRef.current.visible = true; + } + } else { + closingLineRef.current.visible = false; + } + }; + + const updatePreview = () => { + const points = pointsRef.current; + const lastPoint = points[points.length - 1]; + + let cursorPt: [number, number] | null = null; + if (lastPoint) { + cursorPt = calculateSnapPoint(lastPoint, cursorPosition); + } else if (points.length === 0) { + cursorPt = cursorPosition; + } + + setPreview({ points: [...points], cursorPoint: cursorPt }); + updateLines(); + }; + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return; + + // Snap to 0.5 grid + const gridX = Math.round(event.position[0] * 2) / 2; + const gridZ = Math.round(event.position[2] * 2) / 2; + cursorPosition = [gridX, gridZ]; + + // If we have points, snap to axis from last point + const lastPoint = pointsRef.current[pointsRef.current.length - 1]; + if (lastPoint) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]); + } else { + cursorRef.current.position.set(gridX, event.position[1], gridZ); + } + + updatePreview(); + }; + + const onGridClick = (event: GridEvent) => { + if (!currentLevelId) return; + + const gridX = Math.round(event.position[0] * 2) / 2; + const gridZ = Math.round(event.position[2] * 2) / 2; + let clickPoint: [number, number] = [gridX, gridZ]; + + // Snap to axis from last point + const lastPoint = pointsRef.current[pointsRef.current.length - 1]; + if (lastPoint) { + clickPoint = calculateSnapPoint(lastPoint, clickPoint); + } + + // Check if clicking on the first point to close the shape + const firstPoint = pointsRef.current[0]; + if ( + pointsRef.current.length >= 3 && + firstPoint && + Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && + Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 + ) { + // Create the slab + commitSlabDrawing(currentLevelId, pointsRef.current); + + // Reset state + pointsRef.current = []; + setPreview({ points: [], cursorPoint: null }); + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + + // Deactivate tool + setTool(null); + } else { + // Add point to polygon + pointsRef.current = [...pointsRef.current, clickPoint]; + updatePreview(); + } + }; + + const onGridDoubleClick = (_event: GridEvent) => { + if (!currentLevelId) return; + + // Need at least 3 points to form a polygon + if (pointsRef.current.length >= 3) { + commitSlabDrawing(currentLevelId, pointsRef.current); + + // Reset state + pointsRef.current = []; + setPreview({ points: [], cursorPoint: null }); + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + + // Deactivate tool + setTool(null); + } + }; + + // Subscribe to events + emitter.on("grid:move", onGridMove); + emitter.on("grid:click", onGridClick); + emitter.on("grid:double-click", onGridDoubleClick); + + return () => { + emitter.off("grid:move", onGridMove); + emitter.off("grid:click", onGridClick); + emitter.off("grid:double-click", onGridDoubleClick); + + // Reset state on unmount + pointsRef.current = []; + }; + }, [currentLevelId, setTool]); + + const { points, cursorPoint } = preview; + + // Create preview shape when we have 3+ points + const previewShape = useMemo(() => { + if (points.length < 3) return null; + + const allPoints = [...points]; + if (isValidPoint(cursorPoint)) { + allPoints.push(cursorPoint); + } + + // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: + // - Shape X -> World X + // - Shape Y -> World -Z (so we negate Z to get correct orientation) + const firstPt = allPoints[0]; + if (!isValidPoint(firstPt)) return null; + + const shape = new Shape(); + shape.moveTo(firstPt[0], -firstPt[1]); + + for (let i = 1; i < allPoints.length; i++) { + const pt = allPoints[i]; + if (isValidPoint(pt)) { + shape.lineTo(pt[0], -pt[1]); + } + } + shape.closePath(); + + return shape; + }, [points, cursorPoint]); + + return ( + + {/* Cursor */} + + + + + + {/* Preview fill */} + {previewShape && ( + + + + + )} + + {/* Main line - uses native line element with TSL-compatible material */} + {/* @ts-ignore */} + + + + + + {/* Closing line - uses native line element with TSL-compatible material */} + {/* @ts-ignore */} + + + + + + {/* Point markers */} + {points.map(([x, z], index) => + isValidPoint([x, z]) ? ( + + + + + ) : null + )} + + ); +}; diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 914f2a48..5c023bf8 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,6 +1,7 @@ import useEditor, { type Phase, type Tool } from "@/store/use-editor"; import { useViewer } from "@pascal-app/viewer"; import { ItemTool } from "./item/item-tool"; +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"; @@ -9,6 +10,7 @@ const tools: Record>> = { site: {}, structure: { wall: WallTool, + slab: SlabTool, item: ItemTool, zone: ZoneTool, }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12648883..8dc560f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export * from './schema' export { default as useScene } from './store/use-scene' // Systems +export { SlabSystem } from './systems/slab/slab-system' export { WallSystem } from './systems/wall/wall-system' export { isObject } from './utils/types' diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx new file mode 100644 index 00000000..1240ab8a --- /dev/null +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -0,0 +1,79 @@ +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import type { AnyNodeId, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' + +// ============================================================================ +// SLAB SYSTEM +// ============================================================================ + +export const SlabSystem = () => { + const { nodes, dirtyNodes, clearDirty } = useScene() + + useFrame(() => { + if (dirtyNodes.size === 0) return + + // Process dirty slabs + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (!node || node.type !== 'slab') return + + const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh + if (mesh) { + updateSlabGeometry(node as SlabNode, mesh) + } + clearDirty(id as AnyNodeId) + }) + }) + + return null +} + +/** + * Updates the geometry for a single slab + */ +function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { + const newGeo = generateSlabGeometry(node) + + mesh.geometry.dispose() + mesh.geometry = newGeo +} + +/** + * Generates extruded slab geometry from polygon + */ +export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { + const polygon = slabNode.polygon + const elevation = slabNode.elevation ?? 0.05 + + if (polygon.length < 3) { + return new THREE.BufferGeometry() + } + + // Create shape from polygon + // Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion + const shape = new THREE.Shape() + const firstPt = polygon[0]! + + // Negate Y (which becomes Z) to get correct orientation after rotation + shape.moveTo(firstPt[0], -firstPt[1]) + + for (let i = 1; i < polygon.length; i++) { + const pt = polygon[i]! + shape.lineTo(pt[0], -pt[1]) + } + shape.closePath() + + // Extrude the shape by elevation + const geometry = new THREE.ExtrudeGeometry(shape, { + depth: elevation, + bevelEnabled: false, + }) + + // Rotate so extrusion direction (Z) becomes height direction (Y) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + + return geometry +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 54fdec91..7ff8a7a6 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -4,6 +4,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' +import { SlabRenderer } from './slab/slab-renderer' import { WallRenderer } from './wall/wall-renderer' import { ZoneRenderer } from './zone/zone-renderer' @@ -17,6 +18,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'building' && } {node.type === 'level' && } {node.type === 'item' && } + {node.type === 'slab' && } {node.type === 'wall' && } {node.type === 'zone' && } diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx new file mode 100644 index 00000000..96390272 --- /dev/null +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -0,0 +1,17 @@ +import { useRegistry, type SlabNode } from '@pascal-app/core' +import { useRef } from 'react' +import type { Mesh } from 'three' + +export const SlabRenderer = ({ node }: { node: SlabNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'slab', ref) + + return ( + + {/* SlabSystem will replace this geometry in the next frame */} + + + + ) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 760c2df7..363f21ba 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { WallSystem } from '@pascal-app/core' +import { SlabSystem, WallSystem } from '@pascal-app/core' import { Bvh, Environment } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import * as THREE from 'three/webgpu' @@ -40,6 +40,7 @@ const Viewer: React.FC = ({ children }) => { {/* Default Systems */} +