@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
@@ -9,19 +9,23 @@ import { TooltipProvider } from '@/components/ui/tooltip';
9
9
  import { MainToolbar } from './MainToolbar';
10
10
  import { HierarchyPanel } from './HierarchyPanel';
11
11
  import { PropertiesPanel } from './PropertiesPanel';
12
+ import { AddElementPanel } from './AddElementPanel';
12
13
  import { StatusBar } from './StatusBar';
13
14
  import { ViewportContainer } from './ViewportContainer';
14
15
  import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
15
16
  import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
16
17
  import { useViewerStore } from '@/store';
17
18
  import { EntityContextMenu } from './EntityContextMenu';
19
+ import { useDuplicateShortcut } from './useDuplicateShortcut';
18
20
  import { HoverTooltip } from './HoverTooltip';
19
21
  import { BCFPanel } from './BCFPanel';
20
22
  import { IDSPanel } from './IDSPanel';
21
23
  import { LensPanel } from './LensPanel';
22
24
  import { ListPanel } from './lists/ListPanel';
23
25
  import { ScriptPanel } from './ScriptPanel';
26
+ import { GanttPanel } from './schedule/GanttPanel';
24
27
  import { CommandPalette } from './CommandPalette';
28
+ import { SearchModal } from './SearchModal';
25
29
  import { DesktopEntitlementBanner } from './DesktopEntitlementBanner';
