/* 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/. */ /** * Add Element panel — right-side authoring surface for dropping * walls / slabs / beams / columns onto a parsed model. Tool-driven * (rendered when `activeTool === 'addElement'`); the actual drop * happens on a 3D click handled in `selectionHandlers.ts`. * * Activated via the Panels menu in the toolbar or the command palette. * The tool stays active across drops so the user can place several * elements in a row; Esc returns to the select tool. */ import { useEffect, useMemo, useState } from 'react'; import { Box, Cog, DoorOpen, Home, Layers, Minus, Square, SquareDashedBottom, Wand2, X } from 'lucide-react'; import { toast } from '@/components/ui/toast'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useViewerStore } from '@/store'; import { useIfc } from '@/hooks/useIfc'; import { EntityNode } from '@ifc-lite/query'; import type { AddElementType } from '@/store/slices/addElementSlice'; interface ElementOption { type: AddElementType; label: string; Icon: typeof Box; /** Short description shown below the type chips. */ hint: string; } const ELEMENT_OPTIONS: ElementOption[] = [ { type: 'wall', label: 'Wall', Icon: Minus, hint: 'Click Start, then End. Cross-section = Thickness × Height, profile spans the click-to-click axis.' }, { type: 'slab', label: 'Slab', Icon: Square, hint: 'Rectangle: 2 corner clicks. Polygon: N clicks + Enter to close. Extruded up by Thickness.' }, { type: 'beam', label: 'Beam', Icon: Layers, hint: 'Click Start, then End. Cross-section (Width × Height) is centred on the beam axis.' }, { type: 'column', label: 'Column', Icon: Box, hint: 'Single click sets the base centre. Width × Depth cross-section, extruded up by Height.' }, { type: 'door', label: 'Door', Icon: DoorOpen, hint: 'Single click sets the bottom-centre. Width × Height leaf with a thin frame depth. Free-standing — refine wall hosting via Raw STEP if needed.' }, { type: 'window', label: 'Window', Icon: SquareDashedBottom, hint: 'Single click sets the sill-centre. Width × Height sash with a thin frame depth.' }, { type: 'space', label: 'Space', Icon: Home, hint: 'Rectangle: 2 corner clicks. Polygon: N clicks + Enter. Extruded up by Height into a room volume; aggregated to the storey via IfcRelAggregates.' }, { type: 'roof', label: 'Roof', Icon: Square, hint: 'Same shape as a slab — flat-roof emit with .FLAT_ROOF. PredefinedType. Pitched roofs need IfcCreator.addIfcGableRoof.' }, { type: 'plate', label: 'Plate', Icon: Square, hint: 'Thin flat plate (steel / gusset). Rectangle or polygon profile, extruded by Thickness.' }, { type: 'member', label: 'Member', Icon: Cog, hint: 'Generic structural member (brace, post, strut). Click Start, then End. Pick PredefinedType to set role.' }, ]; interface StoreyOption { expressId: number; label: string; } interface AddElementPanelProps { onClose: () => void; } export function AddElementPanel({ onClose }: AddElementPanelProps) { const { models, ifcDataStore } = useIfc(); const addElementType = useViewerStore((s) => s.addElementType); const setAddElementType = useViewerStore((s) => s.setAddElementType); const addElementModelId = useViewerStore((s) => s.addElementModelId); const setAddElementModelId = useViewerStore((s) => s.setAddElementModelId); const addElementStoreyId = useViewerStore((s) => s.addElementStoreyId); const setAddElementStoreyId = useViewerStore((s) => s.setAddElementStoreyId); const wallParams = useViewerStore((s) => s.addElementWallParams); const setWallParams = useViewerStore((s) => s.setAddElementWallParams); const slabParams = useViewerStore((s) => s.addElementSlabParams); const setSlabParams = useViewerStore((s) => s.setAddElementSlabParams); const beamParams = useViewerStore((s) => s.addElementBeamParams); const setBeamParams = useViewerStore((s) => s.setAddElementBeamParams); const columnParams = useViewerStore((s) => s.addElementColumnParams); const setColumnParams = useViewerStore((s) => s.setAddElementColumnParams); const doorParams = useViewerStore((s) => s.addElementDoorParams); const setDoorParams = useViewerStore((s) => s.setAddElementDoorParams); const windowParams = useViewerStore((s) => s.addElementWindowParams); const setWindowParams = useViewerStore((s) => s.setAddElementWindowParams); const spaceParams = useViewerStore((s) => s.addElementSpaceParams); const setSpaceParams = useViewerStore((s) => s.setAddElementSpaceParams); const roofParams = useViewerStore((s) => s.addElementRoofParams); const setRoofParams = useViewerStore((s) => s.setAddElementRoofParams); const plateParams = useViewerStore((s) => s.addElementPlateParams); const setPlateParams = useViewerStore((s) => s.setAddElementPlateParams); const memberParams = useViewerStore((s) => s.addElementMemberParams); const setMemberParams = useViewerStore((s) => s.setAddElementMemberParams); const slabMode = useViewerStore((s) => s.addElementSlabMode); const setSlabMode = useViewerStore((s) => s.setAddElementSlabMode); const pendingPoints = useViewerStore((s) => s.addElementPendingPoints); const hoverPoint = useViewerStore((s) => s.addElementHoverPoint); const clearPending = useViewerStore((s) => s.clearAddElementPending); const activeModelId = useViewerStore((s) => s.activeModelId); // Resolve the effective model + its storeys for the selects. When // the user hasn't pinned a model the panel auto-tracks the active // model; same for storey (auto-tracks first when null). const effectiveModelId = addElementModelId ?? activeModelId ?? (models.size > 0 ? models.keys().next().value ?? null : null); const modelOptions = useMemo(() => { const opts: { id: string; label: string }[] = []; for (const [id, model] of models) { if (!model.ifcDataStore) continue; opts.push({ id, label: model.name || id }); } return opts; }, [models]); const storeyOptions = useMemo(() => { const dataStore = effectiveModelId ? models.get(effectiveModelId)?.ifcDataStore ?? null : ifcDataStore; if (!dataStore) return []; const ids = dataStore.entityIndex.byType.get('IFCBUILDINGSTOREY') ?? []; const opts: StoreyOption[] = []; for (const expressId of ids) { const node = new EntityNode(dataStore, expressId); const name = node.name || `Storey #${expressId}`; opts.push({ expressId, label: name }); } return opts; }, [effectiveModelId, models, ifcDataStore]); // Auto-pick the first storey when the user hasn't chosen one or // the previous choice no longer exists in the active model. Also // reset on model change — storey express ids are model-local, so a // colliding numeric id from a different federated model would // otherwise be silently reused as the placement target. useEffect(() => { if (storeyOptions.length === 0) return; if (addElementStoreyId === null) return; const stillValid = storeyOptions.some((s) => s.expressId === addElementStoreyId); if (!stillValid) setAddElementStoreyId(null); }, [storeyOptions, addElementStoreyId, setAddElementStoreyId, effectiveModelId]); const hasModel = !!effectiveModelId; const hasStorey = storeyOptions.length > 0; const ready = hasModel && hasStorey; const activeOption = ELEMENT_OPTIONS.find((o) => o.type === addElementType) ?? ELEMENT_OPTIONS[0]; return (
{/* Header */}

Add Element

Close (Esc)
{/* Element type chips */}
{ELEMENT_OPTIONS.map(({ type, label, Icon }) => { const selected = addElementType === type; return ( ); })}

{activeOption.hint}

{/* Model + storey context */} {modelOptions.length > 1 && (
)}
{storeyOptions.length > 0 ? ( ) : (

{hasModel ? 'This model has no IfcBuildingStorey — load a model with a spatial hierarchy.' : 'Load a model to begin.'}

)}
{/* Slab mode toggle — rectangle (2 clicks) vs polygon (N clicks + Enter) */} {/* Profile mode toggle — applies to slab, roof, plate, space (anything that supports both rect + polygon) */} {(addElementType === 'slab' || addElementType === 'roof' || addElementType === 'plate' || addElementType === 'space') && (
setSlabMode('rectangle')}> Rectangle (2 clicks) setSlabMode('polygon')}> Polygon (N + Enter)
)} {/* Type-specific dimensions */}
{addElementType === 'wall' && (
setWallParams({ Thickness: v })} /> setWallParams({ Height: v })} />
)} {addElementType === 'slab' && ( setSlabParams({ Thickness: v })} /> )} {addElementType === 'beam' && (
setBeamParams({ Width: v })} /> setBeamParams({ Height: v })} />
)} {addElementType === 'column' && (
setColumnParams({ Width: v })} /> setColumnParams({ Depth: v })} /> setColumnParams({ Height: v })} />
)} {addElementType === 'door' && (
setDoorParams({ Width: v })} /> setDoorParams({ Height: v })} /> setDoorParams({ FrameThickness: v })} />
)} {addElementType === 'window' && (
setWindowParams({ Width: v })} /> setWindowParams({ Height: v })} /> setWindowParams({ FrameThickness: v })} />
)} {addElementType === 'space' && ( setSpaceParams({ Height: v })} /> )} {addElementType === 'roof' && ( setRoofParams({ Thickness: v })} /> )} {addElementType === 'plate' && ( setPlateParams({ Thickness: v })} /> )} {addElementType === 'member' && (
setMemberParams({ Width: v })} /> setMemberParams({ Height: v })} />
)}
{/* Auto Spaces — wall-graph face finder, runs only when the current type is 'space' so the panel stays focused. */} {addElementType === 'space' && ( )} {/* Click-state guidance — drives the user through the multi-click flow */} 0 && hoverPoint ? distance2D(pendingPoints[pendingPoints.length - 1], hoverPoint) : null} onClearPending={clearPending} />

