/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Space Sketch (DCEL) — interactive test surface for the persistent * `SpacePlateHandle` topology editor (rust/geometry `space_dcel`). * * Self-contained: owns its own wasm handle + local state (no shared slice). * Rooms are derived from the active storey's RENDERED wall meshes (min-area * footprint rectangles → gap detection → lifted onto the wall axis), then the * user can drag a shared vertex (both rooms follow), split a room — between * corners OR new nodes added anywhere on a wall — merge two rooms, then Bake to * real `IfcSpace` through the viewer's existing `addSpace`. * * Fluency (RFC §4.2): hover telegraphs the op; drawing/dragging/cutting snap to * other vertices + walls, with Shift = ortho (which dominates snap). Undo/redo * snapshots the plate via `duplicate()` * (each clone owns its heap, freed deterministically — never JS GC). 2D plan * sketch; 3D-on-model registration is the next step. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useViewerStore } from '@/store'; import { useConstructionUnderlay } from '@/hooks/useConstructionUnderlay'; import { useIfc } from '@/hooks/useIfc'; import { snapPoint, alignToAxes, type SnapKind } from '@/lib/space-snap'; import { editError } from '@/lib/space-edit-error'; import { pointerButton, isRemoveModifier } from '@/lib/space-interaction'; import { SpacePlateSession, ensureSpaceWasm, snapshotRoomsFromRects, flattenWallRects, type Room, type Boundary, } from '@/lib/space-plate-session'; import { wallRectsFromMeshes, type WallRect } from '@/lib/wall-rects-from-meshes'; import { polyArea, pointInPoly, centroid, uniqueVerts, distToSeg, projectOnSeg, computeFit, zoomFit, sX, sY, wX, wY, PAD, type Fit, type Pt, } from '@/lib/space-sketch-geometry'; import { existingSpaceFootprintsByStorey, GENERATED_SPACE_OBJECTTYPE, type BoundaryMode, } from '@ifc-lite/create'; import { X, Undo2, Redo2, Layers, Maximize, AlertTriangle, Magnet, SlidersHorizontal, HelpCircle, Eraser } from 'lucide-react'; import { SpaceSketchCanvas } from './space-sketch/SpaceSketchCanvas'; import { OptionsPopover, HelpPopover } from './space-sketch/SpaceSketchPopovers'; import type { Hover, SplitTarget, IntentTone } from './space-sketch/types'; const DEFAULT_W = 580; const DEFAULT_H = 460; const MIN_W = 360; const MIN_H = 280; const PICK_PX = 12; const SNAP_PX = 10; const BAKE_HEIGHT = 3; const EPS = 1e-6; /** Lock `p` to a horizontal or vertical line through `anchor` (Shift-ortho), * whichever axis the cursor moved further along. Used for straight cut lines. */ function orthoLock(anchor: Pt, p: Pt): Pt { return Math.abs(p[0] - anchor[0]) >= Math.abs(p[1] - anchor[1]) ? [p[0], anchor[1]] : [anchor[0], p[1]]; } export function SpaceSketchOverlay() { const setActiveTool = useViewerStore((s) => s.setActiveTool); const addSpace = useViewerStore((s) => s.addSpace); const removeEntity = useViewerStore((s) => s.removeEntity); const activeModelId = useViewerStore((s) => s.activeModelId); // Rooms are derived from the RENDERED wall meshes (the geometry the user sees), // so the room lines land on the rendered wall faces — not from STEP source // geometry, which has no per-wall thickness here and a centroid-biased axis. const geometryResult = useViewerStore((s) => s.geometryResult); const { ifcDataStore } = useIfc(); const sessionRef = useRef(null); const svgRef = useRef(null); const panelRef = useRef(null); const fitRef = useRef({ scale: 1, offX: PAD, offY: DEFAULT_H - PAD }); const rafRef = useRef(null); const rebuildTimerRef = useRef | null>(null); const buildSeqRef = useRef(0); // IfcSpace expressIds this session created per storey — so a re-bake (or // "Generate all") replaces the spaces it dropped instead of duplicating. const generatedRef = useRef>(new Map()); const moveRef = useRef<{ x: number; y: number; shift: boolean; del: boolean } | null>(null); const dragRef = useRef(null); const dragStartRef = useRef(null); const otherVertsRef = useRef([]); const draggedRef = useRef(false); const panningRef = useRef(false); // Issue 4: middle-mouse / empty-drag panning const resizeRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null); // While drawing, Undo pops the last placed point onto this stack and Redo // re-adds it — so point placement uses the panel Undo/Redo, not a separate // draw-only control. Cleared when the draw is committed/cancelled. const drawRedoRef = useRef([]); // Timestamp of the last bare Esc — a second within 400 ms closes the panel. const escTimeRef = useRef(0); // Building wall lines (room frame) for snapping; synced from a memo so the // per-frame pointer math can read it without re-binding processMove. const buildingSegmentsRef = useRef>([]); const [rooms, setRooms] = useState([]); const [hover, setHover] = useState(null); const [splitPick, setSplitPick] = useState(null); const [splitHover, setSplitHover] = useState(null); const [snapPos, setSnapPos] = useState(null); // What the live snap landed on, so the cue can differ for a wall vs a corner. const [snapKind, setSnapKind] = useState('none'); // Snap every node to the building's 2D wall lines (corners + along walls). // Default on; the magnet toggle in the toolbar turns it off (vertex-only). const [snapToBuilding, setSnapToBuilding] = useState(true); // True once the user has edited the plate (drag/split/merge/draw/dissolve) // since the last bake/derive — drives the "close with unbaked edits" guard. const [dirty, setDirty] = useState(false); // Disclosure popovers (self-managed; no radix Popover primitive here) + the // unbaked-edits close confirmation. Keep the default panel clean. const [optionsOpen, setOptionsOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [confirmClose, setConfirmClose] = useState(false); // Issue 3: the vertex that an ⌥/Ctrl-click would dissolve — telegraphed live. const [deleteHover, setDeleteHover] = useState(null); // Issue 2: the in-progress drawn room (world coords) + the live cursor point. const [drawPts, setDrawPts] = useState([]); const [drawCursor, setDrawCursor] = useState(null); // Alignment guides while drawing: reference corners whose X (vertical guide) // / Y (horizontal guide) the cursor is currently locked to — so the closing // corner can line up under the first point, etc. const [alignGuides, setAlignGuides] = useState<{ vRef: Pt | null; hRef: Pt | null }>({ vRef: null, hRef: null }); // Live "what will this click do" label, shown top-right of the canvas. const [intent, setIntent] = useState<{ text: string; tone: IntentTone } | null>(null); // Issue 4: canvas size (resizable) + a tick that forces a re-render whenever // the view transform in fitRef changes (zoom/pan/fit) without making the // per-frame pointer math go through React state. const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H }); const sizeRef = useRef(size); sizeRef.current = size; const [fitTick, setFitTick] = useState(0); const applyFit = useCallback((next: Fit) => { fitRef.current = next; setFitTick((t) => t + 1); }, []); const [derivedStorey, setDerivedStorey] = useState(null); const [snapTol, setSnapTol] = useState(null); // null = auto-escalate const [usedTol, setUsedTol] = useState(0.1); const snapTolRef = useRef(null); const lastBuildRef = useRef<{ rects: WallRect[]; label: string; storey: number | null } | null>(null); // Wall centrelines + thicknesses from the last derive, kept for the leak // diagnostics overlay + the "has wall data" affordance. const extractionRef = useRef<{ segments: { a: Pt; b: Pt }[]; thicknesses: number[]; } | null>(null); const [hist, setHist] = useState(0); const [status, setStatus] = useState('Pick a storey to derive rooms from its walls.'); const [showBuilding, setShowBuilding] = useState(true); const [showDiagnostics, setShowDiagnostics] = useState(false); // Issue 7 — leak diagnostics // Default to the wall AXIS (face-based rooms are the gaps between wall // rectangles): `center` puts the room outline + nodes on the true wall // centreline. `inner`/`outer` show the net/gross faces. const [boundaryMode, setBoundaryMode] = useState('center'); // Transient "12 → 9 rooms" badge after a corner-tolerance rebuild — the // effect on the plan is otherwise invisible (Issue 5). const [snapDelta, setSnapDelta] = useState<{ from: number; to: number } | null>(null); const pendingSnapPrevRef = useRef(null); const snapDeltaTimerRef = useRef | null>(null); // Every IfcBuildingStorey with its resolved name + elevation, low → high. const storeys = useMemo(() => { if (!ifcDataStore) return [] as { id: number; name: string; elev: number }[]; const elevs = ifcDataStore.spatialHierarchy?.storeyElevations; const list = ifcDataStore.getEntitiesByType('IfcBuildingStorey').map((s) => ({ id: s.expressId, name: ifcDataStore.entities.getName(s.expressId) || `Storey #${s.expressId}`, elev: elevs?.get(s.expressId) ?? 0, })); list.sort((a, b) => a.elev - b.elev); return list; }, [ifcDataStore]); const derivedFloorElev = useMemo( () => (derivedStorey == null ? null : storeys.find((s) => s.id === derivedStorey)?.elev ?? null), [derivedStorey, storeys], ); // Compute the underlay whenever a storey is derived (so snapping works even // before any room exists — e.g. drawing the FIRST room must still snap to the // building walls), not only when rooms are already present. Gated by show OR // snap so it's not computed when neither needs it. const { lines: underlay } = useConstructionUnderlay((showBuilding || snapToBuilding) && derivedFloorElev != null, derivedFloorElev); // Building wall lines as snap segments (room frame), only while snapping is on. const buildingSegments = useMemo>( () => (snapToBuilding ? underlay.map((l) => [l.a, l.b] as [Pt, Pt]) : []), [underlay, snapToBuilding], ); useEffect(() => { buildingSegmentsRef.current = buildingSegments; }, [buildingSegments]); // Pre-render the (potentially large) building underlay once per (re)derive, // NOT on every drag frame — re-creating hundreds of SVG lines each frame // froze the editor (and the runaway drag dragged the room off-canvas). It // only changes when the plate is rebuilt, tracked by `hist`. const underlayEls = useMemo(() => { if (!showBuilding || underlay.length === 0) return null; const f = fitRef.current; return underlay.map((l, i) => ( )); }, [underlay, showBuilding, fitTick]); const [storeyId, setStoreyId] = useState(null); const lastDerivedRef = useRef(null); // Connect to the shared active storey instead of always defaulting to the // lowest storey: seed from whatever the user already picked in the hierarchy, // and follow it when it changes while the panel is open. The in-panel storey // setStoreyId(Number(e.target.value))} disabled={!storeys.length}> {storeys.length ? storeys.map((s) => ) : } {/* Action row — modeless, so no mode tabs: history · snap · options · fit, with a live room tally. Secondary settings hide behind Options. */}
{rooms.length} {rooms.length === 1 ? 'room' : 'rooms'} · {total.toFixed(1)} m²
{/* Click-away backdrop for the disclosure popovers (panel-local). */} {(optionsOpen || helpOpen) && (
{ setOptionsOpen(false); setHelpOpen(false); }} /> )} {/* Disclosure popovers (Options / Help) — kept out of the default flow. */} {optionsOpen && ( setShowBuilding((v) => !v)} showDiagnostics={showDiagnostics} onToggleDiagnostics={() => setShowDiagnostics((v) => !v)} /> )} {helpOpen && } { if (drawPts.length > 0) commitDraw(); }} onContextMenu={onContextMenu} onPointerLeave={() => { setHover(null); setSplitHover(null); setDeleteHover(null); setDrawCursor(null); setAlignGuides({ vRef: null, hRef: null }); setIntent(null); }} /> {/* Footer — an in-the-moment hint only while drawing/cutting (the full legend lives behind “?”), the live status, then the primary action. */}
{(drawPts.length > 0 || splitPick) && (
{drawPts.length > 0 ? 'Click corners · Enter / double-click / first dot to close · Shift = straight · Esc cancels.' : 'Click another wall or corner to finish the cut · Esc cancels.'}
)} {status && drawPts.length === 0 && !splitPick && (
{status}
)} {unboundedCount > 0 && (
{unboundedCount} room(s) unchanged by “{boundaryMode}” (dashed) — no wall offset.
)} {showDiagnostics && (
▬ bounds a room ╌ bounds nothing ({leakCount}) ▦ failed to close ({badCount})
)}
{/* Resize grip (Issue 4) — drag to grow/shrink the canvas; the plan stays put (hit ⤢ to reframe). */}
{ e.preventDefault(); e.stopPropagation(); resizeRef.current = { x: e.clientX, y: e.clientY, w: size.w, h: size.h }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }} onPointerMove={(e) => { const r = resizeRef.current; if (!r) return; setSize({ w: Math.max(MIN_W, Math.round(r.w + (e.clientX - r.x))), h: Math.max(MIN_H, Math.round(r.h + (e.clientY - r.y))) }); }} onPointerUp={(e) => { resizeRef.current = null; (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); }} title="Drag to resize the panel" className="absolute bottom-1 right-1 h-3.5 w-3.5 cursor-nwse-resize text-muted-foreground/50 hover:text-foreground" style={{ touchAction: 'none' }}>
); }