@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.
- package/.turbo/turbo-build.log +16 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +436 -0
- package/dist/assets/{basketViewActivator-Cm1QEk_R.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-B_OBqIyD.js → exporters-BraHBeoi.js} +2540 -1958
- package/dist/assets/{geometry.worker-xHHy-9DV.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-BKq-M3Mk.js → index-BOi3BuUI.js} +25546 -23508
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-SHXiQwFW.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/{sandbox-jez21HtV.js → sandbox-Baez7n-t.js} +1366 -1311
- package/dist/assets/{server-client-ncOQVNso.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-DyfBSB8z.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +7 -6
- package/src/components/viewer/MainToolbar.tsx +4 -2
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- package/src/components/viewer/Viewport.tsx +18 -1
- package/src/components/viewer/ViewportContainer.tsx +43 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- package/src/components/viewer/tools/AddElementOverlay.tsx +43 -2
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +67 -3
- package/src/services/file-dialog.ts +4 -2
- package/src/store/index.ts +10 -1
- package/src/store/slices/pointCloudSlice.ts +102 -0
- package/src/store/types.ts +7 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
- package/dist/assets/index-COnQRuqY.css +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{I as f,a as m}from"./exporters-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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)
|
|
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
|
-
|
|
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;
|