From 7a871be7926826ab14769a63535f19cf852bdec6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 7 Feb 2026 14:49:53 -0500 Subject: [PATCH] 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 && }