@ifc-lite/viewer 1.18.0 → 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 (36) hide show
  1. package/.turbo/turbo-build.log +16 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +436 -0
  4. package/dist/assets/{basketViewActivator-Cm1QEk_R.js → basketViewActivator-RZy5c3Td.js} +1 -1
  5. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  6. package/dist/assets/{exporters-B_OBqIyD.js → exporters-BraHBeoi.js} +2540 -1958
  7. package/dist/assets/{geometry.worker-xHHy-9DV.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-BKq-M3Mk.js → index-BOi3BuUI.js} +25546 -23508
  11. package/dist/assets/index-XwKzDuw6.js +22 -0
  12. package/dist/assets/{native-bridge-SHXiQwFW.js → native-bridge-CpBeOPQa.js} +1 -1
  13. package/dist/assets/{sandbox-jez21HtV.js → sandbox-Baez7n-t.js} +1366 -1311
  14. package/dist/assets/{server-client-ncOQVNso.js → server-client-BB6cMAXE.js} +1 -1
  15. package/dist/assets/{wasm-bridge-DyfBSB8z.js → wasm-bridge-CAYCUHbE.js} +1 -1
  16. package/dist/index.html +5 -5
  17. package/package.json +7 -6
  18. package/src/components/viewer/MainToolbar.tsx +4 -2
  19. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  20. package/src/components/viewer/Viewport.tsx +18 -1
  21. package/src/components/viewer/ViewportContainer.tsx +43 -5
  22. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  23. package/src/components/viewer/tools/AddElementOverlay.tsx +43 -2
  24. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  25. package/src/components/viewer/usePointCloudSync.ts +98 -0
  26. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  27. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  28. package/src/hooks/useIfcFederation.ts +72 -3
  29. package/src/hooks/useIfcLoader.ts +67 -3
  30. package/src/services/file-dialog.ts +4 -2
  31. package/src/store/index.ts +10 -1
  32. package/src/store/slices/pointCloudSlice.ts +102 -0
  33. package/src/store/types.ts +7 -0
  34. package/vite.config.ts +1 -0
  35. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  36. package/dist/assets/index-COnQRuqY.css +0 -1
@@ -1,4 +1,4 @@
1
- import { _ as de, __tla as __tla_0 } from "./exporters-B_OBqIyD.js";
1
+ import { _ as de, __tla as __tla_0 } from "./exporters-BraHBeoi.js";
2
2
  let We, je;