26
30
  import {
27
31
  closeActiveAnalysisExtension,
@@ -37,6 +41,8 @@ const BOTTOM_PANEL_MAX_RATIO = 0.7; // max 70% of container
37
41
  export function ViewerLayout() {
38
42
  // Initialize keyboard shortcuts
39
43
  useKeyboardShortcuts();
44
+ // ⌘D / Ctrl+D to duplicate the current selection.
45
+ useDuplicateShortcut();
40
46
  const shortcutsDialog = useKeyboardShortcutsDialog();
41
47
 
42
48
  // Command palette state
@@ -76,6 +82,8 @@ export function ViewerLayout() {
76
82
  const setRightPanelCollapsed = useViewerStore((s) => s.setRightPanelCollapsed);
77
83
  const bcfPanelVisible = useViewerStore((s) => s.bcfPanelVisible);
78
84
  const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
85
+ const activeTool = useViewerStore((s) => s.activeTool);
86
+ const setActiveTool = useViewerStore((s) => s.setActiveTool);
79
87
  const idsPanelVisible = useViewerStore((s) => s.idsPanelVisible);
80
88
  const setIdsPanelVisible = useViewerStore((s) => s.setIdsPanelVisible);
81
89
  const listPanelVisible = useViewerStore((s) => s.listPanelVisible);
@@ -84,6 +92,8 @@ export function ViewerLayout() {
84
92
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
85
93
  const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
86
94
  const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
95
+ const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
96
+ const setGanttPanelVisible = useViewerStore((s) => s.setGanttPanelVisible);
87
97
  const analysisExtensionState = useSyncExternalStore(
88
98
  subscribeAnalysisExtensions,
89
99
  getAnalysisExtensionsSnapshot,
@@ -200,6 +210,7 @@ export function ViewerLayout() {
200
210
  <EntityContextMenu />
201
211
  <HoverTooltip />
202
212
  <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
213
+ <SearchModal />
203
214
 
204
215
  {/* Main Toolbar */}
205
216
  <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
@@ -256,6 +267,8 @@ export function ViewerLayout() {
256
267
  <div className="h-full w-full overflow-hidden panel-container">
257
268
  {activeRightAnalysisExtension ? (
258
269
  activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
270
+ ) : activeTool === 'addElement' ? (
271
+ <AddElementPanel onClose={() => setActiveTool('select')} />
259
272
  ) : lensPanelVisible ? (
260
273
  <LensPanel onClose={() => setLensPanelVisible(false)} />
261
274
  ) : idsPanelVisible ? (
@@ -270,8 +283,8 @@ export function ViewerLayout() {
270
283
  </PanelGroup>
271
284
  </div>
272
285
 
273
- {/* Bottom Panel - Lists or Script (custom resizable, outside PanelGroup) */}
274
- {(listPanelVisible || scriptPanelVisible || !!activeBottomAnalysisExtension) && (
286
+ {/* Bottom Panel - Lists / Script / Gantt / analysis ext (custom resizable) */}
287
+ {(listPanelVisible || scriptPanelVisible || ganttPanelVisible || !!activeBottomAnalysisExtension) && (
275
288
  <div style={{ height: bottomHeight, flexShrink: 0 }} className="relative">
276
289
  {/* Drag handle */}
277
290
  <div
@@ -281,6 +294,8 @@ export function ViewerLayout() {
281
294
  <div className="h-full w-full overflow-hidden border-t pt-1.5">
282
295
  {activeBottomAnalysisExtension ? (
283
296
  activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
297
+ ) : ganttPanelVisible ? (
298
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
284
299
  ) : scriptPanelVisible ? (
285
300
  <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
286
301
  ) : (
@@ -326,7 +341,7 @@ export function ViewerLayout() {
326
341
  <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
327
342
  <div className="flex items-center justify-between p-2 border-b">
328
343
  <span className="font-medium text-sm">
329
- {activeAnalysisExtension ? activeAnalysisExtension.label : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
344
+ {activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Inspector'}
330
345
  </span>
331
346
  <button
332
347
  className="p-1 hover:bg-muted rounded"
@@ -334,6 +349,7 @@ export function ViewerLayout() {
334
349
  setRightPanelCollapsed(true);
335
350
  if (scriptPanelVisible) setScriptPanelVisible(false);
336
351
  if (listPanelVisible) setListPanelVisible(false);
352
+ if (ganttPanelVisible) setGanttPanelVisible(false);
337
353
  if (bcfPanelVisible) setBcfPanelVisible(false);
338
354
  if (lensPanelVisible) setLensPanelVisible(false);
339
355
  if (idsPanelVisible) setIdsPanelVisible(false);
@@ -351,10 +367,14 @@ export function ViewerLayout() {
351
367
  activeBottomAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
352
368
  ) : activeRightAnalysisExtension ? (
353
369
  activeRightAnalysisExtension.renderPanel({ onClose: closeActiveAnalysisExtension })
370
+ ) : ganttPanelVisible ? (
371
+ <GanttPanel onClose={() => setGanttPanelVisible(false)} />
354
372
  ) : scriptPanelVisible ? (
355
373
  <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
356
374
  ) : listPanelVisible ? (
357
375
  <ListPanel onClose={() => setListPanelVisible(false)} />
376
+ ) : activeTool === 'addElement' ? (
377
+ <AddElementPanel onClose={() => setActiveTool('select')} />
358
378
  ) : lensPanelVisible ? (
359
379
  <LensPanel onClose={() => setLensPanelVisible(false)} />
360
380
  ) : idsPanelVisible ? (
@@ -386,7 +406,7 @@ export function ViewerLayout() {
386
406
  setRightPanelCollapsed(!rightPanelCollapsed);
387
407
  }}
388
408
  >
389
- Properties
409
+ Inspector
390
410
  </button>
391
411
  </div>
392
412
  </div>
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
10
10
  import { Renderer, type VisualEnhancementOptions } from '@ifc-lite/renderer';
11
- import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
11
+ import type { MeshData, CoordinateInfo, PointCloudAsset } from '@ifc-lite/geometry';
12
12
  import { useViewerStore, resolveEntityRef, type MeasurePoint, type SnapVisualization } from '@/store';
13
13
  import {
14
14
  useSelectionState,
@@ -36,6 +36,8 @@ import { useTouchControls, type TouchState } from './useTouchControls.js';
36
36
  import { useKeyboardControls } from './useKeyboardControls.js';
37
37
  import { useAnimationLoop } from './useAnimationLoop.js';
38
38
  import { useGeometryStreaming } from './useGeometryStreaming.js';
39
+ import { usePointCloudSync } from './usePointCloudSync.js';
40
+ import { usePointCloudLifecycle } from './usePointCloudLifecycle.js';
39
41
  import { useRenderUpdates } from './useRenderUpdates.js';
40
42
 
41
43
  interface ViewportProps {
@@ -43,6 +45,8 @@ interface ViewportProps {
43
45
  /** Monotonic counter that increments when geometry changes — used to trigger
44
46
  * streaming effects even when the geometry array reference is stable. */
45
47
  geometryVersion?: number;
48
+ /** Point cloud assets aggregated across visible federated models. */
49
+ pointClouds?: ReadonlyArray<PointCloudAsset> | null;
46
50
  coordinateInfo?: CoordinateInfo;
47
51
  computedIsolatedIds?: Set<number> | null;
48
52
  modelIdToIndex?: Map<string, number>;
@@ -56,6 +60,7 @@ interface ViewportProps {
56
60
  export function Viewport({
57
61
  geometry,
58
62
  geometryVersion,
63
+ pointClouds,
59
64
  coordinateInfo,
60
65
  computedIsolatedIds,
61
66
  modelIdToIndex,
@@ -456,11 +461,21 @@ export function Viewport({
456
461
  }
457
462
 
458
463
  // Set cursor based on active tool
459
- if (activeTool === 'measure') {
464
+ if (activeTool === 'measure' || activeTool === 'annotate' || activeTool === 'addElement') {
460
465
  canvas.style.cursor = 'crosshair';
461
466
  } else {
462
467
  canvas.style.cursor = 'default';
463
468
  }
469
+
470
+ // Clear add-element pending state + hover point when leaving the
471
+ // tool so the SVG overlay doesn't paint stale geometry from a
472
+ // previous session.
473
+ if (activeTool !== 'addElement') {
474
+ const state = useViewerStore.getState();
475
+ if (state.addElementPendingPoints.length > 0 || state.addElementHoverPoint !== null) {
476
+ state.clearAddElementPending();
477
+ }
478
+ }
464
479
  }, [activeTool, activeMeasurement, cancelMeasurement]);
465
480
 
466
481
  // Helper: calculate scale bar value (world-space size for 96px scale bar)
@@ -846,6 +861,18 @@ export function Viewport({
846
861
  onGeometryReleased,
847
862
  });
848
863
 
864
+ usePointCloudSync({
865
+ rendererRef,
866
+ isInitialized,
867
+ pointClouds,
868
+ hasMeshes: (geometry?.length ?? 0) > 0,
869
+ });
870
+
871
+ usePointCloudLifecycle({
872
+ rendererRef,
873
+ isInitialized,
874
+ });
875
+
849
876
  useRenderUpdates({
850
877
  rendererRef,
851
878
  isInitialized,
@@ -6,6 +6,7 @@ import { useMemo, useRef, useState, useCallback, useEffect, useSyncExternalStore
6
6
  import { Viewport } from './Viewport';
7
7
  import { ViewportOverlays } from './ViewportOverlays';
8
8
  import { ToolOverlays } from './ToolOverlays';
9
+ import { AnnotationLayer } from './annotations/AnnotationLayer';
9
10
  import { Section2DPanel } from './Section2DPanel';
10
11
  import { BasketPresentationDock } from './BasketPresentationDock';
11
12
  import { BCFOverlay } from './bcf/BCFOverlay';
@@ -19,8 +20,10 @@ import { openIfcFileDialog } from '@/services/file-dialog';
19
20
  import { logToDesktopTerminal } from '@/services/desktop-logger';
20
21
  import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files';
21
22
  import { isTauri } from '@/lib/platform';
23
+ import { toast } from '@/components/ui/toast';
24
+ import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest';
22
25
  import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3 } from 'lucide-react';
23
- import type { MeshData, CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
26
+ import type { MeshData, CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry';
24
27
  import { type IfcDataStore } from '@ifc-lite/parser';
25
28
  import { getEffectiveGeoreference } from '@/lib/geo/effective-georef';
26
29
 
@@ -173,6 +176,30 @@ export function ViewportContainer() {
173
176
  return geometryResult;
174
177
  }, [storeModels, geometryResult, modelIdToIndex]);
175
178
 
179
+ /**
180
+ * Aggregate point clouds across visible models.
181
+ *
182
+ * Phase 0: identity-stamping with modelIndex. Returns the same array
183
+ * reference when nothing has changed so the consumer effect skips work.
184
+ */
185
+ const mergedPointClouds = useMemo(() => {
186
+ const collected: PointCloudAsset[] = [];
187
+ if (storeModels.size > 0) {
188
+ for (const [modelId, model] of storeModels) {
189
+ if (!model.visible) continue;
190
+ const assets = model.geometryResult?.pointClouds;
191
+ if (!assets || assets.length === 0) continue;
192
+ const modelIndex = modelIdToIndex.get(modelId) ?? 0;
193
+ for (const asset of assets) {
194
+ collected.push(asset.modelIndex === modelIndex ? asset : { ...asset, modelIndex });
195
+ }
196
+ }
197
+ } else if (geometryResult?.pointClouds) {
198
+ collected.push(...geometryResult.pointClouds);
199
+ }
200
+ return collected;
201
+ }, [storeModels, geometryResult, modelIdToIndex]);
202
+
176
203
  // Extract georeferencing info merged with any live mutations (for Cesium overlay).
177
204
  // Reacts to: model load, Cesium toggle, and every georef field edit.
178
205
  const georef = useMemo(() => {
@@ -280,12 +307,22 @@ export function ViewportContainer() {
280
307
  return;
281
308
  }
282
309
 
283
- // Filter to supported files (IFC, IFCX, GLB)
284
- const supportedFiles = Array.from(e.dataTransfer.files).filter(
310
+ // Filter to supported files (IFC, IFCX, GLB, point clouds)
311
+ const allDropped = Array.from(e.dataTransfer.files);
312
+ const supportedFiles = allDropped.filter(
285
313
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
314
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
286
315
  );
287
316
 
288
- if (supportedFiles.length === 0) return;
317
+ if (supportedFiles.length === 0) {
318
+ // Tell the user *why* — common case is a Recap project / SketchUp
319
+ // file dropped because they assumed our viewer would understand it.
320
+ const explained = allDropped.find((f) => describeUnsupportedFormat(f.name));
321
+ if (explained) {
322
+ toast.error(`${explained.name}: ${describeUnsupportedFormat(explained.name)}`);
323
+ }
324
+ return;
325
+ }
289
326
 
290
327
  recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size })));
291
328
  void cacheFileBlobs(supportedFiles);
@@ -317,6 +354,7 @@ export function ViewportContainer() {
317
354
  // Filter to supported files (IFC, IFCX, GLB)
318
355
  const supportedFiles = Array.from(files).filter(
319
356
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
357
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
320
358
  );
321
359
 
322
360
  if (supportedFiles.length === 0) return;
@@ -528,7 +566,7 @@ export function ViewportContainer() {
528
566
  <input
529
567
  ref={fileInputRef}
530
568
  type="file"
531
- accept=".ifc,.ifcx,.glb"
569
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
532
570
  multiple
533
571
  onChange={handleFileSelect}
534
572
  className="hidden"
@@ -844,6 +882,7 @@ export function ViewportContainer() {
844
882
  <Viewport
845
883
  geometry={filteredGeometry}
846
884
  geometryVersion={geometryVersion}
885
+ pointClouds={mergedPointClouds}
847
886
  coordinateInfo={mergedGeometryResult?.coordinateInfo}
848
887
  computedIsolatedIds={computedIsolatedIds}
849
888
  modelIdToIndex={modelIdToIndex}
@@ -851,6 +890,7 @@ export function ViewportContainer() {
851
890
  releaseGeometryAfterStream={false}
852
891
  onGeometryReleased={releaseGeometryMemory}
853
892
  />
893
+ <AnnotationLayer />
854
894
  {bcfOverlayVisible && <BCFOverlay />}
855
895
  <ViewportOverlays />
856
896
  <ToolOverlays />
@@ -22,10 +22,11 @@ import { goHomeFromStore } from '@/store/homeView';
22
22
  import { useIfc } from '@/hooks/useIfc';
23
23
  import { cn } from '@/lib/utils';
24
24
  import { isTauri } from '@/lib/platform';
25
-
26
- const isDesktop = isTauri();
27
25
  import { ViewCube, type ViewCubeRef } from './ViewCube';
28
26
  import { AxisHelper, type AxisHelperRef } from './AxisHelper';
27
+ import { PointCloudPanel } from './PointCloudPanel';
28
+
29
+ const isDesktop = isTauri();
29
30
 
30
31
  export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: boolean } = {}) {
31
32
  const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
@@ -149,6 +150,7 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
149
150
 
150
151
  return (
151
152
  <>
153
+ <PointCloudPanelMount />
152
154
  {/* Bottom-right: Cesium settings overlay OR Navigation controls (Cesium is web-only) */}
153
155
  {cesiumEnabled && !isDesktop ? (
154
156
  <CesiumSettingsOverlay
@@ -314,3 +316,12 @@ function CesiumSettingsOverlay({
314
316
  </div>
315
317
  );
316
318
  }
319
+
320
+ /**
321
+ * Tiny indirection so the panel can subscribe to its own slice without
322
+ * pulling extra state into the parent overlay component.
323
+ */
324
+ function PointCloudPanelMount() {
325
+ const count = useViewerStore((s) => s.pointCloudAssetCount);
326
+ return <PointCloudPanel assetCount={count} />;
327
+ }
@@ -0,0 +1,203 @@
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
+ * The inline note input that appears at the click site when the user
7
+ * drops a fresh pin with the Annotate tool. Shape mirrors the popover's
8
+ * edit mode so muscle memory carries over, but the chrome is lighter
9
+ * (a guiding label, no entity-context header) since this is a
10
+ * commit-or-cancel surface.
11
+ */
12
+
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import { Check, X } from 'lucide-react';
15
+ import { Button } from '@/components/ui/button';
16
+ import { cn } from '@/lib/utils';
17
+
18
+ const MAX_NOTE_LEN = 2000;
19
+ const SOFT_NOTE_LIMIT = 200;
20
+ const INPUT_WIDTH = 280;
21
+ const INPUT_OFFSET_X = 16;
22
+
23
+ export interface AnnotationDropInputProps {
24
+ anchorX: number;
25
+ anchorY: number;
26
+ canvasWidth: number;
27
+ canvasHeight: number;
28
+ /** Resolved entity type when the drop landed on a known mesh. */
29
+ entityType?: string | null;
30
+ entityExpressId?: number | null;
31
+ onSave: (note: string) => void;
32
+ onCancel: () => void;
33
+ }
34
+
35
+ export function AnnotationDropInput({
36
+ anchorX,
37
+ anchorY,
38
+ canvasWidth,
39
+ canvasHeight,
40
+ entityType,
41
+ entityExpressId,
42
+ onSave,
43
+ onCancel,
44
+ }: AnnotationDropInputProps) {
45
+ const [draft, setDraft] = useState('');
46
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+
49
+ useEffect(() => {
50
+ textareaRef.current?.focus();
51
+ }, []);
52
+
53
+ // Cancel on outside click, but defer registration so the click that
54
+ // dropped the pin doesn't immediately close the input.
55
+ useEffect(() => {
56
+ const handler = (e: MouseEvent) => {
57
+ const node = containerRef.current;
58
+ if (!node) return;
59
+ if (node.contains(e.target as Node)) return;
60
+ // Empty draft on outside-click → silent cancel; non-empty
61
+ // → commit the draft (matches "blur to save" feel without
62
+ // destroying typed content). An over-limit draft is rejected
63
+ // consistently with the disabled save button.
64
+ if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
65
+ onCancel();
66
+ } else {
67
+ onSave(draft);
68
+ }
69
+ };
70
+ const id = window.setTimeout(() => {
71
+ document.addEventListener('mousedown', handler);
72
+ }, 0);
73
+ return () => {
74
+ window.clearTimeout(id);
75
+ document.removeEventListener('mousedown', handler);
76
+ };
77
+ }, [draft, onSave, onCancel]);
78
+
79
+ const handleKeyDown = useCallback(
80
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
81
+ if (e.key === 'Enter' && !e.shiftKey) {
82
+ e.preventDefault();
83
+ if (draft.trim().length === 0 || draft.length > MAX_NOTE_LEN) {
84
+ // Over-limit Enter does nothing — match the disabled button.
85
+ if (draft.trim().length === 0) onCancel();
86
+ } else {
87
+ onSave(draft);
88
+ }
89
+ } else if (e.key === 'Escape') {
90
+ e.preventDefault();
91
+ onCancel();
92
+ }
93
+ },
94
+ [draft, onSave, onCancel],
95
+ );
96
+
97
+ const wantsLeft = anchorX + INPUT_OFFSET_X + INPUT_WIDTH > canvasWidth;
98
+ const left = wantsLeft
99
+ ? Math.max(8, anchorX - INPUT_OFFSET_X - INPUT_WIDTH)
100
+ : Math.min(anchorX + INPUT_OFFSET_X, canvasWidth - INPUT_WIDTH - 8);
101
+ const top = Math.min(Math.max(8, anchorY - 8), canvasHeight - 140);
102
+
103
+ const charCountVisible = draft.length >= SOFT_NOTE_LIMIT;
104
+ const overSoftLimit = draft.length > SOFT_NOTE_LIMIT;
105
+ const overHardLimit = draft.length > MAX_NOTE_LEN;
106
+
107
+ return (
108
+ <div
109
+ ref={containerRef}
110
+ role="dialog"
111
+ aria-label="New annotation"
112
+ style={{ left, top, width: INPUT_WIDTH }}
113
+ className={cn(
114
+ 'absolute z-[60] pointer-events-auto',
115
+ 'rounded-md border border-amber-400/70 dark:border-amber-600/40',
116
+ 'bg-white/95 dark:bg-zinc-950/95 backdrop-blur-md',
117
+ 'shadow-[0_8px_32px_rgba(0,0,0,0.18)]',
118
+ 'overflow-hidden',
119
+ 'animate-in fade-in-0 zoom-in-95 duration-150',
120
+ )}
121
+ >
122
+ {/* Guiding label — explicit so the user knows what to type and
123
+ establishes "this is for capturing intent, not chat". */}
124
+ <div className="px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20">
125
+ <span className="font-mono text-[10px] uppercase tracking-wider text-amber-700 dark:text-amber-300">
126
+ What's worth noting?
127
+ {entityType && (
128
+ <span className="ml-1.5 text-zinc-500 dark:text-zinc-400">
129
+ · {entityType}
130
+ {entityExpressId !== null && entityExpressId !== undefined && ` #${entityExpressId}`}
131
+ </span>
132
+ )}
133
+ </span>
134
+ </div>
135
+
136
+ <div className="px-3 py-2.5">
137
+ <textarea
138
+ ref={textareaRef}
139
+ value={draft}
140
+ onChange={(e) => setDraft(e.target.value)}
141
+ onKeyDown={handleKeyDown}
142
+ placeholder="A short note — flag a defect, ask a question, leave context…"
143
+ rows={3}
144
+ maxLength={MAX_NOTE_LEN + 100}
145
+ className={cn(
146
+ 'w-full resize-none font-mono text-[11px] leading-relaxed',
147
+ 'bg-zinc-50 dark:bg-zinc-900/60 text-zinc-800 dark:text-zinc-200',
148
+ 'border border-zinc-200 dark:border-zinc-800 rounded-sm',
149
+ 'px-2 py-1.5 outline-none focus:ring-1',
150
+ overHardLimit
151
+ ? 'focus:ring-red-400 border-red-300 dark:border-red-700/60'
152
+ : 'focus:ring-amber-400/50 focus:border-amber-300/60',
153
+ )}
154
+ spellCheck
155
+ autoCorrect="on"
156
+ />
157
+ <div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-mono">
158
+ <span className="text-zinc-400 dark:text-zinc-500">
159
+ ⏎ save · ⇧⏎ newline · esc cancel
160
+ </span>
161
+ {charCountVisible && (
162
+ <span
163
+ className={cn(
164
+ 'tabular-nums',
165
+ overHardLimit
166
+ ? 'text-red-500'
167
+ : overSoftLimit
168
+ ? 'text-amber-600 dark:text-amber-400'
169
+ : 'text-zinc-400',
170
+ )}
171
+ >
172
+ {draft.length}/{MAX_NOTE_LEN}
173
+ </span>
174
+ )}
175
+ </div>
176
+ <div className="mt-2 flex items-center justify-end gap-1">
177
+ <Button
178
+ variant="ghost"
179
+ size="sm"
180
+ className="h-7 px-2 text-[11px]"
181
+ onClick={onCancel}
182
+ >
183
+ <X className="h-3 w-3 mr-1" />
184
+ Cancel
185
+ </Button>
186
+ <Button
187
+ size="sm"
188
+ className="h-7 px-2 text-[11px] bg-amber-500 hover:bg-amber-500/90 text-white"
189
+ onClick={() => {
190
+ if (overHardLimit) return;
191
+ if (draft.trim().length === 0) onCancel();
192
+ else onSave(draft);
193
+ }}
194
+ disabled={overHardLimit}
195
+ >
196
+ <Check className="h-3 w-3 mr-1" />
197
+ Drop pin
198
+ </Button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ }