From 81f132611e86f3133b0fd0dcbc0f7af244fdad61 Mon Sep 17 00:00:00 2001 From: wass08 Date: Mon, 9 Feb 2026 12:23:38 +0900 Subject: [PATCH] remove custom raycasters --- .../components/tools/ceiling/ceiling-tool.tsx | 369 +++++---------- .../components/tools/slab/slab-tool.tsx | 443 ++++++------------ 2 files changed, 273 insertions(+), 539 deletions(-) diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index d01c0850..b7f69c94 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -1,13 +1,11 @@ -import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode, sceneRegistry } from "@pascal-app/core"; +import { emitter, type GridEvent, useScene, CeilingNode } 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 { 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 @@ -52,7 +50,7 @@ const calculateSnapPoint = ( * Creates a ceiling with the given polygon points */ const commitCeilingDrawing = ( - levelId: LevelNode["id"], + levelId: string, points: Array<[number, number]> ) => { const { createNode, nodes } = useScene.getState(); @@ -69,88 +67,115 @@ const commitCeilingDrawing = ( 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]); -}; - 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); - // 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, - }); + // Update cursor position and lines on grid move + useEffect(() => { + if (!currentLevelId) return; - // 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; - }; + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current || !gridCursorRef.current) return; - // Imperatively update line geometries (called from useFrame) - const updateLines = () => { + 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; + + // 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 = points[0]; + if ( + 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, points); + setPoints([]); + setTool(null); + } else { + // Add point to polygon + setPoints([...points, clickPoint]); + } + }; + + const onGridDoubleClick = (_event: GridEvent) => { + if (!currentLevelId) return; + + // Need at least 3 points to form a polygon + if (points.length >= 3) { + commitCeilingDrawing(currentLevelId, points); + setPoints([]); + setTool(null); + } + }; + + 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); + }; + }, [currentLevelId, points, cursorPosition, setTool]); + + // Update line geometries when points change + useEffect(() => { 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 ceilingY = levelY + CEILING_HEIGHT; 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])); - } - } + const snappedCursor = lastPoint + ? calculateSnapPoint(lastPoint, cursorPosition) + : cursorPosition; - // Update main line geometry + // 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); @@ -161,195 +186,49 @@ export const CeilingTool: React.FC = () => { // 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; - } + 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; } - }; - - // 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 - useEffect(() => { - if (!currentLevelId) return; - - // Initialize line geometries - if (mainLineRef.current) { - mainLineRef.current.geometry = new BufferGeometry(); - } - if (closingLineRef.current) { - closingLineRef.current.geometry = new BufferGeometry(); - } - - // Reset state on level change - pointsRef.current = []; - lastDisplayRef.current = null; - setPreview({ points: [], cursorPoint: null, levelY: getLevelY() }); - - const onGridClick = (_event: GridEvent) => { - 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); - } - - // 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 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); - } else { - // Add point to polygon - pointsRef.current = [...pointsRef.current, clickPoint]; - lastDisplayRef.current = null; // Force preview update on next frame - } - }; - - const onGridDoubleClick = (_event: GridEvent) => { - 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); - } - }; - - emitter.on("grid:click", onGridClick); - emitter.on("grid:double-click", onGridDoubleClick); - - return () => { - emitter.off("grid:click", onGridClick); - emitter.off("grid:double-click", onGridDoubleClick); - - // Reset state on unmount - pointsRef.current = []; - }; - }, [currentLevelId, setTool]); - - const { points, cursorPoint, levelY } = preview; + }, [points, cursorPosition, levelY]); // 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); - } + 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; + if (!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)) { + if (pt) { shape.lineTo(pt[0], -pt[1]); } } shape.closePath(); return shape; - }, [points, cursorPoint]); + }, [points, cursorPosition]); return ( @@ -392,7 +271,7 @@ export const CeilingTool: React.FC = () => { )} - {/* Main line - uses native line element with TSL-compatible material */} + {/* Main line */} {/* @ts-ignore */} @@ -404,7 +283,7 @@ export const CeilingTool: React.FC = () => { /> - {/* Closing line - uses native line element with TSL-compatible material */} + {/* Closing line */} {/* @ts-ignore */} @@ -419,18 +298,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/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) => ( + + + + + ))} - ); -}; + ) +}