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 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-02-07 14:49:53 -05:00
co-authored by Claude Opus 4.6
parent 31a560e2c5
commit 7a871be792
3 changed files with 143 additions and 24 deletions
@@ -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<PolygonEditorProps> = ({
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<DragState | 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)
// 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<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)
const displayPolygon = previewPolygon ?? polygon
@@ -141,15 +161,33 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}
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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
/>
</line>
{/* 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 (
<mesh
key={`vertex-${index}`}
position={[x!, Y_OFFSET, z!]}
position={[x!, editY + height / 2, z!]}
castShadow
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredVertex(index)
@@ -218,6 +259,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
setHoveredVertex(null)
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
setDragState({
isDragging: true,
@@ -227,36 +269,36 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
})
}}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onDoubleClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
if (canDelete) {
handleDeleteVertex(index)
}
}}
>
<sphereGeometry args={[isHovered || isDragging ? 0.3 : 0.25, 16, 16]} />
<meshBasicMaterial
color={
isDragging ? '#22c55e' : isHovered ? (canDelete ? '#ef4444' : '#ffffff') : color
}
depthTest={false}
depthWrite={false}
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
/>
</mesh>
)
})}
{/* 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 (
<mesh
key={`midpoint-${index}`}
position={[x!, Y_OFFSET, z!]}
position={[x!, editY + height / 2, z!]}
onPointerEnter={(e) => {
e.stopPropagation()
setHoveredMidpoint(index)
@@ -266,6 +308,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
}
}}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
>
<sphereGeometry args={[isHovered ? 0.3 : 0.25, 16, 16]} />
<meshBasicMaterial
color={isHovered ? '#22c55e' : color}
depthTest={false}
depthWrite={false}
<cylinderGeometry args={[radius, radius, height, 16]} />
<meshStandardMaterial
color={isHovered ? '#4ade80' : '#22c55e'}
transparent
opacity={isHovered ? 1 : 0.6}
opacity={isHovered ? 1 : 0.7}
/>
</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}
/>
)
}
+14 -1
View File
@@ -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 && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && <ZoneBoundaryEditor />}
{showSlabBoundaryEditor && <SlabBoundaryEditor />}
{movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</>