@ifc-lite/viewer 1.17.4 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,758 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Add Element panel — right-side authoring surface for dropping
7
+ * walls / slabs / beams / columns onto a parsed model. Tool-driven
8
+ * (rendered when `activeTool === 'addElement'`); the actual drop
9
+ * happens on a 3D click handled in `selectionHandlers.ts`.
10
+ *
11
+ * Activation only via the command palette — no menubar button. The
12
+ * tool stays active across drops so the user can place several
13
+ * elements in a row; Esc returns to the select tool.
14
+ */
15
+
16
+ import { useEffect, useMemo, useState } from 'react';
17
+ import { Box, Cog, DoorOpen, Home, Layers, Minus, Square, SquareDashedBottom, Wand2, X } from 'lucide-react';
18
+ import { toast } from '@/components/ui/toast';
19
+ import { Button } from '@/components/ui/button';
20
+ import { Input } from '@/components/ui/input';
21
+ import { Label } from '@/components/ui/label';
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from '@/components/ui/select';
29
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
30
+ import { useViewerStore } from '@/store';
31
+ import { useIfc } from '@/hooks/useIfc';
32
+ import { EntityNode } from '@ifc-lite/query';
33
+ import type { AddElementType } from '@/store/slices/addElementSlice';
34
+
35
+ interface ElementOption {
36
+ type: AddElementType;
37
+ label: string;
38
+ Icon: typeof Box;
39
+ /** Short description shown below the type chips. */
40
+ hint: string;
41
+ }
42
+
43
+ const ELEMENT_OPTIONS: ElementOption[] = [
44
+ { type: 'wall', label: 'Wall', Icon: Minus, hint: 'Click Start, then End. Cross-section = Thickness × Height, profile spans the click-to-click axis.' },
45
+ { type: 'slab', label: 'Slab', Icon: Square, hint: 'Rectangle: 2 corner clicks. Polygon: N clicks + Enter to close. Extruded up by Thickness.' },
46
+ { type: 'beam', label: 'Beam', Icon: Layers, hint: 'Click Start, then End. Cross-section (Width × Height) is centred on the beam axis.' },
47
+ { type: 'column', label: 'Column', Icon: Box, hint: 'Single click sets the base centre. Width × Depth cross-section, extruded up by Height.' },
48
+ { 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.' },
49
+ { type: 'window', label: 'Window', Icon: SquareDashedBottom, hint: 'Single click sets the sill-centre. Width × Height sash with a thin frame depth.' },
50
+ { 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.' },
51
+ { type: 'roof', label: 'Roof', Icon: Square, hint: 'Same shape as a slab — flat-roof emit with .FLAT_ROOF. PredefinedType. Pitched roofs need IfcCreator.addIfcGableRoof.' },
52
+ { type: 'plate', label: 'Plate', Icon: Square, hint: 'Thin flat plate (steel / gusset). Rectangle or polygon profile, extruded by Thickness.' },
53
+ { type: 'member', label: 'Member', Icon: Cog, hint: 'Generic structural member (brace, post, strut). Click Start, then End. Pick PredefinedType to set role.' },
54
+ ];
55
+
56
+ interface StoreyOption {
57
+ expressId: number;
58
+ label: string;
59
+ }
60
+
61
+ interface AddElementPanelProps {
62
+ onClose: () => void;
63
+ }
64
+
65
+ export function AddElementPanel({ onClose }: AddElementPanelProps) {
66
+ const { models, ifcDataStore } = useIfc();
67
+
68
+ const addElementType = useViewerStore((s) => s.addElementType);
69
+ const setAddElementType = useViewerStore((s) => s.setAddElementType);
70
+
71
+ const addElementModelId = useViewerStore((s) => s.addElementModelId);
72
+ const setAddElementModelId = useViewerStore((s) => s.setAddElementModelId);
73
+ const addElementStoreyId = useViewerStore((s) => s.addElementStoreyId);
74
+ const setAddElementStoreyId = useViewerStore((s) => s.setAddElementStoreyId);
75
+
76
+ const wallParams = useViewerStore((s) => s.addElementWallParams);
77
+ const setWallParams = useViewerStore((s) => s.setAddElementWallParams);
78
+ const slabParams = useViewerStore((s) => s.addElementSlabParams);
79
+ const setSlabParams = useViewerStore((s) => s.setAddElementSlabParams);
80
+ const beamParams = useViewerStore((s) => s.addElementBeamParams);
81
+ const setBeamParams = useViewerStore((s) => s.setAddElementBeamParams);
82
+ const columnParams = useViewerStore((s) => s.addElementColumnParams);
83
+ const setColumnParams = useViewerStore((s) => s.setAddElementColumnParams);
84
+ const doorParams = useViewerStore((s) => s.addElementDoorParams);
85
+ const setDoorParams = useViewerStore((s) => s.setAddElementDoorParams);
86
+ const windowParams = useViewerStore((s) => s.addElementWindowParams);
87
+ const setWindowParams = useViewerStore((s) => s.setAddElementWindowParams);
88
+ const spaceParams = useViewerStore((s) => s.addElementSpaceParams);
89
+ const setSpaceParams = useViewerStore((s) => s.setAddElementSpaceParams);
90
+ const roofParams = useViewerStore((s) => s.addElementRoofParams);
91
+ const setRoofParams = useViewerStore((s) => s.setAddElementRoofParams);
92
+ const plateParams = useViewerStore((s) => s.addElementPlateParams);
93
+ const setPlateParams = useViewerStore((s) => s.setAddElementPlateParams);
94
+ const memberParams = useViewerStore((s) => s.addElementMemberParams);
95
+ const setMemberParams = useViewerStore((s) => s.setAddElementMemberParams);
96
+
97
+ const slabMode = useViewerStore((s) => s.addElementSlabMode);
98
+ const setSlabMode = useViewerStore((s) => s.setAddElementSlabMode);
99
+ const pendingPoints = useViewerStore((s) => s.addElementPendingPoints);
100
+ const hoverPoint = useViewerStore((s) => s.addElementHoverPoint);
101
+ const clearPending = useViewerStore((s) => s.clearAddElementPending);
102
+
103
+ const activeModelId = useViewerStore((s) => s.activeModelId);
104
+
105
+ // Resolve the effective model + its storeys for the selects. When
106
+ // the user hasn't pinned a model the panel auto-tracks the active
107
+ // model; same for storey (auto-tracks first when null).
108
+ const effectiveModelId = addElementModelId ?? activeModelId ?? (models.size > 0 ? models.keys().next().value ?? null : null);
109
+
110
+ const modelOptions = useMemo(() => {
111
+ const opts: { id: string; label: string }[] = [];
112
+ for (const [id, model] of models) {
113
+ if (!model.ifcDataStore) continue;
114
+ opts.push({ id, label: model.name || id });
115
+ }
116
+ return opts;
117
+ }, [models]);
118
+
119
+ const storeyOptions = useMemo<StoreyOption[]>(() => {
120
+ const dataStore = effectiveModelId
121
+ ? models.get(effectiveModelId)?.ifcDataStore ?? null
122
+ : ifcDataStore;
123
+ if (!dataStore) return [];
124
+ const ids = dataStore.entityIndex.byType.get('IFCBUILDINGSTOREY') ?? [];
125
+ const opts: StoreyOption[] = [];
126
+ for (const expressId of ids) {
127
+ const node = new EntityNode(dataStore, expressId);
128
+ const name = node.name || `Storey #${expressId}`;
129
+ opts.push({ expressId, label: name });
130
+ }
131
+ return opts;
132
+ }, [effectiveModelId, models, ifcDataStore]);
133
+
134
+ // Auto-pick the first storey when the user hasn't chosen one or
135
+ // the previous choice no longer exists in the active model. Also
136
+ // reset on model change — storey express ids are model-local, so a
137
+ // colliding numeric id from a different federated model would
138
+ // otherwise be silently reused as the placement target.
139
+ useEffect(() => {
140
+ if (storeyOptions.length === 0) return;
141
+ if (addElementStoreyId === null) return;
142
+ const stillValid = storeyOptions.some((s) => s.expressId === addElementStoreyId);
143
+ if (!stillValid) setAddElementStoreyId(null);
144
+ }, [storeyOptions, addElementStoreyId, setAddElementStoreyId, effectiveModelId]);
145
+
146
+ const hasModel = !!effectiveModelId;
147
+ const hasStorey = storeyOptions.length > 0;
148
+ const ready = hasModel && hasStorey;
149
+
150
+ const activeOption = ELEMENT_OPTIONS.find((o) => o.type === addElementType) ?? ELEMENT_OPTIONS[0];
151
+
152
+ return (
153
+ <div className="h-full flex flex-col bg-white dark:bg-black">
154
+ {/* Header */}
155
+ <div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-950">
156
+ <div className="flex items-center gap-2">
157
+ <Box className="h-4 w-4 text-emerald-600" />
158
+ <h2 className="font-bold uppercase tracking-wider text-xs text-zinc-900 dark:text-zinc-100">
159
+ Add Element
160
+ </h2>
161
+ </div>
162
+ <Tooltip>
163
+ <TooltipTrigger asChild>
164
+ <Button
165
+ variant="ghost"
166
+ size="icon"
167
+ className="h-6 w-6"
168
+ onClick={onClose}
169
+ aria-label="Close add element panel"
170
+ >
171
+ <X className="h-3.5 w-3.5" />
172
+ </Button>
173
+ </TooltipTrigger>
174
+ <TooltipContent>Close (Esc)</TooltipContent>
175
+ </Tooltip>
176
+ </div>
177
+
178
+ <div className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
179
+ {/* Element type chips */}
180
+ <section className="space-y-1.5">
181
+ <Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
182
+ Type
183
+ </Label>
184
+ <div className="grid grid-cols-3 gap-1">
185
+ {ELEMENT_OPTIONS.map(({ type, label, Icon }) => {
186
+ const selected = addElementType === type;
187
+ return (
188
+ <button
189
+ key={type}
190
+ type="button"
191
+ onClick={() => setAddElementType(type)}
192
+ aria-pressed={selected}
193
+ className={[
194
+ 'flex items-center justify-center gap-1 h-8 px-1.5 rounded-sm text-[10px] font-mono uppercase tracking-wide',
195
+ 'border transition-colors',
196
+ 'outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
197
+ selected
198
+ ? 'bg-emerald-500 border-emerald-500 text-white hover:bg-emerald-600'
199
+ : 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-200 hover:border-emerald-300 dark:hover:border-emerald-800',
200
+ ].join(' ')}
201
+ >
202
+ <Icon className="h-3 w-3 shrink-0" />
203
+ <span className="truncate">{label}</span>
204
+ </button>
205
+ );
206
+ })}
207
+ </div>
208
+ <p className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400 leading-snug pt-1">
209
+ {activeOption.hint}
210
+ </p>
211
+ </section>
212
+
213
+ {/* Model + storey context */}
214
+ {modelOptions.length > 1 && (
215
+ <section className="space-y-1.5">
216
+ <Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
217
+ Model
218
+ </Label>
219
+ <Select
220
+ value={effectiveModelId ?? undefined}
221
+ onValueChange={(v) => setAddElementModelId(v)}
222
+ >
223
+ <SelectTrigger className="h-8 font-mono text-xs">
224
+ <SelectValue placeholder="Select model…" />
225
+ </SelectTrigger>
226
+ <SelectContent>
227
+ {modelOptions.map(({ id, label }) => (
228
+ <SelectItem key={id} value={id} className="font-mono text-xs">
229
+ {label}
230
+ </SelectItem>
231
+ ))}
232
+ </SelectContent>
233
+ </Select>
234
+ </section>
235
+ )}
236
+
237
+ <section className="space-y-1.5">
238
+ <Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
239
+ Storey
240
+ </Label>
241
+ {storeyOptions.length > 0 ? (
242
+ <Select
243
+ value={(addElementStoreyId ?? storeyOptions[0]?.expressId ?? '').toString()}
244
+ onValueChange={(v) => setAddElementStoreyId(Number(v))}
245
+ >
246
+ <SelectTrigger className="h-8 font-mono text-xs">
247
+ <SelectValue placeholder="Pick a storey…" />
248
+ </SelectTrigger>
249
+ <SelectContent>
250
+ {storeyOptions.map(({ expressId, label }) => (
251
+ <SelectItem key={expressId} value={expressId.toString()} className="font-mono text-xs">
252
+ {label}
253
+ </SelectItem>
254
+ ))}
255
+ </SelectContent>
256
+ </Select>
257
+ ) : (
258
+ <p className="text-[11px] font-mono text-amber-600 dark:text-amber-400">
259
+ {hasModel
260
+ ? 'This model has no IfcBuildingStorey — load a model with a spatial hierarchy.'
261
+ : 'Load a model to begin.'}
262
+ </p>
263
+ )}
264
+ </section>
265
+
266
+ {/* Slab mode toggle — rectangle (2 clicks) vs polygon (N clicks + Enter) */}
267
+ {/* Profile mode toggle — applies to slab, roof, plate, space (anything that supports both rect + polygon) */}
268
+ {(addElementType === 'slab' || addElementType === 'roof' || addElementType === 'plate' || addElementType === 'space') && (
269
+ <section className="space-y-1.5">
270
+ <Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
271
+ {activeOption.label} profile
272
+ </Label>
273
+ <div className="grid grid-cols-2 gap-1">
274
+ <ModeChip selected={slabMode === 'rectangle'} onClick={() => setSlabMode('rectangle')}>
275
+ Rectangle (2 clicks)
276
+ </ModeChip>
277
+ <ModeChip selected={slabMode === 'polygon'} onClick={() => setSlabMode('polygon')}>
278
+ Polygon (N + Enter)
279
+ </ModeChip>
280
+ </div>
281
+ </section>
282
+ )}
283
+
284
+ {/* Type-specific dimensions */}
285
+ <section className="space-y-2 pt-1">
286
+ <Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
287
+ {activeOption.label} dimensions
288
+ </Label>
289
+
290
+ {addElementType === 'wall' && (
291
+ <div className="grid grid-cols-2 gap-2">
292
+ <NumberField label="Thickness" suffix="m" value={wallParams.Thickness} min={0.01} onChange={(v) => setWallParams({ Thickness: v })} />
293
+ <NumberField label="Height" suffix="m" value={wallParams.Height} min={0.01} onChange={(v) => setWallParams({ Height: v })} />
294
+ </div>
295
+ )}
296
+
297
+ {addElementType === 'slab' && (
298
+ <NumberField label="Thickness" suffix="m" value={slabParams.Thickness} min={0.01} onChange={(v) => setSlabParams({ Thickness: v })} />
299
+ )}
300
+
301
+ {addElementType === 'beam' && (
302
+ <div className="grid grid-cols-2 gap-2">
303
+ <NumberField label="Width" suffix="m" value={beamParams.Width} min={0.01} onChange={(v) => setBeamParams({ Width: v })} />
304
+ <NumberField label="Height" suffix="m" value={beamParams.Height} min={0.01} onChange={(v) => setBeamParams({ Height: v })} />
305
+ </div>
306
+ )}
307
+
308
+ {addElementType === 'column' && (
309
+ <div className="grid grid-cols-3 gap-2">
310
+ <NumberField label="Width" suffix="m" value={columnParams.Width} min={0.01} onChange={(v) => setColumnParams({ Width: v })} />
311
+ <NumberField label="Depth" suffix="m" value={columnParams.Depth} min={0.01} onChange={(v) => setColumnParams({ Depth: v })} />
312
+ <NumberField label="Height" suffix="m" value={columnParams.Height} min={0.01} onChange={(v) => setColumnParams({ Height: v })} />
313
+ </div>
314
+ )}
315
+
316
+ {addElementType === 'door' && (
317
+ <div className="grid grid-cols-3 gap-2">
318
+ <NumberField label="Width" suffix="m" value={doorParams.Width} min={0.01} onChange={(v) => setDoorParams({ Width: v })} />
319
+ <NumberField label="Height" suffix="m" value={doorParams.Height} min={0.01} onChange={(v) => setDoorParams({ Height: v })} />
320
+ <NumberField label="Frame" suffix="m" value={doorParams.FrameThickness} min={0.005} onChange={(v) => setDoorParams({ FrameThickness: v })} />
321
+ </div>
322
+ )}
323
+
324
+ {addElementType === 'window' && (
325
+ <div className="grid grid-cols-3 gap-2">
326
+ <NumberField label="Width" suffix="m" value={windowParams.Width} min={0.01} onChange={(v) => setWindowParams({ Width: v })} />
327
+ <NumberField label="Height" suffix="m" value={windowParams.Height} min={0.01} onChange={(v) => setWindowParams({ Height: v })} />
328
+ <NumberField label="Frame" suffix="m" value={windowParams.FrameThickness} min={0.005} onChange={(v) => setWindowParams({ FrameThickness: v })} />
329
+ </div>
330
+ )}
331
+
332
+ {addElementType === 'space' && (
333
+ <NumberField label="Height" suffix="m" value={spaceParams.Height} min={0.01} onChange={(v) => setSpaceParams({ Height: v })} />
334
+ )}
335
+
336
+ {addElementType === 'roof' && (
337
+ <NumberField label="Thickness" suffix="m" value={roofParams.Thickness} min={0.01} onChange={(v) => setRoofParams({ Thickness: v })} />
338
+ )}
339
+
340
+ {addElementType === 'plate' && (
341
+ <NumberField label="Thickness" suffix="m" value={plateParams.Thickness} min={0.001} onChange={(v) => setPlateParams({ Thickness: v })} />
342
+ )}
343
+
344
+ {addElementType === 'member' && (
345
+ <div className="grid grid-cols-2 gap-2">
346
+ <NumberField label="Width" suffix="m" value={memberParams.Width} min={0.01} onChange={(v) => setMemberParams({ Width: v })} />
347
+ <NumberField label="Height" suffix="m" value={memberParams.Height} min={0.01} onChange={(v) => setMemberParams({ Height: v })} />
348
+ </div>
349
+ )}
350
+ </section>
351
+
352
+ {/* Auto Spaces — wall-graph face finder, runs only when the
353
+ current type is 'space' so the panel stays focused. */}
354
+ {addElementType === 'space' && (
355
+ <AutoSpacesSection
356
+ modelId={effectiveModelId}
357
+ storeyId={addElementStoreyId ?? storeyOptions[0]?.expressId ?? null}
358
+ />
359
+ )}
360
+
361
+ {/* Click-state guidance — drives the user through the multi-click flow */}
362
+ <DropGuidance
363
+ ready={ready}
364
+ type={addElementType}
365
+ slabMode={slabMode}
366
+ pendingCount={pendingPoints.length}
367
+ hoverDistance={pendingPoints.length > 0 && hoverPoint
368
+ ? distance2D(pendingPoints[pendingPoints.length - 1], hoverPoint)
369
+ : null}
370
+ onClearPending={clearPending}
371
+ />
372
+
373
+ <p className="text-[10px] font-mono text-zinc-400 dark:text-zinc-600 leading-snug">
374
+ Snap to vertices, edges, and faces is on by default — toggle with <span className="font-semibold">S</span>.
375
+ Z is fixed to the storey floor; refine via the Raw STEP tab after dropping.
376
+ </p>
377
+ </div>
378
+ </div>
379
+ );
380
+ }
381
+
382
+ function distance2D(a: { x: number; y: number }, b: { x: number; y: number }): number {
383
+ return Math.hypot(b.x - a.x, b.y - a.y);
384
+ }
385
+
386
+ interface ModeChipProps {
387
+ selected: boolean;
388
+ onClick: () => void;
389
+ children: React.ReactNode;
390
+ }
391
+
392
+ function ModeChip({ selected, onClick, children }: ModeChipProps) {
393
+ return (
394
+ <button
395
+ type="button"
396
+ onClick={onClick}
397
+ aria-pressed={selected}
398
+ className={[
399
+ 'h-7 px-2 rounded-sm text-[11px] font-mono uppercase tracking-wide',
400
+ 'border transition-colors',
401
+ 'outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
402
+ selected
403
+ ? 'bg-emerald-500 border-emerald-500 text-white hover:bg-emerald-600'
404
+ : 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-200 hover:border-emerald-300 dark:hover:border-emerald-800',
405
+ ].join(' ')}
406
+ >
407
+ {children}
408
+ </button>
409
+ );
410
+ }
411
+
412
+ interface DropGuidanceProps {
413
+ ready: boolean;
414
+ type: AddElementType;
415
+ slabMode: 'rectangle' | 'polygon';
416
+ pendingCount: number;
417
+ hoverDistance: number | null;
418
+ onClearPending: () => void;
419
+ }
420
+
421
+ /** Stateful guidance pane — mirrors the multi-click flow so the user always knows what comes next. */
422
+ function DropGuidance({ ready, type, slabMode, pendingCount, hoverDistance, onClearPending }: DropGuidanceProps) {
423
+ if (!ready) {
424
+ return (
425
+ <section className="mt-2 rounded-sm border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-950 p-3 text-[11px] font-mono text-zinc-500 dark:text-zinc-400">
426
+ Authoring is disabled until a model with a building storey is loaded.
427
+ </section>
428
+ );
429
+ }
430
+
431
+ let primary: string;
432
+ let secondary: string;
433
+ // Single-click placements share the same prompt shape.
434
+ if (type === 'column' || type === 'door' || type === 'window') {
435
+ primary = `Click in 3D to drop the ${type}.`;
436
+ secondary = 'Keep clicking to place more — Esc to exit.';
437
+ } else if (type === 'wall' || type === 'beam' || type === 'member') {
438
+ // Two-click axial placements (start → end).
439
+ if (pendingCount === 0) {
440
+ primary = `Click the ${type} start point.`;
441
+ secondary = 'Snap to vertex/edge for precise placement.';
442
+ } else {
443
+ primary = `Click the ${type} end point.`;
444
+ secondary = hoverDistance !== null
445
+ ? `Length so far: ${hoverDistance.toFixed(2)} m — Esc to restart.`
446
+ : 'Esc to restart.';
447
+ }
448
+ } else {
449
+ // slab / roof / plate / space — rectangle (2 clicks) or polygon (N + Enter).
450
+ const polygonable = `${type[0].toUpperCase()}${type.slice(1)}`;
451
+ if (slabMode === 'rectangle') {
452
+ if (pendingCount === 0) {
453
+ primary = `Click the first ${type} corner.`;
454
+ secondary = 'A second click sets the opposite corner.';
455
+ } else {
456
+ primary = 'Click the opposite corner.';
457
+ secondary = 'Esc to restart, or switch to Polygon mode for irregular outlines.';
458
+ }
459
+ } else {
460
+ if (pendingCount === 0) {
461
+ primary = `Click the ${polygonable} polygon's first point.`;
462
+ secondary = 'Need at least 3 points; press Enter to close.';
463
+ } else if (pendingCount < 3) {
464
+ primary = `Click point ${pendingCount + 1} (need at least 3).`;
465
+ secondary = 'Esc to restart.';
466
+ } else {
467
+ primary = `Click point ${pendingCount + 1} or press Enter to close.`;
468
+ secondary = 'Esc to restart the polygon.';
469
+ }
470
+ }
471
+ }
472
+
473
+ return (
474
+ <section
475
+ className="mt-2 rounded-sm border border-emerald-300 dark:border-emerald-800 bg-emerald-50/50 dark:bg-emerald-950/20 p-3 text-[11px] font-mono leading-relaxed text-emerald-800 dark:text-emerald-300"
476
+ aria-live="polite"
477
+ >
478
+ <div className="flex items-start gap-2 justify-between">
479
+ <div className="min-w-0">
480
+ <span className="block font-semibold">{primary}</span>
481
+ <span className="block text-[10px] opacity-80 mt-0.5">{secondary}</span>
482
+ </div>
483
+ {pendingCount > 0 && (
484
+ <button
485
+ type="button"
486
+ onClick={onClearPending}
487
+ className="shrink-0 text-[10px] underline-offset-2 hover:underline opacity-80 hover:opacity-100"
488
+ aria-label="Discard pending points"
489
+ >
490
+ Reset
491
+ </button>
492
+ )}
493
+ </div>
494
+ </section>
495
+ );
496
+ }
497
+
498
+ interface NumberFieldProps {
499
+ label: string;
500
+ suffix?: string;
501
+ value: number;
502
+ min: number;
503
+ onChange: (v: number) => void;
504
+ }
505
+
506
+ interface AutoSpacesSectionProps {
507
+ modelId: string | null;
508
+ storeyId: number | null;
509
+ }
510
+
511
+ /**
512
+ * Compact "Auto Spaces" pane: wires the per-storey wall-graph face
513
+ * finder to the viewer slice. Preview button runs detection without
514
+ * emitting; Generate commits each candidate as an IfcSpace.
515
+ */
516
+ function AutoSpacesSection({ modelId, storeyId }: AutoSpacesSectionProps) {
517
+ const params = useViewerStore((s) => s.addElementAutoSpaceParams);
518
+ const setParams = useViewerStore((s) => s.setAddElementAutoSpaceParams);
519
+ const preview = useViewerStore((s) => s.addElementAutoSpacePreview);
520
+ const setPreview = useViewerStore((s) => s.setAddElementAutoSpacePreview);
521
+ const generate = useViewerStore((s) => s.generateSpacesFromWalls);
522
+ const [busy, setBusy] = useState(false);
523
+
524
+ const ready = modelId !== null && storeyId !== null;
525
+
526
+ const [debugLogging, setDebugLogging] = useState(false);
527
+
528
+ const runPreview = () => {
529
+ if (!ready || busy) return;
530
+ setBusy(true);
531
+ try {
532
+ const result = generate(modelId!, storeyId!, {
533
+ snapTolerance: params.SnapTolerance,
534
+ minArea: params.MinArea,
535
+ height: params.Height,
536
+ namePattern: params.NamePattern,
537
+ predefinedType: params.PredefinedType,
538
+ dryRun: true,
539
+ debug: debugLogging,
540
+ });
541
+ if ('error' in result) {
542
+ toast.error(result.error);
543
+ setPreview(null);
544
+ return;
545
+ }
546
+ const skipReasons: Record<string, number> = {};
547
+ for (const s of result.wallsSkipped) {
548
+ skipReasons[s.reason] = (skipReasons[s.reason] ?? 0) + 1;
549
+ }
550
+ setPreview({
551
+ storeyExpressId: storeyId!,
552
+ outlines: result.detected.map((d) => d.outline.map((p) => [p[0], p[1]])),
553
+ regions: result.detected.map((d) => ({ area: d.area })),
554
+ wallsConsidered: result.wallsConsidered,
555
+ wallsContributing: result.wallsContributing,
556
+ diagnostics: {
557
+ vertices: result.detectionStats.vertices,
558
+ edgesAfterSplit: result.detectionStats.segmentsAfterSplit,
559
+ facesTotal: result.detectionStats.faces,
560
+ outerFacesDropped: result.detectionStats.outerFacesDropped,
561
+ belowMinAreaDropped: result.detectionStats.belowMinAreaDropped,
562
+ largestArea: result.detectionStats.largestArea,
563
+ skipReasons,
564
+ },
565
+ });
566
+ if (result.detected.length === 0) {
567
+ toast.info('No enclosed regions detected. Check wall geometry or snap tolerance.');
568
+ }
569
+ } finally {
570
+ setBusy(false);
571
+ }
572
+ };
573
+
574
+ const runCommit = () => {
575
+ if (!ready || busy) return;
576
+ setBusy(true);
577
+ try {
578
+ const result = generate(modelId!, storeyId!, {
579
+ snapTolerance: params.SnapTolerance,
580
+ minArea: params.MinArea,
581
+ height: params.Height,
582
+ namePattern: params.NamePattern,
583
+ predefinedType: params.PredefinedType,
584
+ debug: debugLogging,
585
+ });
586
+ if ('error' in result) {
587
+ toast.error(result.error);
588
+ return;
589
+ }
590
+ setPreview(null);
591
+ const count = result.emitted.length;
592
+ if (count === 0) {
593
+ toast.info('No enclosed regions to generate.');
594
+ } else {
595
+ toast.success(`Generated ${count} IfcSpace${count === 1 ? '' : 's'}.`);
596
+ }
597
+ } finally {
598
+ setBusy(false);
599
+ }
600
+ };
601
+
602
+ return (
603
+ <section className="space-y-2 pt-1">
604
+ <div className="flex items-center gap-1.5">
605
+ <Wand2 className="h-3 w-3 text-emerald-600" />
606
+ <Label className="text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
607
+ Auto Spaces (from walls)
608
+ </Label>
609
+ </div>
610
+
611
+ <div className="grid grid-cols-2 gap-2">
612
+ <NumberField
613
+ label="Snap" suffix="m"
614
+ value={params.SnapTolerance} min={0.001}
615
+ onChange={(v) => setParams({ SnapTolerance: v })}
616
+ />
617
+ <NumberField
618
+ label="Min area" suffix="m²"
619
+ value={params.MinArea} min={0}
620
+ onChange={(v) => setParams({ MinArea: v })}
621
+ />
622
+ <NumberField
623
+ label="Height" suffix="m"
624
+ value={params.Height} min={0.01}
625
+ onChange={(v) => setParams({ Height: v })}
626
+ />
627
+ <div className="space-y-1">
628
+ <Label className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400" htmlFor="auto-space-type">
629
+ Type
630
+ </Label>
631
+ <Select
632
+ value={params.PredefinedType}
633
+ onValueChange={(v) => setParams({ PredefinedType: v })}
634
+ >
635
+ <SelectTrigger id="auto-space-type" className="h-8 font-mono text-xs">
636
+ <SelectValue />
637
+ </SelectTrigger>
638
+ <SelectContent>
639
+ <SelectItem value="INTERNAL" className="font-mono text-xs">INTERNAL</SelectItem>
640
+ <SelectItem value="EXTERNAL" className="font-mono text-xs">EXTERNAL</SelectItem>
641
+ <SelectItem value="SPACE" className="font-mono text-xs">SPACE</SelectItem>
642
+ <SelectItem value="PARKING" className="font-mono text-xs">PARKING</SelectItem>
643
+ <SelectItem value="GFA" className="font-mono text-xs">GFA</SelectItem>
644
+ <SelectItem value="USERDEFINED" className="font-mono text-xs">USERDEFINED</SelectItem>
645
+ <SelectItem value="NOTDEFINED" className="font-mono text-xs">NOTDEFINED</SelectItem>
646
+ </SelectContent>
647
+ </Select>
648
+ </div>
649
+ </div>
650
+
651
+ <div className="space-y-1">
652
+ <Label htmlFor="auto-space-name" className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400">
653
+ Name pattern <span className="text-zinc-400 dark:text-zinc-600 ml-1">({'{n}'} = index)</span>
654
+ </Label>
655
+ <Input
656
+ id="auto-space-name"
657
+ type="text"
658
+ value={params.NamePattern}
659
+ onChange={(e) => setParams({ NamePattern: e.target.value })}
660
+ className="h-8 font-mono text-xs"
661
+ />
662
+ </div>
663
+
664
+ <div className="grid grid-cols-2 gap-2 pt-1">
665
+ <Button
666
+ variant="outline"
667
+ size="sm"
668
+ onClick={runPreview}
669
+ disabled={!ready || busy}
670
+ className="h-8 text-[11px] font-mono"
671
+ >
672
+ Preview
673
+ </Button>
674
+ <Button
675
+ variant="default"
676
+ size="sm"
677
+ onClick={runCommit}
678
+ disabled={!ready || busy}
679
+ className="h-8 text-[11px] font-mono bg-emerald-600 hover:bg-emerald-700"
680
+ >
681
+ Generate
682
+ </Button>
683
+ </div>
684
+
685
+ <label className="flex items-center gap-1.5 text-[10px] font-mono text-zinc-500 dark:text-zinc-400 select-none cursor-pointer">
686
+ <input
687
+ type="checkbox"
688
+ checked={debugLogging}
689
+ onChange={(e) => setDebugLogging(e.target.checked)}
690
+ className="h-3 w-3 accent-emerald-600"
691
+ />
692
+ Verbose console logging (open devtools)
693
+ </label>
694
+
695
+ {preview && (
696
+ <div className="rounded-sm border border-emerald-200 dark:border-emerald-900 bg-emerald-50/60 dark:bg-emerald-950/20 px-2 py-1.5 text-[10px] font-mono text-emerald-800 dark:text-emerald-300 leading-snug">
697
+ <div>
698
+ {preview.regions.length} region{preview.regions.length === 1 ? '' : 's'} detected
699
+ {' · '}{preview.wallsContributing}/{preview.wallsConsidered} walls
700
+ </div>
701
+ {preview.regions.length > 0 && (
702
+ <div className="opacity-80">
703
+ Total area: {preview.regions.reduce((sum, r) => sum + r.area, 0).toFixed(1)} m²
704
+ </div>
705
+ )}
706
+ {preview.diagnostics && (
707
+ <div className="opacity-80 mt-1">
708
+ graph: {preview.diagnostics.vertices}v / {preview.diagnostics.edgesAfterSplit}e / {preview.diagnostics.facesTotal}f
709
+ {' · '}dropped {preview.diagnostics.outerFacesDropped} outer + {preview.diagnostics.belowMinAreaDropped} small
710
+ </div>
711
+ )}
712
+ {preview.diagnostics && Object.keys(preview.diagnostics.skipReasons).length > 0 && (
713
+ <div className="opacity-80">
714
+ skipped walls:{' '}
715
+ {Object.entries(preview.diagnostics.skipReasons)
716
+ .map(([reason, count]) => `${count}× ${reason}`)
717
+ .join(', ')}
718
+ </div>
719
+ )}
720
+ {preview.regions.length === 0 && preview.wallsContributing > 0 && (
721
+ <div className="mt-1 text-amber-700 dark:text-amber-400">
722
+ Walls extracted but no enclosed regions formed — check that walls actually meet at corners (try a larger Snap value).
723
+ </div>
724
+ )}
725
+ {preview.wallsContributing === 0 && preview.wallsConsidered > 0 && (
726
+ <div className="mt-1 text-amber-700 dark:text-amber-400">
727
+ No wall axes could be extracted. Toggle &quot;Verbose console logging&quot; for per-wall diagnostics.
728
+ </div>
729
+ )}
730
+ </div>
731
+ )}
732
+ </section>
733
+ );
734
+ }
735
+
736
+ function NumberField({ label, suffix, value, min, onChange }: NumberFieldProps) {
737
+ const id = `add-elem-${label.toLowerCase()}`;
738
+ return (
739
+ <div className="space-y-1">
740
+ <Label htmlFor={id} className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400">
741
+ {label}
742
+ {suffix && <span className="text-zinc-400 dark:text-zinc-600 ml-1">({suffix})</span>}
743
+ </Label>
744
+ <Input
745
+ id={id}
746
+ type="number"
747
+ step={0.05}
748
+ min={min}
749
+ value={Number.isFinite(value) ? value : ''}
750
+ onChange={(e) => {
751
+ const next = Number(e.target.value);
752
+ if (Number.isFinite(next) && next >= min) onChange(next);
753
+ }}
754
+ className="h-8 font-mono text-xs"
755
+ />
756
+ </div>
757
+ );
758
+ }