Merge pull request #88 from pascalorg/feat/floor-editing

Feat/floor editing
This commit is contained in:
Aymeric Rabot
2026-02-07 17:00:23 -05:00
committed by GitHub
7 changed files with 467 additions and 246 deletions
+9
View File
@@ -148,6 +148,15 @@ const Grid = ({
const levelMesh = sceneRegistry.nodes.get(currentLevelId) const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) { if (levelMesh) {
targetY = levelMesh.position.y 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) gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
@@ -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 { useViewer } from "@pascal-app/viewer";
import { useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react"; 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"; import useEditor from "@/store/use-editor";
const CEILING_HEIGHT = 2.52; // Slightly above default ceiling height const CEILING_HEIGHT = 2.52; // Slightly above default ceiling height
const GRID_OFFSET = 0.02; // Small offset above floor level 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 * 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<Line>(null!); const mainLineRef = useRef<Line>(null!);
const closingLineRef = useRef<Line>(null!); const closingLineRef = useRef<Line>(null!);
const pointsRef = useRef<Array<[number, number]>>([]); const pointsRef = useRef<Array<[number, number]>>([]);
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 currentLevelId = useViewer((state) => state.selection.levelId);
const setTool = useEditor((state) => state.setTool); 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) // Preview state for reactive rendering (for shape and point markers)
const [preview, setPreview] = useState<PreviewState>({ const [preview, setPreview] = useState<PreviewState>({
points: [], points: [],
@@ -98,17 +107,27 @@ export const CeilingTool: React.FC = () => {
levelY: 0, levelY: 0,
}); });
useEffect(() => { // Resolve the current level's Y position from scene registry or node data
if (!currentLevelId) return; const getLevelY = (): number => {
if (!currentLevelId) return 0;
let cursorPosition: [number, number] = [0, 0]; const levelMesh = sceneRegistry.nodes.get(currentLevelId);
if (levelMesh) return levelMesh.position.y;
// Initialize line geometries const levelNode = useScene.getState().nodes[currentLevelId];
mainLineRef.current.geometry = new BufferGeometry(); if (levelNode && 'level' in levelNode) {
closingLineRef.current.geometry = new BufferGeometry(); 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 = () => { const updateLines = () => {
if (!mainLineRef.current || !closingLineRef.current) return;
const points = pointsRef.current; const points = pointsRef.current;
const cursorPosition = cursorPositionRef.current;
const ceilingY = levelYRef.current + CEILING_HEIGHT; const ceilingY = levelYRef.current + CEILING_HEIGHT;
if (points.length === 0) { if (points.length === 0) {
@@ -158,58 +177,84 @@ export const CeilingTool: React.FC = () => {
} }
}; };
const updatePreview = () => { // Per-frame cursor positioning via direct raycasting onto the level plane.
const points = pointsRef.current; // This bypasses R3F's event propagation (grid:move), ensuring the cursor
const lastPoint = points[points.length - 1]; // always tracks the pointer regardless of tool activation/deactivation state.
useFrame((state) => {
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; if (!cursorRef.current) return;
// Snap to 0.5 grid // Sync level Y from scene registry each frame
const gridX = Math.round(event.position[0] * 2) / 2; levelYRef.current = getLevelY();
const gridZ = Math.round(event.position[2] * 2) / 2;
cursorPosition = [gridX, gridZ];
levelYRef.current = event.position[1];
const ceilingY = event.position[1] + CEILING_HEIGHT; // Raycast from camera through pointer onto horizontal plane at level Y
const gridY = event.position[1] + GRID_OFFSET; raycaster.setFromCamera(state.pointer, state.camera);
levelPlane.constant = -levelYRef.current;
const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint);
if (!hit) return;
// If we have points, snap to axis from last point // 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]; const lastPoint = pointsRef.current[pointsRef.current.length - 1];
let displayPoint: [number, number];
if (lastPoint) { if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition); displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
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 { } else {
cursorRef.current.position.set(gridX, ceilingY, gridZ); displayPoint = [gridX, gridZ];
}
// Update cursor mesh positions imperatively
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]);
if (gridCursorRef.current) { if (gridCursorRef.current) {
gridCursorRef.current.position.set(gridX, gridY, gridZ); gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]);
}
} }
updatePreview(); // Update line geometries imperatively
}; updateLines();
const onGridClick = (event: GridEvent) => { // 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; if (!currentLevelId) return;
const gridX = Math.round(event.position[0] * 2) / 2; // Initialize line geometries
const gridZ = Math.round(event.position[2] * 2) / 2; if (mainLineRef.current) {
let clickPoint: [number, number] = [gridX, gridZ]; 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 // Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1]; const lastPoint = pointsRef.current[pointsRef.current.length - 1];
@@ -230,16 +275,17 @@ export const CeilingTool: React.FC = () => {
// Reset state // Reset state
pointsRef.current = []; pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false; if (mainLineRef.current) mainLineRef.current.visible = false;
closingLineRef.current.visible = false; if (closingLineRef.current) closingLineRef.current.visible = false;
// Deactivate tool // Deactivate tool
setTool(null); setTool(null);
} else { } else {
// Add point to polygon // Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint]; 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 // Reset state
pointsRef.current = []; pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }); setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
mainLineRef.current.visible = false; if (mainLineRef.current) mainLineRef.current.visible = false;
closingLineRef.current.visible = false; if (closingLineRef.current) closingLineRef.current.visible = false;
// Deactivate tool // Deactivate tool
setTool(null); setTool(null);
} }
}; };
// Subscribe to events
emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick); emitter.on("grid:click", onGridClick);
emitter.on("grid:double-click", onGridDoubleClick); emitter.on("grid:double-click", onGridDoubleClick);
return () => { return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
emitter.off("grid:double-click", onGridDoubleClick); emitter.off("grid:double-click", onGridDoubleClick);
@@ -24,20 +24,30 @@ export interface PolygonEditorProps {
color?: string color?: string
onPolygonChange: (polygon: Array<[number, number]>) => void onPolygonChange: (polygon: Array<[number, number]>) => void
minVertices?: number 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 * Generic polygon editor component for editing polygon vertices
* Used by zone and site boundary editors * Used by zone and site boundary editors
*/ */
const MIN_HANDLE_HEIGHT = 0.15
export const PolygonEditor: React.FC<PolygonEditorProps> = ({ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon, polygon,
color = '#3b82f6', color = '#3b82f6',
onPolygonChange, onPolygonChange,
minVertices = 3, minVertices = 3,
levelY = 0,
surfaceHeight = 0,
}) => { }) => {
const { gl, camera } = useThree() const { gl, camera } = useThree()
// Compute the editing plane height (level Y + small offset above floor)
const editY = levelY + Y_OFFSET
// Local state for dragging // Local state for dragging
const [dragState, setDragState] = useState<DragState | null>(null) const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null) const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
@@ -45,10 +55,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null) const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
// Refs for raycasting during drag // 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 raycaster = useRef(new Raycaster())
const lineRef = useRef<Mesh>(null!) const lineRef = useRef<Mesh>(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) // The polygon to display (preview during drag, or actual polygon)
const displayPolygon = previewPolygon ?? polygon const displayPolygon = previewPolygon ?? polygon
@@ -141,15 +161,33 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
} }
const handlePointerUp = (e: PointerEvent) => { 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 // Release pointer capture
if (canvas.hasPointerCapture(e.pointerId)) { if (canvas.hasPointerCapture(e.pointerId)) {
canvas.releasePointerCapture(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() commitPolygonChange()
} }
canvas.addEventListener('pointermove', handlePointerMove) canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerup', handlePointerUp) canvas.addEventListener('pointerup', handlePointerUp, true)
return () => { return () => {
// Release capture on cleanup // Release capture on cleanup
@@ -157,7 +195,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
canvas.releasePointerCapture(pointerId) canvas.releasePointerCapture(pointerId)
} }
canvas.removeEventListener('pointermove', handlePointerMove) canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerup', handlePointerUp) canvas.removeEventListener('pointerup', handlePointerUp, true)
} }
}, [dragState, gl, handleVertexDrag, commitPolygonChange]) }, [dragState, gl, handleVertexDrag, commitPolygonChange])
@@ -167,18 +205,18 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const positions: number[] = [] const positions: number[] = []
for (const [x, z] of displayPolygon) { for (const [x, z] of displayPolygon) {
positions.push(x!, Y_OFFSET + 0.01, z!) positions.push(x!, editY + 0.01, z!)
} }
// Close the loop // Close the loop
const first = displayPolygon[0]! 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() const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
lineRef.current.geometry.dispose() lineRef.current.geometry.dispose()
lineRef.current.geometry = geometry lineRef.current.geometry = geometry
}, [displayPolygon]) }, [displayPolygon, editY])
if (displayPolygon.length < minVertices) return null if (displayPolygon.length < minVertices) return null
@@ -200,15 +238,18 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
/> />
</line> </line>
{/* Vertex handles */} {/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => { {displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index const isHovered = hoveredVertex === index
const isDragging = dragState?.vertexIndex === index const isDragging = dragState?.vertexIndex === index
const radius = 0.1
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
return ( return (
<mesh <mesh
key={`vertex-${index}`} key={`vertex-${index}`}
position={[x!, Y_OFFSET, z!]} position={[x!, editY + height / 2, z!]}
castShadow
onPointerEnter={(e) => { onPointerEnter={(e) => {
e.stopPropagation() e.stopPropagation()
setHoveredVertex(index) setHoveredVertex(index)
@@ -218,6 +259,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
setHoveredVertex(null) setHoveredVertex(null)
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
setDragState({ setDragState({
isDragging: true, isDragging: true,
@@ -227,36 +269,36 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}) })
}} }}
onClick={(e) => { onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
}} }}
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
if (canDelete) { if (canDelete) {
handleDeleteVertex(index) handleDeleteVertex(index)
} }
}} }}
> >
<sphereGeometry args={[isHovered || isDragging ? 0.3 : 0.25, 16, 16]} /> <cylinderGeometry args={[radius, radius, height, 16]} />
<meshBasicMaterial <meshStandardMaterial
color={ color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
isDragging ? '#22c55e' : isHovered ? (canDelete ? '#ef4444' : '#ffffff') : color
}
depthTest={false}
depthWrite={false}
/> />
</mesh> </mesh>
) )
})} })}
{/* Midpoint handles for adding vertices (hidden while dragging) */} {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState && {!dragState &&
midpoints.map(([x, z], index) => { midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index const isHovered = hoveredMidpoint === index
const radius = 0.06
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
return ( return (
<mesh <mesh
key={`midpoint-${index}`} key={`midpoint-${index}`}
position={[x!, Y_OFFSET, z!]} position={[x!, editY + height / 2, z!]}
onPointerEnter={(e) => { onPointerEnter={(e) => {
e.stopPropagation() e.stopPropagation()
setHoveredMidpoint(index) setHoveredMidpoint(index)
@@ -266,6 +308,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
setHoveredMidpoint(null) setHoveredMidpoint(null)
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
const newVertexIndex = handleAddVertex(index, [x!, z!]) const newVertexIndex = handleAddVertex(index, [x!, z!])
if (newVertexIndex >= 0) { if (newVertexIndex >= 0) {
@@ -279,16 +322,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
} }
}} }}
onClick={(e) => { onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
}} }}
> >
<sphereGeometry args={[isHovered ? 0.3 : 0.25, 16, 16]} /> <cylinderGeometry args={[radius, radius, height, 16]} />
<meshBasicMaterial <meshStandardMaterial
color={isHovered ? '#22c55e' : color} color={isHovered ? '#4ade80' : '#22c55e'}
depthTest={false}
depthWrite={false}
transparent transparent
opacity={isHovered ? 1 : 0.6} opacity={isHovered ? 1 : 0.7}
/> />
</mesh> </mesh>
) )
@@ -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 (
<PolygonEditor
polygon={slab.polygon}
color="#a3a3a3"
onPolygonChange={handlePolygonChange}
minVertices={3}
levelY={levelY}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
}
+100 -52
View File
@@ -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 { useViewer } from "@pascal-app/viewer";
import { useFrame } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react"; 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"; import useEditor from "@/store/use-editor";
const Y_OFFSET = 0.02; // Small offset above floor level 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 * 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<Line>(null!); const mainLineRef = useRef<Line>(null!);
const closingLineRef = useRef<Line>(null!); const closingLineRef = useRef<Line>(null!);
const pointsRef = useRef<Array<[number, number]>>([]); const pointsRef = useRef<Array<[number, number]>>([]);
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 currentLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection); const setSelection = useViewer((state) => state.setSelection);
const setTool = useEditor((state) => state.setTool); 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) // Preview state for reactive rendering (for shape and point markers)
const [preview, setPreview] = useState<PreviewState>({ const [preview, setPreview] = useState<PreviewState>({
points: [], points: [],
@@ -98,17 +107,27 @@ export const SlabTool: React.FC = () => {
levelY: 0, levelY: 0,
}); });
useEffect(() => { // Resolve the current level's Y position from scene registry or node data
if (!currentLevelId) return; const getLevelY = (): number => {
if (!currentLevelId) return 0;
let cursorPosition: [number, number] = [0, 0]; const levelMesh = sceneRegistry.nodes.get(currentLevelId);
if (levelMesh) return levelMesh.position.y;
// Initialize line geometries const levelNode = useScene.getState().nodes[currentLevelId];
mainLineRef.current.geometry = new BufferGeometry(); if (levelNode && 'level' in levelNode) {
closingLineRef.current.geometry = new BufferGeometry(); 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 = () => { const updateLines = () => {
if (!mainLineRef.current || !closingLineRef.current) return;
const points = pointsRef.current; const points = pointsRef.current;
const cursorPosition = cursorPositionRef.current;
const y = levelYRef.current + Y_OFFSET; const y = levelYRef.current + Y_OFFSET;
if (points.length === 0) { if (points.length === 0) {
@@ -158,48 +177,78 @@ export const SlabTool: React.FC = () => {
} }
}; };
const updatePreview = () => { // Per-frame cursor positioning via direct raycasting onto the level plane.
const points = pointsRef.current; // This bypasses R3F's event propagation (grid:move), ensuring the cursor
const lastPoint = points[points.length - 1]; // always tracks the pointer regardless of tool activation/deactivation state.
useFrame((state) => {
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; if (!cursorRef.current) return;
// Snap to 0.5 grid // Sync level Y from scene registry each frame
const gridX = Math.round(event.position[0] * 2) / 2; levelYRef.current = getLevelY();
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 // 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]; const lastPoint = pointsRef.current[pointsRef.current.length - 1];
let displayPoint: [number, number];
if (lastPoint) { if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition); displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
} else { } else {
cursorRef.current.position.set(gridX, event.position[1], gridZ); displayPoint = [gridX, gridZ];
} }
updatePreview(); // Update cursor mesh position imperatively
}; cursorRef.current.position.set(displayPoint[0], levelYRef.current, displayPoint[1]);
const onGridClick = (event: GridEvent) => { // 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; if (!currentLevelId) return;
const gridX = Math.round(event.position[0] * 2) / 2; // Initialize line geometries
const gridZ = Math.round(event.position[2] * 2) / 2; if (mainLineRef.current) {
let clickPoint: [number, number] = [gridX, gridZ]; 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 // Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1]; const lastPoint = pointsRef.current[pointsRef.current.length - 1];
@@ -221,13 +270,14 @@ export const SlabTool: React.FC = () => {
// Reset state // Reset state
pointsRef.current = []; pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: 0 }); lastDisplayRef.current = null;
mainLineRef.current.visible = false; setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
closingLineRef.current.visible = false; if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
} else { } else {
// Add point to polygon // Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint]; 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 // Reset state
pointsRef.current = []; pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: 0 }); lastDisplayRef.current = null;
mainLineRef.current.visible = false; setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
closingLineRef.current.visible = false; 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:click", onGridClick);
emitter.on("grid:double-click", onGridDoubleClick); emitter.on("grid:double-click", onGridDoubleClick);
return () => { return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
emitter.off("grid:double-click", onGridDoubleClick); emitter.off("grid:double-click", onGridDoubleClick);
+14 -1
View File
@@ -1,10 +1,12 @@
import useEditor, { type Phase, type Tool } from "@/store/use-editor"; 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 { useViewer } from "@pascal-app/viewer";
import { CeilingTool } from "./ceiling/ceiling-tool"; import { CeilingTool } from "./ceiling/ceiling-tool";
import { ItemTool } from "./item/item-tool"; import { ItemTool } from "./item/item-tool";
import { MoveTool } from "./item/move-tool"; import { MoveTool } from "./item/move-tool";
import { RoofTool } from "./roof/roof-tool"; import { RoofTool } from "./roof/roof-tool";
import { SiteBoundaryEditor } from "./site/site-boundary-editor"; import { SiteBoundaryEditor } from "./site/site-boundary-editor";
import { SlabBoundaryEditor } from "./slab/slab-boundary-editor";
import { SlabTool } from "./slab/slab-tool"; import { SlabTool } from "./slab/slab-tool";
import { WallTool } from "./wall/wall-tool"; import { WallTool } from "./wall/wall-tool";
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor"; import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
@@ -33,13 +35,23 @@ export const ToolManager: React.FC = () => {
const tool = useEditor((state) => state.tool); const tool = useEditor((state) => state.tool);
const movingNode = useEditor((state) => state.movingNode); const movingNode = useEditor((state) => state.movingNode);
const selectedZoneId = useViewer((state) => state.selection.zoneId); 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 // 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;
// Show zone boundary editor when in structure/select mode with a zone selected // Show zone boundary editor when in structure/select mode with a zone selected
// Hide when editing a slab to avoid overlapping handles
const showZoneBoundaryEditor = const showZoneBoundaryEditor =
phase === "structure" && mode === "select" && selectedZoneId !== null; phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor;
// Show build tools when in build mode // Show build tools when in build mode
const showBuildTool = mode === "build" && tool !== null; const showBuildTool = mode === "build" && tool !== null;
@@ -50,6 +62,7 @@ export const ToolManager: React.FC = () => {
<> <>
{showSiteBoundaryEditor && <SiteBoundaryEditor />} {showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && <ZoneBoundaryEditor />} {showZoneBoundaryEditor && <ZoneBoundaryEditor />}
{showSlabBoundaryEditor && <SlabBoundaryEditor />}
{movingNode && <MoveTool />} {movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</> </>
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "editor", "name": "editor",