@ifc-lite/viewer 1.17.6 → 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 (156) hide show
  1. package/.turbo/turbo-build.log +20 -15
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +949 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
  5. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  6. package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
  7. package/dist/assets/{geometry.worker-BFUYA08u.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-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
  11. package/dist/assets/index-XwKzDuw6.js +22 -0
  12. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
  13. package/dist/assets/sandbox-Baez7n-t.js +9682 -0
  14. package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
  15. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
  16. package/dist/index.html +6 -6
  17. package/package.json +11 -10
  18. package/src/apache-arrow.d.ts +30 -0
  19. package/src/components/viewer/AddElementPanel.tsx +758 -0
  20. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  21. package/src/components/viewer/ChatPanel.tsx +64 -2
  22. package/src/components/viewer/CommandPalette.tsx +56 -7
  23. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  24. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  25. package/src/components/viewer/ExportDialog.tsx +19 -1
  26. package/src/components/viewer/MainToolbar.tsx +73 -12
  27. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  28. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  29. package/src/components/viewer/SearchInline.tsx +669 -0
  30. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  31. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  32. package/src/components/viewer/SearchModal.text.tsx +388 -0
  33. package/src/components/viewer/SearchModal.tsx +235 -0
  34. package/src/components/viewer/ToolOverlays.tsx +5 -0
  35. package/src/components/viewer/ViewerLayout.tsx +24 -4
  36. package/src/components/viewer/Viewport.tsx +29 -2
  37. package/src/components/viewer/ViewportContainer.tsx +45 -5
  38. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  39. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  40. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  41. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  42. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  43. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  44. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  45. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  46. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  47. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  48. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  49. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  50. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  51. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  52. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  53. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  54. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  55. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  56. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  57. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  58. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  59. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  60. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  61. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  62. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  63. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  64. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  65. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  66. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  67. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  68. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  69. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  70. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  71. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  72. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  73. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  74. package/src/components/viewer/selectionHandlers.ts +446 -0
  75. package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
  76. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  77. package/src/components/viewer/useMouseControls.ts +9 -1
  78. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  79. package/src/components/viewer/usePointCloudSync.ts +98 -0
  80. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  81. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  82. package/src/hooks/useIfcFederation.ts +72 -3
  83. package/src/hooks/useIfcLoader.ts +89 -13
  84. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  85. package/src/hooks/useSandbox.ts +1 -1
  86. package/src/hooks/useSearchIndex.ts +125 -0
  87. package/src/index.css +66 -0
  88. package/src/lib/llm/system-prompt.test.ts +14 -0
  89. package/src/lib/llm/system-prompt.ts +102 -1
  90. package/src/lib/llm/types.ts +6 -0
  91. package/src/lib/recent-files.ts +38 -4
  92. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  93. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  94. package/src/lib/scripts/templates.ts +7 -0
  95. package/src/lib/search/common-ifc-types.ts +36 -0
  96. package/src/lib/search/filter-evaluate.test.ts +537 -0
  97. package/src/lib/search/filter-evaluate.ts +610 -0
  98. package/src/lib/search/filter-rules.test.ts +119 -0
  99. package/src/lib/search/filter-rules.ts +198 -0
  100. package/src/lib/search/filter-schema.test.ts +233 -0
  101. package/src/lib/search/filter-schema.ts +146 -0
  102. package/src/lib/search/recent-searches.test.ts +116 -0
  103. package/src/lib/search/recent-searches.ts +93 -0
  104. package/src/lib/search/result-export.test.ts +101 -0
  105. package/src/lib/search/result-export.ts +104 -0
  106. package/src/lib/search/saved-filters.test.ts +118 -0
  107. package/src/lib/search/saved-filters.ts +154 -0
  108. package/src/lib/search/tier0-scan.test.ts +196 -0
  109. package/src/lib/search/tier0-scan.ts +237 -0
  110. package/src/lib/search/tier1-index.test.ts +242 -0
  111. package/src/lib/search/tier1-index.ts +448 -0
  112. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  113. package/src/sdk/adapters/export-adapter.ts +404 -1
  114. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  115. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  116. package/src/sdk/adapters/model-compat.ts +8 -2
  117. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  118. package/src/sdk/adapters/store-adapter.ts +201 -0
  119. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  120. package/src/sdk/local-backend.ts +16 -8
  121. package/src/services/desktop-export.ts +3 -1
  122. package/src/services/desktop-native-metadata.ts +41 -18
  123. package/src/services/file-dialog.ts +8 -3
  124. package/src/services/tauri-modules.d.ts +25 -0
  125. package/src/store/basketVisibleSet.ts +3 -0
  126. package/src/store/globalId.ts +4 -1
  127. package/src/store/index.ts +79 -1
  128. package/src/store/slices/addElementMeshes.ts +365 -0
  129. package/src/store/slices/addElementSlice.ts +275 -0
  130. package/src/store/slices/annotationsSlice.test.ts +133 -0
  131. package/src/store/slices/annotationsSlice.ts +251 -0
  132. package/src/store/slices/dataSlice.test.ts +23 -4
  133. package/src/store/slices/dataSlice.ts +1 -1
  134. package/src/store/slices/modelSlice.test.ts +67 -9
  135. package/src/store/slices/modelSlice.ts +39 -7
  136. package/src/store/slices/mutationSlice.ts +964 -3
  137. package/src/store/slices/overlayCompositor.test.ts +164 -0
  138. package/src/store/slices/overlaySlice.test.ts +93 -0
  139. package/src/store/slices/overlaySlice.ts +151 -0
  140. package/src/store/slices/pinboardSlice.test.ts +6 -1
  141. package/src/store/slices/playbackSlice.ts +128 -0
  142. package/src/store/slices/pointCloudSlice.ts +102 -0
  143. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  144. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  145. package/src/store/slices/scheduleSlice.test.ts +694 -0
  146. package/src/store/slices/scheduleSlice.ts +1330 -0
  147. package/src/store/slices/searchSlice.test.ts +342 -0
  148. package/src/store/slices/searchSlice.ts +341 -0
  149. package/src/store/slices/selectionSlice.test.ts +46 -0
  150. package/src/store/slices/selectionSlice.ts +20 -0
  151. package/src/store/types.ts +7 -0
  152. package/src/store.ts +14 -0
  153. package/vite.config.ts +1 -0
  154. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  155. package/dist/assets/index-_bfZsDCC.css +0 -1
  156. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -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;
@@ -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) {