@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
@@ -0,0 +1,98 @@
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
+ * Sync IFCx-derived point cloud assets to the renderer.
7
+ *
8
+ * On every change of the `pointClouds` array we replace the renderer's
9
+ * asset list and request a fresh frame. When the active scene has no
10
+ * triangle meshes (the buildingSMART point-cloud-only samples), we
11
+ * additionally trigger a one-shot camera fit — the geometry streaming
12
+ * hook bails out early in that case and would otherwise leave points
13
+ * stranded outside the camera frustum.
14
+ */
15
+
16
+ import { useEffect, useRef, type MutableRefObject } from 'react';
17
+ import type { PointColorMode, PointSizeMode, Renderer } from '@ifc-lite/renderer';
18
+ import type { PointCloudAsset } from '@ifc-lite/geometry';
19
+ import { useViewerStore } from '@/store';
20
+
21
+ export interface UsePointCloudSyncParams {
22
+ rendererRef: MutableRefObject<Renderer | null>;
23
+ isInitialized: boolean;
24
+ pointClouds: ReadonlyArray<PointCloudAsset> | null | undefined;
25
+ /** True when the scene has triangle meshes — the geometry streaming
26
+ * hook owns fit-to-view in that case and we shouldn't fight it. */
27
+ hasMeshes: boolean;
28
+ }
29
+
30
+ export function usePointCloudSync(params: UsePointCloudSyncParams): void {
31
+ const { rendererRef, isInitialized, pointClouds, hasMeshes } = params;
32
+ const colorMode = useViewerStore((s) => s.pointCloudColorMode) as PointColorMode;
33
+ const fixedColor = useViewerStore((s) => s.pointCloudFixedColor);
34
+ const sizeMode = useViewerStore((s) => s.pointCloudSizeMode) as PointSizeMode;
35
+ const pointSize = useViewerStore((s) => s.pointCloudPointSize);
36
+ const worldRadius = useViewerStore((s) => s.pointCloudWorldRadius);
37
+ const roundShape = useViewerStore((s) => s.pointCloudRoundShape);
38
+ const edlEnabled = useViewerStore((s) => s.pointCloudEdlEnabled);
39
+ const edlStrength = useViewerStore((s) => s.pointCloudEdlStrength);
40
+ const setAssetCount = useViewerStore((s) => s.setPointCloudAssetCount);
41
+ const fittedRef = useRef(false);
42
+
43
+ // Reset the one-shot fit flag whenever the asset list identity changes.
44
+ useEffect(() => {
45
+ fittedRef.current = false;
46
+ }, [pointClouds]);
47
+
48
+ // Replace IFCx-owned assets when the merged list changes
49
+ useEffect(() => {
50
+ const renderer = rendererRef.current;
51
+ if (!renderer || !isInitialized) return;
52
+
53
+ const assets = pointClouds ?? [];
54
+ renderer.setPointClouds(assets);
55
+ const count = renderer.getPointCloudAssetCount();
56
+ setAssetCount(count);
57
+
58
+ // Camera fit for points-only scenes — useGeometryStreaming skips its
59
+ // own fit branch when meshes is empty, so points stay off-screen
60
+ // unless we step in. Run once per fresh asset list.
61
+ if (count > 0 && !hasMeshes && !fittedRef.current) {
62
+ const bounds = renderer.getModelBounds();
63
+ if (bounds && Number.isFinite(bounds.min.x) && Number.isFinite(bounds.max.x)) {
64
+ renderer.getCamera().fitToBounds(bounds.min, bounds.max);
65
+ fittedRef.current = true;
66
+ }
67
+ }
68
+
69
+ renderer.requestRender();
70
+ }, [pointClouds, isInitialized, rendererRef, setAssetCount, hasMeshes]);
71
+
72
+ // Push color + sizing + shape preferences to the renderer whenever the
73
+ // user changes them. The slice already clamps numeric ranges so the
74
+ // shader only ever sees sane values.
75
+ useEffect(() => {
76
+ const renderer = rendererRef.current;
77
+ if (!renderer || !isInitialized) return;
78
+ renderer.setPointCloudOptions({
79
+ colorMode,
80
+ fixedColor,
81
+ sizeMode,
82
+ pointSize,
83
+ worldRadius,
84
+ roundShape,
85
+ });
86
+ renderer.requestRender();
87
+ }, [colorMode, fixedColor, sizeMode, pointSize, worldRadius, roundShape, isInitialized, rendererRef]);
88
+
89
+ // Push EDL toggle + strength to the renderer.
90
+ useEffect(() => {
91
+ const renderer = rendererRef.current;
92
+ if (!renderer || !isInitialized) return;
93
+ renderer.setEdlOptions({ enabled: edlEnabled, strength: edlStrength });
94
+ renderer.requestRender();
95
+ }, [edlEnabled, edlStrength, isInitialized, rendererRef]);
96
+ }
97
+
98
+ export default usePointCloudSync;
@@ -0,0 +1,391 @@
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
+ * LAS / LAZ ingest path for the viewer.
7
+ *
8
+ * Streams a Blob through `@ifc-lite/pointcloud`'s decode worker and
9
+ * pushes chunks directly into the renderer via the streaming API. The
10
+ * federated model entry carries no per-chunk data — it only holds the
11
+ * renderer handle, summary metadata, and bbox so removeModel can free
12
+ * the GPU resources cleanly.
13
+ */
14
+
15
+ import type { Renderer } from '@ifc-lite/renderer';
16
+ import {
17
+ streamPointCloud,
18
+ type DecodedPointChunk,
19
+ type StreamHandle,
20
+ } from '@ifc-lite/pointcloud';
21
+ import type { CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry';
22
+ import type { IfcDataStore } from '@ifc-lite/parser';
23
+ import type { SchemaVersion } from '../../store/types.js';
24
+ import { createCoordinateInfo } from '../../utils/localParsingUtils.js';
25
+
26
+ export type PointCloudFormat = 'las' | 'laz' | 'ply' | 'pcd' | 'e57';
27
+
28
+ /**
29
+ * IfcTypeEnum.IfcGeographicElement — the closest IFC4 entity for a scan
30
+ * is `IfcGeographicElement`. We hard-code the enum value (58) here so
31
+ * we don't pull `@ifc-lite/data` into the viewer ingest path.
32
+ */
33
+ const IFC_GEOGRAPHIC_ELEMENT_ENUM = 58;
34
+
35
+ /**
36
+ * Synthetic IfcDataStore for a point-cloud-only model. Picking a point
37
+ * sets the synthetic expressId as the selected entity, which then runs
38
+ * through the regular property/hover/properties-panel pipeline. That
39
+ * pipeline calls `entities.getTypeName / getName / getGlobalId` and
40
+ * `properties.getForEntity` — without those methods, picking crashes
41
+ * with "getTypeName is not a function". We give it just enough shape
42
+ * to round-trip the single synthetic entity.
43
+ */
44
+ function emptyDataStore(
45
+ buffer: ArrayBuffer,
46
+ expressId: number,
47
+ fileName: string,
48
+ ): IfcDataStore {
49
+ const expressIds = new Uint32Array([expressId]);
50
+ const empty32 = new Uint32Array(0);
51
+ const empty8 = new Uint8Array(0);
52
+ const emptyI32 = new Int32Array(0);
53
+ const indexOf = (id: number) => (id === expressId ? 0 : -1);
54
+ const entities = {
55
+ count: 1,
56
+ expressId: expressIds,
57
+ typeEnum: new Uint16Array([IFC_GEOGRAPHIC_ELEMENT_ENUM]),
58
+ globalId: empty32,
59
+ name: empty32,
60
+ description: empty32,
61
+ objectType: empty32,
62
+ flags: new Uint8Array([0]),
63
+ containedInStorey: new Int32Array([-1]),
64
+ definedByType: new Int32Array([-1]),
65
+ geometryIndex: new Int32Array([-1]),
66
+ typeRanges: new Map(),
67
+ getGlobalId: (id: number) => (indexOf(id) >= 0 ? `pointcloud-${expressId}` : ''),
68
+ getName: (id: number) => (indexOf(id) >= 0 ? fileName : ''),
69
+ getDescription: () => '',
70
+ getObjectType: () => '',
71
+ getTypeName: (id: number) => (indexOf(id) >= 0 ? 'IfcGeographicElement' : 'Unknown'),
72
+ hasGeometry: (id: number) => indexOf(id) >= 0,
73
+ getByType: () => [expressId],
74
+ getTypeEnum: (id: number) =>
75
+ indexOf(id) >= 0 ? IFC_GEOGRAPHIC_ELEMENT_ENUM : 9999, // 9999 = Unknown
76
+ getExpressIdByGlobalId: (gid: string) =>
77
+ gid === `pointcloud-${expressId}` ? expressId : -1,
78
+ getGlobalIdMap: () => new Map([[`pointcloud-${expressId}`, expressId]]),
79
+ };
80
+ const properties = {
81
+ count: 0,
82
+ entityId: empty32, psetName: empty32, psetGlobalId: empty32,
83
+ propName: empty32, propType: empty8,
84
+ valueString: empty32, valueReal: new Float64Array(0),
85
+ valueInt: emptyI32, valueBool: empty8, unitId: emptyI32,
86
+ entityIndex: new Map<number, number[]>(),
87
+ psetIndex: new Map<number, number[]>(),
88
+ propIndex: new Map<number, number[]>(),
89
+ getForEntity: () => [],
90
+ getPropertyValue: () => null,
91
+ findByProperty: () => [],
92
+ };
93
+ const quantities = {
94
+ count: 0,
95
+ entityId: empty32, qsetName: empty32, qsetGlobalId: empty32,
96
+ quantityName: empty32, quantityType: empty8,
97
+ valueReal: new Float64Array(0), unitId: emptyI32,
98
+ entityIndex: new Map<number, number[]>(),
99
+ qsetIndex: new Map<number, number[]>(),
100
+ getForEntity: () => [],
101
+ };
102
+ const relationships = {
103
+ count: 0,
104
+ relType: empty8, relatingId: empty32, relatedId: empty32,
105
+ byRelating: new Map<number, number[]>(),
106
+ byRelated: new Map<number, number[]>(),
107
+ getOutgoing: () => [],
108
+ getIncoming: () => [],
109
+ getRelated: () => [],
110
+ getRelating: () => [],
111
+ };
112
+ const byId = new Map<number, unknown>([[expressId, { expressId }]]);
113
+ return {
114
+ fileSize: buffer.byteLength,
115
+ schemaVersion: 'IFC4' as const,
116
+ entityCount: 1,
117
+ parseTime: 0,
118
+ source: new Uint8Array(0),
119
+ entityIndex: {
120
+ byId: byId as unknown as IfcDataStore['entityIndex']['byId'],
121
+ byType: new Map([['IFCGEOGRAPHICELEMENT', [expressId]]]),
122
+ },
123
+ strings: {
124
+ get: () => '',
125
+ getId: () => 0,
126
+ count: 0,
127
+ } as unknown as IfcDataStore['strings'],
128
+ entities: entities as unknown as IfcDataStore['entities'],
129
+ properties: properties as unknown as IfcDataStore['properties'],
130
+ quantities: quantities as unknown as IfcDataStore['quantities'],
131
+ relationships: relationships as unknown as IfcDataStore['relationships'],
132
+ spatialHierarchy: undefined,
133
+ } as unknown as IfcDataStore;
134
+ }
135
+
136
+ export interface PointCloudIngestResult {
137
+ dataStore: IfcDataStore;
138
+ geometryResult: GeometryResult;
139
+ schemaVersion: SchemaVersion;
140
+ /** Renderer handle so the model removal path can free GPU resources. */
141
+ rendererHandle: { id: number };
142
+ /** Stream handle so the caller can `cancel()` mid-flight. */
143
+ streamHandle: StreamHandle;
144
+ /** Resolves once decoding finishes (or rejects on error / cancel). */
145
+ done: Promise<void>;
146
+ }
147
+
148
+ export interface PointCloudIngestOptions {
149
+ format: PointCloudFormat;
150
+ blob: Blob;
151
+ fileName: string;
152
+ buffer: ArrayBuffer;
153
+ /** Renderer to push chunks into. Streaming starts immediately. */
154
+ renderer: Renderer;
155
+ /** Express ID assigned to this asset (for picking + federation). */
156
+ expressId?: number;
157
+ /** Federation index (set when the model registry is multi-model). */
158
+ modelIndex?: number;
159
+ /** Soft cap on points held on the GPU. Default: 25M. */
160
+ maxPointsInMemory?: number;
161
+ /** Hard cap on file size in bytes. Default: 4 GB. */
162
+ maxFileSize?: number;
163
+ /** Progress callback shared with the existing UI. */
164
+ onProgress?: (progress: { phase: string; percent: number }) => void;
165
+ /** Notified with +1 when streaming starts and -1 if it errors. */
166
+ onAssetCountDelta?: (delta: number) => void;
167
+ /** Abort signal to cancel ingest. */
168
+ signal?: AbortSignal;
169
+ }
170
+
171
+ /**
172
+ * Detect a supported point-cloud format from filename or magic bytes.
173
+ * Returns null when the buffer isn't a recognised format.
174
+ *
175
+ * Magic-byte sniffing covers files renamed by users:
176
+ * - LAS: "LASF" (0x4653414c)
177
+ * - PLY: "ply\n" or "ply\r\n" at offset 0
178
+ * - PCD: "# .PCD" or any `.PCD` token in first 32 bytes
179
+ * - LAZ: shares LAS magic; we trust the extension here
180
+ */
181
+ export function detectPointCloudFormat(
182
+ fileName: string,
183
+ buffer: ArrayBuffer | null,
184
+ ): PointCloudFormat | null {
185
+ const lower = fileName.toLowerCase();
186
+ if (lower.endsWith('.las')) return 'las';
187
+ if (lower.endsWith('.laz')) return 'laz';
188
+ if (lower.endsWith('.ply')) return 'ply';
189
+ if (lower.endsWith('.pcd')) return 'pcd';
190
+ if (lower.endsWith('.e57')) return 'e57';
191
+ if (buffer && buffer.byteLength >= 8) {
192
+ const view = new DataView(buffer, 0, Math.min(buffer.byteLength, 32));
193
+ if (view.getUint32(0, true) === 0x4653414c) return 'las';
194
+ // ASCII probe — first three bytes "ply" → PLY; "# .P" or ".PCD" → PCD.
195
+ const b0 = view.getUint8(0), b1 = view.getUint8(1), b2 = view.getUint8(2);
196
+ if (b0 === 0x70 /* p */ && b1 === 0x6c /* l */ && b2 === 0x79 /* y */) return 'ply';
197
+ if (b0 === 0x23 /* # */ && view.byteLength > 4 && view.getUint8(2) === 0x2e /* . */) return 'pcd';
198
+ // E57 magic = "ASTM-E57" (8 bytes)
199
+ if (
200
+ view.getUint8(0) === 0x41 && view.getUint8(1) === 0x53
201
+ && view.getUint8(2) === 0x54 && view.getUint8(3) === 0x4d
202
+ && view.getUint8(4) === 0x2d && view.getUint8(5) === 0x45
203
+ && view.getUint8(6) === 0x35 && view.getUint8(7) === 0x37
204
+ ) return 'e57';
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Map common unsupported formats to a user-facing explanation. Drop
211
+ * handlers call this when nothing else recognises a dropped file so the
212
+ * user sees "this is a Recap project, export to E57" instead of nothing
213
+ * happening.
214
+ */
215
+ export function describeUnsupportedFormat(fileName: string): string | null {
216
+ const lower = fileName.toLowerCase();
217
+ if (lower.endsWith('.zip')) {
218
+ return 'ZIP archive — please extract first. .ply / .las / .laz / .e57 files inside will load.';
219
+ }
220
+ if (
221
+ lower.endsWith('.rwp') || lower.endsWith('.rwi')
222
+ || lower.endsWith('.rwcx') || lower.endsWith('.dmt')
223
+ || lower.endsWith('.lay') || lower.endsWith('.db1')
224
+ ) {
225
+ return 'Autodesk ReCap (.rwp/.rwi/.rwcx) is a proprietary format we cannot decode. Export to E57 or LAS from ReCap.';
226
+ }
227
+ if (lower.endsWith('.skp')) return 'SketchUp model — not a point cloud.';
228
+ if (lower.endsWith('.fls') || lower.endsWith('.lsproj')) {
229
+ return 'Faro Scene project — export to E57 from Scene to load it here.';
230
+ }
231
+ if (lower.endsWith('.pts') || lower.endsWith('.xyz')) {
232
+ return 'PTS / XYZ ASCII points — not yet supported (export to PLY or LAS).';
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Counter for synthetic expressIds when callers don't supply one.
239
+ * Multiple inline-LAS/LAZ/E57 ingests in the same session would
240
+ * otherwise collide on `1`, breaking federation lookup, picking, and
241
+ * BCF hooks. Bumping a process-local counter is enough — the
242
+ * FederationRegistry then layers in the per-model offset on top.
243
+ */
244
+ let nextSyntheticExpressId = 1;
245
+
246
+ /**
247
+ * Stream a point cloud into the renderer. Returns immediately; await
248
+ * `result.done` for completion.
249
+ */
250
+ export function ingestPointCloud(opts: PointCloudIngestOptions): PointCloudIngestResult {
251
+ const expressId = opts.expressId ?? nextSyntheticExpressId++;
252
+ // Use 'IfcGeographicElement' for PLY/PCD/LAS/LAZ — IFC4 doesn't define
253
+ // an IfcPointCloud entity, and IfcGeographicElement is the closest
254
+ // semantic fit (a real-world geographic feature backed by a scan).
255
+ const handle = opts.renderer.beginPointCloudStream({
256
+ expressId,
257
+ ifcType: 'IfcGeographicElement',
258
+ modelIndex: opts.modelIndex,
259
+ });
260
+ const onCountChange = opts.onAssetCountDelta ?? (() => {});
261
+ onCountChange(+1);
262
+
263
+ // `streamPointCloud()` can throw synchronously during validation /
264
+ // worker setup (e.g. invalid `chunkSize`, oversized blob). The
265
+ // renderer asset + counter increment have already happened above, so
266
+ // a sync throw must clean those up before propagating — otherwise
267
+ // we leak an empty GPU asset and the `pointCloudAssetCount` stays
268
+ // permanently inflated.
269
+ let stream: StreamHandle;
270
+ try {
271
+ stream = streamPointCloud({
272
+ format: opts.format,
273
+ blob: opts.blob,
274
+ label: opts.fileName,
275
+ maxPointsInMemory: opts.maxPointsInMemory,
276
+ maxFileSize: opts.maxFileSize,
277
+ signal: opts.signal,
278
+ onOpen: (info) => {
279
+ opts.onProgress?.({
280
+ phase: info.stride > 1
281
+ ? `Streaming (${info.stride}× downsampled, ${info.totalPointCount.toLocaleString()} pts)`
282
+ : `Streaming (${info.totalPointCount.toLocaleString()} pts)`,
283
+ percent: 10,
284
+ });
285
+ },
286
+ onChunk: (chunk) => {
287
+ // LAS / LAZ / E57 / typical scan-style PLY + PCD all store data
288
+ // Z-up by convention (LIDAR / surveying tradition). The renderer
289
+ // is Y-up internally — the IFCx ingest path applies the same
290
+ // swap inside `pointcloud-extractor.ts`. Without this, the scan
291
+ // shows up rotated 90° onto its side.
292
+ const yUp = swapZupChunkToYup(chunk);
293
+ opts.renderer.appendPointCloudChunk(handle, yUp);
294
+ opts.renderer.requestRender();
295
+ },
296
+ onProgress: (loaded, total) => {
297
+ const pct = total > 0 ? Math.min(99, 10 + Math.floor((loaded / total) * 89)) : 50;
298
+ opts.onProgress?.({
299
+ phase: `Streaming (${loaded.toLocaleString()} / ${total.toLocaleString()})`,
300
+ percent: pct,
301
+ });
302
+ },
303
+ onComplete: () => {
304
+ opts.renderer.endPointCloudStream(handle);
305
+ opts.onProgress?.({ phase: 'Streaming complete', percent: 100 });
306
+ },
307
+ onError: () => {
308
+ opts.renderer.removePointCloudAsset(handle);
309
+ onCountChange(-1);
310
+ },
311
+ });
312
+ } catch (err) {
313
+ opts.renderer.removePointCloudAsset(handle);
314
+ onCountChange(-1);
315
+ throw err;
316
+ }
317
+
318
+ // Build a minimal GeometryResult that satisfies the model registry.
319
+ // The actual point data is on the GPU, not in memory.
320
+ const coordinateInfo: CoordinateInfo = createCoordinateInfo({
321
+ min: { x: 0, y: 0, z: 0 },
322
+ max: { x: 0, y: 0, z: 0 },
323
+ });
324
+ // Synthetic pointcloud descriptor. Federation (`useIfcFederation`)
325
+ // folds `idOffset` into every entry's `expressId` and then calls
326
+ // `relabelPointCloudAsset` on the renderer; without an entry here
327
+ // streamed assets keep their local synthetic id and pick collisions
328
+ // appear once a second model is added.
329
+ const pointClouds: PointCloudAsset[] = [{
330
+ expressId,
331
+ ifcType: 'IfcGeographicElement',
332
+ modelIndex: opts.modelIndex,
333
+ chunk: {
334
+ // Empty placeholder — actual point data is GPU-resident, never
335
+ // re-uploaded from JS.
336
+ positions: new Float32Array(0),
337
+ pointCount: 0,
338
+ bbox: { min: [0, 0, 0], max: [0, 0, 0] },
339
+ },
340
+ }];
341
+ const geometryResult: GeometryResult = {
342
+ meshes: [],
343
+ pointClouds,
344
+ totalVertices: 0,
345
+ totalTriangles: 0,
346
+ coordinateInfo,
347
+ };
348
+
349
+ return {
350
+ dataStore: emptyDataStore(opts.buffer, expressId, opts.fileName),
351
+ geometryResult,
352
+ schemaVersion: 'IFC4',
353
+ rendererHandle: handle,
354
+ streamHandle: stream,
355
+ done: stream.done,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Re-orient a Z-up chunk into the renderer's Y-up convention.
361
+ * Z-up: X=right, Y=forward, Z=up
362
+ * Y-up: X=right, Y=up, Z=back (negate Y to keep right-hand rule)
363
+ *
364
+ * Mirrors the geometry / pointcloud extractors' Z↔Y handling for IFCx.
365
+ * Allocates a fresh positions buffer so the source chunk's typed array
366
+ * (often a transferable from the worker) stays untouched.
367
+ */
368
+ function swapZupChunkToYup(chunk: DecodedPointChunk): DecodedPointChunk {
369
+ const src = chunk.positions;
370
+ const positions = new Float32Array(src.length);
371
+ for (let i = 0; i < src.length; i += 3) {
372
+ const x = src[i];
373
+ const y = src[i + 1];
374
+ const z = src[i + 2];
375
+ positions[i] = x;
376
+ positions[i + 1] = z; // new Y = old Z
377
+ positions[i + 2] = -y; // new Z = -old Y
378
+ }
379
+ // BBox transforms the same way. New min/max derive from the swapped
380
+ // axes; note the negation flips min and max on the Z-back axis.
381
+ const oldMin = chunk.bbox.min;
382
+ const oldMax = chunk.bbox.max;
383
+ return {
384
+ ...chunk,
385
+ positions,
386
+ bbox: {
387
+ min: [oldMin[0], oldMin[2], -oldMax[1]],
388
+ max: [oldMax[0], oldMax[2], -oldMin[1]],
389
+ },
390
+ };
391
+ }
@@ -2,8 +2,8 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { IfcParser, parseIfcx, type IfcDataStore } from '@ifc-lite/parser';
6
- import { GeometryProcessor, GeometryQuality, type CoordinateInfo, type DynamicBatchConfig, type GeometryResult, type MeshData } from '@ifc-lite/geometry';
5
+ import { IfcParser, parseIfcx, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
6
+ import { GeometryProcessor, GeometryQuality, type CoordinateInfo, type DynamicBatchConfig, type GeometryResult, type MeshData, type PointCloudAsset } from '@ifc-lite/geometry';
7
7
  import { loadGLBToMeshData } from '@ifc-lite/cache';
8
8
  import type { SchemaVersion } from '../../store/types.js';
9
9
  import { calculateMeshBounds, calculateStoreyHeights, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
@@ -133,11 +133,26 @@ export async function parseIfcxViewerModel(
133
133
  });
134
134
 
135
135
  const meshes = convertIfcxMeshes(ifcxResult.meshes);
136
- if (meshes.length === 0 && ifcxResult.entityCount > 0) {
136
+ const pointClouds = convertIfcxPointClouds(ifcxResult.pointClouds ?? []);
137
+ // Treat as overlay-only ONLY when neither meshes nor pointclouds were extracted.
138
+ // Files that carry just point cloud assets (the buildingSMART Point_Cloud
139
+ // samples) still represent a renderable model on their own.
140
+ if (meshes.length === 0 && pointClouds.length === 0 && ifcxResult.entityCount > 0) {
137
141
  throw new Error('overlay-only-ifcx');
138
142
  }
139
143
 
140
144
  const { bounds, stats } = calculateMeshBounds(meshes);
145
+ // Expand bounds to include point cloud asset extents so fit-to-view, the
146
+ // section-plane slider, and camera near/far all see the points too.
147
+ for (const pc of pointClouds) {
148
+ const { min, max } = pc.chunk.bbox;
149
+ bounds.min.x = Math.min(bounds.min.x, min[0]);
150
+ bounds.min.y = Math.min(bounds.min.y, min[1]);
151
+ bounds.min.z = Math.min(bounds.min.z, min[2]);
152
+ bounds.max.x = Math.max(bounds.max.x, max[0]);
153
+ bounds.max.y = Math.max(bounds.max.y, max[1]);
154
+ bounds.max.z = Math.max(bounds.max.z, max[2]);
155
+ }
141
156
  return {
142
157
  dataStore: {
143
158
  fileSize: ifcxResult.fileSize,
@@ -155,6 +170,7 @@ export async function parseIfcxViewerModel(
155
170
  } as unknown as IfcDataStore,
156
171
  geometryResult: {
157
172
  meshes,
173
+ pointClouds,
158
174
  totalVertices: stats.totalVertices,
159
175
  totalTriangles: stats.totalTriangles,
160
176
  coordinateInfo: createCoordinateInfo(bounds),
@@ -163,6 +179,19 @@ export async function parseIfcxViewerModel(
163
179
  };
164
180
  }
165
181
 
182
+ export function convertIfcxPointClouds(extractions: PointCloudExtraction[]): PointCloudAsset[] {
183
+ return extractions.map((pc) => ({
184
+ expressId: pc.expressId,
185
+ ifcType: pc.ifcType,
186
+ chunk: {
187
+ positions: pc.positions,
188
+ colors: pc.colors,
189
+ pointCount: pc.pointCount,
190
+ bbox: pc.bbox,
191
+ },
192
+ }));
193
+ }
194
+
166
195
  export async function parseGlbViewerModel(buffer: ArrayBuffer): Promise<ViewerModelPayload> {
167
196
  const meshes = loadGLBToMeshData(new Uint8Array(buffer));
168
197
  if (meshes.length === 0) {
@@ -33,6 +33,12 @@ import {
33
33
  parseIfcxViewerModel,
34
34
  parseStepBufferViewerModel,
35
35
  } from './ingest/viewerModelIngest.js';
36
+ import {
37
+ detectPointCloudFormat,
38
+ ingestPointCloud,
39
+ type PointCloudFormat,
40
+ } from './ingest/pointCloudIngest.js';
41
+ import { getGlobalRenderer } from './useBCF.js';
36
42
  import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
37
43
  import { getEffectiveGeoreference, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
38
44
 
@@ -439,14 +445,53 @@ export function useIfcFederation() {
439
445
  : await file.arrayBuffer();
440
446
  const fileSizeMB = buffer.byteLength / (1024 * 1024);
441
447
 
448
+ // Detect point cloud formats first — we never run them through
449
+ // detectFormat() (which is IFC-shaped) because they have their own
450
+ // streaming pipeline that bypasses geometryResult.meshes.
451
+ const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
452
+
442
453
  // Detect file format
443
- const format = detectFormat(buffer);
454
+ const format: ReturnType<typeof detectFormat> | PointCloudFormat =
455
+ pointCloudFormat ?? detectFormat(buffer);
444
456
 
445
457
  let parsedDataStore: IfcDataStore | null = null;
446
458
  let parsedGeometry: FederatedModel['geometryResult'] = null;
447
459
  let schemaVersion: SchemaVersion = 'IFC4';
448
-
449
- if (format === 'ifcx') {
460
+ // Renderer handle for streamed point clouds; surviving model lifecycle
461
+ // depends on persisting it onto the FederatedModel record.
462
+ let pointCloudHandleId: number | undefined;
463
+
464
+ if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
465
+ const renderer = getGlobalRenderer();
466
+ if (!renderer) {
467
+ setError('Renderer not initialised — try again after the viewer mounts.');
468
+ setLoading(false);
469
+ return null;
470
+ }
471
+ setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
472
+ const blob = isNativeFileHandle(file)
473
+ ? new Blob([buffer])
474
+ : (file as File);
475
+ const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
476
+ const ingest = ingestPointCloud({
477
+ format,
478
+ blob,
479
+ fileName: file.name,
480
+ buffer,
481
+ renderer,
482
+ onProgress: setProgress,
483
+ onAssetCountDelta: incCount,
484
+ });
485
+ // ingest.done rejects on stream errors; ingestPointCloud's onError
486
+ // callback already calls removePointCloudAsset + incCount(-1), so
487
+ // the outer catch must NOT repeat that cleanup or the count goes
488
+ // negative when other point clouds are still loaded.
489
+ await ingest.done;
490
+ parsedDataStore = ingest.dataStore;
491
+ parsedGeometry = ingest.geometryResult;
492
+ schemaVersion = ingest.schemaVersion;
493
+ pointCloudHandleId = ingest.rendererHandle.id;
494
+ } else if (format === 'ifcx') {
450
495
  setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
451
496
  try {
452
497
  const result = await parseIfcxViewerModel(buffer, setProgress);
@@ -541,6 +586,29 @@ export function useIfcFederation() {
541
586
  for (const mesh of parsedGeometry.meshes) {
542
587
  mesh.expressId = mesh.expressId + idOffset;
543
588
  }
589
+ // Point clouds need the same offset so picking / isolation /
590
+ // property lookup resolve through the FederationRegistry's
591
+ // global ID space — otherwise two pointcloud models with the
592
+ // same local expressId collide.
593
+ for (const asset of parsedGeometry.pointClouds ?? []) {
594
+ asset.expressId = asset.expressId + idOffset;
595
+ }
596
+ }
597
+ // Streamed point cloud: the GPU asset was opened with a synthetic
598
+ // local expressId. After registerModelOffset() hands us an
599
+ // idOffset, the renderer needs to emit the post-offset globalId
600
+ // in picking + selection outputs — otherwise picks resolve to
601
+ // the local id and collide across federated models. The shader
602
+ // reads expressId from a per-asset uniform (`flags.x`) so this
603
+ // is just a metadata update; no GPU buffer rewrite.
604
+ if (idOffset > 0 && pointCloudHandleId !== undefined) {
605
+ const renderer = getGlobalRenderer();
606
+ if (renderer && parsedGeometry.pointClouds && parsedGeometry.pointClouds.length > 0) {
607
+ // Use the asset that's already had idOffset folded in above
608
+ // as the source of truth for the global id.
609
+ const asset = parsedGeometry.pointClouds[0];
610
+ renderer.relabelPointCloudAsset({ id: pointCloudHandleId }, asset.expressId);
611
+ }
544
612
  }
545
613
 
546
614
  // =========================================================================
@@ -567,6 +635,7 @@ export function useIfcFederation() {
567
635
  sourceFile: file,
568
636
  idOffset,
569
637
  maxExpressId,
638
+ pointCloudHandleId,
570
639
  };
571
640
 
572
641
  // Add to store