@ifc-lite/viewer 1.19.1 → 1.21.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 (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -0,0 +1,326 @@
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
+ * Mobile-optimized toolbar for the 3D viewport.
7
+ * Compact, touch-friendly layout with essential actions visible
8
+ * and secondary actions in an overflow menu.
9
+ */
10
+
11
+ import React, { useRef, useCallback, useMemo } from 'react';
12
+ import {
13
+ FolderOpen,
14
+ MousePointer2,
15
+ Ruler,
16
+ Scissors,
17
+ Eye,
18
+ EyeOff,
19
+ Home,
20
+ Maximize2,
21
+ Crosshair,
22
+ Loader2,
23
+ MoreHorizontal,
24
+ Plus,
25
+ Download,
26
+ Orbit,
27
+ Sun,
28
+ Moon,
29
+ PersonStanding,
30
+ } from 'lucide-react';
31
+ import { Button } from '@/components/ui/button';
32
+ import {
33
+ DropdownMenu,
34
+ DropdownMenuContent,
35
+ DropdownMenuItem,
36
+ DropdownMenuSeparator,
37
+ DropdownMenuTrigger,
38
+ DropdownMenuCheckboxItem,
39
+ } from '@/components/ui/dropdown-menu';
40
+ import { Progress } from '@/components/ui/progress';
41
+ import { useViewerStore } from '@/store';
42
+ import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView';
43
+ import { executeBasketIsolate } from '@/store/basket/basketCommands';
44
+ import { useIfc } from '@/hooks/useIfc';
45
+ import { cn } from '@/lib/utils';
46
+ import { GLTFExporter } from '@ifc-lite/export';
47
+ import { openIfcFileDialog } from '@/services/file-dialog';
48
+ import { logToDesktopTerminal } from '@/services/desktop-logger';
49
+ import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
50
+ import { toast } from '@/components/ui/toast';
51
+
52
+ type Tool = 'select' | 'walk' | 'measure' | 'section';
53
+
54
+ export function MobileToolbar() {
55
+ const fileInputRef = useRef<HTMLInputElement>(null);
56
+ const addModelInputRef = useRef<HTMLInputElement>(null);
57
+ const {
58
+ loadFile,
59
+ loading,
60
+ progress,
61
+ geometryProgress,
62
+ metadataProgress,
63
+ geometryResult,
64
+ models,
65
+ loadFilesSequentially,
66
+ addModel,
67
+ } = useIfc();
68
+
69
+ const hasModelsLoaded = models.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0);
70
+ const activeTool = useViewerStore((state) => state.activeTool);
71
+ const setActiveTool = useViewerStore((state) => state.setActiveTool);
72
+ const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
73
+ const hideEntities = useViewerStore((state) => state.hideEntities);
74
+ const error = useViewerStore((state) => state.error);
75
+ const cameraCallbacks = useViewerStore((state) => state.cameraCallbacks);
76
+ const resetViewerState = useViewerStore((state) => state.resetViewerState);
77
+ const clearAllModels = useViewerStore((state) => state.clearAllModels);
78
+ const projectionMode = useViewerStore((state) => state.projectionMode);
79
+ const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode);
80
+ const theme = useViewerStore((state) => state.theme);
81
+ const toggleTheme = useViewerStore((state) => state.toggleTheme);
82
+
83
+ const hasSelection = selectedEntityId !== null;
84
+
85
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
86
+ const files = e.target.files;
87
+ if (!files || files.length === 0) return;
88
+ const supportedFiles = Array.from(files).filter(
89
+ f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
90
+ );
91
+ if (supportedFiles.length === 0) return;
92
+ recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size })));
93
+ void cacheFileBlobs(supportedFiles);
94
+ if (supportedFiles.length === 1) {
95
+ loadFile(supportedFiles[0]);
96
+ } else {
97
+ resetViewerState();
98
+ clearAllModels();
99
+ loadFilesSequentially(supportedFiles);
100
+ }
101
+ e.target.value = '';
102
+ }, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels]);
103
+
104
+ const handleAddModelSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
105
+ const files = e.target.files;
106
+ if (!files || files.length === 0) return;
107
+ const supportedFiles = Array.from(files).filter(
108
+ f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
109
+ );
110
+ if (supportedFiles.length === 0) return;
111
+ recordRecentFiles(supportedFiles.map((file) => ({ name: file.name, size: file.size })));
112
+ void cacheFileBlobs(supportedFiles);
113
+ loadFilesSequentially(supportedFiles);
114
+ e.target.value = '';
115
+ }, [loadFilesSequentially]);
116
+
117
+ const handleIsolate = useCallback(() => {
118
+ executeBasketIsolate();
119
+ }, []);
120
+
121
+ const handleShowAll = useCallback(() => {
122
+ resetVisibilityForHomeFromStore();
123
+ }, []);
124
+
125
+ const handleHide = useCallback(() => {
126
+ if (selectedEntityId !== null) {
127
+ hideEntities([selectedEntityId]);
128
+ }
129
+ }, [selectedEntityId, hideEntities]);
130
+
131
+ const handleHome = useCallback(() => {
132
+ goHomeFromStore();
133
+ }, []);
134
+
135
+ const handleExportGLB = useCallback(async () => {
136
+ if (!geometryResult) return;
137
+ try {
138
+ const exporter = new GLTFExporter(geometryResult);
139
+ const glb = exporter.exportGLB({ includeMetadata: true });
140
+ const blob = new Blob([new Uint8Array(glb)], { type: 'model/gltf-binary' });
141
+ const url = URL.createObjectURL(blob);
142
+ const a = document.createElement('a');
143
+ a.href = url;
144
+ a.download = 'model.glb';
145
+ a.click();
146
+ URL.revokeObjectURL(url);
147
+ toast.success(`Exported GLB (${(blob.size / 1024).toFixed(0)} KB)`);
148
+ } catch (err) {
149
+ toast.error(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
150
+ }
151
+ }, [geometryResult]);
152
+
153
+ const toolButtons: { tool: Tool; icon: React.ElementType; label: string }[] = [
154
+ { tool: 'select', icon: MousePointer2, label: 'Select' },
155
+ { tool: 'measure', icon: Ruler, label: 'Measure' },
156
+ { tool: 'section', icon: Scissors, label: 'Section' },
157
+ ];
158
+
159
+ return (
160
+ <div className="flex items-center gap-0.5 px-1.5 h-11 border-b bg-white dark:bg-black border-zinc-200 dark:border-zinc-800 relative z-50 overflow-x-auto">
161
+ {/* Hidden file inputs */}
162
+ <input
163
+ ref={fileInputRef}
164
+ type="file"
165
+ accept=".ifc,.ifcx,.glb"
166
+ multiple
167
+ onChange={handleFileSelect}
168
+ className="hidden"
169
+ />
170
+ <input
171
+ ref={addModelInputRef}
172
+ type="file"
173
+ accept=".ifc,.ifcx,.glb"
174
+ multiple
175
+ onChange={handleAddModelSelect}
176
+ className="hidden"
177
+ />
178
+
179
+ {/* Open File */}
180
+ <Button
181
+ variant="ghost"
182
+ size="icon-sm"
183
+ className="h-9 w-9 flex-shrink-0"
184
+ onClick={async () => {
185
+ const file = await openIfcFileDialog();
186
+ if (file) {
187
+ recordRecentFiles([{ name: file.name, size: file.size, path: file.path, modifiedMs: file.modifiedMs ?? null }]);
188
+ void loadFile(file);
189
+ return;
190
+ }
191
+ fileInputRef.current?.click();
192
+ }}
193
+ disabled={loading}
194
+ >
195
+ {loading ? (
196
+ <Loader2 className="h-4 w-4 animate-spin" />
197
+ ) : (
198
+ <FolderOpen className="h-4 w-4" />
199
+ )}
200
+ </Button>
201
+
202
+ {/* Add Model */}
203
+ {hasModelsLoaded && (
204
+ <Button
205
+ variant="ghost"
206
+ size="icon-sm"
207
+ className="h-9 w-9 flex-shrink-0 text-[#9ece6a]"
208
+ onClick={() => addModelInputRef.current?.click()}
209
+ disabled={loading}
210
+ >
211
+ <Plus className="h-4 w-4" />
212
+ </Button>
213
+ )}
214
+
215
+ {/* Divider */}
216
+ <div className="w-px h-5 bg-border mx-0.5 flex-shrink-0" />
217
+
218
+ {/* Tool buttons */}
219
+ {toolButtons.map(({ tool, icon: Icon, label }) => (
220
+ <Button
221
+ key={tool}
222
+ variant={activeTool === tool ? 'default' : 'ghost'}
223
+ size="icon-sm"
224
+ className={cn('h-9 w-9 flex-shrink-0', activeTool === tool && 'bg-primary text-primary-foreground')}
225
+ onClick={() => setActiveTool(tool)}
226
+ aria-label={label}
227
+ >
228
+ <Icon className="h-4 w-4" />
229
+ </Button>
230
+ ))}
231
+
232
+ {/* Divider */}
233
+ <div className="w-px h-5 bg-border mx-0.5 flex-shrink-0" />
234
+
235
+ {/* Quick actions: Home, Fit, Show All */}
236
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0" onClick={handleHome} aria-label="Home">
237
+ <Home className="h-4 w-4" />
238
+ </Button>
239
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0" onClick={() => cameraCallbacks.fitAll?.()} aria-label="Fit All">
240
+ <Maximize2 className="h-4 w-4" />
241
+ </Button>
242
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0" onClick={handleShowAll} aria-label="Show All">
243
+ <Eye className="h-4 w-4" />
244
+ </Button>
245
+
246
+ {/* Spacer */}
247
+ <div className="flex-1 min-w-2" />
248
+
249
+ {/* Loading progress (compact) */}
250
+ {loading && (geometryProgress || metadataProgress || progress) && (
251
+ <div className="flex items-center gap-1.5 mr-1 flex-shrink-0">
252
+ <Progress value={(geometryProgress ?? metadataProgress ?? progress)?.percent ?? 0} className="w-16 h-1.5" />
253
+ <span className="text-[10px] text-muted-foreground tabular-nums">
254
+ {Math.round((geometryProgress ?? metadataProgress ?? progress)?.percent ?? 0)}%
255
+ </span>
256
+ </div>
257
+ )}
258
+
259
+ {/* Error */}
260
+ {error && (
261
+ <span className="text-[10px] text-destructive mr-1 truncate max-w-24 flex-shrink-0">{error}</span>
262
+ )}
263
+
264
+ {/* Overflow menu */}
265
+ <DropdownMenu>
266
+ <DropdownMenuTrigger asChild>
267
+ <Button variant="ghost" size="icon-sm" className="h-9 w-9 flex-shrink-0">
268
+ <MoreHorizontal className="h-4 w-4" />
269
+ </Button>
270
+ </DropdownMenuTrigger>
271
+ <DropdownMenuContent align="end" className="w-52">
272
+ {/* Walk Mode */}
273
+ <DropdownMenuCheckboxItem
274
+ checked={activeTool === 'walk'}
275
+ onCheckedChange={() => setActiveTool(activeTool === 'walk' ? 'select' : 'walk')}
276
+ >
277
+ <PersonStanding className="h-4 w-4 mr-2" />
278
+ Walk Mode
279
+ </DropdownMenuCheckboxItem>
280
+
281
+ <DropdownMenuSeparator />
282
+
283
+ {/* Visibility */}
284
+ <DropdownMenuItem onClick={handleIsolate}>
285
+ <Eye className="h-4 w-4 mr-2" />
286
+ Isolate Selection
287
+ </DropdownMenuItem>
288
+ <DropdownMenuItem onClick={handleHide} disabled={!hasSelection}>
289
+ <EyeOff className="h-4 w-4 mr-2" />
290
+ Hide Selection
291
+ </DropdownMenuItem>
292
+ {hasSelection && (
293
+ <DropdownMenuItem onClick={() => cameraCallbacks.frameSelection?.()}>
294
+ <Crosshair className="h-4 w-4 mr-2" />
295
+ Frame Selection
296
+ </DropdownMenuItem>
297
+ )}
298
+
299
+ <DropdownMenuSeparator />
300
+
301
+ {/* Camera */}
302
+ <DropdownMenuItem onClick={() => toggleProjectionMode()}>
303
+ <Orbit className="h-4 w-4 mr-2" />
304
+ {projectionMode === 'orthographic' ? 'Perspective' : 'Orthographic'}
305
+ </DropdownMenuItem>
306
+
307
+ <DropdownMenuSeparator />
308
+
309
+ {/* Export */}
310
+ {geometryResult && (
311
+ <DropdownMenuItem onClick={() => void handleExportGLB()}>
312
+ <Download className="h-4 w-4 mr-2" />
313
+ Export GLB
314
+ </DropdownMenuItem>
315
+ )}
316
+
317
+ {/* Theme */}
318
+ <DropdownMenuItem onClick={() => toggleTheme()}>
319
+ {theme === 'dark' ? <Sun className="h-4 w-4 mr-2" /> : <Moon className="h-4 w-4 mr-2" />}
320
+ {theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
321
+ </DropdownMenuItem>
322
+ </DropdownMenuContent>
323
+ </DropdownMenu>
324
+ </div>
325
+ );
326
+ }
@@ -0,0 +1,111 @@
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
+ * Per-ASPRS-class visibility toggles. Renders an inline list of every
7
+ * known LAS 1.4 standard class with a checkbox bound to the
8
+ * `pointCloudClassMask` bitmask. Hidden classes are pushed behind the
9
+ * near plane in the splat shader (`flags.w` cull).
10
+ *
11
+ * The colour swatches mirror `point-shader.wgsl.ts` so the UI stays
12
+ * in sync with what the user actually sees on screen.
13
+ */
14
+
15
+ import { useViewerStore } from '@/store';
16
+
17
+ interface ClassEntry {
18
+ id: number;
19
+ label: string;
20
+ rgb: [number, number, number];
21
+ }
22
+
23
+ const CLASSES: ClassEntry[] = [
24
+ { id: 0, label: 'Never classified', rgb: [0.65, 0.65, 0.65] },
25
+ { id: 1, label: 'Unclassified', rgb: [0.65, 0.65, 0.65] },
26
+ { id: 2, label: 'Ground', rgb: [0.55, 0.40, 0.25] },
27
+ { id: 3, label: 'Low vegetation', rgb: [0.55, 0.85, 0.45] },
28
+ { id: 4, label: 'Medium vegetation', rgb: [0.30, 0.75, 0.30] },
29
+ { id: 5, label: 'High vegetation', rgb: [0.10, 0.45, 0.15] },
30
+ { id: 6, label: 'Building', rgb: [0.95, 0.55, 0.20] },
31
+ { id: 7, label: 'Low point (noise)', rgb: [0.95, 0.20, 0.20] },
32
+ { id: 9, label: 'Water', rgb: [0.20, 0.40, 0.95] },
33
+ { id: 10, label: 'Rail', rgb: [0.55, 0.20, 0.85] },
34
+ { id: 11, label: 'Road surface', rgb: [0.30, 0.30, 0.30] },
35
+ { id: 13, label: 'Wire — guard', rgb: [0.95, 0.85, 0.20] },
36
+ { id: 14, label: 'Wire — conductor', rgb: [0.95, 0.95, 0.50] },
37
+ { id: 15, label: 'Transmission tower', rgb: [0.20, 0.20, 0.55] },
38
+ { id: 16, label: 'Wire-structure', rgb: [0.30, 0.65, 0.65] },
39
+ { id: 17, label: 'Bridge deck', rgb: [0.85, 0.70, 0.50] },
40
+ { id: 18, label: 'High noise', rgb: [0.95, 0.20, 0.20] },
41
+ ];
42
+
43
+ const ALL_VISIBLE = 0xFFFFFFFF;
44
+
45
+ export function PointCloudClasses() {
46
+ const mask = useViewerStore((s) => s.pointCloudClassMask);
47
+ const toggle = useViewerStore((s) => s.togglePointCloudClass);
48
+ const setMask = useViewerStore((s) => s.setPointCloudClassMask);
49
+ const allOn = (mask >>> 0) === ALL_VISIBLE;
50
+ return (
51
+ <details className="flex flex-col gap-0.5">
52
+ <summary className="text-[9px] uppercase text-muted-foreground tracking-wider cursor-pointer select-none">
53
+ Classes {!allOn && (
54
+ <span className="text-[9px] normal-case text-amber-500"> · {countSet(mask)} of 32 visible</span>
55
+ )}
56
+ </summary>
57
+ <div className="flex flex-col gap-0.5 mt-1 max-h-40 overflow-y-auto pr-1">
58
+ <button
59
+ type="button"
60
+ onClick={() => setMask(ALL_VISIBLE)}
61
+ className="text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted px-1 py-0.5 rounded text-left"
62
+ >
63
+ Show all
64
+ </button>
65
+ {CLASSES.map((c) => {
66
+ const visible = ((mask >>> c.id) & 1) !== 0;
67
+ return (
68
+ <label
69
+ key={c.id}
70
+ className="flex items-center gap-1.5 text-[10px] cursor-pointer hover:bg-muted/40 rounded px-1 py-0.5"
71
+ >
72
+ <input
73
+ type="checkbox"
74
+ checked={visible}
75
+ onChange={() => toggle(c.id)}
76
+ className="accent-teal-600"
77
+ aria-label={`Toggle ${c.label}`}
78
+ />
79
+ <span
80
+ className="inline-block h-3 w-3 rounded-sm shrink-0 border border-foreground/10"
81
+ style={{ backgroundColor: rgbCss(c.rgb) }}
82
+ aria-hidden="true"
83
+ />
84
+ <span className="text-muted-foreground tabular-nums w-4 shrink-0">{c.id}</span>
85
+ <span className={visible ? 'text-foreground truncate' : 'text-muted-foreground line-through truncate'}>
86
+ {c.label}
87
+ </span>
88
+ </label>
89
+ );
90
+ })}
91
+ </div>
92
+ </details>
93
+ );
94
+ }
95
+
96
+ function countSet(mask: number): number {
97
+ // Hamming weight via Brian Kernighan's algorithm. JS bitwise ops
98
+ // are 32-bit so we naturally cover the full ASPRS range.
99
+ let n = mask >>> 0;
100
+ let count = 0;
101
+ while (n !== 0) {
102
+ n &= n - 1;
103
+ count++;
104
+ }
105
+ return count;
106
+ }
107
+
108
+ function rgbCss([r, g, b]: [number, number, number]): string {
109
+ const c = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255)));
110
+ return `rgb(${c(r)},${c(g)},${c(b)})`;
111
+ }
@@ -0,0 +1,119 @@
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
+ * Per-mode legend for the point-cloud panel.
7
+ *
8
+ * Renders only when the active colour mode benefits from a legend
9
+ * (classification / intensity / height); RGB and Solid don't need one.
10
+ * The palettes here MUST stay in sync with `point-shader.wgsl.ts` —
11
+ * any colour change in the shader has to come back to this file.
12
+ */
13
+
14
+ import type { PointColorModeUi } from '@/store/slices/pointCloudSlice';
15
+
16
+ interface ClassificationEntry {
17
+ id: number;
18
+ label: string;
19
+ rgb: [number, number, number];
20
+ }
21
+
22
+ // ASPRS LAS 1.4 standard classes — ids that don't appear here all
23
+ // fall back to the shader's "default" entry (0.65 grey) and are
24
+ // shown collectively at the bottom of the legend.
25
+ const CLASSIFICATION: ClassificationEntry[] = [
26
+ { id: 0, label: 'Never classified', rgb: [0.65, 0.65, 0.65] },
27
+ { id: 1, label: 'Unclassified', rgb: [0.65, 0.65, 0.65] },
28
+ { id: 2, label: 'Ground', rgb: [0.55, 0.40, 0.25] },
29
+ { id: 3, label: 'Low vegetation', rgb: [0.55, 0.85, 0.45] },
30
+ { id: 4, label: 'Medium vegetation', rgb: [0.30, 0.75, 0.30] },
31
+ { id: 5, label: 'High vegetation', rgb: [0.10, 0.45, 0.15] },
32
+ { id: 6, label: 'Building', rgb: [0.95, 0.55, 0.20] },
33
+ { id: 7, label: 'Low point (noise)', rgb: [0.95, 0.20, 0.20] },
34
+ { id: 9, label: 'Water', rgb: [0.20, 0.40, 0.95] },
35
+ { id: 10, label: 'Rail', rgb: [0.55, 0.20, 0.85] },
36
+ { id: 11, label: 'Road surface', rgb: [0.30, 0.30, 0.30] },
37
+ { id: 13, label: 'Wire — guard', rgb: [0.95, 0.85, 0.20] },
38
+ { id: 14, label: 'Wire — conductor', rgb: [0.95, 0.95, 0.50] },
39
+ { id: 15, label: 'Transmission tower', rgb: [0.20, 0.20, 0.55] },
40
+ { id: 16, label: 'Wire-structure', rgb: [0.30, 0.65, 0.65] },
41
+ { id: 17, label: 'Bridge deck', rgb: [0.85, 0.70, 0.50] },
42
+ { id: 18, label: 'High noise', rgb: [0.95, 0.20, 0.20] },
43
+ ];
44
+
45
+ const HEIGHT_GRADIENT =
46
+ 'linear-gradient(to right, '
47
+ + 'rgb(26,51,217), ' // 0.10, 0.20, 0.85
48
+ + 'rgb(26,217,217), ' // 0.10, 0.85, 0.85
49
+ + 'rgb(51,217,51), ' // 0.20, 0.85, 0.20
50
+ + 'rgb(242,242,51), ' // 0.95, 0.95, 0.20
51
+ + 'rgb(242,51,26))'; // 0.95, 0.20, 0.10
52
+
53
+ export interface PointCloudLegendProps {
54
+ colorMode: PointColorModeUi;
55
+ }
56
+
57
+ export function PointCloudLegend({ colorMode }: PointCloudLegendProps) {
58
+ if (colorMode === 'classification') {
59
+ return (
60
+ <div className="flex flex-col gap-0.5 mt-1 max-h-40 overflow-y-auto">
61
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider sticky top-0 bg-background/95 py-0.5">
62
+ Classes (ASPRS LAS 1.4)
63
+ </span>
64
+ {CLASSIFICATION.map((c) => (
65
+ <div key={c.id} className="flex items-center gap-1.5 text-[10px]">
66
+ <span
67
+ className="inline-block h-3 w-3 rounded-sm shrink-0 border border-foreground/10"
68
+ style={{ backgroundColor: rgbCss(c.rgb) }}
69
+ aria-hidden="true"
70
+ />
71
+ <span className="text-muted-foreground tabular-nums w-4 shrink-0">{c.id}</span>
72
+ <span className="text-foreground truncate">{c.label}</span>
73
+ </div>
74
+ ))}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ if (colorMode === 'intensity') {
80
+ return (
81
+ <div className="flex flex-col gap-0.5 mt-1">
82
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">Intensity</span>
83
+ <div
84
+ className="h-2 rounded-sm border border-foreground/10"
85
+ style={{ background: 'linear-gradient(to right, rgb(0,0,0), rgb(255,255,255))' }}
86
+ aria-label="Intensity ramp from low (black) to high (white)"
87
+ />
88
+ <div className="flex justify-between text-[9px] text-muted-foreground">
89
+ <span>low</span>
90
+ <span>high</span>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ if (colorMode === 'height') {
97
+ return (
98
+ <div className="flex flex-col gap-0.5 mt-1">
99
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">Height (Y-up)</span>
100
+ <div
101
+ className="h-2 rounded-sm border border-foreground/10"
102
+ style={{ background: HEIGHT_GRADIENT }}
103
+ aria-label="Height ramp from low (blue) to high (red)"
104
+ />
105
+ <div className="flex justify-between text-[9px] text-muted-foreground">
106
+ <span>low</span>
107
+ <span>high</span>
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ function rgbCss([r, g, b]: [number, number, number]): string {
117
+ const c = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255)));
118
+ return `rgb(${c(r)},${c(g)},${c(b)})`;
119
+ }
@@ -12,6 +12,9 @@
12
12
  import { useViewerStore } from '@/store';
13
13
  import type { PointColorModeUi, PointSizeModeUi } from '@/store/slices/pointCloudSlice';
14
14
  import { cn } from '@/lib/utils';
15
+ import { PointCloudLegend } from './PointCloudLegend';
16
+ import { PointCloudClasses } from './PointCloudClasses';
17
+ import { DeviationPanel } from './DeviationPanel';
15
18
 
16
19
  const COLOR_MODES: Array<{ value: PointColorModeUi; label: string; hint: string }> = [
17
20
  { value: 'rgb', label: 'RGB', hint: 'Per-point colour from the source' },
@@ -19,6 +22,7 @@ const COLOR_MODES: Array<{ value: PointColorModeUi; label: string; hint: string
19
22
  { value: 'intensity', label: 'Intensity', hint: 'Greyscale ramp from per-point intensity' },
20
23
  { value: 'height', label: 'Height', hint: 'Cool-warm ramp by Y-up world height' },
21
24
  { value: 'fixed', label: 'Solid', hint: 'Single colour override' },
25
+ { value: 'deviation', label: 'Deviation', hint: 'Signed distance to nearest BIM surface (compute below)' },
22
26
  ];
23
27
 
24
28
  const SIZE_MODES: Array<{ value: PointSizeModeUi; label: string; hint: string }> = [
@@ -30,9 +34,12 @@ const SIZE_MODES: Array<{ value: PointSizeModeUi; label: string; hint: string }>
30
34
  export interface PointCloudPanelProps {
31
35
  /** Number of currently-loaded point cloud assets — panel hides when 0. */
32
36
  assetCount: number;
37
+ /** Total triangle count across the scene (gates the BIM↔scan deviation
38
+ * compute button — useless without a BIM model loaded). */
39
+ triangleCount: number;
33
40
  }
34
41
 
35
- export function PointCloudPanel({ assetCount }: PointCloudPanelProps) {
42
+ export function PointCloudPanel({ assetCount, triangleCount }: PointCloudPanelProps) {
36
43
  const colorMode = useViewerStore((s) => s.pointCloudColorMode);
37
44
  const setColorMode = useViewerStore((s) => s.setPointCloudColorMode);
38
45
  const sizeMode = useViewerStore((s) => s.pointCloudSizeMode);
@@ -45,6 +52,8 @@ export function PointCloudPanel({ assetCount }: PointCloudPanelProps) {
45
52
  const setEdlEnabled = useViewerStore((s) => s.setPointCloudEdlEnabled);
46
53
  const edlStrength = useViewerStore((s) => s.pointCloudEdlStrength);
47
54
  const setEdlStrength = useViewerStore((s) => s.setPointCloudEdlStrength);
55
+ const fixedColor = useViewerStore((s) => s.pointCloudFixedColor);
56
+ const setFixedColor = useViewerStore((s) => s.setPointCloudFixedColor);
48
57
 
49
58
  if (assetCount <= 0) return null;
50
59
 
@@ -81,8 +90,31 @@ export function PointCloudPanel({ assetCount }: PointCloudPanelProps) {
81
90
  </button>
82
91
  );
83
92
  })}
93
+ <PointCloudLegend colorMode={colorMode} />
94
+ {colorMode === 'fixed' && (
95
+ // Native colour input — keeps the panel dependency-free.
96
+ // Hex round-trips through float[0..1]: parse `#rrggbb` to a
97
+ // [r,g,b,1] tuple on input, format the active rgb back to hex
98
+ // on display. Alpha stays 1 since fixed-mode opacity is
99
+ // controlled by the splat shape, not the colour swatch.
100
+ <label className="flex items-center justify-between gap-2 mt-1 px-2 py-1 rounded bg-muted/40">
101
+ <span className="text-[10px] text-muted-foreground">Solid colour</span>
102
+ <input
103
+ type="color"
104
+ value={rgbToHex(fixedColor)}
105
+ onChange={(e) => setFixedColor(hexToRgba(e.target.value, fixedColor[3]))}
106
+ aria-label="Pick the solid colour applied in fixed mode"
107
+ className="h-6 w-10 rounded border-0 cursor-pointer bg-transparent"
108
+ />
109
+ </label>
110
+ )}
84
111
  </div>
85
112
 
113
+ {/* Per-ASPRS-class visibility — toggles the splat shader's
114
+ class-mask uniform; works in any colour mode but most
115
+ discoverable when colorMode === 'classification'. */}
116
+ <PointCloudClasses />
117
+
86
118
  {/* Size mode */}
87
119
  <div className="flex flex-col gap-0.5">
88
120
  <span className="text-[9px] uppercase text-muted-foreground tracking-wider">Size</span>
@@ -169,6 +201,25 @@ export function PointCloudPanel({ assetCount }: PointCloudPanelProps) {
169
201
  </label>
170
202
  )}
171
203
  </div>
204
+
205
+ {/* BIM↔scan deviation heatmap — only useful when both meshes
206
+ and points are loaded. The panel renders nothing when there
207
+ are no triangles in the scene. */}
208
+ <DeviationPanel triangleCount={triangleCount} />
172
209
  </div>
173
210
  );
174
211
  }
212
+
213
+ function rgbToHex([r, g, b]: [number, number, number, number]): string {
214
+ const c = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, '0');
215
+ return `#${c(r)}${c(g)}${c(b)}`;
216
+ }
217
+
218
+ function hexToRgba(hex: string, alpha: number): [number, number, number, number] {
219
+ // Browsers always emit "#rrggbb" from <input type="color">, so we
220
+ // can skip the 3-char shorthand path. Parse byte-by-byte and divide.
221
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
222
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
223
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
224
+ return [r, g, b, alpha];
225
+ }