diff --git a/apps/editor/components/editor/grid.tsx b/apps/editor/components/editor/grid.tsx new file mode 100644 index 00000000..1b5c9c0b --- /dev/null +++ b/apps/editor/components/editor/grid.tsx @@ -0,0 +1,145 @@ +'use client' + +import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' + +import { useFrame } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { MathUtils, type Mesh, Vector2 } from 'three' + +import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' +import { MeshBasicNodeMaterial } from 'three/webgpu' +import { useGridEvents } from '@/hooks/use-grid-events' + +export const Grid = ({ + cellSize = 0.5, + cellThickness = 0.5, + cellColor = '#888888', + sectionSize = 1, + sectionThickness = 1, + sectionColor = '#000000', + fadeDistance = 100, + fadeStrength = 1, + revealRadius = 10, +}: { + cellSize?: number + cellThickness?: number + cellColor?: string + sectionSize?: number + sectionThickness?: number + sectionColor?: string + fadeDistance?: number + fadeStrength?: number + revealRadius?: number +}) => { + const cursorPositionRef = useRef(new Vector2(0, 0)) + + const material = useMemo(() => { + // Use xy since plane geometry is in XY space (before rotation) + const pos = positionLocal.xy + + // Cursor position uniform + const cursorPos = uniform(cursorPositionRef.current) + + // Grid line function using fwidth for anti-aliasing + // Returns 1 on grid lines, 0 elsewhere + const getGrid = (size: number, thickness: number) => { + const r = pos.div(size) + const fw = fwidth(r) + // Distance to nearest grid line for each axis + const grid = fract(r.sub(0.5)).sub(0.5).abs() + // Anti-aliased step: divide by fwidth and clamp + const lineX = float(1).sub( + grid.x + .div(fw.x) + .add(1 - thickness) + .min(1), + ) + const lineY = float(1).sub( + grid.y + .div(fw.y) + .add(1 - thickness) + .min(1), + ) + // Combine both axes - max gives us lines in both directions + return lineX.max(lineY) + } + + const g1 = getGrid(cellSize, cellThickness) + const g2 = getGrid(sectionSize, sectionThickness) + + // Distance fade from center + const dist = pos.length() + const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength) + + // Cursor reveal effect - distance from cursor + const cursorDist = pos.sub(cursorPos).length() + const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1) + + // Mix colors based on section grid + const gridColor = mix( + color(cellColor), + color(sectionColor), + float(sectionThickness).mul(g2).min(1), + ) + + // Combined alpha with cursor fade + const alpha = g1.add(g2).mul(fade).mul(cursorFade) + const finalAlpha = mix(alpha.mul(0.75), alpha, g2) + + return new MeshBasicNodeMaterial({ + transparent: true, + colorNode: gridColor, + opacityNode: finalAlpha, + depthWrite: false, + }) + }, [ + cellSize, + cellThickness, + cellColor, + sectionSize, + sectionThickness, + sectionColor, + fadeDistance, + fadeStrength, + revealRadius, + ]) + + const gridRef = useRef(null!) + const [gridY, setGridY] = useState(0) + + // Use custom raycasting for grid events (independent of mesh events) + useGridEvents(gridY) + + // Update cursor position from grid:move events + useEffect(() => { + const onGridMove = (event: GridEvent) => { + cursorPositionRef.current.set(event.position[0], -event.position[2]) + } + + emitter.on('grid:move', onGridMove) + return () => { + emitter.off('grid:move', onGridMove) + } + }, []) + + useFrame((_, delta) => { + const currentLevelId = useViewer.getState().selection.levelId + let targetY = 0 + if (currentLevelId) { + const levelMesh = sceneRegistry.nodes.get(currentLevelId) + if (levelMesh) { + targetY = levelMesh.position.y + } + } + const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta) + gridRef.current.position.y = newY + setGridY(newY) + }) + + return ( + + + + ) +} diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 0d8a8000..4e7da320 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -1,19 +1,7 @@ 'use client' -import { - initSpaceDetectionSync, - initSpatialGridSync, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer' - -import { useFrame } from '@react-three/fiber' -import { useMemo, useRef } from 'react' -import { MathUtils, type Mesh } from 'three' - -import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl' -import { MeshBasicNodeMaterial } from 'three/webgpu' +import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' +import { Viewer } from '@pascal-app/viewer' import { useKeyboard } from '@/hooks/use-keyboard' import useEditor from '@/store/use-editor' import { ZoneSystem } from '../systems/zone/zone-system' @@ -24,6 +12,7 @@ import { SidebarProvider } from '../ui/primitives/sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar' import { CustomCameraControls } from './custom-camera-controls' import { ExportManager } from './export-manager' +import { Grid } from './grid' import { SelectionManager } from './selection-manager' useScene.getState().loadScene() @@ -55,116 +44,3 @@ export default function Editor() { ) } - -const Grid = ({ - cellSize = 0.5, - cellThickness = 0.5, - cellColor = '#888888', - sectionSize = 1, - sectionThickness = 1, - sectionColor = '#000000', - fadeDistance = 100, - fadeStrength = 1, -}: { - cellSize?: number - cellThickness?: number - cellColor?: string - sectionSize?: number - sectionThickness?: number - sectionColor?: string - fadeDistance?: number - fadeStrength?: number -}) => { - const material = useMemo(() => { - // Use xy since plane geometry is in XY space (before rotation) - const pos = positionLocal.xy - - // Grid line function using fwidth for anti-aliasing - // Returns 1 on grid lines, 0 elsewhere - const getGrid = (size: number, thickness: number) => { - const r = pos.div(size) - const fw = fwidth(r) - // Distance to nearest grid line for each axis - const grid = fract(r.sub(0.5)).sub(0.5).abs() - // Anti-aliased step: divide by fwidth and clamp - const lineX = float(1).sub( - grid.x - .div(fw.x) - .add(1 - thickness) - .min(1), - ) - const lineY = float(1).sub( - grid.y - .div(fw.y) - .add(1 - thickness) - .min(1), - ) - // Combine both axes - max gives us lines in both directions - return lineX.max(lineY) - } - - const g1 = getGrid(cellSize, cellThickness) - const g2 = getGrid(sectionSize, sectionThickness) - - // Distance fade from center - const dist = pos.length() - const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength) - - // Mix colors based on section grid - const gridColor = mix( - color(cellColor), - color(sectionColor), - float(sectionThickness).mul(g2).min(1), - ) - - // Combined alpha - const alpha = g1.add(g2).mul(fade) - const finalAlpha = mix(alpha.mul(0.75), alpha, g2) - - return new MeshBasicNodeMaterial({ - transparent: true, - colorNode: gridColor, - opacityNode: finalAlpha, - depthWrite: false, - }) - }, [ - cellSize, - cellThickness, - cellColor, - sectionSize, - sectionThickness, - sectionColor, - fadeDistance, - fadeStrength, - ]) - - const handlers = useGridEvents() - const gridRef = useRef(null!) - - useFrame((_, delta) => { - const currentLevelId = useViewer.getState().selection.levelId - let targetY = 0 - if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId) - if (levelMesh) { - targetY = levelMesh.position.y - } else { - // Fallback: compute from level node data when mesh isn't registered yet - const levelNode = useScene.getState().nodes[currentLevelId] - if (levelNode && 'level' in levelNode) { - const levelMode = useViewer.getState().levelMode - const LEVEL_HEIGHT = 2.5 - const EXPLODED_GAP = 5 - targetY = ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0)) - } - } - } - gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta) - }) - - return ( - - - - ) -} diff --git a/apps/editor/components/tools/ceiling/ceiling-boundary-editor.tsx b/apps/editor/components/tools/ceiling/ceiling-boundary-editor.tsx new file mode 100644 index 00000000..6f8865b7 --- /dev/null +++ b/apps/editor/components/tools/ceiling/ceiling-boundary-editor.tsx @@ -0,0 +1,42 @@ +import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' + +interface CeilingBoundaryEditorProps { + ceilingId: CeilingNode['id'] +} + +/** + * Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling + * Uses the generic PolygonEditor component + */ +export const CeilingBoundaryEditor: React.FC = ({ ceilingId }) => { + const ceilingNode = useScene((state) => state.nodes[ceilingId]) + const updateNode = useScene((state) => state.updateNode) + const setSelection = useViewer((state) => state.setSelection) + + const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null + + const handlePolygonChange = useCallback( + (newPolygon: Array<[number, number]>) => { + updateNode(ceilingId, { polygon: newPolygon }) + // Re-assert selection so the ceiling stays selected after the edit + setSelection({ selectedIds: [ceilingId] }) + }, + [ceilingId, updateNode, setSelection], + ) + + if (!ceiling || !ceiling.polygon || ceiling.polygon.length < 3) return null + + return ( + + ) +} diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index d01c0850..24f909dc 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -1,377 +1,233 @@ -import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode, sceneRegistry } from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -import { useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three"; -import useEditor from "@/store/use-editor"; +import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } 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 CEILING_HEIGHT = 2.52; // Slightly above default ceiling height -const GRID_OFFSET = 0.02; // Small offset above floor level -const UP = new Vector3(0, 1, 0); +const CEILING_HEIGHT = 2.52 +const GRID_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] + currentPoint: [number, number], ): [number, number] => { - const [x1, y1] = lastPoint; - const [x, y] = currentPoint; + 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); + 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); + 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); + 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, - ]; + 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]; + return [x, y1] } else { // Snap to vertical - return [x1, y]; + return [x1, y] } -}; +} /** * Creates a ceiling with the given polygon points */ -const commitCeilingDrawing = ( - levelId: LevelNode["id"], - points: Array<[number, number]> -) => { - const { createNode, nodes } = useScene.getState(); +const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => { + const { createNode, nodes } = useScene.getState() // Count existing ceilings for naming - const ceilingCount = Object.values(nodes).filter((n) => n.type === "ceiling").length; - const name = `Ceiling ${ceilingCount + 1}`; + const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length + const name = `Ceiling ${ceilingCount + 1}` const ceiling = CeilingNode.parse({ name, polygon: points, - }); + }) - createNode(ceiling, levelId); -}; - -type PreviewState = { - points: Array<[number, number]>; - cursorPoint: [number, number] | null; - levelY: number; -}; - -// 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]); -}; + createNode(ceiling, levelId) +} export const CeilingTool: React.FC = () => { - const cursorRef = useRef(null); - const gridCursorRef = useRef(null); - const mainLineRef = useRef(null!); - const closingLineRef = useRef(null!); - const pointsRef = useRef>([]); - const levelYRef = useRef(0); - const cursorPositionRef = useRef<[number, number]>([0, 0]); - const lastDisplayRef = useRef<{ x: number; z: number } | null>(null); - const currentLevelId = useViewer((state) => state.selection.levelId); - const setTool = useEditor((state) => state.setTool); + const cursorRef = useRef(null) + const gridCursorRef = useRef(null) + const mainLineRef = useRef(null!) + const closingLineRef = useRef(null!) + const currentLevelId = useViewer((state) => state.selection.levelId) + const setTool = useEditor((state) => state.setTool) - // Reusable objects for raycasting (created once, avoid per-frame allocations) - const raycaster = useMemo(() => new Raycaster(), []); - const levelPlane = useMemo(() => new Plane(UP, 0), []); - const hitPoint = useMemo(() => new Vector3(), []); + const [points, setPoints] = useState>([]) + const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) + const [levelY, setLevelY] = useState(0) - // Preview state for reactive rendering (for shape and point markers) - const [preview, setPreview] = useState({ - points: [], - cursorPoint: null, - levelY: 0, - }); - - // Resolve the current level's Y position from scene registry or node data - const getLevelY = (): number => { - if (!currentLevelId) return 0; - const levelMesh = sceneRegistry.nodes.get(currentLevelId); - if (levelMesh) return levelMesh.position.y; - const levelNode = useScene.getState().nodes[currentLevelId]; - if (levelNode && 'level' in levelNode) { - const levelMode = useViewer.getState().levelMode; - const LEVEL_HEIGHT = 2.5; - const EXPLODED_GAP = 5; - return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0)); - } - return 0; - }; - - // Imperatively update line geometries (called from useFrame) - const updateLines = () => { - if (!mainLineRef.current || !closingLineRef.current) return; - - const points = pointsRef.current; - const cursorPosition = cursorPositionRef.current; - const ceilingY = levelYRef.current + CEILING_HEIGHT; - - 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, ceilingY, 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], ceilingY, 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], ceilingY, snapped[1]), - new Vector3(firstPoint[0], ceilingY, firstPoint[1]), - ]; - closingLineRef.current.geometry.dispose(); - closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints); - closingLineRef.current.visible = true; - } - } else { - closingLineRef.current.visible = false; - } - }; - - // Per-frame cursor positioning via direct raycasting onto the level plane. - // This bypasses R3F's event propagation (grid:move), ensuring the cursor - // always tracks the pointer regardless of tool activation/deactivation state. - useFrame((state) => { - if (!cursorRef.current) return; - - // Sync level Y from scene registry each frame - levelYRef.current = getLevelY(); - - // Raycast from camera through pointer onto horizontal plane at level Y - raycaster.setFromCamera(state.pointer, state.camera); - levelPlane.constant = -levelYRef.current; - const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint); - if (!hit) return; - - // Snap to 0.5m grid - const gridX = Math.round(hit.x * 2) / 2; - const gridZ = Math.round(hit.z * 2) / 2; - cursorPositionRef.current = [gridX, gridZ]; - - const ceilingY = levelYRef.current + CEILING_HEIGHT; - const gridY = levelYRef.current + GRID_OFFSET; - - // Apply axis snapping from last placed point - const lastPoint = pointsRef.current[pointsRef.current.length - 1]; - let displayPoint: [number, number]; - if (lastPoint) { - displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current); - } else { - displayPoint = [gridX, gridZ]; - } - - // Update cursor mesh positions imperatively - cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]); - if (gridCursorRef.current) { - gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]); - } - - // Update line geometries imperatively - updateLines(); - - // Only trigger React state update when snapped display position changes - if ( - !lastDisplayRef.current || - lastDisplayRef.current.x !== displayPoint[0] || - lastDisplayRef.current.z !== displayPoint[1] - ) { - lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] }; - setPreview({ - points: [...pointsRef.current], - cursorPoint: displayPoint, - levelY: levelYRef.current, - }); - } - }); - - // Click and double-click handlers for point placement + // Update cursor position and lines on grid move useEffect(() => { - if (!currentLevelId) return; + if (!currentLevelId) return - // Initialize line geometries - if (mainLineRef.current) { - mainLineRef.current.geometry = new BufferGeometry(); - } - if (closingLineRef.current) { - closingLineRef.current.geometry = new BufferGeometry(); - } + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current || !gridCursorRef.current) return - // Reset state on level change - pointsRef.current = []; - lastDisplayRef.current = null; - setPreview({ points: [], cursorPoint: null, levelY: getLevelY() }); + const gridX = Math.round(event.position[0] * 2) / 2 + const gridZ = Math.round(event.position[2] * 2) / 2 + const gridPosition: [number, number] = [gridX, gridZ] + + setCursorPosition(gridPosition) + setLevelY(event.position[1]) + + const ceilingY = event.position[1] + CEILING_HEIGHT + const gridY = event.position[1] + GRID_OFFSET + + // Calculate snapped display position + const lastPoint = points[points.length - 1] + const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition + + cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]) + gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) + } const onGridClick = (_event: GridEvent) => { - if (!currentLevelId) return; + if (!currentLevelId) return - // Use the cursor position tracked by useFrame (matches what user sees) - let clickPoint: [number, number] = [...cursorPositionRef.current]; - - // Snap to axis from last point - const lastPoint = pointsRef.current[pointsRef.current.length - 1]; - if (lastPoint) { - clickPoint = calculateSnapPoint(lastPoint, clickPoint); - } + // Calculate snapped click point + const lastPoint = points[points.length - 1] + const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition // Check if clicking on the first point to close the shape - const firstPoint = pointsRef.current[0]; + const firstPoint = points[0] if ( - pointsRef.current.length >= 3 && + points.length >= 3 && firstPoint && Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { // Create the ceiling - commitCeilingDrawing(currentLevelId, pointsRef.current); - - // Reset state - pointsRef.current = []; - lastDisplayRef.current = null; - setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); - if (mainLineRef.current) mainLineRef.current.visible = false; - if (closingLineRef.current) closingLineRef.current.visible = false; - - // Deactivate tool - setTool(null); + commitCeilingDrawing(currentLevelId, points) + setPoints([]) + setTool(null) } else { // Add point to polygon - pointsRef.current = [...pointsRef.current, clickPoint]; - lastDisplayRef.current = null; // Force preview update on next frame + setPoints([...points, clickPoint]) } - }; + } const onGridDoubleClick = (_event: GridEvent) => { - if (!currentLevelId) return; + if (!currentLevelId) return // Need at least 3 points to form a polygon - if (pointsRef.current.length >= 3) { - commitCeilingDrawing(currentLevelId, pointsRef.current); - - // Reset state - pointsRef.current = []; - lastDisplayRef.current = null; - setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); - if (mainLineRef.current) mainLineRef.current.visible = false; - if (closingLineRef.current) closingLineRef.current.visible = false; - - // Deactivate tool - setTool(null); + if (points.length >= 3) { + commitCeilingDrawing(currentLevelId, points) + setPoints([]) + setTool(null) } - }; + } - emitter.on("grid:click", onGridClick); - emitter.on("grid:double-click", onGridDoubleClick); + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('grid:double-click', onGridDoubleClick) return () => { - emitter.off("grid:click", onGridClick); - emitter.off("grid:double-click", onGridDoubleClick); + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('grid:double-click', onGridDoubleClick) + } + }, [currentLevelId, points, cursorPosition, setTool]) - // Reset state on unmount - pointsRef.current = []; - }; - }, [currentLevelId, setTool]); + // Update line geometries when points change + useEffect(() => { + if (!mainLineRef.current || !closingLineRef.current) return - const { points, cursorPoint, levelY } = preview; + if (points.length === 0) { + mainLineRef.current.visible = false + closingLineRef.current.visible = false + return + } + + const ceilingY = levelY + CEILING_HEIGHT + const lastPoint = points[points.length - 1] + const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition + + // Build main line points + const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z)) + linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1])) + + // Update main line + 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 && firstPoint) { + const closingPoints = [ + new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]), + new Vector3(firstPoint[0], ceilingY, firstPoint[1]), + ] + closingLineRef.current.geometry.dispose() + closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) + closingLineRef.current.visible = true + } else { + closingLineRef.current.visible = false + } + }, [points, cursorPosition, levelY]) // Create preview shape when we have 3+ points const previewShape = useMemo(() => { - if (points.length < 3) return null; + if (points.length < 3) return null - const allPoints = [...points]; - if (isValidPoint(cursorPoint)) { - allPoints.push(cursorPoint); - } + const lastPoint = points[points.length - 1] + const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition + + const allPoints = [...points, snappedCursor] // 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 firstPt = allPoints[0] + if (!firstPt) return null - const shape = new Shape(); - shape.moveTo(firstPt[0], -firstPt[1]); + 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]); + const pt = allPoints[i] + if (pt) { + shape.lineTo(pt[0], -pt[1]) } } - shape.closePath(); + shape.closePath() - return shape; - }, [points, cursorPoint]); + return shape + }, [points, cursorPosition]) return ( {/* Cursor at ceiling height */} - + {/* Grid-level cursor indicator */} - + {/* Preview fill */} @@ -392,19 +248,14 @@ export const CeilingTool: React.FC = () => { )} - {/* Main line - uses native line element with TSL-compatible material */} + {/* Main line */} {/* @ts-ignore */} - + - {/* Closing line - uses native line element with TSL-compatible material */} + {/* Closing line */} {/* @ts-ignore */} @@ -419,18 +270,16 @@ export const CeilingTool: React.FC = () => { {/* Point markers */} - {points.map(([x, z], index) => - isValidPoint([x, z]) ? ( - - - - - ) : null - )} + {points.map(([x, z], index) => ( + + + + + ))} - ); -}; + ) +} diff --git a/apps/editor/components/tools/shared/polygon-editor.tsx b/apps/editor/components/tools/shared/polygon-editor.tsx index 670404c8..6e57a3e1 100644 --- a/apps/editor/components/tools/shared/polygon-editor.tsx +++ b/apps/editor/components/tools/shared/polygon-editor.tsx @@ -1,14 +1,7 @@ -import { useThree } from '@react-three/fiber' +import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' +import { createPortal } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - BufferGeometry, - Float32BufferAttribute, - type Mesh, - Plane, - Raycaster, - Vector2, - Vector3, -} from 'three' +import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three' const Y_OFFSET = 0.02 @@ -24,7 +17,8 @@ export interface PolygonEditorProps { color?: string onPolygonChange: (polygon: Array<[number, number]>) => void minVertices?: number - levelY?: number + /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */ + levelId?: string /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */ surfaceHeight?: number } @@ -40,24 +34,23 @@ export const PolygonEditor: React.FC = ({ color = '#3b82f6', onPolygonChange, minVertices = 3, - levelY = 0, + levelId, surfaceHeight = 0, }) => { - const { gl, camera } = useThree() + // Get level node from registry if levelId is provided + const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null - // Compute the editing plane height (level Y + small offset above floor) - const editY = levelY + Y_OFFSET + // When using portal, edit at Y_OFFSET (local to level) + // When not using portal, edit at world origin + const editY = levelNode ? Y_OFFSET : 0 // 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) + const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) - // Refs for raycasting during drag - const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -editY)) - dragPlane.current.constant = -editY - const raycaster = useRef(new Raycaster()) const lineRef = useRef(null!) // Track the last polygon prop to detect external changes (undo/redo) @@ -82,30 +75,15 @@ export const PolygonEditor: React.FC = ({ }) }, [displayPolygon]) - // Handle vertex drag + // Update vertex position using grid cursor position 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) - } + (vertexIndex: number) => { + const basePolygon = previewPolygon ?? polygon + const newPolygon = [...basePolygon] + newPolygon[vertexIndex] = cursorPosition + setPreviewPolygon(newPolygon) }, - [gl, camera, previewPolygon, polygon], + [cursorPosition, previewPolygon, polygon], ) // Commit polygon changes @@ -146,58 +124,58 @@ export const PolygonEditor: React.FC = ({ [polygon, previewPolygon, onPolygonChange, minVertices], ) - // Set up pointer move/up listeners for dragging with pointer capture + // Listen to grid:move events to track cursor position + useEffect(() => { + const onGridMove = (event: GridEvent) => { + const gridX = Math.round(event.position[0] * 2) / 2 + const gridZ = Math.round(event.position[2] * 2) / 2 + setCursorPosition([gridX, gridZ]) + + // Update vertex position during drag + if (dragState?.isDragging) { + handleVertexDrag(dragState.vertexIndex) + } + } + + emitter.on('grid:move', onGridMove) + return () => { + emitter.off('grid:move', onGridMove) + } + }, [dragState, handleVertexDrag]) + + // Set up pointer up listener for ending drag 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) => { - // Stop the event from reaching R3F's handlers, which would otherwise - // fire a grid:click and deselect the node being edited. + // Only handle the specific pointer that started the drag + if (e.pointerId !== dragState.pointerId) return + + // Stop the event from propagating to prevent grid click e.stopImmediatePropagation() e.preventDefault() - // Release pointer capture - if (canvas.hasPointerCapture(e.pointerId)) { - canvas.releasePointerCapture(e.pointerId) - } - // Suppress the follow-up click event that browsers fire after pointerup const suppressClick = (ce: MouseEvent) => { ce.stopImmediatePropagation() ce.preventDefault() - canvas.removeEventListener('click', suppressClick, true) + window.removeEventListener('click', suppressClick, true) } - canvas.addEventListener('click', suppressClick, true) + window.addEventListener('click', suppressClick, true) + // Safety cleanup in case no click fires requestAnimationFrame(() => { - canvas.removeEventListener('click', suppressClick, true) + window.removeEventListener('click', suppressClick, true) }) commitPolygonChange() } - canvas.addEventListener('pointermove', handlePointerMove) - canvas.addEventListener('pointerup', handlePointerUp, true) - + window.addEventListener('pointerup', handlePointerUp, true) return () => { - // Release capture on cleanup - if (canvas.hasPointerCapture(pointerId)) { - canvas.releasePointerCapture(pointerId) - } - canvas.removeEventListener('pointermove', handlePointerMove) - canvas.removeEventListener('pointerup', handlePointerUp, true) + window.removeEventListener('pointerup', handlePointerUp, true) } - }, [dragState, gl, handleVertexDrag, commitPolygonChange]) + }, [dragState, commitPolygonChange]) // Update line geometry when polygon changes useEffect(() => { @@ -222,11 +200,11 @@ export const PolygonEditor: React.FC = ({ const canDelete = displayPolygon.length > minVertices - return ( + const editorContent = ( {/* Border line */} {/* @ts-ignore */} - + {}}> = ({ })} ) + + // Mount to level node if available, otherwise render at world origin + return levelNode ? createPortal(editorContent, levelNode) : editorContent } diff --git a/apps/editor/components/tools/site/site-boundary-editor.tsx b/apps/editor/components/tools/site/site-boundary-editor.tsx index bf197fff..4783bd4d 100644 --- a/apps/editor/components/tools/site/site-boundary-editor.tsx +++ b/apps/editor/components/tools/site/site-boundary-editor.tsx @@ -1,4 +1,4 @@ -import { useScene, type SiteNode } from '@pascal-app/core' +import { type SiteNode, useScene } from '@pascal-app/core' import { useCallback } from 'react' import { PolygonEditor } from '../shared/polygon-editor' @@ -34,7 +34,7 @@ export const SiteBoundaryEditor: React.FC = () => { return ( diff --git a/apps/editor/components/tools/slab/slab-boundary-editor.tsx b/apps/editor/components/tools/slab/slab-boundary-editor.tsx index 24f0e3e5..2fdd7f02 100644 --- a/apps/editor/components/tools/slab/slab-boundary-editor.tsx +++ b/apps/editor/components/tools/slab/slab-boundary-editor.tsx @@ -1,52 +1,30 @@ -import { sceneRegistry, useScene, type AnyNodeId, type SlabNode } from '@pascal-app/core' +import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' import { PolygonEditor } from '../shared/polygon-editor' +interface SlabBoundaryEditorProps { + slabId: SlabNode['id'] +} + /** - * Slab boundary editor - allows editing slab polygon vertices when a slab is selected + * Slab boundary editor - allows editing slab polygon vertices for a specific slab * Uses the generic PolygonEditor component */ -export const SlabBoundaryEditor: React.FC = () => { - const selectedIds = useViewer((state) => state.selection.selectedIds) - const levelId = useViewer((state) => state.selection.levelId) - const setSelection = useViewer((state) => state.setSelection) - const nodes = useScene((state) => state.nodes) +export const SlabBoundaryEditor: React.FC = ({ slabId }) => { + const slabNode = useScene((state) => state.nodes[slabId]) const updateNode = useScene((state) => state.updateNode) + const setSelection = useViewer((state) => state.setSelection) - // Find the first selected slab - const selectedSlabId = - selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') ?? null - const slab = selectedSlabId ? (nodes[selectedSlabId as AnyNodeId] as SlabNode) : null - - // Get level Y position for the editing plane - let levelY = 0 - if (levelId) { - const levelMesh = sceneRegistry.nodes.get(levelId) - if (levelMesh) { - levelY = levelMesh.position.y - } else { - const levelNode = nodes[levelId] - if (levelNode && 'level' in levelNode) { - const levelMode = useViewer.getState().levelMode - const LEVEL_HEIGHT = 2.5 - const EXPLODED_GAP = 5 - levelY = - ((levelNode as any).level || 0) * - (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0)) - } - } - } + const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { - if (selectedSlabId) { - updateNode(selectedSlabId as SlabNode['id'], { polygon: newPolygon }) - // Re-assert selection so the slab stays selected after the edit - setSelection({ selectedIds: [selectedSlabId] }) - } + updateNode(slabId, { polygon: newPolygon }) + // Re-assert selection so the slab stays selected after the edit + setSelection({ selectedIds: [slabId] }) }, - [selectedSlabId, updateNode, setSelection], + [slabId, updateNode, setSelection], ) if (!slab || !slab.polygon || slab.polygon.length < 3) return null @@ -57,7 +35,7 @@ export const SlabBoundaryEditor: React.FC = () => { color="#a3a3a3" onPolygonChange={handlePolygonChange} minVertices={3} - levelY={levelY} + levelId={resolveLevelId(slab, useScene.getState().nodes)} surfaceHeight={slab.elevation ?? 0.05} /> ) diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx index b3db9672..7f887c36 100644 --- a/apps/editor/components/tools/slab/slab-tool.tsx +++ b/apps/editor/components/tools/slab/slab-tool.tsx @@ -1,357 +1,221 @@ -import { emitter, type GridEvent, useScene, SlabNode, type LevelNode, sceneRegistry } from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -import { useFrame } from "@react-three/fiber"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three"; -import useEditor from "@/store/use-editor"; +import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } 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' -const Y_OFFSET = 0.02; // Small offset above floor level -const UP = new Vector3(0, 1, 0); +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] + currentPoint: [number, number], ): [number, number] => { - const [x1, y1] = lastPoint; - const [x, y] = currentPoint; + 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); + 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); + 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); + 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, - ]; + 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]; + return [x, y1] } else { // Snap to vertical - return [x1, y]; + return [x1, y] } -}; +} /** * Creates a slab with the given polygon points and returns its ID */ -const commitSlabDrawing = ( - levelId: LevelNode["id"], - points: Array<[number, number]> -): string => { - const { createNode, nodes } = useScene.getState(); +const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => { + 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 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); - return slab.id; -}; - -type PreviewState = { - points: Array<[number, number]>; - cursorPoint: [number, number] | null; - levelY: number; -}; - -// 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]); -}; + createNode(slab, levelId) + return slab.id +} export const SlabTool: React.FC = () => { - const cursorRef = useRef(null); - const mainLineRef = useRef(null!); - const closingLineRef = useRef(null!); - const pointsRef = useRef>([]); - const levelYRef = useRef(0); - const cursorPositionRef = useRef<[number, number]>([0, 0]); - const lastDisplayRef = useRef<{ x: number; z: number } | null>(null); - const currentLevelId = useViewer((state) => state.selection.levelId); - const setSelection = useViewer((state) => state.setSelection); - const setTool = useEditor((state) => state.setTool); + const cursorRef = useRef(null) + const mainLineRef = useRef(null!) + const closingLineRef = useRef(null!) + const currentLevelId = useViewer((state) => state.selection.levelId) + const setSelection = useViewer((state) => state.setSelection) - // Reusable objects for raycasting (created once, avoid per-frame allocations) - const raycaster = useMemo(() => new Raycaster(), []); - const levelPlane = useMemo(() => new Plane(UP, 0), []); - const hitPoint = useMemo(() => new Vector3(), []); + const [points, setPoints] = useState>([]) + const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) + const [levelY, setLevelY] = useState(0) - // Preview state for reactive rendering (for shape and point markers) - const [preview, setPreview] = useState({ - points: [], - cursorPoint: null, - levelY: 0, - }); - - // Resolve the current level's Y position from scene registry or node data - const getLevelY = (): number => { - if (!currentLevelId) return 0; - const levelMesh = sceneRegistry.nodes.get(currentLevelId); - if (levelMesh) return levelMesh.position.y; - const levelNode = useScene.getState().nodes[currentLevelId]; - if (levelNode && 'level' in levelNode) { - const levelMode = useViewer.getState().levelMode; - const LEVEL_HEIGHT = 2.5; - const EXPLODED_GAP = 5; - return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0)); - } - return 0; - }; - - // Imperatively update line geometries (called from useFrame) - const updateLines = () => { - if (!mainLineRef.current || !closingLineRef.current) return; - - const points = pointsRef.current; - const cursorPosition = cursorPositionRef.current; - const y = levelYRef.current + Y_OFFSET; - - 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, 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, 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, snapped[1]), - new Vector3(firstPoint[0], y, firstPoint[1]), - ]; - closingLineRef.current.geometry.dispose(); - closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints); - closingLineRef.current.visible = true; - } - } else { - closingLineRef.current.visible = false; - } - }; - - // Per-frame cursor positioning via direct raycasting onto the level plane. - // This bypasses R3F's event propagation (grid:move), ensuring the cursor - // always tracks the pointer regardless of tool activation/deactivation state. - useFrame((state) => { - if (!cursorRef.current) return; - - // Sync level Y from scene registry each frame - levelYRef.current = getLevelY(); - - // Raycast from camera through pointer onto horizontal plane at level Y - raycaster.setFromCamera(state.pointer, state.camera); - levelPlane.constant = -levelYRef.current; - const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint); - if (!hit) return; - - // Snap to 0.5m grid - const gridX = Math.round(hit.x * 2) / 2; - const gridZ = Math.round(hit.z * 2) / 2; - cursorPositionRef.current = [gridX, gridZ]; - - // Apply axis snapping from last placed point - const lastPoint = pointsRef.current[pointsRef.current.length - 1]; - let displayPoint: [number, number]; - if (lastPoint) { - displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current); - } else { - displayPoint = [gridX, gridZ]; - } - - // Update cursor mesh position imperatively - cursorRef.current.position.set(displayPoint[0], levelYRef.current, displayPoint[1]); - - // Update line geometries imperatively - updateLines(); - - // Only trigger React state update when snapped display position changes - if ( - !lastDisplayRef.current || - lastDisplayRef.current.x !== displayPoint[0] || - lastDisplayRef.current.z !== displayPoint[1] - ) { - lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] }; - setPreview({ - points: [...pointsRef.current], - cursorPoint: displayPoint, - levelY: levelYRef.current, - }); - } - }); - - // Click and double-click handlers for point placement + // Update cursor position and lines on grid move useEffect(() => { - if (!currentLevelId) return; + if (!currentLevelId) return - // Initialize line geometries - if (mainLineRef.current) { - mainLineRef.current.geometry = new BufferGeometry(); - } - if (closingLineRef.current) { - closingLineRef.current.geometry = new BufferGeometry(); - } + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return - // Reset state on level change - pointsRef.current = []; - lastDisplayRef.current = null; - setPreview({ points: [], cursorPoint: null, levelY: getLevelY() }); + const gridX = Math.round(event.position[0] * 2) / 2 + const gridZ = Math.round(event.position[2] * 2) / 2 + const gridPosition: [number, number] = [gridX, gridZ] + + setCursorPosition(gridPosition) + setLevelY(event.position[1]) + + // Calculate snapped display position + const lastPoint = points[points.length - 1] + const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition + + cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1]) + } const onGridClick = (_event: GridEvent) => { - if (!currentLevelId) return; + if (!currentLevelId) return - // Use the cursor position tracked by useFrame (matches what user sees) - let clickPoint: [number, number] = [...cursorPositionRef.current]; - - // Snap to axis from last point - const lastPoint = pointsRef.current[pointsRef.current.length - 1]; - if (lastPoint) { - clickPoint = calculateSnapPoint(lastPoint, clickPoint); - } + // Calculate snapped click point + const lastPoint = points[points.length - 1] + const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition // Check if clicking on the first point to close the shape - const firstPoint = pointsRef.current[0]; + const firstPoint = points[0] if ( - pointsRef.current.length >= 3 && + points.length >= 3 && firstPoint && Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { // Create the slab and select it - const slabId = commitSlabDrawing(currentLevelId, pointsRef.current); - setSelection({ selectedIds: [slabId] }); - - // Reset state - pointsRef.current = []; - lastDisplayRef.current = null; - setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); - if (mainLineRef.current) mainLineRef.current.visible = false; - if (closingLineRef.current) closingLineRef.current.visible = false; + const slabId = commitSlabDrawing(currentLevelId, points) + setSelection({ selectedIds: [slabId] }) + setPoints([]) } else { // Add point to polygon - pointsRef.current = [...pointsRef.current, clickPoint]; - lastDisplayRef.current = null; // Force preview update on next frame + setPoints([...points, clickPoint]) } - }; + } const onGridDoubleClick = (_event: GridEvent) => { - if (!currentLevelId) return; + if (!currentLevelId) return // Need at least 3 points to form a polygon - if (pointsRef.current.length >= 3) { - // Create the slab and select it - const slabId = commitSlabDrawing(currentLevelId, pointsRef.current); - setSelection({ selectedIds: [slabId] }); - - // Reset state - pointsRef.current = []; - lastDisplayRef.current = null; - setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); - if (mainLineRef.current) mainLineRef.current.visible = false; - if (closingLineRef.current) closingLineRef.current.visible = false; + if (points.length >= 3) { + const slabId = commitSlabDrawing(currentLevelId, points) + setSelection({ selectedIds: [slabId] }) + setPoints([]) } - }; + } - emitter.on("grid:click", onGridClick); - emitter.on("grid:double-click", onGridDoubleClick); + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('grid:double-click', onGridDoubleClick) return () => { - emitter.off("grid:click", onGridClick); - emitter.off("grid:double-click", onGridDoubleClick); + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('grid:double-click', onGridDoubleClick) + } + }, [currentLevelId, points, cursorPosition, setSelection]) - // Reset state on unmount - pointsRef.current = []; - }; - }, [currentLevelId, setSelection]); + // Update line geometries when points change + useEffect(() => { + if (!mainLineRef.current || !closingLineRef.current) return - const { points, cursorPoint, levelY } = preview; + if (points.length === 0) { + mainLineRef.current.visible = false + closingLineRef.current.visible = false + return + } + + const y = levelY + Y_OFFSET + const lastPoint = points[points.length - 1] + const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition + + // Build main line points + const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z)) + linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1])) + + // Update main line + 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 && firstPoint) { + const closingPoints = [ + new Vector3(snappedCursor[0], y, snappedCursor[1]), + new Vector3(firstPoint[0], y, firstPoint[1]), + ] + closingLineRef.current.geometry.dispose() + closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints) + closingLineRef.current.visible = true + } else { + closingLineRef.current.visible = false + } + }, [points, cursorPosition, levelY]) // Create preview shape when we have 3+ points const previewShape = useMemo(() => { - if (points.length < 3) return null; + if (points.length < 3) return null - const allPoints = [...points]; - if (isValidPoint(cursorPoint)) { - allPoints.push(cursorPoint); - } + const lastPoint = points[points.length - 1] + const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition + + const allPoints = [...points, snappedCursor] // 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 firstPt = allPoints[0] + if (!firstPt) return null - const shape = new Shape(); - shape.moveTo(firstPt[0], -firstPt[1]); + 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]); + const pt = allPoints[i] + if (pt) { + shape.lineTo(pt[0], -pt[1]) } } - shape.closePath(); + shape.closePath() - return shape; - }, [points, cursorPoint]); + return shape + }, [points, cursorPosition]) return ( {/* Cursor */} - + {/* Preview fill */} @@ -372,19 +236,14 @@ export const SlabTool: React.FC = () => { )} - {/* Main line - uses native line element with TSL-compatible material */} + {/* Main line */} {/* @ts-ignore */} - + - {/* Closing line - uses native line element with TSL-compatible material */} + {/* Closing line */} {/* @ts-ignore */} @@ -399,18 +258,16 @@ export const SlabTool: React.FC = () => { {/* Point markers */} - {points.map(([x, z], index) => - isValidPoint([x, z]) ? ( - - - - - ) : null - )} + {points.map(([x, z], index) => ( + + + + + ))} - ); -}; + ) +} diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 43ea883b..a467c411 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,20 +1,21 @@ -import useEditor, { type Phase, type Tool } from "@/store/use-editor"; -import { useScene, type AnyNodeId } from "@pascal-app/core"; -import { useViewer } from "@pascal-app/viewer"; -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 { SlabBoundaryEditor } from "./slab/slab-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"; +import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import useEditor, { type Phase, type Tool } from '@/store/use-editor' +import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' +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 { SlabBoundaryEditor } from './slab/slab-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: { - "property-line": SiteBoundaryEditor, + 'property-line': SiteBoundaryEditor, }, structure: { wall: WallTool, @@ -27,44 +28,62 @@ const tools: Record>> = { furnish: { item: ItemTool, }, -}; +} export const ToolManager: React.FC = () => { - const phase = useEditor((state) => state.phase); - const mode = useEditor((state) => state.mode); - const tool = useEditor((state) => state.tool); - const movingNode = useEditor((state) => state.movingNode); - const selectedZoneId = useViewer((state) => state.selection.zoneId); - const selectedIds = useViewer((state) => state.selection.selectedIds); - const nodes = useScene((state) => state.nodes); + const phase = useEditor((state) => state.phase) + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const movingNode = useEditor((state) => state.movingNode) + const selectedZoneId = useViewer((state) => state.selection.zoneId) + const selectedIds = useViewer((state) => state.selection.selectedIds) + const nodes = useScene((state) => state.nodes) // Check if a slab is selected - const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === "slab") ?? null; + const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as + | SlabNode['id'] + | undefined + + // Check if a ceiling is selected + const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as + | CeilingNode['id'] + | undefined // Show site boundary editor when in site phase and edit mode - const showSiteBoundaryEditor = phase === "site" && mode === "edit"; + const showSiteBoundaryEditor = phase === 'site' && mode === 'edit' // Show slab boundary editor when in structure/select mode with a slab selected const showSlabBoundaryEditor = - phase === "structure" && mode === "select" && selectedSlabId !== null; + phase === 'structure' && mode === 'select' && selectedSlabId !== undefined + + // Show ceiling boundary editor when in structure/select mode with a ceiling selected + const showCeilingBoundaryEditor = + phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined // Show zone boundary editor when in structure/select mode with a zone selected - // Hide when editing a slab to avoid overlapping handles + // Hide when editing a slab or ceiling to avoid overlapping handles const showZoneBoundaryEditor = - phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor; + phase === 'structure' && + mode === 'select' && + selectedZoneId !== null && + !showSlabBoundaryEditor && + !showCeilingBoundaryEditor // Show build tools when in build mode - const showBuildTool = mode === "build" && tool !== null; + const showBuildTool = mode === 'build' && tool !== null - const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null; + const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null return ( <> {showSiteBoundaryEditor && } - {showZoneBoundaryEditor && } - {showSlabBoundaryEditor && } + {showZoneBoundaryEditor && selectedZoneId && } + {showSlabBoundaryEditor && selectedSlabId && } + {showCeilingBoundaryEditor && selectedCeilingId && ( + + )} {movingNode && } {!movingNode && BuildToolComponent && } - ); -}; + ) +} diff --git a/apps/editor/components/tools/zone/zone-boundary-editor.tsx b/apps/editor/components/tools/zone/zone-boundary-editor.tsx index 2e66eef9..9dc26d9a 100644 --- a/apps/editor/components/tools/zone/zone-boundary-editor.tsx +++ b/apps/editor/components/tools/zone/zone-boundary-editor.tsx @@ -1,25 +1,26 @@ -import { useScene, type ZoneNode } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core' import { useCallback } from 'react' import { PolygonEditor } from '../shared/polygon-editor' +interface ZoneBoundaryEditorProps { + zoneId: ZoneNode['id'] +} + /** - * Zone boundary editor - allows editing zone polygon vertices when a zone is selected + * Zone boundary editor - allows editing zone polygon vertices for a specific zone * Uses the generic PolygonEditor component */ -export const ZoneBoundaryEditor: React.FC = () => { - 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 +export const ZoneBoundaryEditor: React.FC = ({ zoneId }) => { + const zoneNode = useScene((state) => state.nodes[zoneId]) const updateNode = useScene((state) => state.updateNode) + const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null + const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { - if (selectedZoneId) { - updateNode(selectedZoneId, { polygon: newPolygon }) - } + updateNode(zoneId, { polygon: newPolygon }) }, - [selectedZoneId, updateNode], + [zoneId, updateNode], ) if (!zone || !zone.polygon || zone.polygon.length < 3) return null @@ -32,6 +33,7 @@ export const ZoneBoundaryEditor: React.FC = () => { color={zoneColor} onPolygonChange={handlePolygonChange} minVertices={3} + levelId={resolveLevelId(zone, useScene.getState().nodes)} /> ) } 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 494dd895..67aa1d74 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx @@ -2,6 +2,7 @@ import { type BuildingNode, emitter, LevelNode, + type SiteNode, useScene, type ZoneNode, } from "@pascal-app/core"; @@ -11,7 +12,9 @@ import { Camera, ChevronDown, Layers, + MapPin, MoreHorizontal, + Pencil, Plus, Trash2, } from "lucide-react"; @@ -40,7 +43,186 @@ const PRESET_COLORS = [ ]; // ============================================================================ -// SITE PHASE VIEW - Simple building buttons +// PROPERTY LINE SECTION +// ============================================================================ + +function calculatePerimeter(points: Array<[number, number]>): number { + if (points.length < 2) return 0; + let perimeter = 0; + for (let i = 0; i < points.length; i++) { + const [x1, z1] = points[i]!; + const [x2, z2] = points[(i + 1) % points.length]!; + perimeter += Math.sqrt((x2 - x1) ** 2 + (z2 - z1) ** 2); + } + return perimeter; +} + +function calculatePolygonArea(polygon: Array<[number, number]>): number { + if (polygon.length < 3) return 0; + let area = 0; + const n = polygon.length; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + area += polygon[i]![0] * polygon[j]![1]; + area -= polygon[j]![0] * polygon[i]![1]; + } + return Math.abs(area) / 2; +} + +function useSiteNode(): SiteNode | null { + const siteId = useScene((state) => { + for (const id of state.rootNodeIds) { + if (state.nodes[id]?.type === "site") return id; + } + return null; + }); + return useScene((state) => + siteId ? ((state.nodes[siteId] as SiteNode | undefined) ?? null) : null + ); +} + +function PropertyLineSection() { + const siteNode = useSiteNode(); + const updateNode = useScene((state) => state.updateNode); + const mode = useEditor((state) => state.mode); + const setMode = useEditor((state) => state.setMode); + + if (!siteNode) return null; + + const points = siteNode.polygon?.points ?? []; + const area = calculatePolygonArea(points); + const perimeter = calculatePerimeter(points); + const isEditing = mode === "edit"; + + const handleToggleEdit = () => { + setMode(isEditing ? "select" : "edit"); + }; + + const handlePointChange = (index: number, axis: 0 | 1, value: number) => { + const newPoints = [...points.map((p) => [...p] as [number, number])]; + newPoints[index]![axis] = value; + updateNode(siteNode.id, { + polygon: { type: "polygon" as const, points: newPoints }, + }); + }; + + const handleAddPoint = () => { + const lastPoint = points[points.length - 1]; + const firstPoint = points[0]; + if (!lastPoint || !firstPoint) return; + + const newPoint: [number, number] = [ + (lastPoint[0] + firstPoint[0]) / 2, + (lastPoint[1] + firstPoint[1]) / 2, + ]; + const newPoints = [...points, newPoint]; + updateNode(siteNode.id, { + polygon: { type: "polygon" as const, points: newPoints }, + }); + }; + + const handleDeletePoint = (index: number) => { + if (points.length <= 3) return; + const newPoints = points.filter((_, i) => i !== index); + updateNode(siteNode.id, { + polygon: { type: "polygon" as const, points: newPoints }, + }); + }; + + return ( +
+ {/* Header */} +
+
+ + Property Line +
+ +
+ + {/* Measurements */} +
+
+ Area: {area.toFixed(1)} m² +
+
+ Perimeter:{" "} + {perimeter.toFixed(1)} m +
+
+ + {/* Vertex list (shown when editing) */} + {isEditing && ( +
+
+ {points.map((point, index) => ( +
+ + {index + 1} + + + + handlePointChange(index, 0, parseFloat(e.target.value) || 0) + } + step={0.5} + className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary" + /> + + + handlePointChange(index, 1, parseFloat(e.target.value) || 0) + } + step={0.5} + className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary" + /> + +
+ ))} +
+ +
+ )} +
+ ); +} + +// ============================================================================ +// SITE PHASE VIEW - Property line + building buttons // ============================================================================ function SitePhaseView() { @@ -55,31 +237,32 @@ function SitePhaseView() { .map((child) => typeof child === 'string' ? nodes[child] : child) .filter((node): node is BuildingNode => node?.type === "building"); - if (buildings.length === 0) { - return ( -
- No buildings yet -
- ); - } - return ( -
- {buildings.map((building) => ( - - ))} +
+ + {buildings.length === 0 ? ( +
+ No buildings yet +
+ ) : ( +
+ {buildings.map((building) => ( + + ))} +
+ )}
); } @@ -367,18 +550,6 @@ function LayerToggle() { ); } -function calculatePolygonArea(polygon: Array<[number, number]>): number { - if (polygon.length < 3) return 0; - let area = 0; - const n = polygon.length; - for (let i = 0; i < n; i++) { - const j = (i + 1) % n; - area += polygon[i]![0] * polygon[j]![1]; - area -= polygon[j]![0] * polygon[i]![1]; - } - return Math.abs(area) / 2; -} - function ZoneItem({ zone }: { zone: ZoneNode }) { const [renameOpen, setRenameOpen] = useState(false); const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false); diff --git a/apps/editor/hooks/use-grid-events.ts b/apps/editor/hooks/use-grid-events.ts new file mode 100644 index 00000000..2a48514e --- /dev/null +++ b/apps/editor/hooks/use-grid-events.ts @@ -0,0 +1,99 @@ +import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { Plane, Raycaster, Vector2, Vector3 } from 'three' + +/** + * Custom grid events hook that uses manual raycasting instead of mesh events. + * This ensures grid events work even when other meshes block pointer events with stopPropagation. + */ +export function useGridEvents(gridY: number) { + const { camera, gl } = useThree() + const raycaster = useRef(new Raycaster()) + const pointer = useRef(new Vector2()) + const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0)) + const intersectionPoint = useRef(new Vector3()) + + // Update ground plane when grid Y changes + useEffect(() => { + groundPlane.current.constant = -gridY + }, [gridY]) + + useEffect(() => { + const canvas = gl.domElement + + const getIntersection = (nativeEvent: MouseEvent | PointerEvent): Vector3 | null => { + // Convert mouse position to normalized device coordinates (-1 to +1) + const rect = canvas.getBoundingClientRect() + pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1 + pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1 + + // Update raycaster + raycaster.current.setFromCamera(pointer.current, camera) + + // Intersect with ground plane + if (raycaster.current.ray.intersectPlane(groundPlane.current, intersectionPoint.current)) { + return intersectionPoint.current.clone() + } + + return null + } + + const emit = (suffix: EventSuffix, nativeEvent: MouseEvent | PointerEvent) => { + const point = getIntersection(nativeEvent) + if (!point) return + + const eventKey = `grid:${suffix}` as `grid:${EventSuffix}` + const payload: GridEvent = { + position: [point.x, point.y, point.z], + nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent + } + + emitter.emit(eventKey, payload) + } + + const handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0) return + emit('pointerdown', e) + } + + const handlePointerUp = (e: PointerEvent) => { + if (e.button !== 0) return + emit('pointerup', e) + } + + const handleClick = (e: PointerEvent) => { + if (e.button !== 0) return + emit('click', e) + } + + const handlePointerMove = (e: PointerEvent) => { + emit('move', e) + } + + const handleDoubleClick = (e: MouseEvent) => { + emit('double-click', e) + } + + const handleContextMenu = (e: MouseEvent) => { + emit('context-menu', e) + } + + // Attach listeners to canvas + canvas.addEventListener('pointerdown', handlePointerDown) + canvas.addEventListener('pointerup', handlePointerUp) + canvas.addEventListener('click', handleClick) + canvas.addEventListener('pointermove', handlePointerMove) + canvas.addEventListener('dblclick', handleDoubleClick) + canvas.addEventListener('contextmenu', handleContextMenu) + + return () => { + canvas.removeEventListener('pointerdown', handlePointerDown) + canvas.removeEventListener('pointerup', handlePointerUp) + canvas.removeEventListener('click', handleClick) + canvas.removeEventListener('pointermove', handlePointerMove) + canvas.removeEventListener('dblclick', handleDoubleClick) + canvas.removeEventListener('contextmenu', handleContextMenu) + } + }, [camera, gl, gridY]) +} diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 66132dc5..8042b1da 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -38,7 +38,6 @@ export const WallSystem = () => { const node = nodes[id] if (!node || node.type !== 'wall') return - console.log('wall front/back', node.frontSide, node.backSide) const levelId = node.parentId if (!levelId) return diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index 58e3d222..ce50a4bc 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -1,4 +1,5 @@ import { type SiteNode, useRegistry } from '@pascal-app/core' +import { Html } from '@react-three/drei' import { useMemo, useRef } from 'react' import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' @@ -60,6 +61,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { return createBoundaryLineGeometry(node.polygon.points) }, [node?.polygon?.points]) + // Edge distances for labels + const edges = useMemo(() => { + const polygon = node?.polygon?.points ?? [] + if (polygon.length < 2) return [] + return polygon.map(([x1, z1], i) => { + const [x2, z2] = polygon[(i + 1) % polygon.length]! + const midX = (x1! + x2) / 2 + const midZ = (z1! + z2) / 2 + const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2) + return { midX, midZ, dist } + }) + }, [node?.polygon?.points]) + const handlers = useNodeEvents(node, 'site') if (!node || !floorShape || !lineGeometry) { @@ -98,6 +112,21 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { opacity={0.6} /> + + {/* Edge distance labels */} + {edges.map((edge, i) => ( + +
+ {edge.dist.toFixed(2)}m +
+ + ))} ) } diff --git a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx index 96974b36..7fe2496a 100644 --- a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx +++ b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx @@ -2,7 +2,7 @@ import { useRegistry, type ZoneNode } from '@pascal-app/core' import { Html } from '@react-three/drei' import { useMemo, useRef } from 'react' import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' -import { color, float, uv } from 'three/tsl' +import { color, float, uniform, uv } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' @@ -19,16 +19,20 @@ const createWallGradientMaterial = (zoneColor: string) => { // Use UV y coordinate for vertical gradient (0 at bottom, 1 at top) const gradientT = uv().y + const opacity = uniform(0); // Fade opacity from 0.6 at bottom to 0 at top - const opacity = float(0.6).mul(float(1).sub(gradientT)) + const finalOpacity = float(0.6).mul(float(1).sub(gradientT)).mul(opacity); return new MeshBasicNodeMaterial({ transparent: true, colorNode: baseColor, - opacityNode: opacity, + opacityNode: finalOpacity, side: DoubleSide, depthWrite: false, depthTest: false, + userData: { + uOpacity: opacity, + } }) } @@ -37,13 +41,15 @@ const createWallGradientMaterial = (zoneColor: string) => { */ const createFloorMaterial = (zoneColor: string) => { const baseColor = color(new Color(zoneColor)) - + const opacity = uniform(0) return new MeshBasicNodeMaterial({ transparent: true, colorNode: baseColor, - opacityNode: float(0.15), + opacityNode: float(0.25).mul(opacity), side: DoubleSide, depthWrite: false, + depthTest: false, + userData: { uOpacity: opacity} }) } @@ -174,7 +180,8 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { + }} + zIndexRange={[10, 0]}>
{ {node.name}
{/* Floor fill */} - + {/* Wall borders with gradient */} - +
) } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index df13ebb8..3533c0c4 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -8,6 +8,7 @@ import { GuideSystem } from '../../systems/guide/guide-system' import { LevelSystem } from '../../systems/level/level-system' import { ScanSystem } from '../../systems/scan/scan-system' import { WallCutout } from '../../systems/wall/wall-cutout' +import { ZoneSystem } from '../../systems/zone/zone-system' import { SceneRenderer } from '../renderers/scene-renderer' import { Lights } from './lights' import PostProcessing from './post-processing' @@ -28,6 +29,7 @@ interface ViewerProps { const Viewer: React.FC = ({ children, selectionManager = 'default' }) => { return ( { const renderer = new THREE.WebGPURenderer(props as any) @@ -63,6 +65,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' + {selectionManager === 'default' && } diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index d4af84a9..65a4fe41 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -26,10 +26,10 @@ import useViewer from '../../store/use-viewer' // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 2, + sliceCount: 1, stepCount: 8, - radius: 2, - expFactor: 2, + radius: 1, + expFactor: 1.5, thickness: 0.5, backfaceLighting: 0.5, aoIntensity: 1.5, @@ -80,6 +80,8 @@ const PostProcessingPasses = () => { // SSGI Pass (cast to PerspectiveCamera for SSGI) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) + + giPass.sliceCount.value = SSGI_PARAMS.sliceCount giPass.stepCount.value = SSGI_PARAMS.stepCount giPass.radius.value = SSGI_PARAMS.radius diff --git a/packages/viewer/src/hooks/use-grid-events.ts b/packages/viewer/src/hooks/use-grid-events.ts deleted file mode 100644 index 08196a86..00000000 --- a/packages/viewer/src/hooks/use-grid-events.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type EventSuffix, emitter, type GridEvent } from "@pascal-app/core"; -import type { ThreeEvent } from "@react-three/fiber"; - -export function useGridEvents() { - const emit = (suffix: EventSuffix, e: ThreeEvent) => { - const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`; - const payload: GridEvent = { - position: [e.point.x, e.point.y, e.point.z], - nativeEvent: e, - }; - - emitter.emit(eventKey, payload); - }; - - return { - onPointerDown: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("pointerdown", e); - }, - onPointerUp: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("pointerup", e); - }, - onClick: (e: ThreeEvent) => { - if (e.button !== 0) return; - emit("click", e); - }, - onPointerEnter: (e: ThreeEvent) => emit("enter", e), - onPointerLeave: (e: ThreeEvent) => emit("leave", e), - onPointerMove: (e: ThreeEvent) => emit("move", e), - onDoubleClick: (e: ThreeEvent) => emit("double-click", e), - onContextMenu: (e: ThreeEvent) => emit("context-menu", e), - }; -} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 21aa3ebd..2c417fbe 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,4 +1,3 @@ export { default as Viewer } from './components/viewer' -export { useGridEvents } from './hooks/use-grid-events' export { default as useViewer } from './store/use-viewer' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' \ No newline at end of file diff --git a/packages/viewer/src/systems/zone/zone-system.tsx b/packages/viewer/src/systems/zone/zone-system.tsx new file mode 100644 index 00000000..4062fc5b --- /dev/null +++ b/packages/viewer/src/systems/zone/zone-system.tsx @@ -0,0 +1,70 @@ +import { sceneRegistry, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useRef } from 'react' +import { type Group, MathUtils, type Mesh } from 'three' +import type { MeshBasicNodeMaterial } from 'three/webgpu' +import useViewer from '../../store/use-viewer' + +const TRANSITION_DURATION = 400 // ms + +export const ZoneSystem = () => { + const lastHighlightedZoneRef = useRef(null) + const lastChangeTimeRef = useRef(0) + const isTransitioningRef = useRef(false) + + useFrame(({clock}, delta) => { + const hoveredId = useViewer.getState().hoveredId + let highlightedZone: string | null = null + + if (hoveredId) { + const hoveredNode = useScene.getState().nodes[hoveredId] + if (hoveredNode?.type === 'zone') { + highlightedZone = hoveredId + } + } + + // Detect zone change + if (highlightedZone !== lastHighlightedZoneRef.current) { + lastHighlightedZoneRef.current = highlightedZone + lastChangeTimeRef.current = clock.elapsedTime * 1000 + isTransitioningRef.current = true + } + + // Skip frame if not transitioning + if (!isTransitioningRef.current) return + + const elapsed = clock.elapsedTime * 1000 - lastChangeTimeRef.current + + // Stop transitioning after duration + if (elapsed >= TRANSITION_DURATION) { + isTransitioningRef.current = false + } + + // Lerp speed: complete transition in ~400ms + const lerpSpeed = 10 * delta + + sceneRegistry.byType.zone.forEach((zoneId) => { + const zone = sceneRegistry.nodes.get(zoneId) + if (!zone) return + + const isHighlighted = zoneId === highlightedZone + const targetOpacity = isHighlighted ? 1 : 0 + + const walls = (zone as Group).getObjectByName('walls') as Mesh | undefined + if (walls) { + const material = walls.material as MeshBasicNodeMaterial + const currentOpacity = material.userData.uOpacity.value + material.userData.uOpacity.value = MathUtils.lerp(currentOpacity, targetOpacity, lerpSpeed) + } + + const floor = (zone as Group).getObjectByName('floor') as Mesh | undefined + if (floor) { + const material = floor.material as MeshBasicNodeMaterial + const currentOpacity = material.userData.uOpacity.value + material.userData.uOpacity.value = MathUtils.lerp(currentOpacity, targetOpacity, lerpSpeed) + } + }) + }) + + return null +}