3
3
  let __tla = Promise.all([
4
4
  (()=>{
@@ -1 +1 @@
1
- import{I as f,a as m}from"./exporters-B_OBqIyD.js";import"./bcf-DOG9_WPX.js";import"./zip-DBEtpeu6.js";import"./cesium-DUOzBlqv.js";import"./arrow-CZ5kQ26f.js";class b{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}toIfcContent(e){return typeof e=="string"?e:new TextDecoder().decode(e)}async processGeometry(e){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),this.toIfcContent(e)),r=i.collectMeshes(),s=i.getBuildingRotation();performance.now();let o=0,n=0;for(const a of r)o+=a.positions.length/3,n+=a.indices.length/3;return{meshes:r,totalVertices:o,totalTriangles:n,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:s}}}async processGeometryStreaming(e,i){this.initialized||await this.init();const r=performance.now(),s=new m(this.bridge.getApi(),this.toIfcContent(e));let o=0,n=0,c=0;try{for await(const t of s.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;o+=l.length;for(const g of l)n+=g.positions.length/3,c+=g.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:o,total:o,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const d=performance.now()-r,h={totalMeshes:o,totalVertices:n,totalTriangles:c,parseTimeMs:d*.3,geometryTimeMs:d*.7};return i.onComplete?.(h),h}getApi(){return this.bridge.getApi()}}export{b as WasmBridge};
1
+ import{I as f,a as m}from"./exporters-BraHBeoi.js";import"./bcf-DOG9_WPX.js";import"./zip-DBEtpeu6.js";import"./cesium-DUOzBlqv.js";import"./arrow-CZ5kQ26f.js";class b{bridge;initialized=!1;constructor(){this.bridge=new f}async init(){this.initialized||(await this.bridge.init(),this.initialized=!0)}isInitialized(){return this.initialized}toIfcContent(e){return typeof e=="string"?e:new TextDecoder().decode(e)}async processGeometry(e){this.initialized||await this.init(),performance.now();const i=new m(this.bridge.getApi(),this.toIfcContent(e)),r=i.collectMeshes(),s=i.getBuildingRotation();performance.now();let o=0,n=0;for(const a of r)o+=a.positions.length/3,n+=a.indices.length/3;return{meshes:r,totalVertices:o,totalTriangles:n,coordinateInfo:{originShift:{x:0,y:0,z:0},originalBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},shiftedBounds:{min:{x:0,y:0,z:0},max:{x:0,y:0,z:0}},hasLargeCoordinates:!1,buildingRotation:s}}}async processGeometryStreaming(e,i){this.initialized||await this.init();const r=performance.now(),s=new m(this.bridge.getApi(),this.toIfcContent(e));let o=0,n=0,c=0;try{for await(const t of s.collectMeshesStreaming(50)){if(t&&typeof t=="object"&&"type"in t&&t.type==="colorUpdate")continue;const l=t;o+=l.length;for(const g of l)n+=g.positions.length/3,c+=g.indices.length/3;i.onBatch?.({meshes:l,progress:{processed:o,total:o,currentType:"processing"}})}}catch(t){throw i.onError?.(t instanceof Error?t:new Error(String(t))),t}const d=performance.now()-r,h={totalMeshes:o,totalVertices:n,totalTriangles:c,parseTimeMs:d*.3,geometryTimeMs:d*.7};return i.onComplete?.(h),h}getApi(){return this.bridge.getApi()}}export{b as WasmBridge};
package/dist/index.html CHANGED
@@ -50,19 +50,19 @@
50
50
  <meta name="theme-color" content="#7aa2f7">
51
51
  <meta name="msapplication-TileColor" content="#1a1b26">
52
52
  <meta name="msapplication-TileImage" content="/favicon-192x192-cropped.png">
53
- <script type="module" crossorigin src="/assets/index-BKq-M3Mk.js"></script>
53
+ <script type="module" crossorigin src="/assets/index-BOi3BuUI.js"></script>
54
54
  <link rel="modulepreload" crossorigin href="/assets/arrow-CZ5kQ26f.js">
55
55
  <link rel="modulepreload" crossorigin href="/assets/cesium-DUOzBlqv.js">
56
56
  <link rel="modulepreload" crossorigin href="/assets/zip-DBEtpeu6.js">
57
57
  <link rel="modulepreload" crossorigin href="/assets/bcf-DOG9_WPX.js">
58
- <link rel="modulepreload" crossorigin href="/assets/exporters-B_OBqIyD.js">
58
+ <link rel="modulepreload" crossorigin href="/assets/exporters-BraHBeoi.js">
59
59
  <link rel="modulepreload" crossorigin href="/assets/lens-CSASnhAL.js">
60
- <link rel="modulepreload" crossorigin href="/assets/sandbox-jez21HtV.js">
60
+ <link rel="modulepreload" crossorigin href="/assets/sandbox-Baez7n-t.js">
61
61
  <link rel="modulepreload" crossorigin href="/assets/drawing-2d-DoxKMqbO.js">
62
- <link rel="modulepreload" crossorigin href="/assets/server-client-ncOQVNso.js">
62
+ <link rel="modulepreload" crossorigin href="/assets/server-client-BB6cMAXE.js">
63
63
  <link rel="modulepreload" crossorigin href="/assets/ids-DQ5jY0E8.js">
64
64
  <link rel="stylesheet" crossorigin href="/assets/cesium-ADbP7waU.css">
65
- <link rel="stylesheet" crossorigin href="/assets/index-COnQRuqY.css">
65
+ <link rel="stylesheet" crossorigin href="/assets/index-0XpVr_S5.css">
66
66
  </head>
67
67
  <body>
68
68
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifc-lite/viewer",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "IFC-Lite viewer application",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -52,19 +52,20 @@
52
52
  "@ifc-lite/drawing-2d": "^1.15.3",
53
53
  "@ifc-lite/encoding": "^1.14.6",
54
54
  "@ifc-lite/export": "^1.18.0",
55
- "@ifc-lite/geometry": "^1.16.6",
55
+ "@ifc-lite/geometry": "^1.17.0",
56
56
  "@ifc-lite/ids": "^1.14.10",
57
57
  "@ifc-lite/lens": "^1.14.4",
58
58
  "@ifc-lite/lists": "^1.14.10",
59
59
  "@ifc-lite/mutations": "^1.15.0",
60
- "@ifc-lite/parser": "^2.2.0",
60
+ "@ifc-lite/parser": "^2.3.0",
61
+ "@ifc-lite/pointcloud": "^0.2.0",
61
62
  "@ifc-lite/query": "^1.14.7",
62
- "@ifc-lite/renderer": "^1.17.0",
63
+ "@ifc-lite/renderer": "^1.18.0",
63
64
  "@ifc-lite/sandbox": "^1.15.0",
64
- "@ifc-lite/server-client": "^1.15.3",
65
65
  "@ifc-lite/sdk": "^1.15.0",
66
+ "@ifc-lite/server-client": "^1.15.3",
66
67
  "@ifc-lite/spatial": "^1.14.5",
67
- "@ifc-lite/wasm": "^1.16.7"
68
+ "@ifc-lite/wasm": "^1.16.8"
68
69
  },