Snap to vertices, edges, and faces is on by default — toggle with S. Z is fixed to the storey floor; refine via the Raw STEP tab after dropping.

); } function distance2D(a: { x: number; y: number }, b: { x: number; y: number }): number { return Math.hypot(b.x - a.x, b.y - a.y); } interface ModeChipProps { selected: boolean; onClick: () => void; children: React.ReactNode; } function ModeChip({ selected, onClick, children }: ModeChipProps) { return ( ); } interface DropGuidanceProps { ready: boolean; type: AddElementType; slabMode: 'rectangle' | 'polygon'; pendingCount: number; hoverDistance: number | null; onClearPending: () => void; } /** Stateful guidance pane — mirrors the multi-click flow so the user always knows what comes next. */ function DropGuidance({ ready, type, slabMode, pendingCount, hoverDistance, onClearPending }: DropGuidanceProps) { if (!ready) { return (
Authoring is disabled until a model with a building storey is loaded.
); } let primary: string; let secondary: string; // Single-click placements share the same prompt shape. if (type === 'column' || type === 'door' || type === 'window') { primary = `Click in 3D to drop the ${type}.`; secondary = 'Keep clicking to place more — Esc to exit.'; } else if (type === 'wall' || type === 'beam' || type === 'member') { // Two-click axial placements (start → end). if (pendingCount === 0) { primary = `Click the ${type} start point.`; secondary = 'Snap to vertex/edge for precise placement.'; } else { primary = `Click the ${type} end point.`; secondary = hoverDistance !== null ? `Length so far: ${hoverDistance.toFixed(2)} m — Esc to restart.` : 'Esc to restart.'; } } else { // slab / roof / plate / space — rectangle (2 clicks) or polygon (N + Enter). const polygonable = `${type[0].toUpperCase()}${type.slice(1)}`; if (slabMode === 'rectangle') { if (pendingCount === 0) { primary = `Click the first ${type} corner.`; secondary = 'A second click sets the opposite corner.'; } else { primary = 'Click the opposite corner.'; secondary = 'Esc to restart, or switch to Polygon mode for irregular outlines.'; } } else { if (pendingCount === 0) { primary = `Click the ${polygonable} polygon's first point.`; secondary = 'Need at least 3 points; press Enter to close.'; } else if (pendingCount < 3) { primary = `Click point ${pendingCount + 1} (need at least 3).`; secondary = 'Esc to restart.'; } else { primary = `Click point ${pendingCount + 1} or press Enter to close.`; secondary = 'Esc to restart the polygon.'; } } } return (
{primary} {secondary}
{pendingCount > 0 && ( )}
); } interface NumberFieldProps { label: string; suffix?: string; value: number; min: number; onChange: (v: number) => void; } interface AutoSpacesSectionProps { modelId: string | null; storeyId: number | null; } /** * Compact "Auto Spaces" pane: wires the per-storey wall-graph face * finder to the viewer slice. Preview button runs detection without * emitting; Generate commits each candidate as an IfcSpace. */ function AutoSpacesSection({ modelId, storeyId }: AutoSpacesSectionProps) { const params = useViewerStore((s) => s.addElementAutoSpaceParams); const setParams = useViewerStore((s) => s.setAddElementAutoSpaceParams); const preview = useViewerStore((s) => s.addElementAutoSpacePreview); const setPreview = useViewerStore((s) => s.setAddElementAutoSpacePreview); const generate = useViewerStore((s) => s.generateSpacesFromWalls); const [busy, setBusy] = useState(false); const ready = modelId !== null && storeyId !== null; const [debugLogging, setDebugLogging] = useState(false); const runPreview = () => { if (!ready || busy) return; setBusy(true); try { const result = generate(modelId!, storeyId!, { snapTolerance: params.SnapTolerance, minArea: params.MinArea, height: params.Height, namePattern: params.NamePattern, predefinedType: params.PredefinedType, dryRun: true, debug: debugLogging, }); if ('error' in result) { toast.error(result.error); setPreview(null); return; } const skipReasons: Record = {}; for (const s of result.wallsSkipped) { skipReasons[s.reason] = (skipReasons[s.reason] ?? 0) + 1; } setPreview({ storeyExpressId: storeyId!, outlines: result.detected.map((d) => d.outline.map((p) => [p[0], p[1]])), regions: result.detected.map((d) => ({ area: d.area })), wallsConsidered: result.wallsConsidered, wallsContributing: result.wallsContributing, diagnostics: { vertices: result.detectionStats.vertices, edgesAfterSplit: result.detectionStats.segmentsAfterSplit, facesTotal: result.detectionStats.faces, outerFacesDropped: result.detectionStats.outerFacesDropped, belowMinAreaDropped: result.detectionStats.belowMinAreaDropped, largestArea: result.detectionStats.largestArea, skipReasons, }, }); if (result.detected.length === 0) { toast.info('No enclosed regions detected. Check wall geometry or snap tolerance.'); } } finally { setBusy(false); } }; const runCommit = () => { if (!ready || busy) return; setBusy(true); try { const result = generate(modelId!, storeyId!, { snapTolerance: params.SnapTolerance, minArea: params.MinArea, height: params.Height, namePattern: params.NamePattern, predefinedType: params.PredefinedType, debug: debugLogging, }); if ('error' in result) { toast.error(result.error); return; } setPreview(null); const count = result.emitted.length; if (count === 0) { toast.info('No enclosed regions to generate.'); } else { toast.success(`Generated ${count} IfcSpace${count === 1 ? '' : 's'}.`); } } finally { setBusy(false); } }; return (
setParams({ SnapTolerance: v })} /> setParams({ MinArea: v })} /> setParams({ Height: v })} />
setParams({ NamePattern: e.target.value })} className="h-8 font-mono text-xs" />
{preview && (
{preview.regions.length} region{preview.regions.length === 1 ? '' : 's'} detected {' · '}{preview.wallsContributing}/{preview.wallsConsidered} walls
{preview.regions.length > 0 && (
Total area: {preview.regions.reduce((sum, r) => sum + r.area, 0).toFixed(1)} m²
)} {preview.diagnostics && (
graph: {preview.diagnostics.vertices}v / {preview.diagnostics.edgesAfterSplit}e / {preview.diagnostics.facesTotal}f {' · '}dropped {preview.diagnostics.outerFacesDropped} outer + {preview.diagnostics.belowMinAreaDropped} small
)} {preview.diagnostics && Object.keys(preview.diagnostics.skipReasons).length > 0 && (
skipped walls:{' '} {Object.entries(preview.diagnostics.skipReasons) .map(([reason, count]) => `${count}× ${reason}`) .join(', ')}
)} {preview.regions.length === 0 && preview.wallsContributing > 0 && (
Walls extracted but no enclosed regions formed — check that walls actually meet at corners (try a larger Snap value).
)} {preview.wallsContributing === 0 && preview.wallsConsidered > 0 && (
No wall axes could be extracted. Toggle "Verbose console logging" for per-wall diagnostics.
)}
)}
); } function NumberField({ label, suffix, value, min, onChange }: NumberFieldProps) { const id = `add-elem-${label.toLowerCase()}`; return (
{ const next = Number(e.target.value); if (Number.isFinite(next) && next >= min) onChange(next); }} className="h-8 font-mono text-xs" />
); }