From 31a560e2c5aa5b9a3781ac6216d67eebc75943e8 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 7 Feb 2026 11:58:17 -0500 Subject: [PATCH 1/2] Use raycast cursor & level Y fallback Ceiling and slab tools: switch cursor tracking to per-frame raycasting onto a level plane (useFrame) instead of relying on grid:move events, and memoize Raycaster/Plane to reduce allocations. Update line geometry updates to be performed imperatively from the frame loop, ensure previews only trigger React updates when the snapped display position changes, and keep cursor/preview snapping consistent with user-visible pointer. Add sceneRegistry-based fallback to resolve a level's Y position (used by Grid, CeilingTool and SlabTool) when the level mesh isn't registered yet, computing Y from level node data and viewer levelMode. Misc: import/add necessary three.js types and small refactors to visibility/reset logic. --- apps/editor/components/editor/index.tsx | 9 + .../components/tools/ceiling/ceiling-tool.tsx | 274 ++++++++++-------- .../components/tools/slab/slab-tool.tsx | 262 ++++++++++------- bun.lock | 1 + 4 files changed, 324 insertions(+), 222 deletions(-) diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index c96a64e4..0d8a8000 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -148,6 +148,15 @@ const Grid = ({ 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) diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index 1abcbcd2..d01c0850 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -1,11 +1,13 @@ -import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode } from "@pascal-app/core"; +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, Shape, Vector3 } from "three"; +import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, 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); /** * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point @@ -87,10 +89,17 @@ export const CeilingTool: React.FC = () => { const mainLineRef = useRef(null!); const closingLineRef = useRef(null!); const pointsRef = useRef>([]); - const levelYRef = useRef(0); // Track current level Y position + 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(), []); + // Preview state for reactive rendering (for shape and point markers) const [preview, setPreview] = useState({ points: [], @@ -98,118 +107,154 @@ export const CeilingTool: React.FC = () => { 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 useEffect(() => { if (!currentLevelId) return; - let cursorPosition: [number, number] = [0, 0]; - // Initialize line geometries - mainLineRef.current.geometry = new BufferGeometry(); - closingLineRef.current.geometry = new BufferGeometry(); + if (mainLineRef.current) { + mainLineRef.current.geometry = new BufferGeometry(); + } + if (closingLineRef.current) { + closingLineRef.current.geometry = new BufferGeometry(); + } - const updateLines = () => { - const points = pointsRef.current; - const ceilingY = levelYRef.current + CEILING_HEIGHT; + // Reset state on level change + pointsRef.current = []; + lastDisplayRef.current = null; + setPreview({ points: [], cursorPoint: null, levelY: getLevelY() }); - 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; - } - }; - - const updatePreview = () => { - const points = pointsRef.current; - const lastPoint = points[points.length - 1]; - - let cursorPt: [number, number] | null = null; - if (lastPoint) { - cursorPt = calculateSnapPoint(lastPoint, cursorPosition); - } else if (points.length === 0) { - cursorPt = cursorPosition; - } - - setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current }); - updateLines(); - }; - - const onGridMove = (event: GridEvent) => { - if (!cursorRef.current) return; - - // Snap to 0.5 grid - const gridX = Math.round(event.position[0] * 2) / 2; - const gridZ = Math.round(event.position[2] * 2) / 2; - cursorPosition = [gridX, gridZ]; - levelYRef.current = event.position[1]; - - const ceilingY = event.position[1] + CEILING_HEIGHT; - const gridY = event.position[1] + GRID_OFFSET; - - // If we have points, snap to axis from last point - const lastPoint = pointsRef.current[pointsRef.current.length - 1]; - if (lastPoint) { - const snapped = calculateSnapPoint(lastPoint, cursorPosition); - cursorRef.current.position.set(snapped[0], ceilingY, snapped[1]); - // Also update grid-level cursor - if (gridCursorRef.current) { - gridCursorRef.current.position.set(snapped[0], gridY, snapped[1]); - } - } else { - cursorRef.current.position.set(gridX, ceilingY, gridZ); - if (gridCursorRef.current) { - gridCursorRef.current.position.set(gridX, gridY, gridZ); - } - } - - updatePreview(); - }; - - const onGridClick = (event: GridEvent) => { + const onGridClick = (_event: GridEvent) => { if (!currentLevelId) return; - const gridX = Math.round(event.position[0] * 2) / 2; - const gridZ = Math.round(event.position[2] * 2) / 2; - let clickPoint: [number, number] = [gridX, gridZ]; + // 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]; @@ -230,16 +275,17 @@ export const CeilingTool: React.FC = () => { // Reset state pointsRef.current = []; + lastDisplayRef.current = null; setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); - mainLineRef.current.visible = false; - closingLineRef.current.visible = false; + 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]; - updatePreview(); + lastDisplayRef.current = null; // Force preview update on next frame } }; @@ -252,22 +298,20 @@ export const CeilingTool: React.FC = () => { // Reset state pointsRef.current = []; + lastDisplayRef.current = null; setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); - mainLineRef.current.visible = false; - closingLineRef.current.visible = false; + if (mainLineRef.current) mainLineRef.current.visible = false; + if (closingLineRef.current) closingLineRef.current.visible = false; // Deactivate tool setTool(null); } }; - // Subscribe to events - emitter.on("grid:move", onGridMove); emitter.on("grid:click", onGridClick); emitter.on("grid:double-click", onGridDoubleClick); return () => { - emitter.off("grid:move", onGridMove); emitter.off("grid:click", onGridClick); emitter.off("grid:double-click", onGridDoubleClick); diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx index 6440b302..b3db9672 100644 --- a/apps/editor/components/tools/slab/slab-tool.tsx +++ b/apps/editor/components/tools/slab/slab-tool.tsx @@ -1,10 +1,12 @@ -import { emitter, type GridEvent, useScene, SlabNode, type LevelNode } from "@pascal-app/core"; +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, Shape, Vector3 } from "three"; +import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three"; import useEditor from "@/store/use-editor"; const Y_OFFSET = 0.02; // Small offset above floor level +const UP = new Vector3(0, 1, 0); /** * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point @@ -86,11 +88,18 @@ export const SlabTool: React.FC = () => { const mainLineRef = useRef(null!); const closingLineRef = useRef(null!); const pointsRef = useRef>([]); - const levelYRef = useRef(0); // Track current level Y position + 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); + // 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(), []); + // Preview state for reactive rendering (for shape and point markers) const [preview, setPreview] = useState({ points: [], @@ -98,108 +107,148 @@ export const SlabTool: React.FC = () => { 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 useEffect(() => { if (!currentLevelId) return; - let cursorPosition: [number, number] = [0, 0]; - // Initialize line geometries - mainLineRef.current.geometry = new BufferGeometry(); - closingLineRef.current.geometry = new BufferGeometry(); + if (mainLineRef.current) { + mainLineRef.current.geometry = new BufferGeometry(); + } + if (closingLineRef.current) { + closingLineRef.current.geometry = new BufferGeometry(); + } - const updateLines = () => { - const points = pointsRef.current; - const y = levelYRef.current + Y_OFFSET; + // Reset state on level change + pointsRef.current = []; + lastDisplayRef.current = null; + setPreview({ points: [], cursorPoint: null, levelY: getLevelY() }); - 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; - } - }; - - const updatePreview = () => { - const points = pointsRef.current; - const lastPoint = points[points.length - 1]; - - let cursorPt: [number, number] | null = null; - if (lastPoint) { - cursorPt = calculateSnapPoint(lastPoint, cursorPosition); - } else if (points.length === 0) { - cursorPt = cursorPosition; - } - - setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current }); - updateLines(); - }; - - const onGridMove = (event: GridEvent) => { - if (!cursorRef.current) return; - - // Snap to 0.5 grid - const gridX = Math.round(event.position[0] * 2) / 2; - const gridZ = Math.round(event.position[2] * 2) / 2; - cursorPosition = [gridX, gridZ]; - levelYRef.current = event.position[1]; - - // If we have points, snap to axis from last point - const lastPoint = pointsRef.current[pointsRef.current.length - 1]; - if (lastPoint) { - const snapped = calculateSnapPoint(lastPoint, cursorPosition); - cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]); - } else { - cursorRef.current.position.set(gridX, event.position[1], gridZ); - } - - updatePreview(); - }; - - const onGridClick = (event: GridEvent) => { + const onGridClick = (_event: GridEvent) => { if (!currentLevelId) return; - const gridX = Math.round(event.position[0] * 2) / 2; - const gridZ = Math.round(event.position[2] * 2) / 2; - let clickPoint: [number, number] = [gridX, gridZ]; + // 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]; @@ -221,13 +270,14 @@ export const SlabTool: React.FC = () => { // Reset state pointsRef.current = []; - setPreview({ points: [], cursorPoint: null, levelY: 0 }); - mainLineRef.current.visible = false; - closingLineRef.current.visible = false; + lastDisplayRef.current = null; + setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); + if (mainLineRef.current) mainLineRef.current.visible = false; + if (closingLineRef.current) closingLineRef.current.visible = false; } else { // Add point to polygon pointsRef.current = [...pointsRef.current, clickPoint]; - updatePreview(); + lastDisplayRef.current = null; // Force preview update on next frame } }; @@ -242,19 +292,17 @@ export const SlabTool: React.FC = () => { // Reset state pointsRef.current = []; - setPreview({ points: [], cursorPoint: null, levelY: 0 }); - mainLineRef.current.visible = false; - closingLineRef.current.visible = false; + lastDisplayRef.current = null; + setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); + if (mainLineRef.current) mainLineRef.current.visible = false; + if (closingLineRef.current) closingLineRef.current.visible = false; } }; - // Subscribe to events - emitter.on("grid:move", onGridMove); emitter.on("grid:click", onGridClick); emitter.on("grid:double-click", onGridDoubleClick); return () => { - emitter.off("grid:move", onGridMove); emitter.off("grid:click", onGridClick); emitter.off("grid:double-click", onGridDoubleClick); diff --git a/bun.lock b/bun.lock index eba03d97..93c676af 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "editor", From 7a871be7926826ab14769a63535f19cf852bdec6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 7 Feb 2026 14:49:53 -0500 Subject: [PATCH 2/2] Add slab polygon editing with drag, add, and delete vertices Reuse the shared PolygonEditor for slab boundary editing with levelY and surfaceHeight support. Handles render as 3D cylinders that adapt to slab thickness. Prevent deselection after drag by suppressing grid:click propagation, and reset preview state on undo/redo. Co-Authored-By: Claude Opus 4.6 --- .../tools/shared/polygon-editor.tsx | 88 ++++++++++++++----- .../tools/slab/slab-boundary-editor.tsx | 64 ++++++++++++++ apps/editor/components/tools/tool-manager.tsx | 15 +++- 3 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 apps/editor/components/tools/slab/slab-boundary-editor.tsx diff --git a/apps/editor/components/tools/shared/polygon-editor.tsx b/apps/editor/components/tools/shared/polygon-editor.tsx index e71f1266..670404c8 100644 --- a/apps/editor/components/tools/shared/polygon-editor.tsx +++ b/apps/editor/components/tools/shared/polygon-editor.tsx @@ -24,20 +24,30 @@ export interface PolygonEditorProps { color?: string onPolygonChange: (polygon: Array<[number, number]>) => void minVertices?: number + levelY?: number + /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */ + surfaceHeight?: number } /** * Generic polygon editor component for editing polygon vertices * Used by zone and site boundary editors */ +const MIN_HANDLE_HEIGHT = 0.15 + export const PolygonEditor: React.FC = ({ polygon, color = '#3b82f6', onPolygonChange, minVertices = 3, + levelY = 0, + surfaceHeight = 0, }) => { const { gl, camera } = useThree() + // Compute the editing plane height (level Y + small offset above floor) + const editY = levelY + Y_OFFSET + // Local state for dragging const [dragState, setDragState] = useState(null) const [previewPolygon, setPreviewPolygon] = useState | null>(null) @@ -45,10 +55,20 @@ export const PolygonEditor: React.FC = ({ const [hoveredMidpoint, setHoveredMidpoint] = useState(null) // Refs for raycasting during drag - const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET)) + 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) + const lastPolygonRef = useRef(polygon) + if (polygon !== lastPolygonRef.current) { + lastPolygonRef.current = polygon + // External change (e.g. undo/redo) — clear any stale preview/drag state + if (previewPolygon) setPreviewPolygon(null) + if (dragState) setDragState(null) + } + // The polygon to display (preview during drag, or actual polygon) const displayPolygon = previewPolygon ?? polygon @@ -141,15 +161,33 @@ export const PolygonEditor: React.FC = ({ } 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. + 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) + } + canvas.addEventListener('click', suppressClick, true) + // Safety cleanup in case no click fires + requestAnimationFrame(() => { + canvas.removeEventListener('click', suppressClick, true) + }) + commitPolygonChange() } canvas.addEventListener('pointermove', handlePointerMove) - canvas.addEventListener('pointerup', handlePointerUp) + canvas.addEventListener('pointerup', handlePointerUp, true) return () => { // Release capture on cleanup @@ -157,7 +195,7 @@ export const PolygonEditor: React.FC = ({ canvas.releasePointerCapture(pointerId) } canvas.removeEventListener('pointermove', handlePointerMove) - canvas.removeEventListener('pointerup', handlePointerUp) + canvas.removeEventListener('pointerup', handlePointerUp, true) } }, [dragState, gl, handleVertexDrag, commitPolygonChange]) @@ -167,18 +205,18 @@ export const PolygonEditor: React.FC = ({ const positions: number[] = [] for (const [x, z] of displayPolygon) { - positions.push(x!, Y_OFFSET + 0.01, z!) + positions.push(x!, editY + 0.01, z!) } // Close the loop const first = displayPolygon[0]! - positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!) + positions.push(first[0]!, editY + 0.01, first[1]!) const geometry = new BufferGeometry() geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) lineRef.current.geometry.dispose() lineRef.current.geometry = geometry - }, [displayPolygon]) + }, [displayPolygon, editY]) if (displayPolygon.length < minVertices) return null @@ -200,15 +238,18 @@ export const PolygonEditor: React.FC = ({ /> - {/* Vertex handles */} + {/* Vertex handles - blue cylinders that match surface height */} {displayPolygon.map(([x, z], index) => { const isHovered = hoveredVertex === index const isDragging = dragState?.vertexIndex === index + const radius = 0.1 + const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) return ( { e.stopPropagation() setHoveredVertex(index) @@ -218,6 +259,7 @@ export const PolygonEditor: React.FC = ({ setHoveredVertex(null) }} onPointerDown={(e) => { + if (e.button !== 0) return e.stopPropagation() setDragState({ isDragging: true, @@ -227,36 +269,36 @@ export const PolygonEditor: React.FC = ({ }) }} onClick={(e) => { + if (e.button !== 0) return e.stopPropagation() }} onDoubleClick={(e) => { + if (e.button !== 0) return e.stopPropagation() if (canDelete) { handleDeleteVertex(index) } }} > - - + ) })} - {/* Midpoint handles for adding vertices (hidden while dragging) */} + {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */} {!dragState && midpoints.map(([x, z], index) => { const isHovered = hoveredMidpoint === index + const radius = 0.06 + const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) return ( { e.stopPropagation() setHoveredMidpoint(index) @@ -266,6 +308,7 @@ export const PolygonEditor: React.FC = ({ setHoveredMidpoint(null) }} onPointerDown={(e) => { + if (e.button !== 0) return e.stopPropagation() const newVertexIndex = handleAddVertex(index, [x!, z!]) if (newVertexIndex >= 0) { @@ -279,16 +322,15 @@ export const PolygonEditor: React.FC = ({ } }} onClick={(e) => { + if (e.button !== 0) return e.stopPropagation() }} > - - + ) diff --git a/apps/editor/components/tools/slab/slab-boundary-editor.tsx b/apps/editor/components/tools/slab/slab-boundary-editor.tsx new file mode 100644 index 00000000..24f0e3e5 --- /dev/null +++ b/apps/editor/components/tools/slab/slab-boundary-editor.tsx @@ -0,0 +1,64 @@ +import { sceneRegistry, useScene, type AnyNodeId, type SlabNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' +import { PolygonEditor } from '../shared/polygon-editor' + +/** + * Slab boundary editor - allows editing slab polygon vertices when a slab is selected + * 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) + const updateNode = useScene((state) => state.updateNode) + + // 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 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] }) + } + }, + [selectedSlabId, updateNode, setSelection], + ) + + if (!slab || !slab.polygon || slab.polygon.length < 3) return null + + return ( + + ) +} diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index d7740e54..43ea883b 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,10 +1,12 @@ 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"; @@ -33,13 +35,23 @@ export const ToolManager: React.FC = () => { 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; // Show site boundary editor when in site phase and edit mode 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; + // Show zone boundary editor when in structure/select mode with a zone selected + // Hide when editing a slab to avoid overlapping handles const showZoneBoundaryEditor = - phase === "structure" && mode === "select" && selectedZoneId !== null; + phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor; // Show build tools when in build mode const showBuildTool = mode === "build" && tool !== null; @@ -50,6 +62,7 @@ export const ToolManager: React.FC = () => { <> {showSiteBoundaryEditor && } {showZoneBoundaryEditor && } + {showSlabBoundaryEditor && } {movingNode && } {!movingNode && BuildToolComponent && }