@ifc-lite/viewer 1.17.6 → 1.19.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 (156) hide show
  1. package/.turbo/turbo-build.log +20 -15
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +949 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
  5. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  6. package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
  7. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
  8. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  9. package/dist/assets/index-0XpVr_S5.css +1 -0
  10. package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
  11. package/dist/assets/index-XwKzDuw6.js +22 -0
  12. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
  13. package/dist/assets/sandbox-Baez7n-t.js +9682 -0
  14. package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
  15. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
  16. package/dist/index.html +6 -6
  17. package/package.json +11 -10
  18. package/src/apache-arrow.d.ts +30 -0
  19. package/src/components/viewer/AddElementPanel.tsx +758 -0
  20. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  21. package/src/components/viewer/ChatPanel.tsx +64 -2
  22. package/src/components/viewer/CommandPalette.tsx +56 -7
  23. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  24. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  25. package/src/components/viewer/ExportDialog.tsx +19 -1
  26. package/src/components/viewer/MainToolbar.tsx +73 -12
  27. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  28. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  29. package/src/components/viewer/SearchInline.tsx +669 -0
  30. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  31. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  32. package/src/components/viewer/SearchModal.text.tsx +388 -0
  33. package/src/components/viewer/SearchModal.tsx +235 -0
  34. package/src/components/viewer/ToolOverlays.tsx +5 -0
  35. package/src/components/viewer/ViewerLayout.tsx +24 -4
  36. package/src/components/viewer/Viewport.tsx +29 -2
  37. package/src/components/viewer/ViewportContainer.tsx +45 -5
  38. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  39. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  40. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  41. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  42. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  43. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  44. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  45. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  46. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  47. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  48. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  49. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  50. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  51. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  52. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  53. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  54. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  55. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  56. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  57. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  58. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  59. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  60. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  61. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  62. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  63. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  64. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  65. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  66. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  67. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  68. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  69. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  70. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  71. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  72. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  73. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  74. package/src/components/viewer/selectionHandlers.ts +446 -0
  75. package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
  76. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  77. package/src/components/viewer/useMouseControls.ts +9 -1
  78. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  79. package/src/components/viewer/usePointCloudSync.ts +98 -0
  80. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  81. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  82. package/src/hooks/useIfcFederation.ts +72 -3
  83. package/src/hooks/useIfcLoader.ts +89 -13
  84. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  85. package/src/hooks/useSandbox.ts +1 -1
  86. package/src/hooks/useSearchIndex.ts +125 -0
  87. package/src/index.css +66 -0
  88. package/src/lib/llm/system-prompt.test.ts +14 -0
  89. package/src/lib/llm/system-prompt.ts +102 -1
  90. package/src/lib/llm/types.ts +6 -0
  91. package/src/lib/recent-files.ts +38 -4
  92. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  93. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  94. package/src/lib/scripts/templates.ts +7 -0
  95. package/src/lib/search/common-ifc-types.ts +36 -0
  96. package/src/lib/search/filter-evaluate.test.ts +537 -0
  97. package/src/lib/search/filter-evaluate.ts +610 -0
  98. package/src/lib/search/filter-rules.test.ts +119 -0
  99. package/src/lib/search/filter-rules.ts +198 -0
  100. package/src/lib/search/filter-schema.test.ts +233 -0
  101. package/src/lib/search/filter-schema.ts +146 -0
  102. package/src/lib/search/recent-searches.test.ts +116 -0
  103. package/src/lib/search/recent-searches.ts +93 -0
  104. package/src/lib/search/result-export.test.ts +101 -0
  105. package/src/lib/search/result-export.ts +104 -0
  106. package/src/lib/search/saved-filters.test.ts +118 -0
  107. package/src/lib/search/saved-filters.ts +154 -0
  108. package/src/lib/search/tier0-scan.test.ts +196 -0
  109. package/src/lib/search/tier0-scan.ts +237 -0
  110. package/src/lib/search/tier1-index.test.ts +242 -0
  111. package/src/lib/search/tier1-index.ts +448 -0
  112. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  113. package/src/sdk/adapters/export-adapter.ts +404 -1
  114. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  115. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  116. package/src/sdk/adapters/model-compat.ts +8 -2
  117. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  118. package/src/sdk/adapters/store-adapter.ts +201 -0
  119. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  120. package/src/sdk/local-backend.ts +16 -8
  121. package/src/services/desktop-export.ts +3 -1
  122. package/src/services/desktop-native-metadata.ts +41 -18
  123. package/src/services/file-dialog.ts +8 -3
  124. package/src/services/tauri-modules.d.ts +25 -0
  125. package/src/store/basketVisibleSet.ts +3 -0
  126. package/src/store/globalId.ts +4 -1
  127. package/src/store/index.ts +79 -1
  128. package/src/store/slices/addElementMeshes.ts +365 -0
  129. package/src/store/slices/addElementSlice.ts +275 -0
  130. package/src/store/slices/annotationsSlice.test.ts +133 -0
  131. package/src/store/slices/annotationsSlice.ts +251 -0
  132. package/src/store/slices/dataSlice.test.ts +23 -4
  133. package/src/store/slices/dataSlice.ts +1 -1
  134. package/src/store/slices/modelSlice.test.ts +67 -9
  135. package/src/store/slices/modelSlice.ts +39 -7
  136. package/src/store/slices/mutationSlice.ts +964 -3
  137. package/src/store/slices/overlayCompositor.test.ts +164 -0
  138. package/src/store/slices/overlaySlice.test.ts +93 -0
  139. package/src/store/slices/overlaySlice.ts +151 -0
  140. package/src/store/slices/pinboardSlice.test.ts +6 -1
  141. package/src/store/slices/playbackSlice.ts +128 -0
  142. package/src/store/slices/pointCloudSlice.ts +102 -0
  143. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  144. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  145. package/src/store/slices/scheduleSlice.test.ts +694 -0
  146. package/src/store/slices/scheduleSlice.ts +1330 -0
  147. package/src/store/slices/searchSlice.test.ts +342 -0
  148. package/src/store/slices/searchSlice.ts +341 -0
  149. package/src/store/slices/selectionSlice.test.ts +46 -0
  150. package/src/store/slices/selectionSlice.ts +20 -0
  151. package/src/store/types.ts +7 -0
  152. package/src/store.ts +14 -0
  153. package/vite.config.ts +1 -0
  154. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  155. package/dist/assets/index-_bfZsDCC.css +0 -1
  156. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -33,6 +33,12 @@ import {
33
33
  parseIfcxViewerModel,
34
34
  parseStepBufferViewerModel,
35
35
  } from './ingest/viewerModelIngest.js';
