@ifc-lite/viewer 1.17.6 → 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 (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -9,6 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { MouseHandlerContext } from './mouseHandlerTypes.js';
12
+ import { useViewerStore } from '@/store';
13
+ import { fromGlobalIdFromModels, toGlobalIdFromModels } from '@/store/globalId';
14
+ import { toast } from '@/components/ui/toast';
12
15
 
13
16
  /**
14
17
  * Handle click event for selection (single click and double click).
@@ -36,6 +39,63 @@ export async function handleSelectionClick(ctx: MouseHandlerContext, e: MouseEve
36
39
  return; // Skip click handling for measure tool
37
40
  }
38
41
 
42
+ // Add-element tool — multi-click placement (start→end for walls/beams,
43
+ // corner→opposite for slab rectangle, N+Enter for slab polygon, single
44
+ // for columns). Uses magnetic snap so points lock to vertices/edges
45
+ // when the cursor is near them — same UX as the measure tool.
46
+ if (tool === 'addElement') {
47
+ const currentLock = ctx.edgeLockStateRef.current;
48
+ const result = renderer.raycastSceneMagnetic(x, y, {
49
+ edge: currentLock.edge,
50
+ meshExpressId: currentLock.meshExpressId,
51
+ lockStrength: currentLock.lockStrength,
52
+ }, {
53
+ hiddenIds: ctx.hiddenEntitiesRef.current,
54
+ isolatedIds: ctx.isolatedEntitiesRef.current,
55
+ snapOptions: ctx.snapEnabledRef.current ? {
56
+ snapToVertices: true,
57
+ snapToEdges: true,
58
+ snapToFaces: true,
59
+ screenSnapRadius: 40,
60
+ } : {
61
+ snapToVertices: false,
62
+ snapToEdges: false,
63
+ snapToFaces: false,
64
+ screenSnapRadius: 0,
65
+ },
66
+ });
67
+ const point = result.snapTarget?.position
68
+ ?? result.intersection?.point
69
+ ?? raycastStoreyFloor(ctx, x, y);
70
+ if (!point) return;
71
+ await handleAddElementDrop(point);
72
+ return;
73
+ }
74
+
75
+ // Annotate tool — drop a pin at the cursor's world point.
76
+ // Raycasts the scene; if the click misses geometry the draft is
77
+ // not opened (annotations are anchored to surface points by
78
+ // design, not floating in space).
79
+ if (tool === 'annotate') {
80
+ const result = renderer.raycastScene(x, y, ctx.getPickOptions());
81
+ if (!result?.intersection) return;
82
+ const { intersection } = result;
83
+ const store = useViewerStore.getState();
84
+ // Federated models — resolve which model the hit globalId belongs
85
+ // to so the annotation carries enough context to render its
86
+ // popover header. Falls back to (null, expressId) when there's
87
+ // only the legacy single-model state.
88
+ const modelLookup = fromGlobalIdFromModels(store.models, intersection.expressId);
89
+ const modelId = modelLookup?.modelId ?? null;
90
+ const localExpressId = modelLookup?.expressId ?? intersection.expressId;
91
+ store.beginDraft(
92
+ { x: intersection.point.x, y: intersection.point.y, z: intersection.point.z },
93
+ localExpressId ?? null,
94
+ modelId,
95
+ );
96
+ return;
97
+ }
98
+
39
99
  const now = Date.now();
40
100
  const timeSinceLastClick = now - ctx.lastClickTimeRef.current;
41
101
  const clickPos = { x, y };
@@ -71,6 +131,392 @@ export async function handleSelectionClick(ctx: MouseHandlerContext, e: MouseEve
71
131
  }
72
132
  }
73
133
 
134
+ /**
135
+ * Find the first IfcBuildingStorey entity in the active model. Used as a
136
+ * fallback when the user hasn't picked a target storey in the panel.
137
+ */
138
+ function firstStoreyExpressId(modelId: string): number | null {
139
+ const state = useViewerStore.getState();
140
+ const model = state.models.get(modelId);
141
+ const ids = model?.ifcDataStore?.entityIndex.byType.get('IFCBUILDINGSTOREY');
142
+ return ids && ids.length > 0 ? ids[0] : null;
143
+ }
144
+
145
+ /**
146
+ * Active model resolver — falls back through the same legacy chain
147
+ * the rest of the viewer uses when a single model is loaded.
148
+ */
149
+ function resolveActiveModelId(): string | null {
150
+ const state = useViewerStore.getState();
151
+ if (state.activeModelId) return state.activeModelId;
152
+ const first = state.models.keys().next();
153
+ return first.done ? null : first.value;
154
+ }
155
+
156
+ /**
157
+ * Convert a renderer Y-up world point to IFC Z-up storey-local
158
+ * coordinates with Z forced to the storey floor (0). Mirrors the
159
+ * matrix in `packages/renderer/src/pipeline.ts`. Z is clamped so the
160
+ * click landing on a vertical surface doesn't lift the element above
161
+ * the floor — matches construction-tool placement intuition. Refine
162
+ * via the Raw STEP tab if needed.
163
+ */
164
+ export function rendererPointToIfcStoreyLocal(point: { x: number; y: number; z: number }): [number, number, number] {
165
+ return [point.x, -point.z, 0];
166
+ }
167
+
168
+ /**
169
+ * Storey-floor ray-plane intersection — used as a fallback when the
170
+ * scene raycast misses every mesh (so the user can place new elements
171
+ * in empty space, not just on existing surfaces). The floor sits at
172
+ * renderer Y = storey elevation; if no storey is selected we use 0
173
+ * (the renderer's default ground plane).
174
+ */
175
+ function raycastStoreyFloor(
176
+ ctx: MouseHandlerContext,
177
+ x: number,
178
+ y: number,
179
+ ): { x: number; y: number; z: number } | null {
180
+ const camera = ctx.renderer.getCamera();
181
+ const canvas = ctx.renderer.getCanvas();
182
+ if (!camera || !canvas) return null;
183
+ const ray = camera.unprojectToRay(x, y, canvas.clientWidth, canvas.clientHeight);
184
+ if (!ray) return null;
185
+ const planeY = resolveStoreyFloorY();
186
+ // Looking down typically means D.y < 0; reject parallel / near-parallel
187
+ // cases so we don't hand back a wildly extrapolated intersection.
188
+ const dy = ray.direction.y;
189
+ if (Math.abs(dy) < 1e-6) return null;
190
+ const t = (planeY - ray.origin.y) / dy;
191
+ if (!Number.isFinite(t) || t <= 0) return null;
192
+ return {
193
+ x: ray.origin.x + ray.direction.x * t,
194
+ y: planeY,
195
+ z: ray.origin.z + ray.direction.z * t,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Resolve the renderer Y of the currently selected (or first
201
+ * available) storey's floor. Falls back to 0 when nothing is loaded.
202
+ */
203
+ function resolveStoreyFloorY(): number {
204
+ const state = useViewerStore.getState();
205
+ const modelId = state.addElementModelId ?? state.activeModelId;
206
+ if (!modelId) return 0;
207
+ const model = state.models.get(modelId);
208
+ const ds = model?.ifcDataStore;
209
+ if (!ds) return 0;
210
+ const storeyId = state.addElementStoreyId ?? firstStoreyExpressId(modelId);
211
+ if (storeyId === null) return 0;
212
+ return ds.spatialHierarchy?.storeyElevations?.get(storeyId) ?? 0;
213
+ }
214
+
215
+ /**
216
+ * Update the live hover preview for the add-element tool. Runs the
217
+ * same magnetic raycast as the click handler and keeps `hoverPoint`
218
+ * in sync with whatever the next click would place. Used by the
219
+ * 3D-overlay preview so the user sees the in-progress edge / rectangle
220
+ * / polygon segment as they move the cursor.
221
+ *
222
+ * Returns true when handled so the mouse-controls hook can early-out
223
+ * before falling through to the generic hover-tooltip path.
224
+ */
225
+ export function handleAddElementHover(ctx: MouseHandlerContext, x: number, y: number): boolean {
226
+ const { renderer } = ctx;
227
+ if (!ctx.measureRaycastPendingRef.current) {
228
+ ctx.measureRaycastPendingRef.current = true;
229
+ ctx.measureRaycastFrameRef.current = requestAnimationFrame(() => {
230
+ ctx.measureRaycastPendingRef.current = false;
231
+ ctx.measureRaycastFrameRef.current = null;
232
+
233
+ const currentLock = ctx.edgeLockStateRef.current;
234
+ const result = renderer.raycastSceneMagnetic(x, y, {
235
+ edge: currentLock.edge,
236
+ meshExpressId: currentLock.meshExpressId,
237
+ lockStrength: currentLock.lockStrength,
238
+ }, {
239
+ hiddenIds: ctx.hiddenEntitiesRef.current,
240
+ isolatedIds: ctx.isolatedEntitiesRef.current,
241
+ snapOptions: ctx.snapEnabledRef.current ? {
242
+ snapToVertices: true,
243
+ snapToEdges: true,
244
+ snapToFaces: true,
245
+ screenSnapRadius: 40,
246
+ } : {
247
+ snapToVertices: false,
248
+ snapToEdges: false,
249
+ snapToFaces: false,
250
+ screenSnapRadius: 0,
251
+ },
252
+ });
253
+
254
+ const point = result.snapTarget?.position
255
+ ?? result.intersection?.point
256
+ ?? raycastStoreyFloor(ctx, x, y);
257
+ const store = useViewerStore.getState();
258
+ store.setAddElementHoverPoint(point ? { x: point.x, y: point.y, z: point.z } : null);
259
+
260
+ // Mirror measure's snap-viz behaviour so vertex/edge/face indicators
261
+ // appear under the cursor with the same UX shape.
262
+ ctx.setSnapTarget(result.snapTarget ?? null);
263
+ if (result.snapTarget) {
264
+ if (result.edgeLock.shouldRelease) {
265
+ ctx.clearEdgeLock();
266
+ } else if (result.edgeLock.shouldLock && result.edgeLock.edge) {
267
+ ctx.setEdgeLock(result.edgeLock.edge, result.edgeLock.meshExpressId!, result.edgeLock.edgeT);
268
+ }
269
+ } else {
270
+ ctx.clearEdgeLock();
271
+ }
272
+ });
273
+ }
274
+ return true;
275
+ }
276
+
277
+ /**
278
+ * Resolve the active model + storey + a snap-aware world point. Surfaces
279
+ * the same toast errors all add-element entry points share.
280
+ */
281
+ function resolveAddElementContext(): { modelId: string; storeyId: number } | null {
282
+ const state = useViewerStore.getState();
283
+ const modelId = state.addElementModelId ?? resolveActiveModelId();
284
+ if (!modelId) {
285
+ toast.error("Couldn't add element: no model loaded");
286
+ return null;
287
+ }
288
+ const storeyId = state.addElementStoreyId ?? firstStoreyExpressId(modelId);
289
+ if (storeyId === null) {
290
+ toast.error("Couldn't add element: model has no IfcBuildingStorey");
291
+ return null;
292
+ }
293
+ return { modelId, storeyId };
294
+ }
295
+
296
+ /** Common post-place: pick the new entity's global id, toast, clear pending. */
297
+ function finishAddElement(
298
+ result: { expressId: number } | { error: string },
299
+ modelId: string,
300
+ label: string,
301
+ ): void {
302
+ const state = useViewerStore.getState();
303
+ if ('error' in result) {
304
+ toast.error(`Couldn't add ${label.toLowerCase()}: ${result.error}`);
305
+ return;
306
+ }
307
+ const globalId = toGlobalIdFromModels(state.models, modelId, result.expressId);
308
+ state.setSelectedEntityId(globalId);
309
+ state.clearAddElementPending();
310
+ toast.success(`${label} #${result.expressId} added — undo to remove`);
311
+ }
312
+
313
+ /**
314
+ * Handle a click landing on the scene while the addElement tool is
315
+ * active. Implements a per-type click state machine:
316
+ *
317
+ * - column: 1 click → place
318
+ * - wall / beam: 1st click → start, 2nd click → end + place
319
+ * - slab (rectangle): 1st click → corner, 2nd click → opposite + place
320
+ * - slab (polygon): N clicks accumulate; Enter / double-click closes
321
+ * (handled in the keyboard layer; this function only appends)
322
+ */
323
+ async function handleAddElementDrop(point: { x: number; y: number; z: number }): Promise<void> {
324
+ const ctx = resolveAddElementContext();
325
+ if (!ctx) return;
326
+ const { modelId, storeyId } = ctx;
327
+
328
+ const state = useViewerStore.getState();
329
+ const type = state.addElementType;
330
+
331
+ // Single-click placements: column / door / window all drop on one click.
332
+ if (type === 'column') {
333
+ const ifc = rendererPointToIfcStoreyLocal(point);
334
+ const p = state.addElementColumnParams;
335
+ finishAddElement(state.addColumn(modelId, storeyId, {
336
+ Position: ifc, Width: p.Width, Depth: p.Depth, Height: p.Height,
337
+ }), modelId, 'Column');
338
+ return;
339
+ }
340
+ if (type === 'door') {
341
+ const ifc = rendererPointToIfcStoreyLocal(point);
342
+ const p = state.addElementDoorParams;
343
+ finishAddElement(state.addDoor(modelId, storeyId, {
344
+ Position: ifc, Width: p.Width, Height: p.Height, FrameThickness: p.FrameThickness,
345
+ }), modelId, 'Door');
346
+ return;
347
+ }
348
+ if (type === 'window') {
349
+ const ifc = rendererPointToIfcStoreyLocal(point);
350
+ const p = state.addElementWindowParams;
351
+ finishAddElement(state.addWindow(modelId, storeyId, {
352
+ Position: ifc, Width: p.Width, Height: p.Height, FrameThickness: p.FrameThickness,
353
+ }), modelId, 'Window');
354
+ return;
355
+ }
356
+
357
+ if (type === 'wall' || type === 'beam' || type === 'member') {
358
+ const pending = state.addElementPendingPoints;
359
+ if (pending.length === 0) {
360
+ // Start point — store the renderer-frame point and wait for end.
361
+ state.appendAddElementPendingPoint({ x: point.x, y: point.y, z: point.z });
362
+ return;
363
+ }
364
+ // End point — convert both points to IFC at dispatch time.
365
+ const startIfc = rendererPointToIfcStoreyLocal(pending[0]);
366
+ const endIfc = rendererPointToIfcStoreyLocal(point);
367
+ if (type === 'wall') {
368
+ const p = state.addElementWallParams;
369
+ finishAddElement(state.addWall(modelId, storeyId, {
370
+ Start: startIfc, End: endIfc, Thickness: p.Thickness, Height: p.Height,
371
+ }), modelId, 'Wall');
372
+ } else if (type === 'beam') {
373
+ const p = state.addElementBeamParams;
374
+ finishAddElement(state.addBeam(modelId, storeyId, {
375
+ Start: startIfc, End: endIfc, Width: p.Width, Height: p.Height,
376
+ }), modelId, 'Beam');
377
+ } else {
378
+ // member
379
+ const p = state.addElementMemberParams;
380
+ finishAddElement(state.addMember(modelId, storeyId, {
381
+ Start: startIfc, End: endIfc, Width: p.Width, Height: p.Height,
382
+ }), modelId, 'Member');
383
+ }
384
+ return;
385
+ }
386
+
387
+ if (type === 'slab' || type === 'roof' || type === 'plate' || type === 'space') {
388
+ if (state.addElementSlabMode === 'rectangle') {
389
+ const pending = state.addElementPendingPoints;
390
+ if (pending.length === 0) {
391
+ state.appendAddElementPendingPoint({ x: point.x, y: point.y, z: point.z });
392
+ return;
393
+ }
394
+ const cornerIfc = rendererPointToIfcStoreyLocal(pending[0]);
395
+ const oppositeIfc = rendererPointToIfcStoreyLocal(point);
396
+ const minX = Math.min(cornerIfc[0], oppositeIfc[0]);
397
+ const minY = Math.min(cornerIfc[1], oppositeIfc[1]);
398
+ const width = Math.abs(oppositeIfc[0] - cornerIfc[0]);
399
+ const depth = Math.abs(oppositeIfc[1] - cornerIfc[1]);
400
+ if (width <= 0 || depth <= 0) {
401
+ toast.error(`${capitalize(type)} corners must span a non-zero rectangle`);
402
+ return;
403
+ }
404
+ const position: [number, number, number] = [minX, minY, 0];
405
+ switch (type) {
406
+ case 'slab': {
407
+ const p = state.addElementSlabParams;
408
+ finishAddElement(state.addSlab(modelId, storeyId, {
409
+ Position: position, Width: width, Depth: depth, Thickness: p.Thickness,
410
+ }), modelId, 'Slab');
411
+ return;
412
+ }
413
+ case 'roof': {
414
+ const p = state.addElementRoofParams;
415
+ finishAddElement(state.addRoof(modelId, storeyId, {
416
+ Position: position, Width: width, Depth: depth, Thickness: p.Thickness,
417
+ }), modelId, 'Roof');
418
+ return;
419
+ }
420
+ case 'plate': {
421
+ const p = state.addElementPlateParams;
422
+ finishAddElement(state.addPlate(modelId, storeyId, {
423
+ Position: position, Width: width, Depth: depth, Thickness: p.Thickness,
424
+ }), modelId, 'Plate');
425
+ return;
426
+ }
427
+ case 'space': {
428
+ const p = state.addElementSpaceParams;
429
+ finishAddElement(state.addSpace(modelId, storeyId, {
430
+ Position: position, Width: width, Depth: depth, Height: p.Height,
431
+ }), modelId, 'Space');
432
+ return;
433
+ }
434
+ }
435
+ }
436
+ // Polygon mode — append; close handled by Enter.
437
+ state.appendAddElementPendingPoint({ x: point.x, y: point.y, z: point.z });
438
+ return;
439
+ }
440
+ }
441
+
442
+ function capitalize(s: string): string {
443
+ return s.charAt(0).toUpperCase() + s.slice(1);
444
+ }
445
+
446
+ /** Signed 2D polygon area via the shoelace formula. */
447
+ function polygonArea2D(points: Array<[number, number]>): number {
448
+ if (points.length < 3) return 0;
449
+ let area = 0;
450
+ for (let i = 0; i < points.length; i++) {
451
+ const [x1, y1] = points[i];
452
+ const [x2, y2] = points[(i + 1) % points.length];
453
+ area += x1 * y2 - x2 * y1;
454
+ }
455
+ return area * 0.5;
456
+ }
457
+
458
+ /**
459
+ * Close an in-progress polygon for any slab-style type
460
+ * (slab / roof / plate / space). Triggered by Enter. Requires
461
+ * ≥3 points; the builder's auto-closure handles the trailing edge.
462
+ */
463
+ export function commitAddElementSlabPolygon(): void {
464
+ const state = useViewerStore.getState();
465
+ if (state.activeTool !== 'addElement') return;
466
+ const type = state.addElementType;
467
+ const polygonable = type === 'slab' || type === 'roof' || type === 'plate' || type === 'space';
468
+ if (!polygonable || state.addElementSlabMode !== 'polygon') return;
469
+ const pending = state.addElementPendingPoints;
470
+ if (pending.length < 3) {
471
+ toast.error(`${capitalize(type)} polygon needs at least 3 points`);
472
+ return;
473
+ }
474
+ const ctx = resolveAddElementContext();
475
+ if (!ctx) return;
476
+ const { modelId, storeyId } = ctx;
477
+ const outer = pending.map((pt) => {
478
+ const ifc = rendererPointToIfcStoreyLocal(pt);
479
+ return [ifc[0], ifc[1]] as [number, number];
480
+ });
481
+ // Reject degenerate (zero-area) polygons — repeated or collinear
482
+ // pending points would otherwise produce an OuterCurve that exports
483
+ // as an invalid slab/roof/plate/space profile.
484
+ if (Math.abs(polygonArea2D(outer)) < 1e-6) {
485
+ toast.error(`${capitalize(type)} polygon must have a non-zero area`);
486
+ return;
487
+ }
488
+ switch (type) {
489
+ case 'slab': {
490
+ const p = state.addElementSlabParams;
491
+ finishAddElement(state.addSlab(modelId, storeyId, {
492
+ Profile: 'polygon', OuterCurve: outer, Thickness: p.Thickness,
493
+ }), modelId, 'Slab');
494
+ return;
495
+ }
496
+ case 'roof': {
497
+ const p = state.addElementRoofParams;
498
+ finishAddElement(state.addRoof(modelId, storeyId, {
499
+ Profile: 'polygon', OuterCurve: outer, Thickness: p.Thickness,
500
+ }), modelId, 'Roof');
501
+ return;
502
+ }
503
+ case 'plate': {
504
+ const p = state.addElementPlateParams;
505
+ finishAddElement(state.addPlate(modelId, storeyId, {
506
+ Profile: 'polygon', OuterCurve: outer, Thickness: p.Thickness,
507
+ }), modelId, 'Plate');
508
+ return;
509
+ }
510
+ case 'space': {
511
+ const p = state.addElementSpaceParams;
512
+ finishAddElement(state.addSpace(modelId, storeyId, {
513
+ Profile: 'polygon', OuterCurve: outer, Height: p.Height,
514
+ }), modelId, 'Space');
515
+ return;
516
+ }
517
+ }
518
+ }
519
+
74
520
  /**
75
521
  * Handle context menu event (right-click).
76
522
  * Picks the entity under the cursor and opens the context menu.