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 && }