36
+ import {
37
+ detectPointCloudFormat,
38
+ ingestPointCloud,
39
+ type PointCloudFormat,
40
+ } from './ingest/pointCloudIngest.js';
41
+ import { getGlobalRenderer } from './useBCF.js';
36
42
  import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
37
43
  import { getEffectiveGeoreference, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
38
44
 
@@ -439,14 +445,53 @@ export function useIfcFederation() {
439
445
  : await file.arrayBuffer();
440
446
  const fileSizeMB = buffer.byteLength / (1024 * 1024);
441
447
 
448
+ // Detect point cloud formats first — we never run them through
449
+ // detectFormat() (which is IFC-shaped) because they have their own
450
+ // streaming pipeline that bypasses geometryResult.meshes.
451
+ const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
452
+
442
453
  // Detect file format
443
- const format = detectFormat(buffer);
454
+ const format: ReturnType<typeof detectFormat> | PointCloudFormat =
455
+ pointCloudFormat ?? detectFormat(buffer);
444
456
 
445
457
  let parsedDataStore: IfcDataStore | null = null;
446
458
  let parsedGeometry: FederatedModel['geometryResult'] = null;
447
459
  let schemaVersion: SchemaVersion = 'IFC4';
448
-
449
- if (format === 'ifcx') {
460
+ // Renderer handle for streamed point clouds; surviving model lifecycle
461
+ // depends on persisting it onto the FederatedModel record.
462
+ let pointCloudHandleId: number | undefined;
463
+
464
+ if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
465
+ const renderer = getGlobalRenderer();
466
+ if (!renderer) {
467
+ setError('Renderer not initialised — try again after the viewer mounts.');
468
+ setLoading(false);
469
+ return null;
470
+ }
471
+ setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
472
+ const blob = isNativeFileHandle(file)
473
+ ? new Blob([buffer])
474
+ : (file as File);
475
+ const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
476
+ const ingest = ingestPointCloud({
477
+ format,
478
+ blob,
479
+ fileName: file.name,
480
+ buffer,
481
+ renderer,
482
+ onProgress: setProgress,
483
+ onAssetCountDelta: incCount,
484
+ });
485
+ // ingest.done rejects on stream errors; ingestPointCloud's onError
486
+ // callback already calls removePointCloudAsset + incCount(-1), so
487
+ // the outer catch must NOT repeat that cleanup or the count goes
488
+ // negative when other point clouds are still loaded.
489
+ await ingest.done;
490
+ parsedDataStore = ingest.dataStore;
491
+ parsedGeometry = ingest.geometryResult;
492
+ schemaVersion = ingest.schemaVersion;
493
+ pointCloudHandleId = ingest.rendererHandle.id;
494
+ } else if (format === 'ifcx') {
450
495
  setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
451
496
  try {
452
497
  const result = await parseIfcxViewerModel(buffer, setProgress);
@@ -541,6 +586,29 @@ export function useIfcFederation() {
541
586
  for (const mesh of parsedGeometry.meshes) {
542
587
  mesh.expressId = mesh.expressId + idOffset;
543
588
  }
589
+ // Point clouds need the same offset so picking / isolation /
590
+ // property lookup resolve through the FederationRegistry's
591
+ // global ID space — otherwise two pointcloud models with the
592
+ // same local expressId collide.
593
+ for (const asset of parsedGeometry.pointClouds ?? []) {
594
+ asset.expressId = asset.expressId + idOffset;
595
+ }
596
+ }
597
+ // Streamed point cloud: the GPU asset was opened with a synthetic
598
+ // local expressId. After registerModelOffset() hands us an
599
+ // idOffset, the renderer needs to emit the post-offset globalId
600
+ // in picking + selection outputs — otherwise picks resolve to
601
+ // the local id and collide across federated models. The shader
602
+ // reads expressId from a per-asset uniform (`flags.x`) so this
603
+ // is just a metadata update; no GPU buffer rewrite.
604
+ if (idOffset > 0 && pointCloudHandleId !== undefined) {
605
+ const renderer = getGlobalRenderer();
606
+ if (renderer && parsedGeometry.pointClouds && parsedGeometry.pointClouds.length > 0) {
607
+ // Use the asset that's already had idOffset folded in above
608
+ // as the source of truth for the global id.
609
+ const asset = parsedGeometry.pointClouds[0];
610
+ renderer.relabelPointCloudAsset({ id: pointCloudHandleId }, asset.expressId);
611
+ }
544
612
  }
545
613
 
546
614
  // =========================================================================
@@ -567,6 +635,7 @@ export function useIfcFederation() {
567
635
  sourceFile: file,
568
636
  idOffset,
569
637
  maxExpressId,
638
+ pointCloudHandleId,
570
639
  };
571
640
 
572
641
  // Add to store
@@ -46,6 +46,8 @@ import { useIfcCache, getCached } from './useIfcCache.js';
46
46
  import { useIfcServer } from './useIfcServer.js';
47
47
 
48
48
  import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './ingest/viewerModelIngest.js';
49
+ import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
50
+ import { getGlobalRenderer } from './useBCF.js';
49
51
 
50
52
  /**
51
53
  * Compute a fast content fingerprint from the first and last 4KB of a buffer.
@@ -253,7 +255,7 @@ export function useIfcLoader() {
253
255
  dataStore: IfcDataStore | null,
254
256
  geometryResult: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null,
255
257
  schemaVersion: 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5',
256
- patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null },
258
+ patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number },
257
259
  ) => {
258
260
  let idOffset = 0;
259
261
  let maxExpressId = 0;
@@ -271,6 +273,7 @@ export function useIfcLoader() {
271
273
  loadState: patch?.loadState ?? 'complete',
272
274
  cacheState: patch?.cacheState ?? 'none',
273
275
  loadError: patch?.loadError ?? null,
276
+ pointCloudHandleId: patch?.pointCloudHandleId,
274
277
  });
275
278
  };
276
279
  const getSchemaVersion = (dataStore: IfcDataStore | null): 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5' => {
@@ -281,15 +284,23 @@ export function useIfcLoader() {
281
284
  return 'IFC2X3';
282
285
  };
283
286
 
287
+ // Native renderer streaming path is currently disabled — the
288
+ // `huge native file` block further down handles real desktop
289
+ // streaming. This branch is retained as a scaffold for the future
290
+ // always-on native renderer integration.
291
+ const NATIVE_RENDERER_PATH_ENABLED = false as boolean;
284
292
  if (
293
+ NATIVE_RENDERER_PATH_ENABLED &&
285
294
  isNativeFileHandle(file) &&
286
- fileName.toLowerCase().endsWith('.ifc') &&
287
- false
295
+ fileName.toLowerCase().endsWith('.ifc')
288
296
  ) {
297
+ // Re-narrow `file` for the body — TS occasionally drops the
298
+ // type-predicate result inside a dead branch.
299
+ const nativeFile: NativeFileHandle = file;
289
300
  const harnessRequest = getActiveHarnessRequest();
290
- const nativeCacheKey = computeNativeCacheKey(file);
291
- const shouldUseNativeCache = file.size >= CACHE_SIZE_THRESHOLD;
292
- const hugeNativeMode = file.size >= HUGE_NATIVE_FILE_THRESHOLD;
301
+ const nativeCacheKey = computeNativeCacheKey(nativeFile);
302
+ const shouldUseNativeCache = nativeFile.size >= CACHE_SIZE_THRESHOLD;
303
+ const hugeNativeMode = nativeFile.size >= HUGE_NATIVE_FILE_THRESHOLD;
293
304
  let firstBatchWaitMs: number | null = null;
294
305
  let firstVisibleGeometryMs: number | null = null;
295
306
  let modelOpenMs: number | null = null;
@@ -312,7 +323,7 @@ export function useIfcLoader() {
312
323
  let nativeGeometryCacheHit = false;
313
324
  let nativeMetadataSnapshotHit = false;
314
325
  let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
315
- let nativeMetadataStartGate: 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete' = 'immediate';
326
+ let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
316
327
  let finalCoordinateInfo: CoordinateInfo | null = null;
317
328
 
318
329
  console.log(`[useIfc] Native renderer load: ${fileName}, size: ${fileSizeMB.toFixed(2)}MB`);
@@ -727,7 +738,7 @@ export function useIfcLoader() {
727
738
  let fullNativeDataStore: IfcDataStore | null = null;
728
739
  let nativeLoadStage: 'open' | 'streamGeometry' | 'finalizeGeometry' | 'hydrateMetadata' | 'complete' = 'open';
729
740
  let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
730
- let nativeMetadataStartGate: 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete' = 'immediate';
741
+ let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
731
742
 
732
743
  setGeometryResult(null);
733
744
 
@@ -1552,8 +1563,69 @@ export function useIfcLoader() {
1552
1563
  const fileReadMs = performance.now() - fileReadStart;
1553
1564
  console.log(`[useIfc] File: ${file.name}, size: ${fileSizeMB.toFixed(2)}MB, read in ${fileReadMs.toFixed(0)}ms`);
1554
1565
 
1555
- // Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB)
1556
- const format = detectFormat(buffer);
1566
+ // Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB vs LAS/LAZ)
1567
+ const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
1568
+ const format = pointCloudFormat ?? detectFormat(buffer);
1569
+
1570
+ // LAS / LAZ point clouds: stream chunks straight to the renderer.
1571
+ // No on-disk cache, no server upload — the data goes worker → GPU.
1572
+ if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
1573
+ const renderer = getGlobalRenderer();
1574
+ if (!renderer) {
1575
+ setError('Renderer not initialised — try again after the viewer mounts.');
1576
+ updateModel(primaryModelId, { loadState: 'error', loadError: 'renderer-missing' });
1577
+ setLoading(false);
1578
+ return;
1579
+ }
1580
+ setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
1581
+ setGeometryStreamingActive(false);
1582
+ const blob = isNativeFileHandle(file) ? new Blob([buffer]) : (file as File);
1583
+ const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
1584
+ const ingest = ingestPointCloud({
1585
+ format,
1586
+ blob,
1587
+ fileName: file.name,
1588
+ buffer,
1589
+ renderer,
1590
+ onProgress: setProgress,
1591
+ onAssetCountDelta: incCount,
1592
+ });
1593
+ // ingestPointCloud's onError callback already runs renderer cleanup
1594
+ // + incCount(-1); the outer catch must NOT repeat them or the
1595
+ // pointCloudAssetCount will go negative.
1596
+ try {
1597
+ await ingest.done;
1598
+ } catch (err) {
1599
+ // Bail without touching store/UI state if a newer load
1600
+ // session has already started — the more recent flow owns
1601
+ // the spinner / model record now. Free the renderer handle
1602
+ // so we don't leak the half-streamed asset.
1603
+ if (loadSessionRef.current !== currentSession) {
1604
+ renderer.removePointCloudAsset(ingest.rendererHandle);
1605
+ return;
1606
+ }
1607
+ const message = err instanceof Error ? err.message : String(err);
1608
+ updateModel(primaryModelId, { loadState: 'error', loadError: message });
1609
+ setError(`${format.toUpperCase()} parsing failed: ${message}`);
1610
+ setLoading(false);
1611
+ return;
1612
+ }
1613
+ if (loadSessionRef.current !== currentSession) {
1614
+ // A newer load already began. Drop our streamed asset and
1615
+ // skip every store/UI mutation so we don't overwrite the
1616
+ // newer model's state.
1617
+ renderer.removePointCloudAsset(ingest.rendererHandle);
1618
+ return;
1619
+ }
1620
+ setGeometryResult(ingest.geometryResult);
1621
+ setIfcDataStore(ingest.dataStore);
1622
+ finalizePrimaryModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
1623
+ pointCloudHandleId: ingest.rendererHandle.id,
1624
+ });
1625
+ setProgress({ phase: 'Complete', percent: 100 });
1626
+ setLoading(false);
1627
+ return;
1628
+ }
1557
1629
 
1558
1630
  // IFCX files must be parsed client-side (server only supports IFC4 STEP)
1559
1631
  if (format === 'ifcx') {
@@ -1638,8 +1710,10 @@ export function useIfcLoader() {
1638
1710
  }
1639
1711
 
1640
1712
  // Try server parsing first (enabled by default for multi-core performance)
1641
- // Only for IFC4 STEP files (server doesn't support IFCX)
1642
- if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
1713
+ // Only for IFC4 STEP files (server doesn't support IFCX). Native
1714
+ // file handles (Tauri) don't have an HTTP-uploadable body, so skip
1715
+ // the server path and fall through to the WASM loader.
1716
+ if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
1643
1717
  // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
1644
1718
  const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
1645
1719
  if (serverSuccess) {
@@ -1792,7 +1866,9 @@ export function useIfcLoader() {
1792
1866
  if (geometryIteratorClosed || typeof geometryIterator.return !== 'function') return;
1793
1867
  geometryIteratorClosed = true;
1794
1868
  try {
1795
- await geometryIterator.return();
1869
+ // `AsyncIterator.return()` is signed as taking a value in
1870
+ // current TS libs; callers conventionally pass `undefined`.
1871
+ await geometryIterator.return(undefined);
1796
1872
  } catch {
1797
1873
  // Ignore iterator shutdown failures during recovery.
1798
1874
  }
@@ -88,6 +88,10 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
88
88
  e.preventDefault();
89
89
  setActiveTool('section');
90
90
  }
91
+ if (key === 'p' && !ctrl && !shift) {
92
+ e.preventDefault();
93
+ setActiveTool('annotate');
94
+ }
91
95
 
92
96
  // Basket controls (automatic context source)
93
97
  // I = Isolate from current context
@@ -150,6 +154,26 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
150
154
  resetVisibilityForHomeFromStore();
151
155
  }
152
156
 
157
+ // Add-element tool shortcuts — Enter commits an in-progress slab
158
+ // polygon; Esc clears any pending points before falling through to
159
+ // the global Esc handler (which exits the tool).
160
+ if (activeTool === 'addElement') {
161
+ const state = useViewerStore.getState();
162
+ const polygonable = ['slab', 'roof', 'plate', 'space'].includes(state.addElementType);
163
+ if (key === 'enter' && polygonable && state.addElementSlabMode === 'polygon') {
164
+ e.preventDefault();
165
+ // Lazy import keeps this module out of the keyboard hook's
166
+ // synchronous bundle (the close handler pulls in toast).
167
+ import('@/components/viewer/selectionHandlers').then((mod) => mod.commitAddElementSlabPolygon());
168
+ return;
169
+ }
170
+ if (key === 'escape' && state.addElementPendingPoints.length > 0) {
171
+ e.preventDefault();
172
+ state.clearAddElementPending();
173
+ return;
174
+ }
175
+ }
176
+
153
177
  // Measure tool shortcuts
154
178
  if (activeTool === 'measure') {
155
179
  // Cancel active measurement with ESC
@@ -243,6 +267,7 @@ export const KEYBOARD_SHORTCUTS = [
243
267
  { key: 'V', description: 'Select tool', category: 'Tools' },
244
268
  { key: 'C', description: 'Walk mode', category: 'Tools' },
245
269
  { key: 'M', description: 'Measure tool', category: 'Tools' },
270
+ { key: 'P', description: 'Annotate tool — drop a pin with a note', category: 'Tools' },
246
271
  { key: 'X', description: 'Section tool', category: 'Tools' },
247
272
  { key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
248
273
  { key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
@@ -165,7 +165,7 @@ export function useSandbox(config?: SandboxConfig) {
165
165
  // Create a fresh sandbox for every execution — full isolation
166
166
  const { createSandbox } = await import('@ifc-lite/sandbox');
167
167
  sandbox = await createSandbox(bim, {
168
- permissions: { model: true, query: true, viewer: true, mutate: true, lens: true, export: true, files: true, ...config?.permissions },
168
+ permissions: { model: true, query: true, viewer: true, mutate: true, store: true, lens: true, export: true, files: true, ...config?.permissions },
169
169
  limits: { timeoutMs: 30_000, ...config?.limits },
170
170
  });
171
171
  activeSandboxRef.current = sandbox;
@@ -0,0 +1,125 @@
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
+ * useSearchIndex — lazy builder for the Tier-1 search index.
7
+ *
8
+ * Mount once near the root of the viewer shell (currently `SearchInline`,
9
+ * since it's always rendered once the toolbar is up). The hook watches
10
+ * the federated `models` map; for each model with a populated
11
+ * `ifcDataStore` that doesn't yet have a Tier-1 record, it spawns a
12
+ * chunked build. Models that disappear get their index record dropped.
13
+ *
14
+ * Load-perf guarantee: the build NEVER runs during the actual IFC load
15
+ * because `ifcDataStore` is non-null only after the parser reports the
16
+ * model is ready (`onSpatialReady` + geometry). The build itself yields
17
+ * to the event loop every `DEFAULT_CHUNK_SIZE` rows so a 4M-entity
18
+ * index doesn't hog the main thread.
19
+ */
20
+
21
+ import { useEffect, useRef } from 'react';
22
+ import { useShallow } from 'zustand/react/shallow';
23
+ import { useViewerStore } from '@/store';
24
+ import { buildTier1Index } from '@/lib/search/tier1-index';
25
+
26
+ export function useSearchIndex(): void {
27
+ const {
28
+ models,
29
+ searchIndexes,
30
+ setSearchIndexRecord,
31
+ removeSearchIndexRecord,
32
+ searchFilterSchema,
33
+ removeFilterSchema,
34
+ } = useViewerStore(
35
+ useShallow((s) => ({
36
+ models: s.models,
37
+ searchIndexes: s.searchIndexes,
38
+ setSearchIndexRecord: s.setSearchIndexRecord,
39
+ removeSearchIndexRecord: s.removeSearchIndexRecord,
40
+ searchFilterSchema: s.searchFilterSchema,
41
+ removeFilterSchema: s.removeFilterSchema,
42
+ })),
43
+ );
44
+
45
+ // One AbortController per in-flight build. Lets us cancel cleanly when a
46
+ // model is removed mid-build or when the component unmounts.
47
+ const controllersRef = useRef<Map<string, AbortController>>(new Map());
48
+
49
+ useEffect(() => {
50
+ const controllers = controllersRef.current;
51
+
52
+ // Drop records / abort builds for models that no longer exist.
53
+ for (const modelId of Array.from(searchIndexes.keys())) {
54
+ if (!models.has(modelId)) {
55
+ controllers.get(modelId)?.abort();
56
+ controllers.delete(modelId);
57
+ removeSearchIndexRecord(modelId);
58
+ }
59
+ }
60
+
61
+ // Drop the filter-schema cache for departed models too. Stale entries
62
+ // would surface in the chip dropdowns the next time a model with the
63
+ // same id loaded (e.g. user reopens a different file as model_0).
64
+ for (const modelId of Array.from(searchFilterSchema.keys())) {
65
+ if (!models.has(modelId)) removeFilterSchema(modelId);
66
+ }
67
+
68
+ // Kick off builds for models that are loaded but not yet indexed.
69
+ for (const [modelId, model] of models) {
70
+ if (!model.ifcDataStore) continue;
71
+ const existing = searchIndexes.get(modelId);
72
+ if (existing && existing.status !== 'pending') continue;
73
+ if (controllers.has(modelId)) continue;
74
+
75
+ const controller = new AbortController();
76
+ controllers.set(modelId, controller);
77
+
78
+ setSearchIndexRecord(modelId, { status: 'building', progress: 0 });
79
+
80
+ // Fire-and-forget — the build is cancellable via the controller, and
81
+ // the completion handlers update the store without needing a ref.
82
+ void buildTier1Index(modelId, model.ifcDataStore, {
83
+ signal: controller.signal,
84
+ onProgress: (done, total) => {
85
+ if (controller.signal.aborted) return;
86
+ const progress = total > 0 ? done / total : 1;
87
+ setSearchIndexRecord(modelId, { status: 'building', progress });
88
+ },
89
+ })
90
+ .then((index) => {
91
+ if (controller.signal.aborted) return;
92
+ controllers.delete(modelId);
93
+ setSearchIndexRecord(modelId, { status: 'ready', index, progress: 1 });
94
+ })
95
+ .catch((err: unknown) => {
96
+ controllers.delete(modelId);
97
+ if (err instanceof DOMException && err.name === 'AbortError') return;
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ // Don't set a 'ready' record — Tier-0 fallback stays live.
100
+ console.warn(`[useSearchIndex] build failed for ${modelId}:`, message);
101
+ setSearchIndexRecord(modelId, { status: 'error', error: message });
102
+ });
103
+ }
104
+
105
+ // On unmount OR next effect pass, abort everything. The effect re-runs
106
+ // only when `models` / `searchIndexes` changes, so steady-state
107
+ // incurs no abort — the `controllers.has(modelId)` guard above makes
108
+ // re-entry idempotent.
109
+ return () => {
110
+ // Intentionally NOT aborting everything on every re-render — only
111
+ // models that went missing got aborted above. The real cleanup is
112
+ // the component-unmount pass below.
113
+ };
114
+ }, [models, searchIndexes, setSearchIndexRecord, removeSearchIndexRecord, searchFilterSchema, removeFilterSchema]);
115
+
116
+ // Abort any in-flight builds when the consumer unmounts. Separate effect
117
+ // so it only fires on unmount (no deps).
118
+ useEffect(() => {
119
+ const controllers = controllersRef.current;
120
+ return () => {
121
+ for (const c of controllers.values()) c.abort();
122
+ controllers.clear();
123
+ };
124
+ }, []);
125
+ }
package/src/index.css CHANGED
@@ -379,6 +379,43 @@ body {
379
379
  color: var(--color-primary) !important;
380
380
  }
381
381
 
382
+ /* Raw STEP tab — terminal-flavoured dev affordance.
383
+ Compact (icon-only </>), separates visually from the three "human"
384
+ tabs with a left divider and a green active state that nods to a
385
+ shell cursor. Stays width-stable at narrow panel widths because
386
+ it explicitly opts out of `flex: 1` (uses an `auto`-ish width
387
+ driven by the </> glyph plus padding). */
388
+ .properties-tab-trigger.raw-step-tab-trigger {
389
+ flex: 0 0 auto;
390
+ min-width: 2.25rem;
391
+ padding-left: 0.5rem;
392
+ padding-right: 0.5rem;
393
+ border-left: 1px solid var(--tabs-border);
394
+ letter-spacing: 0;
395
+ color: color-mix(in srgb, var(--tab-text) 70%, transparent) !important;
396
+ }
397
+
398
+ .properties-tab-trigger.raw-step-tab-trigger:hover {
399
+ background-color: color-mix(in srgb, #10b981 16%, var(--tabs-bg)) !important;
400
+ color: #047857 !important;
401
+ }
402
+
403
+ .dark .properties-tab-trigger.raw-step-tab-trigger:hover {
404
+ background-color: color-mix(in srgb, #10b981 22%, var(--tabs-bg)) !important;
405
+ color: #34d399 !important;
406
+ }
407
+
408
+ .properties-tab-trigger.raw-step-tab-trigger[data-state="active"] {
409
+ background-color: color-mix(in srgb, #10b981 14%, var(--tab-active-bg)) !important;
410
+ color: #047857 !important;
411
+ border-top-color: #10b981 !important;
412
+ }
413
+
414
+ .dark .properties-tab-trigger.raw-step-tab-trigger[data-state="active"] {
415
+ color: #34d399 !important;
416
+ border-top-color: #34d399 !important;
417
+ }
418
+
382
419
  /* Quantity cards - cyan accent */
383
420
  .dark .border-blue-200,
384
421
  .dark .border-blue-800,
@@ -997,6 +1034,35 @@ body {
997
1034
  cursor: not-allowed;
998
1035
  }
999
1036
 
1037
+ /* Annotation pin idle introduction — fires once on mount, then settles.
1038
+ Driven by a `prefers-reduced-motion` guard so accessibility users
1039
+ don't get hit by the pulse. */
1040
+ @keyframes annotation-pin-idle {
1041
+ 0% {
1042
+ transform: scale(0.7);
1043
+ box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
1044
+ }
1045
+ 60% {
1046
+ transform: scale(1.08);
1047
+ box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
1048
+ }
1049
+ 100% {
1050
+ transform: scale(1);
1051
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35),
1052
+ 0 0 0 1px rgba(0, 0, 0, 0.15);
1053
+ }
1054
+ }
1055
+
1056
+ .annotation-pin-idle {
1057
+ animation: annotation-pin-idle 360ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
1058
+ }
1059
+
1060
+ @media (prefers-reduced-motion: reduce) {
1061
+ .annotation-pin-idle {
1062
+ animation: none;
1063
+ }
1064
+ }
1065
+
1000
1066
  /* Loading skeleton animation */
1001
1067
  @keyframes skeleton-pulse {
1002
1068
  0%, 100% {
@@ -91,6 +91,20 @@ test('system prompt includes selected entity IFC context when provided', () => {
91
91
  assert.match(prompt, /Selected entities: Tower: IfcCurtainWall "Facade Panel A", kind=occurrence, storey=Level 10@31.5m, psets=Pset_CurtainWallCommon, typePsets=Pset_CurtainWallTypeCommon, qsets=Qto_CurtainWallBaseQuantities, material=Aluminium, classifications=A-123 \| Tower: IfcWallType "Exterior Wall Type", kind=type, psets=Pset_WallCommon, classifications=A-WALL/);
92
92
  });
93
93
 
94
+ test('system prompt includes the BIM.STORE cheat sheet and routing rule', () => {
95
+ const prompt = buildSystemPrompt();
96
+
97
+ // Section heading + the three method examples
98
+ assert.match(prompt, /## BIM\.STORE CHEAT SHEET/);
99
+ assert.match(prompt, /bim\.store\.setPositionalAttribute\(profile, 3, 0\.6\)/);
100
+ assert.match(prompt, /bim\.store\.addEntity\("arch"/);
101
+ assert.match(prompt, /bim\.store\.removeEntity\(unwantedRef\)/);
102
+
103
+ // Routing rule disambiguating store / mutate / create
104
+ assert.match(prompt, /bim\.store\.setPositionalAttribute\(entity, index, value\)`? for positional STEP-argument edits/);
105
+ assert.match(prompt, /Do NOT use `bim\.create` for these/);
106
+ });
107
+
94
108
  test('system prompt includes method-specific create contract guidance', () => {
95
109
  const prompt = buildSystemPrompt();
96
110
  assert.match(prompt, /BIM\.CREATE CONTRACT CHEAT SHEET/);