69
70
  "devDependencies": {
70
71
  "@tailwindcss/postcss": "^4.1.18",
@@ -425,6 +425,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
425
425
  // Filter to supported files (IFC, IFCX, GLB)
426
426
  const supportedFiles = Array.from(files).filter(
427
427
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
428
+ || 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')
428
429
  );
429
430
 
430
431
  if (supportedFiles.length === 0) return;
@@ -465,6 +466,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
465
466
  // Filter to supported files (IFC, IFCX, GLB)
466
467
  const supportedFiles = Array.from(files).filter(
467
468
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
469
+ || 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')
468
470
  );
469
471
 
470
472
  if (supportedFiles.length === 0) return;
@@ -779,7 +781,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
779
781
  id="file-input-open"
780
782
  ref={fileInputRef}
781
783
  type="file"
782
- accept=".ifc,.ifcx,.glb"
784
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
783
785
  multiple
784
786
  onChange={handleFileSelect}
785
787
  className="hidden"
@@ -787,7 +789,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
787
789
  <input
788
790
  ref={addModelInputRef}
789
791
  type="file"
790
- accept=".ifc,.ifcx,.glb"
792
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
791
793
  multiple
792
794
  onChange={handleAddModelSelect}
793
795
  className="hidden"
@@ -0,0 +1,174 @@
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
+ * Compact panel that exposes point cloud rendering controls (color mode,
7
+ * size mode, point size, EDL). Renders only when point cloud assets are
8
+ * loaded — sits over the canvas without affecting layout for IFC-only
9
+ * models.
10
+ */
11
+
12
+ import { useViewerStore } from '@/store';
13
+ import type { PointColorModeUi, PointSizeModeUi } from '@/store/slices/pointCloudSlice';
14
+ import { cn } from '@/lib/utils';
15
+
16
+ const COLOR_MODES: Array<{ value: PointColorModeUi; label: string; hint: string }> = [
17
+ { value: 'rgb', label: 'RGB', hint: 'Per-point colour from the source' },
18
+ { value: 'classification', label: 'Classification', hint: 'ASPRS class palette (ground, vegetation, building...)' },
19
+ { value: 'intensity', label: 'Intensity', hint: 'Greyscale ramp from per-point intensity' },
20
+ { value: 'height', label: 'Height', hint: 'Cool-warm ramp by Y-up world height' },
21
+ { value: 'fixed', label: 'Solid', hint: 'Single colour override' },
22
+ ];
23
+
24
+ const SIZE_MODES: Array<{ value: PointSizeModeUi; label: string; hint: string }> = [
25
+ { value: 'fixed-px', label: 'Fixed', hint: 'Always render at the slider value (in pixels)' },
26
+ { value: 'attenuated', label: 'Auto', hint: 'Adaptive (closer = bigger), clamped to the slider as max' },
27
+ { value: 'adaptive-world', label: 'World', hint: 'Pure world-space radius — splat covers N mm in source space' },
28
+ ];
29
+
30
+ export interface PointCloudPanelProps {
31
+ /** Number of currently-loaded point cloud assets — panel hides when 0. */
32
+ assetCount: number;
33
+ }
34
+
35
+ export function PointCloudPanel({ assetCount }: PointCloudPanelProps) {
36
+ const colorMode = useViewerStore((s) => s.pointCloudColorMode);
37
+ const setColorMode = useViewerStore((s) => s.setPointCloudColorMode);
38
+ const sizeMode = useViewerStore((s) => s.pointCloudSizeMode);
39
+ const setSizeMode = useViewerStore((s) => s.setPointCloudSizeMode);
40
+ const pointSize = useViewerStore((s) => s.pointCloudPointSize);
41
+ const setPointSize = useViewerStore((s) => s.setPointCloudPointSize);
42
+ const worldRadius = useViewerStore((s) => s.pointCloudWorldRadius);
43
+ const setWorldRadius = useViewerStore((s) => s.setPointCloudWorldRadius);
44
+ const edlEnabled = useViewerStore((s) => s.pointCloudEdlEnabled);
45
+ const setEdlEnabled = useViewerStore((s) => s.setPointCloudEdlEnabled);
46
+ const edlStrength = useViewerStore((s) => s.pointCloudEdlStrength);
47
+ const setEdlStrength = useViewerStore((s) => s.setPointCloudEdlStrength);
48
+
49
+ if (assetCount <= 0) return null;
50
+
51
+ return (
52
+ <div className="absolute bottom-4 left-4 z-10 pointer-events-auto bg-background/90 backdrop-blur-sm rounded-lg border shadow-lg p-2 flex flex-col gap-2 min-w-[200px]">
53
+ <div className="flex items-center justify-between gap-2">
54
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
55
+ Point Cloud
56
+ </span>
57
+ <span className="text-[10px] text-muted-foreground">
58
+ {assetCount} asset{assetCount === 1 ? '' : 's'}
59
+ </span>
60
+ </div>
61
+
62
+ {/* Color mode */}
63
+ <div className="flex flex-col gap-0.5">
64
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">Colour</span>
65
+ {COLOR_MODES.map((mode) => {
66
+ const active = colorMode === mode.value;
67
+ return (
68
+ <button
69
+ key={mode.value}
70
+ aria-pressed={active}
71
+ onClick={() => setColorMode(mode.value)}
72
+ title={mode.hint}
73
+ className={cn(
74
+ 'flex items-center gap-2 px-2 py-1 rounded text-xs transition-colors text-left',
75
+ active
76
+ ? 'bg-teal-600 text-white'
77
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
78
+ )}
79
+ >
80
+ {mode.label}
81
+ </button>
82
+ );
83
+ })}
84
+ </div>
85
+
86
+ {/* Size mode */}
87
+ <div className="flex flex-col gap-0.5">
88
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">Size</span>
89
+ <div className="grid grid-cols-3 gap-0.5">
90
+ {SIZE_MODES.map((mode) => {
91
+ const active = sizeMode === mode.value;
92
+ return (
93
+ <button
94
+ key={mode.value}
95
+ aria-pressed={active}
96
+ onClick={() => setSizeMode(mode.value)}
97
+ title={mode.hint}
98
+ className={cn(
99
+ 'px-1.5 py-1 rounded text-[11px] transition-colors',
100
+ active
101
+ ? 'bg-teal-600 text-white'
102
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
103
+ )}
104
+ >
105
+ {mode.label}
106
+ </button>
107
+ );
108
+ })}
109
+ </div>
110
+ <label className="flex items-center gap-2 mt-1">
111
+ <span className="text-[10px] text-muted-foreground w-8 shrink-0">{pointSize.toFixed(0)}px</span>
112
+ <input
113
+ type="range"
114
+ min={1}
115
+ max={20}
116
+ step={1}
117
+ value={pointSize}
118
+ onChange={(e) => setPointSize(Number(e.target.value))}
119
+ className="flex-1 h-1 accent-teal-600 cursor-pointer"
120
+ title="Splat size in pixels (or upper cap in Auto mode)"
121
+ />
122
+ </label>
123
+ {sizeMode !== 'fixed-px' && (
124
+ <label className="flex items-center gap-2">
125
+ <span className="text-[10px] text-muted-foreground w-8 shrink-0">
126
+ {(worldRadius * 1000).toFixed(0)}mm
127
+ </span>
128
+ <input
129
+ type="range"
130
+ min={1}
131
+ max={100}
132
+ step={1}
133
+ value={Math.round(worldRadius * 1000)}
134
+ onChange={(e) => setWorldRadius(Number(e.target.value) / 1000)}
135
+ className="flex-1 h-1 accent-teal-600 cursor-pointer"
136
+ title="World-space splat radius in millimetres"
137
+ />
138
+ </label>
139
+ )}
140
+ </div>
141
+
142
+ {/* EDL */}
143
+ <div className="flex flex-col gap-0.5">
144
+ <label className="flex items-center justify-between gap-2 cursor-pointer">
145
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">EDL</span>
146
+ <input
147
+ type="checkbox"
148
+ checked={edlEnabled}
149
+ onChange={(e) => setEdlEnabled(e.target.checked)}
150
+ className="accent-teal-600"
151
+ title="Eye-Dome Lighting — adds depth perception via screen-space depth gradient"
152
+ />
153
+ </label>
154
+ {edlEnabled && (
155
+ <label className="flex items-center gap-2">
156
+ <span className="text-[10px] text-muted-foreground w-8 shrink-0">
157
+ {edlStrength.toFixed(1)}
158
+ </span>
159
+ <input
160
+ type="range"
161
+ min={0}
162
+ max={3}
163
+ step={0.1}
164
+ value={edlStrength}
165
+ onChange={(e) => setEdlStrength(Number(e.target.value))}
166
+ className="flex-1 h-1 accent-teal-600 cursor-pointer"
167
+ title="EDL strength multiplier"
168
+ />
169
+ </label>
170
+ )}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
@@ -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,
@@ -856,6 +861,18 @@ export function Viewport({
856
861
  onGeometryReleased,
857
862
  });
858
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
+
859
876
  useRenderUpdates({
860
877
  rendererRef,
861
878
  isInitialized,
@@ -20,8 +20,10 @@ import { openIfcFileDialog } from '@/services/file-dialog';
20
20
  import { logToDesktopTerminal } from '@/services/desktop-logger';
21
21
  import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files';
22
22
  import { isTauri } from '@/lib/platform';
23
+ import { toast } from '@/components/ui/toast';
24
+ import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest';
23
25
  import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3 } from 'lucide-react';
24
- import type { MeshData, CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
26
+ import type { MeshData, CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry';
25
27
  import { type IfcDataStore } from '@ifc-lite/parser';
26
28
  import { getEffectiveGeoreference } from '@/lib/geo/effective-georef';
27
29
 
@@ -174,6 +176,30 @@ export function ViewportContainer() {
174
176
  return geometryResult;
175
177
  }, [storeModels, geometryResult, modelIdToIndex]);
176
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
+
177
203
  // Extract georeferencing info merged with any live mutations (for Cesium overlay).
178
204
  // Reacts to: model load, Cesium toggle, and every georef field edit.
179
205
  const georef = useMemo(() => {
@@ -281,12 +307,22 @@ export function ViewportContainer() {
281
307
  return;
282
308
  }
283
309
 
284
- // Filter to supported files (IFC, IFCX, GLB)
285
- 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(
286
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')
287
315
  );
288
316
 
289
- 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
+ }
290
326
 
291
327
  recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size })));
292
328
  void cacheFileBlobs(supportedFiles);
@@ -318,6 +354,7 @@ export function ViewportContainer() {
318
354
  // Filter to supported files (IFC, IFCX, GLB)
319
355
  const supportedFiles = Array.from(files).filter(
320
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')
321
358
  );
322
359
 
323
360
  if (supportedFiles.length === 0) return;
@@ -529,7 +566,7 @@ export function ViewportContainer() {
529
566
  <input
530
567
  ref={fileInputRef}
531
568
  type="file"
532
- accept=".ifc,.ifcx,.glb"
569
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
533
570
  multiple
534
571
  onChange={handleFileSelect}
535
572
  className="hidden"
@@ -845,6 +882,7 @@ export function ViewportContainer() {
845
882
  <Viewport
846
883
  geometry={filteredGeometry}
847
884
  geometryVersion={geometryVersion}
885
+ pointClouds={mergedPointClouds}
848
886
  coordinateInfo={mergedGeometryResult?.coordinateInfo}
849
887
  computedIsolatedIds={computedIsolatedIds}
850
888
  modelIdToIndex={modelIdToIndex}
@@ -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
+ }
@@ -51,14 +51,54 @@ export function AddElementOverlay() {
51
51
  // hover points each frame. The tick state is just a number that
52
52
  // forces a re-render; the projection itself is read fresh from the
53
53
  // store callback.
54
+ //
55
+ // Two perf gates:
56
+ // 1. Skip the loop entirely when there's nothing to project.
57
+ // pendingPoints / hoverPoint / autoSpacePreview already trigger
58
+ // React re-renders via the store, so the only reason we'd need
59
+ // a per-frame tick is to track the camera while content exists.
60
+ // 2. Only re-render when the camera actually moved since last tick.
61
+ // A held tool with a static camera does ~0 work.
62
+ const getViewpoint = useViewerStore((s) => s.cameraCallbacks.getViewpoint);
63
+ const hasOverlayContent =
64
+ pendingPoints.length > 0 ||
65
+ hoverPoint !== null ||
66
+ (autoSpacePreview != null && autoSpacePreview.outlines.length > 0);
54
67
  const [frameTick, setFrameTick] = useState(0);
55
68
  const rafRef = useRef<number | null>(null);
69
+ const lastViewpointRef = useRef<{
70
+ px: number; py: number; pz: number;
71
+ tx: number; ty: number; tz: number;
72
+ fov: number;
73
+ } | null>(null);
56
74
  useEffect(() => {
57
75
  if (activeTool !== 'addElement') return;
76
+ if (!hasOverlayContent) return;
58
77
  let mounted = true;
59
78
  const loop = () => {
60
79
  if (!mounted) return;
61
- setFrameTick((t) => (t + 1) & 0xffff);
80
+ const vp = getViewpoint?.();
81
+ if (vp) {
82
+ const last = lastViewpointRef.current;
83
+ const moved =
84
+ !last ||
85
+ last.px !== vp.position.x || last.py !== vp.position.y || last.pz !== vp.position.z ||
86
+ last.tx !== vp.target.x || last.ty !== vp.target.y || last.tz !== vp.target.z ||
87
+ last.fov !== vp.fov;
88
+ if (moved) {
89
+ lastViewpointRef.current = {
90
+ px: vp.position.x, py: vp.position.y, pz: vp.position.z,
91
+ tx: vp.target.x, ty: vp.target.y, tz: vp.target.z,
92
+ fov: vp.fov,
93
+ };
94
+ setFrameTick((t) => (t + 1) & 0xffff);
95
+ }
96
+ } else {
97
+ // Fallback for environments without getViewpoint — preserves the
98
+ // pre-fix behaviour of an unconditional tick so the projection
99
+ // can't get stuck stale.
100
+ setFrameTick((t) => (t + 1) & 0xffff);
101
+ }
62
102
  rafRef.current = requestAnimationFrame(loop);
63
103
  };
64
104
  rafRef.current = requestAnimationFrame(loop);
@@ -66,8 +106,9 @@ export function AddElementOverlay() {
66
106
  mounted = false;
67
107
  if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
68
108
  rafRef.current = null;
109
+ lastViewpointRef.current = null;
69
110
  };
70
- }, [activeTool]);
111
+ }, [activeTool, hasOverlayContent, getViewpoint]);
71
112
 
72
113
  const projection = useMemo(
73
114
  () => makeProjection(projectToScreen),
@@ -0,0 +1,64 @@
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
+ * Tear down streamed point cloud GPU resources when a model is removed.
7
+ *
8
+ * Streamed assets (LAS/LAZ) live in a separate ownership bucket on the
9
+ * renderer (see `PointCloudRenderer`'s `'streamed'` owner tag), so they
10
+ * survive `setPointClouds` calls. That isolation cuts both ways: nothing
11
+ * else clears them, so when a model is removed we have to do it here or
12
+ * the GPU buffers leak for the rest of the session.
13
+ *
14
+ * The hook tracks the previous set of `(modelId → handleId)` pairs and,
15
+ * on every store change, frees the handles for models that disappeared.
16
+ * Pure cleanup — no state mutation.
17
+ */
18
+
19
+ import { useEffect, useRef, type MutableRefObject } from 'react';
20
+ import type { Renderer } from '@ifc-lite/renderer';
21
+ import { useViewerStore } from '@/store';
22
+
23
+ export interface UsePointCloudLifecycleParams {
24
+ rendererRef: MutableRefObject<Renderer | null>;
25
+ isInitialized: boolean;
26
+ }
27
+
28
+ export function usePointCloudLifecycle(params: UsePointCloudLifecycleParams): void {
29
+ const { rendererRef, isInitialized } = params;
30
+ const models = useViewerStore((s) => s.models);
31
+ const decCount = useViewerStore((s) => s.incrementPointCloudAssetCount);
32
+ const previousRef = useRef<Map<string, number>>(new Map());
33
+
34
+ useEffect(() => {
35
+ if (!isInitialized) return;
36
+ const renderer = rendererRef.current;
37
+ if (!renderer) return;
38
+
39
+ const current = new Map<string, number>();
40
+ for (const [modelId, model] of models) {
41
+ if (typeof model.pointCloudHandleId === 'number') {
42
+ current.set(modelId, model.pointCloudHandleId);
43
+ }
44
+ }
45
+
46
+ // Dispose handles whose model disappeared OR whose model still
47
+ // exists but was rebound to a new handle (e.g. the user reloaded
48
+ // the same file and got a fresh streaming session). Without the
49
+ // rebind branch the old GPU buffers stay allocated for the rest
50
+ // of the session.
51
+ for (const [modelId, handleId] of previousRef.current) {
52
+ const nextHandle = current.get(modelId);
53
+ if (nextHandle !== handleId) {
54
+ renderer.removePointCloudAsset({ id: handleId });
55
+ decCount(-1);
56
+ }
57
+ }
58
+
59
+ previousRef.current = current;
60
+ renderer.requestRender();
61
+ }, [models, isInitialized, rendererRef, decCount]);
62
+ }
63
+
64
+ export default usePointCloudLifecycle;