@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
@@ -46,6 +46,8 @@ import { useIfcCache, getCached } from './useIfcCache.js';
46
46
  import { useIfcServer } from './useIfcServer.js';
47
47
 
48
48
  import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './ingest/viewerModelIngest.js';
49
+ import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
50
+ import { getGlobalRenderer } from './useBCF.js';
49
51
 
50
52
  /**
51
53
  * Compute a fast content fingerprint from the first and last 4KB of a buffer.
@@ -253,7 +255,7 @@ export function useIfcLoader() {
253
255
  dataStore: IfcDataStore | null,
254
256
  geometryResult: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null,
255
257
  schemaVersion: 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5',
256
- patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null },
258
+ patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number },
257
259
  ) => {
258
260
  let idOffset = 0;
259
261
  let maxExpressId = 0;
@@ -271,6 +273,7 @@ export function useIfcLoader() {
271
273
  loadState: patch?.loadState ?? 'complete',
272
274
  cacheState: patch?.cacheState ?? 'none',
273
275
  loadError: patch?.loadError ?? null,
276
+ pointCloudHandleId: patch?.pointCloudHandleId,
274
277
  });
275
278
  };
276
279
  const getSchemaVersion = (dataStore: IfcDataStore | null): 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5' => {
@@ -1560,8 +1563,69 @@ export function useIfcLoader() {
1560
1563
  const fileReadMs = performance.now() - fileReadStart;
1561
1564
  console.log(`[useIfc] File: ${file.name}, size: ${fileSizeMB.toFixed(2)}MB, read in ${fileReadMs.toFixed(0)}ms`);
1562
1565
 
1563
- // Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB)
1564
- const format = detectFormat(buffer);
1566
+ // Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB vs LAS/LAZ)
1567
+ const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
1568
+ const format = pointCloudFormat ?? detectFormat(buffer);
1569
+
1570
+ // LAS / LAZ point clouds: stream chunks straight to the renderer.
1571
+ // No on-disk cache, no server upload — the data goes worker → GPU.
1572
+ if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
1573
+ const renderer = getGlobalRenderer();
1574
+ if (!renderer) {
1575
+ setError('Renderer not initialised — try again after the viewer mounts.');
1576
+ updateModel(primaryModelId, { loadState: 'error', loadError: 'renderer-missing' });
1577
+ setLoading(false);
1578
+ return;
1579
+ }
1580
+ setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
1581
+ setGeometryStreamingActive(false);
1582
+ const blob = isNativeFileHandle(file) ? new Blob([buffer]) : (file as File);
1583
+ const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
1584
+ const ingest = ingestPointCloud({
1585
+ format,
1586
+ blob,
1587
+ fileName: file.name,
1588
+ buffer,
1589
+ renderer,
1590
+ onProgress: setProgress,
1591
+ onAssetCountDelta: incCount,
1592
+ });
1593
+ // ingestPointCloud's onError callback already runs renderer cleanup
1594
+ // + incCount(-1); the outer catch must NOT repeat them or the
1595
+ // pointCloudAssetCount will go negative.
1596
+ try {
1597
+ await ingest.done;
1598
+ } catch (err) {
1599
+ // Bail without touching store/UI state if a newer load
1600
+ // session has already started — the more recent flow owns
1601
+ // the spinner / model record now. Free the renderer handle
1602
+ // so we don't leak the half-streamed asset.
1603
+ if (loadSessionRef.current !== currentSession) {
1604
+ renderer.removePointCloudAsset(ingest.rendererHandle);
1605
+ return;
1606
+ }
1607
+ const message = err instanceof Error ? err.message : String(err);
1608
+ updateModel(primaryModelId, { loadState: 'error', loadError: message });
1609
+ setError(`${format.toUpperCase()} parsing failed: ${message}`);
1610
+ setLoading(false);
1611
+ return;
1612
+ }
1613
+ if (loadSessionRef.current !== currentSession) {
1614
+ // A newer load already began. Drop our streamed asset and
1615
+ // skip every store/UI mutation so we don't overwrite the
1616
+ // newer model's state.
1617
+ renderer.removePointCloudAsset(ingest.rendererHandle);
1618
+ return;
1619
+ }
1620
+ setGeometryResult(ingest.geometryResult);
1621
+ setIfcDataStore(ingest.dataStore);
1622
+ finalizePrimaryModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
1623
+ pointCloudHandleId: ingest.rendererHandle.id,
1624
+ });
1625
+ setProgress({ phase: 'Complete', percent: 100 });
1626
+ setLoading(false);
1627
+ return;
1628
+ }
1565
1629
 
1566
1630
  // IFCX files must be parsed client-side (server only supports IFC4 STEP)
1567
1631
  if (format === 'ifcx') {
@@ -80,9 +80,11 @@ export async function openIfcFileDialog(): Promise<NativeFileHandle | null> {
80
80
  const selected = await dialog.open({
81
81
  multiple: false,
82
82
  directory: false,
83
- title: 'Open IFC File',
83
+ title: 'Open IFC, Mesh or Point Cloud File',
84
84
  filters: [
85
- { name: 'IFC Files', extensions: ['ifc', 'ifczip', 'ifcxml'] },
85
+ { name: 'IFC Files', extensions: ['ifc', 'ifczip', 'ifcxml', 'ifcx'] },
86
+ { name: 'Mesh Files', extensions: ['glb'] },
87
+ { name: 'Point Clouds', extensions: ['las', 'laz', 'ply', 'pcd', 'e57'] },
86
88
  { name: 'All Files', extensions: ['*'] },
87
89
  ],
88
90
  });
@@ -40,6 +40,7 @@ import { createOverlaySlice, type OverlaySlice } from './slices/overlaySlice.js'
40
40
  import { createSearchSlice, type SearchSlice } from './slices/searchSlice.js';
41
41
  import { createAnnotationsSlice, type AnnotationsSlice } from './slices/annotationsSlice.js';
42
42
  import { createAddElementSlice, type AddElementSlice } from './slices/addElementSlice.js';
43
+ import { createPointCloudSlice, type PointCloudSlice, POINT_CLOUD_DEFAULTS } from './slices/pointCloudSlice.js';
43
44
  import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
44
45
 
45
46
  // Import constants for reset function
@@ -132,7 +133,8 @@ export type ViewerState = LoadingSlice &
132
133
  OverlaySlice &
133
134
  SearchSlice &
134
135
  AnnotationsSlice &
135
- AddElementSlice & {
136
+ AddElementSlice &
137
+ PointCloudSlice & {
136
138
  resetViewerState: () => void;
137
139
  };
138
140
 
@@ -169,6 +171,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
169
171
  ...createSearchSlice(...args),
170
172
  ...createAnnotationsSlice(...args),
171
173
  ...createAddElementSlice(...args),
174
+ ...createPointCloudSlice(...args),
172
175
 
173
176
  // Reset all viewer state when loading new file
174
177
  // Note: Does NOT clear models - use clearAllModels() for that
@@ -415,6 +418,12 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
415
418
  // pins themselves stay in localStorage (cross-file workspace).
416
419
  draft: null,
417
420
  selectedAnnotationId: null,
421
+
422
+ // Point cloud — clear runtime fields so a new file doesn't
423
+ // inherit the previous file's color mode / size / EDL state.
424
+ // Single-source-of-truth defaults shared with createPointCloudSlice.
425
+ ...POINT_CLOUD_DEFAULTS,
426
+ pointCloudFixedColor: [...POINT_CLOUD_DEFAULTS.pointCloudFixedColor] as [number, number, number, number],
418
427
  });
419
428
  },
420
429
  }));
@@ -0,0 +1,102 @@
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
+ * Point cloud rendering preferences.
7
+ *
8
+ * The renderer reads these via `usePointCloudSync`; UI components write
9
+ * them via the actions below. EDL is opt-in (default on) — costs ~5
10
+ * extra texture taps per pixel.
11
+ */
12
+
13
+ import type { StateCreator } from 'zustand';
14
+
15
+ export type PointColorModeUi = 'rgb' | 'classification' | 'intensity' | 'height' | 'fixed';
16
+ export type PointSizeModeUi = 'fixed-px' | 'adaptive-world' | 'attenuated';
17
+
18
+ export interface PointCloudSlice {
19
+ pointCloudColorMode: PointColorModeUi;
20
+ pointCloudFixedColor: [number, number, number, number];
21
+ /** Splat sizing strategy. Default: 'fixed-px' (sized by the px slider). */
22
+ pointCloudSizeMode: PointSizeModeUi;
23
+ /** Splat size in pixels (fixed/attenuated) or upper cap (attenuated). 1..20. */
24
+ pointCloudPointSize: number;
25
+ /** World-space splat radius in metres for adaptive/attenuated modes.
26
+ * Typical scans: 0.005–0.05. Default 0.02. */
27
+ pointCloudWorldRadius: number;
28
+ /** Render splats as discs vs squares. Default true. */
29
+ pointCloudRoundShape: boolean;
30
+ /** Enable Eye-Dome Lighting post-pass. Default true. */
31
+ pointCloudEdlEnabled: boolean;
32
+ /** EDL strength multiplier. 0..3, default 1. */
33
+ pointCloudEdlStrength: number;
34
+ /**
35
+ * Best-effort count of point cloud assets currently uploaded to the
36
+ * renderer. Updated by ingest paths; UI uses it to show/hide the
37
+ * controls panel and the EDL post-pass.
38
+ */
39
+ pointCloudAssetCount: number;
40
+ setPointCloudColorMode: (mode: PointColorModeUi) => void;
41
+ setPointCloudFixedColor: (rgba: [number, number, number, number]) => void;
42
+ setPointCloudSizeMode: (mode: PointSizeModeUi) => void;
43
+ setPointCloudPointSize: (px: number) => void;
44
+ setPointCloudWorldRadius: (m: number) => void;
45
+ setPointCloudRoundShape: (enabled: boolean) => void;
46
+ setPointCloudEdlEnabled: (enabled: boolean) => void;
47
+ setPointCloudEdlStrength: (strength: number) => void;
48
+ setPointCloudAssetCount: (count: number) => void;
49
+ incrementPointCloudAssetCount: (n?: number) => void;
50
+ }
51
+
52
+ /**
53
+ * Single source of truth for the slice's runtime field defaults.
54
+ * Both the slice initializer and `resetViewerState` consume this so
55
+ * the two paths can't drift.
56
+ */
57
+ export const POINT_CLOUD_DEFAULTS = {
58
+ // Fixed-px is the default so the size slider feels responsive on first
59
+ // contact. `attenuated` is nicer at extreme zooms but its "slider =
60
+ // upper cap" semantic confuses users at typical wide views because the
61
+ // projected world radius sits well below the cap.
62
+ pointCloudColorMode: 'rgb' as PointColorModeUi,
63
+ pointCloudFixedColor: [1, 1, 1, 1] as [number, number, number, number],
64
+ pointCloudSizeMode: 'fixed-px' as PointSizeModeUi,
65
+ pointCloudPointSize: 4,
66
+ pointCloudWorldRadius: 0.02,
67
+ pointCloudRoundShape: true,
68
+ pointCloudEdlEnabled: true,
69
+ pointCloudEdlStrength: 1,
70
+ pointCloudAssetCount: 0,
71
+ } as const;
72
+
73
+ export const createPointCloudSlice: StateCreator<PointCloudSlice, [], [], PointCloudSlice> = (set) => ({
74
+ ...POINT_CLOUD_DEFAULTS,
75
+ // Re-spread typed-array fields so consumers get fresh references
76
+ // instead of the readonly literal in POINT_CLOUD_DEFAULTS.
77
+ pointCloudFixedColor: [...POINT_CLOUD_DEFAULTS.pointCloudFixedColor] as [number, number, number, number],
78
+ setPointCloudColorMode: (mode) => set({ pointCloudColorMode: mode }),
79
+ setPointCloudFixedColor: (rgba) => set({ pointCloudFixedColor: rgba }),
80
+ setPointCloudSizeMode: (mode) => set({ pointCloudSizeMode: mode }),
81
+ // NaN/Infinity slip past Math.max+min unchanged ((NaN < x) === false),
82
+ // so guard with isFinite to keep invalid values out of GPU uniforms.
83
+ setPointCloudPointSize: (px) => set({
84
+ pointCloudPointSize: Number.isFinite(px) ? Math.max(1, Math.min(20, px)) : 4,
85
+ }),
86
+ setPointCloudWorldRadius: (m) => set({
87
+ pointCloudWorldRadius: Number.isFinite(m) ? Math.max(1e-4, m) : 0.02,
88
+ }),
89
+ setPointCloudRoundShape: (enabled) => set({ pointCloudRoundShape: enabled }),
90
+ setPointCloudEdlEnabled: (enabled) => set({ pointCloudEdlEnabled: enabled }),
91
+ setPointCloudEdlStrength: (strength) => set({
92
+ pointCloudEdlStrength: Number.isFinite(strength) ? Math.max(0, Math.min(3, strength)) : 1,
93
+ }),
94
+ setPointCloudAssetCount: (count) => set({
95
+ pointCloudAssetCount: Number.isFinite(count) ? Math.max(0, count) : 0,
96
+ }),
97
+ incrementPointCloudAssetCount: (n = 1) => set((s) => ({
98
+ pointCloudAssetCount: Number.isFinite(n)
99
+ ? Math.max(0, s.pointCloudAssetCount + n)
100
+ : s.pointCloudAssetCount,
101
+ })),
102
+ });
@@ -340,6 +340,13 @@ export interface FederatedModel {
340
340
  cacheState?: 'none' | 'hit' | 'miss' | 'writing';
341
341
  /** Optional load error for this model. */
342
342
  loadError?: string | null;
343
+ /**
344
+ * Renderer handle for a streamed point cloud (LAS/LAZ) attached to
345
+ * this model. Stored as a plain number so the field stays JSON-safe.
346
+ * The viewport's removal effect calls `renderer.removePointCloudAsset`
347
+ * when the model is dropped from the store.
348
+ */
349
+ pointCloudHandleId?: number;
343
350
  }
344
351
 
345
352
  /** Convert EntityRef to string for use as Map/Set key */
package/vite.config.ts CHANGED
@@ -253,6 +253,7 @@ export default defineConfig({
253
253
  '@ifc-lite/export': path.resolve(__dirname, '../../packages/export/src'),
254
254
  '@ifc-lite/cache': path.resolve(__dirname, '../../packages/cache/src'),
255
255
  '@ifc-lite/ifcx': path.resolve(__dirname, '../../packages/ifcx/src'),
256
+ '@ifc-lite/pointcloud': path.resolve(__dirname, '../../packages/pointcloud/src'),
256
257
  '@ifc-lite/wasm': path.resolve(__dirname, '../../packages/wasm/pkg/ifc-lite.js'),
257
258
  '@ifc-lite/sdk': path.resolve(__dirname, '../../packages/sdk/src'),
258
259
  '@ifc-lite/create': path.resolve(__dirname, '../../packages/create/src'),