@ifc-lite/viewer 1.27.0 → 1.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +35 -42
- package/CHANGELOG.md +74 -0
- package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
- package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
- package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
- package/dist/assets/drawing-2d-DW98umlt.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
- package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
- package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
- package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
- package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
- package/dist/assets/index-E9wB0zWt.css +1 -0
- package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
- package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
- package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
- package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
- package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
- package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
- package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
- package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
- package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
- package/dist/assets/raw-C0ZJYGmN.js +1 -0
- package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
- package/dist/assets/server-client-DVZ2huNS.js +719 -0
- package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
- package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
- package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
- package/dist/index.html +9 -9
- package/package.json +24 -23
- package/src/App.tsx +1 -3
- package/src/components/mcp/playground-dispatcher.ts +3 -0
- package/src/components/mcp/playground-files.ts +33 -1
- package/src/components/viewer/BCFPanel.tsx +1 -16
- package/src/components/viewer/ChatPanel.tsx +11 -46
- package/src/components/viewer/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +48 -183
- package/src/components/viewer/IDSPanel.tsx +1 -26
- package/src/components/viewer/MainToolbar.tsx +94 -187
- package/src/components/viewer/MobileToolbar.tsx +1 -9
- package/src/components/viewer/PropertiesPanel.tsx +98 -127
- package/src/components/viewer/ScriptPanel.tsx +8 -34
- package/src/components/viewer/Section2DPanel.tsx +32 -1
- package/src/components/viewer/ViewerLayout.tsx +5 -2
- package/src/components/viewer/Viewport.tsx +3 -0
- package/src/components/viewer/ViewportContainer.tsx +24 -42
- package/src/components/viewer/ViewportOverlays.tsx +1 -4
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/components/viewer/useGeometryStreaming.ts +0 -2
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +488 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +234 -14
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +100 -24
- package/src/hooks/useIfcFederation.ts +42 -811
- package/src/hooks/useIfcLoader.ts +349 -1517
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- package/src/lib/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- package/src/lib/search/result-export.ts +7 -1
- package/src/sdk/adapters/export-adapter.ts +6 -1
- package/src/services/cacheService.ts +9 -25
- package/src/services/desktop-export.ts +2 -59
- package/src/services/file-dialog.ts +8 -142
- package/src/store/constants.ts +23 -0
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +19 -6
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/lensSlice.ts +8 -0
- package/src/store/slices/visibilitySlice.ts +22 -1
- package/src/store/types.ts +1 -71
- package/src/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/ifcConfig.ts +0 -12
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/vite.config.ts +6 -3
- package/DESKTOP_CONTRACT_VERSION +0 -1
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- package/dist/assets/event-B0kAzHa-.js +0 -1
- package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
- package/dist/assets/index-ajK6D32J.css +0 -1
- package/dist/assets/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-D591Zu_-.js +0 -182
- package/dist/assets/raw-D9iw0tmc.js +0 -1
- package/dist/assets/server-client-Cjwnm7il.js +0 -706
- package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
- package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
- package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
- package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
- package/src/components/viewer/SettingsPage.tsx +0 -581
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
- package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
- package/src/lib/desktop-entitlement.ts +0 -43
- package/src/lib/desktop-product.ts +0 -130
- package/src/lib/platform.ts +0 -23
- package/src/services/desktop-cache.ts +0 -186
- package/src/services/desktop-harness.ts +0 -196
- package/src/services/desktop-logger.ts +0 -20
- package/src/services/desktop-native-metadata.ts +0 -230
- package/src/services/desktop-panel-actions.ts +0 -43
- package/src/services/desktop-preferences.ts +0 -44
- package/src/services/fs-cache.ts +0 -212
- package/src/services/tauri-core-stub.ts +0 -7
- package/src/services/tauri-dialog-stub.ts +0 -7
- package/src/services/tauri-fs-stub.ts +0 -7
- package/src/services/tauri-modules.d.ts +0 -50
- package/src/store/slices/desktopEntitlementSlice.ts +0 -86
- package/src/utils/desktopModelSnapshot.ts +0 -358
- package/src/utils/nativeSpatialDataStore.ts +0 -277
- package/src-tauri/Cargo.toml +0 -29
- package/src-tauri/build.rs +0 -7
- package/src-tauri/capabilities/default.json +0 -18
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +0 -21
- package/src-tauri/src/main.rs +0 -10
- package/src-tauri/tauri.conf.json +0 -39
|
@@ -18,8 +18,6 @@ import {
|
|
|
18
18
|
parseFederatedIfcx,
|
|
19
19
|
type IfcDataStore,
|
|
20
20
|
type FederatedIfcxParseResult,
|
|
21
|
-
type MapConversion,
|
|
22
|
-
type ProjectedCRS,
|
|
23
21
|
} from '@ifc-lite/parser';
|
|
24
22
|
import type { CoordinateInfo, MeshData } from '@ifc-lite/geometry';
|
|
25
23
|
import { IfcQuery } from '@ifc-lite/query';
|
|
@@ -28,493 +26,10 @@ import { getDynamicBatchConfig } from '../utils/ifcConfig.js';
|
|
|
28
26
|
import { calculateMeshBounds, createCoordinateInfo } from '../utils/localParsingUtils.js';
|
|
29
27
|
import {
|
|
30
28
|
convertIfcxMeshes,
|
|
31
|
-
getMaxExpressId,
|
|
32
|
-
parseGlbViewerModel,
|
|
33
|
-
parseIfcxViewerModel,
|
|
34
|
-
parseStepBufferViewerModel,
|
|
35
29
|
} from './ingest/viewerModelIngest.js';
|
|
36
|
-
import {
|
|
37
|
-
detectPointCloudFormat,
|
|
38
|
-
ingestPointCloud,
|
|
39
|
-
type PointCloudFormat,
|
|
40
|
-
} from './ingest/pointCloudIngest.js';
|
|
41
|
-
import { getGlobalRenderer } from './useBCF.js';
|
|
42
|
-
import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
|
|
43
|
-
import { getEffectiveGeoreference, getEffectiveHorizontalScale, hasStandardGeoreferencing, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
|
|
44
|
-
import { resolveMapUnitToMetreScale } from '../lib/geo/geo-scale.js';
|
|
45
|
-
import { resolveProjection } from '../lib/geo/reproject.js';
|
|
30
|
+
import { extractModelGeoref, alignGeometryToReference, findReferenceGeorefModel } from './ingest/federationAlign.js';
|
|
46
31
|
import { toast } from '../components/ui/toast.js';
|
|
47
|
-
import proj4 from 'proj4';
|
|
48
32
|
import { acquireFederationLoadSlot, releaseFederationLoadSlot } from './federationLoadGate.js';
|
|
49
|
-
import { acquireFileBuffer } from '../utils/acquireFileBuffer.js';
|
|
50
|
-
|
|
51
|
-
function isNativeFileHandle(file: File | NativeFileHandle): file is NativeFileHandle {
|
|
52
|
-
return typeof (file as NativeFileHandle).path === 'string';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function toExactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
56
|
-
if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
|
|
57
|
-
return bytes.buffer;
|
|
58
|
-
}
|
|
59
|
-
return bytes.slice().buffer;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type FederatedGeometryResult = NonNullable<FederatedModel['geometryResult']>;
|
|
63
|
-
|
|
64
|
-
interface ModelGeoref {
|
|
65
|
-
mapConversion: MapConversion;
|
|
66
|
-
projectedCRS: ProjectedCRS;
|
|
67
|
-
lengthUnitScale: number;
|
|
68
|
-
coordinateInfo?: CoordinateInfo;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface AffineTransform3D {
|
|
72
|
-
m00: number;
|
|
73
|
-
m01: number;
|
|
74
|
-
m02: number;
|
|
75
|
-
tx: number;
|
|
76
|
-
m10: number;
|
|
77
|
-
m11: number;
|
|
78
|
-
m12: number;
|
|
79
|
-
ty: number;
|
|
80
|
-
m20: number;
|
|
81
|
-
m21: number;
|
|
82
|
-
m22: number;
|
|
83
|
-
tz: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function getMapUnitScale(georef: ModelGeoref): number {
|
|
87
|
-
return resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function getAxis(georef: ModelGeoref): { a: number; o: number; scale: number; denom: number } {
|
|
91
|
-
const conversion = georef.mapConversion;
|
|
92
|
-
const a = conversion.xAxisAbscissa ?? 1;
|
|
93
|
-
const o = conversion.xAxisOrdinate ?? 0;
|
|
94
|
-
// Use the effective horizontal scale: viewer geometry is already in metres,
|
|
95
|
-
// so applying IfcMapConversion.Scale raw would double-scale — see issue #595.
|
|
96
|
-
const mapUnitScale = resolveMapUnitToMetreScale(georef.projectedCRS.mapUnitScale, georef.lengthUnitScale ?? 1);
|
|
97
|
-
const scale = getEffectiveHorizontalScale(conversion.scale, mapUnitScale, georef.lengthUnitScale ?? 1);
|
|
98
|
-
const denom = Math.max(a * a + o * o, 1e-12);
|
|
99
|
-
return { a, o, scale, denom };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function extractModelGeoref(
|
|
103
|
-
dataStore: IfcDataStore,
|
|
104
|
-
coordinateInfo?: CoordinateInfo,
|
|
105
|
-
mutations?: GeorefMutationDataLike,
|
|
106
|
-
): ModelGeoref | null {
|
|
107
|
-
const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations);
|
|
108
|
-
// Only TRUE georeferencing (real IfcMapConversion + IfcProjectedCRS) may drive
|
|
109
|
-
// federation alignment. A file with no IfcMapConversion gets a synthesised
|
|
110
|
-
// `source: 'siteLocation'` georef (EPSG:4326 from IfcSite RefLatitude/Longitude/
|
|
111
|
-
// Elevation) so it can still be pinned on the location map — but those are
|
|
112
|
-
// geographic degrees plus a raw, un-unit-scaled site elevation, not a projected
|
|
113
|
-
// metric frame. buildGeorefAlignmentTransform assumes projected eastings/
|
|
114
|
-
// northings/height in metres, so feeding it site data places the second model
|
|
115
|
-
// kilometres away: the BIMcollab ARC/STR pair share a site GUID but carry
|
|
116
|
-
// RefElevation 0 vs 20000 mm, and the height term lands ARC ~20 km below STR.
|
|
117
|
-
// Such models have no real georef relationship, so leave them in their own local
|
|
118
|
-
// frames where they overlay correctly. hasStandardGeoreferencing() excludes
|
|
119
|
-
// 'siteLocation' (see effective-georef.test.ts). (Regression from #658.)
|
|
120
|
-
if (!hasStandardGeoreferencing(georef) || !georef?.mapConversion || !georef.projectedCRS?.name) {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
return {
|
|
124
|
-
mapConversion: georef.mapConversion,
|
|
125
|
-
projectedCRS: georef.projectedCRS,
|
|
126
|
-
lengthUnitScale: georef.lengthUnitScale,
|
|
127
|
-
coordinateInfo,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function crsKey(crs: ProjectedCRS): string {
|
|
132
|
-
return `${crs.name ?? ''}|${crs.geodeticDatum ?? ''}|${crs.mapProjection ?? ''}|${crs.mapZone ?? ''}`.toUpperCase();
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function canAlignInSameProjectedCrs(a: ModelGeoref, b: ModelGeoref): boolean {
|
|
136
|
-
return crsKey(a.projectedCRS) === crsKey(b.projectedCRS);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function totalYupOffset(coordinateInfo?: CoordinateInfo): { x: number; y: number; z: number } {
|
|
140
|
-
const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 };
|
|
141
|
-
const rtc = coordinateInfo?.wasmRtcOffset;
|
|
142
|
-
const rtcYup = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : { x: 0, y: 0, z: 0 };
|
|
143
|
-
return {
|
|
144
|
-
x: shift.x + rtcYup.x,
|
|
145
|
-
y: shift.y + rtcYup.y,
|
|
146
|
-
z: shift.z + rtcYup.z,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function emptyBounds() {
|
|
151
|
-
return {
|
|
152
|
-
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
153
|
-
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function zeroBounds() {
|
|
158
|
-
return {
|
|
159
|
-
min: { x: 0, y: 0, z: 0 },
|
|
160
|
-
max: { x: 0, y: 0, z: 0 },
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function updateBounds(bounds: ReturnType<typeof emptyBounds>, x: number, y: number, z: number): boolean {
|
|
165
|
-
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
|
|
166
|
-
bounds.min.x = Math.min(bounds.min.x, x);
|
|
167
|
-
bounds.min.y = Math.min(bounds.min.y, y);
|
|
168
|
-
bounds.min.z = Math.min(bounds.min.z, z);
|
|
169
|
-
bounds.max.x = Math.max(bounds.max.x, x);
|
|
170
|
-
bounds.max.y = Math.max(bounds.max.y, y);
|
|
171
|
-
bounds.max.z = Math.max(bounds.max.z, z);
|
|
172
|
-
return true;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function buildGeorefAlignmentTransform(source: ModelGeoref, reference: ModelGeoref): AffineTransform3D | null {
|
|
176
|
-
const sourceConv = source.mapConversion;
|
|
177
|
-
const refConv = reference.mapConversion;
|
|
178
|
-
const sourceAxis = getAxis(source);
|
|
179
|
-
const refAxis = getAxis(reference);
|
|
180
|
-
const refDenom = refAxis.scale * refAxis.denom;
|
|
181
|
-
if (Math.abs(refDenom) < 1e-12) return null;
|
|
182
|
-
|
|
183
|
-
const sourceMapUnitScale = getMapUnitScale(source);
|
|
184
|
-
const refMapUnitScale = getMapUnitScale(reference);
|
|
185
|
-
const sourceOffset = totalYupOffset(source.coordinateInfo);
|
|
186
|
-
const refOffset = totalYupOffset(reference.coordinateInfo);
|
|
187
|
-
|
|
188
|
-
const eVx = sourceAxis.scale * sourceAxis.a;
|
|
189
|
-
const eVz = sourceAxis.scale * sourceAxis.o;
|
|
190
|
-
const eC = sourceConv.eastings * sourceMapUnitScale
|
|
191
|
-
+ sourceAxis.scale * (sourceAxis.a * sourceOffset.x + sourceAxis.o * sourceOffset.z)
|
|
192
|
-
- refConv.eastings * refMapUnitScale;
|
|
193
|
-
|
|
194
|
-
const nVx = sourceAxis.scale * sourceAxis.o;
|
|
195
|
-
const nVz = -sourceAxis.scale * sourceAxis.a;
|
|
196
|
-
const nC = sourceConv.northings * sourceMapUnitScale
|
|
197
|
-
+ sourceAxis.scale * (sourceAxis.o * sourceOffset.x - sourceAxis.a * sourceOffset.z)
|
|
198
|
-
- refConv.northings * refMapUnitScale;
|
|
199
|
-
|
|
200
|
-
const hC = sourceConv.orthogonalHeight * sourceMapUnitScale
|
|
201
|
-
+ sourceOffset.y
|
|
202
|
-
- refConv.orthogonalHeight * refMapUnitScale;
|
|
203
|
-
|
|
204
|
-
const invRefDenom = 1 / refDenom;
|
|
205
|
-
const xVx = (refAxis.a * eVx + refAxis.o * nVx) * invRefDenom;
|
|
206
|
-
const xVz = (refAxis.a * eVz + refAxis.o * nVz) * invRefDenom;
|
|
207
|
-
const xC = (refAxis.a * eC + refAxis.o * nC) * invRefDenom - refOffset.x;
|
|
208
|
-
|
|
209
|
-
const yVx = (-refAxis.o * eVx + refAxis.a * nVx) * invRefDenom;
|
|
210
|
-
const yVz = (-refAxis.o * eVz + refAxis.a * nVz) * invRefDenom;
|
|
211
|
-
const yC = (-refAxis.o * eC + refAxis.a * nC) * invRefDenom;
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
m00: xVx,
|
|
215
|
-
m01: 0,
|
|
216
|
-
m02: xVz,
|
|
217
|
-
tx: xC,
|
|
218
|
-
m10: 0,
|
|
219
|
-
m11: 1,
|
|
220
|
-
m12: 0,
|
|
221
|
-
ty: hC - refOffset.y,
|
|
222
|
-
m20: -yVx,
|
|
223
|
-
m21: 0,
|
|
224
|
-
m22: -yVz,
|
|
225
|
-
tz: -yC - refOffset.z,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function isIdentityTransform(transform: AffineTransform3D): boolean {
|
|
230
|
-
const eps = 1e-7;
|
|
231
|
-
return Math.abs(transform.m00 - 1) < eps
|
|
232
|
-
&& Math.abs(transform.m01) < eps
|
|
233
|
-
&& Math.abs(transform.m02) < eps
|
|
234
|
-
&& Math.abs(transform.tx) < eps
|
|
235
|
-
&& Math.abs(transform.m10) < eps
|
|
236
|
-
&& Math.abs(transform.m11 - 1) < eps
|
|
237
|
-
&& Math.abs(transform.m12) < eps
|
|
238
|
-
&& Math.abs(transform.ty) < eps
|
|
239
|
-
&& Math.abs(transform.m20) < eps
|
|
240
|
-
&& Math.abs(transform.m21) < eps
|
|
241
|
-
&& Math.abs(transform.m22 - 1) < eps
|
|
242
|
-
&& Math.abs(transform.tz) < eps;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function applyAlignmentTransformAndUpdateBounds(
|
|
246
|
-
geometry: FederatedGeometryResult,
|
|
247
|
-
transform: AffineTransform3D,
|
|
248
|
-
referenceInfo?: CoordinateInfo,
|
|
249
|
-
): void {
|
|
250
|
-
const bounds = emptyBounds();
|
|
251
|
-
let found = false;
|
|
252
|
-
|
|
253
|
-
for (const mesh of geometry.meshes) {
|
|
254
|
-
const positions = mesh.positions;
|
|
255
|
-
for (let i = 0; i < positions.length; i += 3) {
|
|
256
|
-
const x = positions[i];
|
|
257
|
-
const y = positions[i + 1];
|
|
258
|
-
const z = positions[i + 2];
|
|
259
|
-
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const alignedX = transform.m00 * x + transform.m01 * y + transform.m02 * z + transform.tx;
|
|
264
|
-
const alignedY = transform.m10 * x + transform.m11 * y + transform.m12 * z + transform.ty;
|
|
265
|
-
const alignedZ = transform.m20 * x + transform.m21 * y + transform.m22 * z + transform.tz;
|
|
266
|
-
positions[i] = alignedX;
|
|
267
|
-
positions[i + 1] = alignedY;
|
|
268
|
-
positions[i + 2] = alignedZ;
|
|
269
|
-
found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Rotate normals by the transform's 3×3 linear part (translation omitted)
|
|
273
|
-
// and renormalize. CRS alignment is a rigid rotation, so the linear part
|
|
274
|
-
// itself is the correct transform for normals; degenerate results from
|
|
275
|
-
// zero-length or non-finite inputs are left in place.
|
|
276
|
-
const normals = mesh.normals;
|
|
277
|
-
if (normals && normals.length >= 3) {
|
|
278
|
-
for (let i = 0; i < normals.length; i += 3) {
|
|
279
|
-
const nx = normals[i];
|
|
280
|
-
const ny = normals[i + 1];
|
|
281
|
-
const nz = normals[i + 2];
|
|
282
|
-
if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) {
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
const rx = transform.m00 * nx + transform.m01 * ny + transform.m02 * nz;
|
|
286
|
-
const ry = transform.m10 * nx + transform.m11 * ny + transform.m12 * nz;
|
|
287
|
-
const rz = transform.m20 * nx + transform.m21 * ny + transform.m22 * nz;
|
|
288
|
-
const len = Math.sqrt(rx * rx + ry * ry + rz * rz);
|
|
289
|
-
if (!Number.isFinite(len) || len < 1e-12) {
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
normals[i] = rx / len;
|
|
293
|
-
normals[i + 1] = ry / len;
|
|
294
|
-
normals[i + 2] = rz / len;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
geometry.coordinateInfo = {
|
|
300
|
-
originShift: referenceInfo?.originShift ?? { x: 0, y: 0, z: 0 },
|
|
301
|
-
originalBounds: found ? bounds : zeroBounds(),
|
|
302
|
-
shiftedBounds: found ? bounds : zeroBounds(),
|
|
303
|
-
hasLargeCoordinates: referenceInfo?.hasLargeCoordinates ?? false,
|
|
304
|
-
wasmRtcOffset: referenceInfo?.wasmRtcOffset,
|
|
305
|
-
buildingRotation: referenceInfo?.buildingRotation,
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Reproject every vertex from a source model's georeference into the reference
|
|
311
|
-
* model's viewer-space frame using proj4 between the two projected CRSs.
|
|
312
|
-
*
|
|
313
|
-
* Used for federated loads where models declare different IfcProjectedCRSs
|
|
314
|
-
* (e.g. EPSG:28992 + EPSG:7415 mixed RD/NAP Dutch sets, or EPSG:25831 UTM +
|
|
315
|
-
* EPSG:28992 mixed). The pipeline per vertex:
|
|
316
|
-
*
|
|
317
|
-
* viewer(Yup) ──(source RTC/shift, axis swap)──▶ IFC(Zup, source)
|
|
318
|
-
* IFC(source) ──(source MapConversion)──────────▶ source projected (eS,nS,hS)
|
|
319
|
-
* projected ──(proj4: srcDef → refDef)────────▶ reference projected (eR,nR)
|
|
320
|
-
* projected ──(reference MapConversion inverse)▶ IFC(Zup, reference)
|
|
321
|
-
* IFC(ref) ──(axis swap, reference RTC/shift)─▶ viewer(Yup, reference frame)
|
|
322
|
-
*
|
|
323
|
-
* Vertical: height passes through unchanged. Browser-side proj4 has no vertical
|
|
324
|
-
* datum transforms (no NTv2/gtx grids), so cross-CRS vertical mismatches are
|
|
325
|
-
* left for the user to resolve via the per-model orthogonalHeight editor.
|
|
326
|
-
*
|
|
327
|
-
* Normals are NOT rotated. Cross-CRS rotations between projected systems in the
|
|
328
|
-
* same locality are sub-degree, and recomputing per-vertex would require a
|
|
329
|
-
* Jacobian per mesh — acceptable trade-off for now, document if it bites.
|
|
330
|
-
*/
|
|
331
|
-
async function alignGeometryAcrossCrs(
|
|
332
|
-
geometry: FederatedGeometryResult,
|
|
333
|
-
source: ModelGeoref,
|
|
334
|
-
reference: ModelGeoref,
|
|
335
|
-
): Promise<boolean> {
|
|
336
|
-
const sourceProjDef = await resolveProjection(source.projectedCRS);
|
|
337
|
-
const refProjDef = await resolveProjection(reference.projectedCRS);
|
|
338
|
-
if (!sourceProjDef || !refProjDef) return false;
|
|
339
|
-
|
|
340
|
-
const sourceMapUnitScale = getMapUnitScale(source);
|
|
341
|
-
const refMapUnitScale = getMapUnitScale(reference);
|
|
342
|
-
const sourceAxis = getAxis(source);
|
|
343
|
-
const refAxis = getAxis(reference);
|
|
344
|
-
const sourceOffset = totalYupOffset(source.coordinateInfo);
|
|
345
|
-
const refOffset = totalYupOffset(reference.coordinateInfo);
|
|
346
|
-
|
|
347
|
-
const refDenom = refAxis.scale * refAxis.denom;
|
|
348
|
-
if (Math.abs(refDenom) < 1e-12) return false;
|
|
349
|
-
const invRefDenom = 1 / refDenom;
|
|
350
|
-
|
|
351
|
-
const sourceConv = source.mapConversion;
|
|
352
|
-
const refConv = reference.mapConversion;
|
|
353
|
-
|
|
354
|
-
const bounds = emptyBounds();
|
|
355
|
-
let found = false;
|
|
356
|
-
let projFailures = 0;
|
|
357
|
-
let attempts = 0;
|
|
358
|
-
let firstProjError: unknown = null;
|
|
359
|
-
|
|
360
|
-
for (const mesh of geometry.meshes) {
|
|
361
|
-
const positions = mesh.positions;
|
|
362
|
-
for (let i = 0; i < positions.length; i += 3) {
|
|
363
|
-
const vx = positions[i];
|
|
364
|
-
const vy = positions[i + 1];
|
|
365
|
-
const vz = positions[i + 2];
|
|
366
|
-
if (!Number.isFinite(vx) || !Number.isFinite(vy) || !Number.isFinite(vz)) continue;
|
|
367
|
-
|
|
368
|
-
// viewer(Y-up, source-local) → world(Y-up) → IFC(Z-up, source)
|
|
369
|
-
const wx = vx + sourceOffset.x;
|
|
370
|
-
const wy = vy + sourceOffset.y;
|
|
371
|
-
const wz = vz + sourceOffset.z;
|
|
372
|
-
const ifcXs = wx;
|
|
373
|
-
const ifcYs = -wz;
|
|
374
|
-
const ifcZs = wy;
|
|
375
|
-
|
|
376
|
-
// IFC(source) → source projected (apply source MapConversion)
|
|
377
|
-
const eS = sourceConv.eastings * sourceMapUnitScale
|
|
378
|
-
+ sourceAxis.scale * (sourceAxis.a * ifcXs - sourceAxis.o * ifcYs);
|
|
379
|
-
const nS = sourceConv.northings * sourceMapUnitScale
|
|
380
|
-
+ sourceAxis.scale * (sourceAxis.o * ifcXs + sourceAxis.a * ifcYs);
|
|
381
|
-
const hS = sourceConv.orthogonalHeight * sourceMapUnitScale + ifcZs;
|
|
382
|
-
|
|
383
|
-
// source projected → reference projected via proj4
|
|
384
|
-
attempts += 1;
|
|
385
|
-
let eR: number;
|
|
386
|
-
let nR: number;
|
|
387
|
-
try {
|
|
388
|
-
const projected = proj4(sourceProjDef, refProjDef, [eS, nS]);
|
|
389
|
-
eR = projected[0];
|
|
390
|
-
nR = projected[1];
|
|
391
|
-
} catch (error) {
|
|
392
|
-
projFailures += 1;
|
|
393
|
-
if (firstProjError == null) firstProjError = error;
|
|
394
|
-
continue;
|
|
395
|
-
}
|
|
396
|
-
if (!Number.isFinite(eR) || !Number.isFinite(nR)) {
|
|
397
|
-
projFailures += 1;
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
// Height transformed under identity (no vertical datum hop in browser).
|
|
401
|
-
const hR = hS;
|
|
402
|
-
|
|
403
|
-
// reference projected → IFC(reference): invert reference MapConversion
|
|
404
|
-
const dE = eR - refConv.eastings * refMapUnitScale;
|
|
405
|
-
const dN = nR - refConv.northings * refMapUnitScale;
|
|
406
|
-
const ifcXr = invRefDenom * (refAxis.a * dE + refAxis.o * dN);
|
|
407
|
-
const ifcYr = invRefDenom * (-refAxis.o * dE + refAxis.a * dN);
|
|
408
|
-
const ifcZr = hR - refConv.orthogonalHeight * refMapUnitScale;
|
|
409
|
-
|
|
410
|
-
// IFC(Z-up, reference) → world(Y-up) → viewer(Y-up, reference-local)
|
|
411
|
-
const refWorldX = ifcXr;
|
|
412
|
-
const refWorldY = ifcZr;
|
|
413
|
-
const refWorldZ = -ifcYr;
|
|
414
|
-
const alignedX = refWorldX - refOffset.x;
|
|
415
|
-
const alignedY = refWorldY - refOffset.y;
|
|
416
|
-
const alignedZ = refWorldZ - refOffset.z;
|
|
417
|
-
|
|
418
|
-
positions[i] = alignedX;
|
|
419
|
-
positions[i + 1] = alignedY;
|
|
420
|
-
positions[i + 2] = alignedZ;
|
|
421
|
-
found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (!found) {
|
|
426
|
-
console.warn(
|
|
427
|
-
`[ifc-lite] Cross-CRS alignment failed: ${projFailures}/${attempts} `
|
|
428
|
-
+ `vertex transforms failed for ${source.projectedCRS.name} → ${reference.projectedCRS.name}; `
|
|
429
|
-
+ 'no vertices were successfully reprojected. Leaving geometry untouched.',
|
|
430
|
-
firstProjError,
|
|
431
|
-
);
|
|
432
|
-
return false;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
geometry.coordinateInfo = {
|
|
436
|
-
originShift: reference.coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 },
|
|
437
|
-
originalBounds: bounds,
|
|
438
|
-
shiftedBounds: bounds,
|
|
439
|
-
hasLargeCoordinates: reference.coordinateInfo?.hasLargeCoordinates ?? false,
|
|
440
|
-
wasmRtcOffset: reference.coordinateInfo?.wasmRtcOffset,
|
|
441
|
-
buildingRotation: reference.coordinateInfo?.buildingRotation,
|
|
442
|
-
};
|
|
443
|
-
|
|
444
|
-
if (projFailures > 0) {
|
|
445
|
-
console.warn(
|
|
446
|
-
`[ifc-lite] Cross-CRS alignment: ${projFailures}/${attempts} vertex transforms `
|
|
447
|
-
+ `failed from ${source.projectedCRS.name} to ${reference.projectedCRS.name}. `
|
|
448
|
-
+ 'Those vertices are left at their original positions.',
|
|
449
|
-
firstProjError,
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
return true;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export type FederationAlignmentStatus = 'same-crs' | 'reprojected' | 'identity' | 'failed';
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Route alignment to the right strategy based on whether the source and
|
|
459
|
-
* reference share a projected CRS. Returns a status describing how the model
|
|
460
|
-
* was placed in the federation, suitable for surfacing in the UI.
|
|
461
|
-
*/
|
|
462
|
-
async function alignGeometryToReference(
|
|
463
|
-
geometry: FederatedGeometryResult,
|
|
464
|
-
source: ModelGeoref,
|
|
465
|
-
reference: ModelGeoref,
|
|
466
|
-
): Promise<FederationAlignmentStatus> {
|
|
467
|
-
if (canAlignInSameProjectedCrs(source, reference)) {
|
|
468
|
-
const transform = buildGeorefAlignmentTransform(source, reference);
|
|
469
|
-
if (!transform) return 'failed';
|
|
470
|
-
if (isIdentityTransform(transform)) return 'identity';
|
|
471
|
-
applyAlignmentTransformAndUpdateBounds(geometry, transform, reference.coordinateInfo);
|
|
472
|
-
return 'same-crs';
|
|
473
|
-
}
|
|
474
|
-
const ok = await alignGeometryAcrossCrs(geometry, source, reference);
|
|
475
|
-
return ok ? 'reprojected' : 'failed';
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Select the federation anchor model.
|
|
480
|
-
*
|
|
481
|
-
* Resolution order:
|
|
482
|
-
* 1. `anchorModelIdOverride` from the store, if it points to a loaded model
|
|
483
|
-
* with a valid georeference.
|
|
484
|
-
* 2. Earliest `loadedAt` model with a valid georeference (the default — gives
|
|
485
|
-
* a stable anchor across loads while letting the user override when they
|
|
486
|
-
* want a different model to drive the world frame).
|
|
487
|
-
*/
|
|
488
|
-
function findReferenceGeorefModel(): { modelId: string; georef: ModelGeoref } | null {
|
|
489
|
-
const state = useViewerStore.getState();
|
|
490
|
-
const override = state.anchorModelIdOverride;
|
|
491
|
-
if (override) {
|
|
492
|
-
const model = state.models.get(override) as FederatedModel | undefined;
|
|
493
|
-
if (model?.ifcDataStore && model.geometryResult) {
|
|
494
|
-
const georef = extractModelGeoref(
|
|
495
|
-
model.ifcDataStore,
|
|
496
|
-
model.geometryResult.coordinateInfo,
|
|
497
|
-
state.georefMutations.get(override),
|
|
498
|
-
);
|
|
499
|
-
if (georef) return { modelId: override, georef };
|
|
500
|
-
}
|
|
501
|
-
// Fall through if the override no longer resolves — keeps loads
|
|
502
|
-
// recoverable even if the user removed the anchor they had pinned.
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const modelEntries = Array.from(state.models.entries()) as Array<[string, FederatedModel]>;
|
|
506
|
-
const sorted = [...modelEntries].sort(([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
|
|
507
|
-
for (const [modelId, model] of sorted) {
|
|
508
|
-
if (!model.ifcDataStore || !model.geometryResult) continue;
|
|
509
|
-
const georef = extractModelGeoref(
|
|
510
|
-
model.ifcDataStore,
|
|
511
|
-
model.geometryResult.coordinateInfo,
|
|
512
|
-
state.georefMutations.get(modelId),
|
|
513
|
-
);
|
|
514
|
-
if (georef) return { modelId, georef };
|
|
515
|
-
}
|
|
516
|
-
return null;
|
|
517
|
-
}
|
|
518
33
|
|
|
519
34
|
/**
|
|
520
35
|
* Extended data store type for IFCX (IFC5) files.
|
|
@@ -537,7 +52,11 @@ export interface IfcxDataStore extends IfcDataStore {
|
|
|
537
52
|
* Includes addModel, removeModel, federated IFCX loading, overlay management,
|
|
538
53
|
* and ID resolution helpers
|
|
539
54
|
*/
|
|
540
|
-
export function useIfcFederation(
|
|
55
|
+
export function useIfcFederation(
|
|
56
|
+
// The ONE canonical loader. Federated adds route through it (target
|
|
57
|
+
// 'federated') so model #1 and model #N share an identical pipeline.
|
|
58
|
+
loadFile: (file: File, target?: import('./useIfcLoader.js').LoadTarget) => Promise<void>,
|
|
59
|
+
) {
|
|
541
60
|
const {
|
|
542
61
|
setLoading,
|
|
543
62
|
setError,
|
|
@@ -583,7 +102,7 @@ export function useIfcFederation() {
|
|
|
583
102
|
* Returns the model ID on success, null on failure
|
|
584
103
|
*/
|
|
585
104
|
const addModel = useCallback(async (
|
|
586
|
-
file: File
|
|
105
|
+
file: File,
|
|
587
106
|
options?: {
|
|
588
107
|
name?: string;
|
|
589
108
|
modelId?: string;
|
|
@@ -605,336 +124,48 @@ export function useIfcFederation() {
|
|
|
605
124
|
const fileSizeForGateMB = (typeof (file as File).size === 'number' ? (file as File).size : 0) / (1024 * 1024);
|
|
606
125
|
const gateSlot = await acquireFederationLoadSlot(fileSizeForGateMB);
|
|
607
126
|
try {
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const currentGeometryResult = useViewerStore.getState().geometryResult;
|
|
613
|
-
|
|
614
|
-
if (currentModels.size === 0 && currentIfcDataStore && currentGeometryResult) {
|
|
615
|
-
// Migrate the legacy model to the Map
|
|
616
|
-
// Legacy model has offset 0 (IDs are unchanged)
|
|
617
|
-
const legacyModelId = crypto.randomUUID();
|
|
618
|
-
const legacyName = currentIfcDataStore.spatialHierarchy?.project?.name || 'Model 1';
|
|
619
|
-
|
|
620
|
-
// Find max expressId in legacy model for registry
|
|
621
|
-
// IMPORTANT: Include ALL entities, not just meshes, for proper globalId resolution
|
|
622
|
-
const legacyMeshes = currentGeometryResult.meshes || [];
|
|
623
|
-
const legacyMaxExpressIdFromMeshes = legacyMeshes.reduce((max: number, m: MeshData) => Math.max(max, m.expressId), 0);
|
|
624
|
-
// FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
|
|
625
|
-
let legacyMaxExpressIdFromEntities = 0;
|
|
626
|
-
if (currentIfcDataStore.entityIndex?.byId) {
|
|
627
|
-
for (const key of currentIfcDataStore.entityIndex.byId.keys()) {
|
|
628
|
-
if (key > legacyMaxExpressIdFromEntities) legacyMaxExpressIdFromEntities = key;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const legacyMaxExpressId = Math.max(legacyMaxExpressIdFromMeshes, legacyMaxExpressIdFromEntities);
|
|
632
|
-
|
|
633
|
-
// Register legacy model with offset 0 (IDs already in use as-is)
|
|
634
|
-
const legacyOffset = registerModelOffset(legacyModelId, legacyMaxExpressId);
|
|
635
|
-
|
|
636
|
-
const legacyModel: FederatedModel = {
|
|
637
|
-
id: legacyModelId,
|
|
638
|
-
name: legacyName,
|
|
639
|
-
ifcDataStore: currentIfcDataStore,
|
|
640
|
-
geometryResult: currentGeometryResult,
|
|
641
|
-
visible: true,
|
|
642
|
-
collapsed: false,
|
|
643
|
-
schemaVersion: 'IFC4',
|
|
644
|
-
loadedAt: Date.now() - 1000,
|
|
645
|
-
fileSize: 0,
|
|
646
|
-
sourceFile: undefined,
|
|
647
|
-
idOffset: legacyOffset,
|
|
648
|
-
maxExpressId: legacyMaxExpressId,
|
|
649
|
-
};
|
|
650
|
-
storeAddModel(legacyModel);
|
|
651
|
-
}
|
|
652
|
-
|
|
127
|
+
// (Removed the legacy→Map migration: every model — including model #1 —
|
|
128
|
+
// now registers in the FederationRegistry + models Map via loadFile's
|
|
129
|
+
// upsertModel/finalizeModel, so a top-level-only "legacy" model can no
|
|
130
|
+
// longer exist. See PR description for the audit.)
|
|
653
131
|
setLoading(true);
|
|
654
132
|
setError(null);
|
|
655
133
|
setProgress({ phase: 'Loading file', percent: 0 });
|
|
656
134
|
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
// The cast preserves the previous ArrayBuffer-shaped contract for
|
|
668
|
-
// every downstream consumer. When the underlying store is a SAB,
|
|
669
|
-
// downstream code only ever reads bytes via `new Uint8Array(buffer)`
|
|
670
|
-
// / `new DataView(buffer)`, both of which work on either backing
|
|
671
|
-
// store. The cast is purely type-system; runtime is identical.
|
|
672
|
-
const acquired = await acquireFileBuffer(file as File);
|
|
673
|
-
buffer = acquired.buffer as ArrayBuffer;
|
|
674
|
-
}
|
|
675
|
-
const fileSizeMB = buffer.byteLength / (1024 * 1024);
|
|
676
|
-
|
|
677
|
-
// Detect point cloud formats first — we never run them through
|
|
678
|
-
// detectFormat() (which is IFC-shaped) because they have their own
|
|
679
|
-
// streaming pipeline that bypasses geometryResult.meshes.
|
|
680
|
-
const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
|
|
681
|
-
|
|
682
|
-
// Detect file format
|
|
683
|
-
const format: ReturnType<typeof detectFormat> | PointCloudFormat =
|
|
684
|
-
pointCloudFormat ?? detectFormat(buffer);
|
|
685
|
-
|
|
686
|
-
let parsedDataStore: IfcDataStore | null = null;
|
|
687
|
-
let parsedGeometry: FederatedModel['geometryResult'] = null;
|
|
688
|
-
let schemaVersion: SchemaVersion = 'IFC4';
|
|
689
|
-
// Renderer handle for streamed point clouds; surviving model lifecycle
|
|
690
|
-
// depends on persisting it onto the FederatedModel record.
|
|
691
|
-
let pointCloudHandleId: number | undefined;
|
|
692
|
-
|
|
693
|
-
if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57' || format === 'pts' || format === 'xyz') {
|
|
694
|
-
const renderer = getGlobalRenderer();
|
|
695
|
-
if (!renderer) {
|
|
696
|
-
setError('Renderer not initialised — try again after the viewer mounts.');
|
|
697
|
-
setLoading(false);
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
|
|
701
|
-
const blob = isNativeFileHandle(file)
|
|
702
|
-
? new Blob([buffer])
|
|
703
|
-
: (file as File);
|
|
704
|
-
const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
|
|
705
|
-
const ingest = ingestPointCloud({
|
|
706
|
-
format,
|
|
707
|
-
blob,
|
|
708
|
-
fileName: file.name,
|
|
709
|
-
buffer,
|
|
710
|
-
renderer,
|
|
711
|
-
onProgress: setProgress,
|
|
712
|
-
onAssetCountDelta: incCount,
|
|
713
|
-
});
|
|
714
|
-
// Expose cancellation while the stream is in-flight. Capture
|
|
715
|
-
// the canceller as a named ref so the cleanup can verify the
|
|
716
|
-
// store still points at us before clearing — a second
|
|
717
|
-
// addModel() that began before this one settles must not lose
|
|
718
|
-
// its Cancel button to our finally block.
|
|
719
|
-
const { setActiveStreamCanceller } = useViewerStore.getState();
|
|
720
|
-
const cancelStream = () => ingest.streamHandle.cancel();
|
|
721
|
-
setActiveStreamCanceller(cancelStream);
|
|
722
|
-
// ingest.done rejects on stream errors; ingestPointCloud's onError
|
|
723
|
-
// callback already calls removePointCloudAsset + incCount(-1), so
|
|
724
|
-
// the outer catch must NOT repeat that cleanup or the count goes
|
|
725
|
-
// negative when other point clouds are still loaded.
|
|
726
|
-
try {
|
|
727
|
-
await ingest.done;
|
|
728
|
-
} finally {
|
|
729
|
-
if (useViewerStore.getState().activeStreamCanceller === cancelStream) {
|
|
730
|
-
setActiveStreamCanceller(null);
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
parsedDataStore = ingest.dataStore;
|
|
734
|
-
parsedGeometry = ingest.geometryResult;
|
|
735
|
-
schemaVersion = ingest.schemaVersion;
|
|
736
|
-
pointCloudHandleId = ingest.rendererHandle.id;
|
|
737
|
-
} else if (format === 'ifcx') {
|
|
738
|
-
setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
|
|
739
|
-
try {
|
|
740
|
-
const result = await parseIfcxViewerModel(buffer, setProgress);
|
|
741
|
-
parsedDataStore = result.dataStore;
|
|
742
|
-
parsedGeometry = result.geometryResult;
|
|
743
|
-
schemaVersion = result.schemaVersion;
|
|
744
|
-
} catch (error) {
|
|
745
|
-
if (error instanceof Error && error.message === 'overlay-only-ifcx') {
|
|
746
|
-
console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this is an overlay file.`);
|
|
747
|
-
setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once for federated loading).`);
|
|
748
|
-
setLoading(false);
|
|
749
|
-
return null;
|
|
750
|
-
}
|
|
751
|
-
throw error;
|
|
752
|
-
}
|
|
753
|
-
} else if (format === 'glb') {
|
|
754
|
-
setProgress({ phase: 'Parsing GLB', percent: 10 });
|
|
755
|
-
const result = await parseGlbViewerModel(buffer);
|
|
756
|
-
parsedDataStore = result.dataStore;
|
|
757
|
-
parsedGeometry = result.geometryResult;
|
|
758
|
-
schemaVersion = result.schemaVersion;
|
|
759
|
-
} else {
|
|
760
|
-
setProgress({ phase: 'Starting geometry streaming', percent: 10 });
|
|
761
|
-
|
|
762
|
-
// For federated models: use the first model's RTC offset so all models
|
|
763
|
-
// share the same coordinate origin. This ensures pixel-perfect alignment
|
|
764
|
-
// without error-prone delta adjustments.
|
|
765
|
-
let sharedRtcOffset: { x: number; y: number; z: number } | undefined;
|
|
766
|
-
const existingModelsForRtc = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
|
|
767
|
-
if (existingModelsForRtc.length > 0) {
|
|
768
|
-
const sorted = [...existingModelsForRtc].sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
|
|
769
|
-
sharedRtcOffset = sorted.find(
|
|
770
|
-
(model) => model.geometryResult?.coordinateInfo?.wasmRtcOffset != null,
|
|
771
|
-
)?.geometryResult?.coordinateInfo?.wasmRtcOffset;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const result = await parseStepBufferViewerModel({
|
|
775
|
-
fileName: file.name,
|
|
776
|
-
buffer,
|
|
777
|
-
fileSizeMB,
|
|
778
|
-
getDynamicBatchSize: getDynamicBatchConfig,
|
|
779
|
-
onProgress: setProgress,
|
|
780
|
-
sharedRtcOffset,
|
|
781
|
-
});
|
|
782
|
-
parsedDataStore = result.dataStore;
|
|
783
|
-
parsedGeometry = result.geometryResult;
|
|
784
|
-
schemaVersion = result.schemaVersion;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
if (!parsedDataStore || !parsedGeometry) {
|
|
788
|
-
throw new Error('Failed to parse file');
|
|
135
|
+
// Pick the shared RTC origin from the earliest existing model so every
|
|
136
|
+
// federated model lands in one coordinate space (pixel-perfect alignment,
|
|
137
|
+
// no post-shift). Threaded into the canonical loader below.
|
|
138
|
+
let sharedRtcOffset: { x: number; y: number; z: number } | undefined;
|
|
139
|
+
const existingModelsForRtc = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
|
|
140
|
+
if (existingModelsForRtc.length > 0) {
|
|
141
|
+
const sorted = [...existingModelsForRtc].sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
|
|
142
|
+
sharedRtcOffset = sorted.find(
|
|
143
|
+
(model) => model.geometryResult?.coordinateInfo?.wasmRtcOffset != null,
|
|
144
|
+
)?.geometryResult?.coordinateInfo?.wasmRtcOffset;
|
|
789
145
|
}
|
|
790
146
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
let preAlignmentPositions: Float32Array[] | undefined;
|
|
807
|
-
let preAlignmentNormals: (Float32Array | undefined)[] | undefined;
|
|
808
|
-
let preAlignmentCoordinateInfo: CoordinateInfo | undefined;
|
|
809
|
-
let federationAlignmentStatus: FederatedModel['federationAlignmentStatus'] = 'none';
|
|
810
|
-
|
|
811
|
-
if (referenceGeoref && parsedGeoref) {
|
|
812
|
-
// referenceSelection.modelId !== modelId always holds — the anchor was
|
|
813
|
-
// already in the store before this addModel call.
|
|
814
|
-
setProgress({ phase: 'Aligning georeferenced model', percent: 90 });
|
|
815
|
-
preAlignmentPositions = parsedGeometry.meshes.map((mesh) => new Float32Array(mesh.positions));
|
|
816
|
-
preAlignmentNormals = parsedGeometry.meshes.map((mesh) =>
|
|
817
|
-
mesh.normals && mesh.normals.length > 0 ? new Float32Array(mesh.normals) : undefined,
|
|
818
|
-
);
|
|
819
|
-
preAlignmentCoordinateInfo = parsedGeometry.coordinateInfo;
|
|
820
|
-
const status = await alignGeometryToReference(parsedGeometry, parsedGeoref, referenceGeoref);
|
|
821
|
-
federationAlignmentStatus = status;
|
|
822
|
-
if (status === 'reprojected') {
|
|
823
|
-
toast.info(
|
|
824
|
-
`Reprojected "${file.name}" from ${parsedGeoref.projectedCRS.name} `
|
|
825
|
-
+ `to ${referenceGeoref.projectedCRS.name} for federation alignment.`,
|
|
826
|
-
);
|
|
827
|
-
} else if (status === 'failed') {
|
|
828
|
-
toast.error(
|
|
829
|
-
`Could not align "${file.name}" with the federation anchor — `
|
|
830
|
-
+ `${parsedGeoref.projectedCRS.name} → ${referenceGeoref.projectedCRS.name} `
|
|
831
|
-
+ 'reprojection failed. The model is shown in its own local frame and may '
|
|
832
|
-
+ 'appear at the wrong real-world position.',
|
|
833
|
-
);
|
|
834
|
-
}
|
|
835
|
-
} else if (parsedGeoref) {
|
|
836
|
-
// This load is itself the federation anchor (first georeferenced model
|
|
837
|
-
// in the federation, or the only one). Surface that to the UI.
|
|
838
|
-
federationAlignmentStatus = 'anchor';
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// =========================================================================
|
|
842
|
-
// FEDERATION REGISTRY: Transform expressIds to globally unique IDs
|
|
843
|
-
// This is the BULLETPROOF fix for multi-model ID collisions
|
|
844
|
-
// =========================================================================
|
|
845
|
-
|
|
846
|
-
// Step 1: Find max expressId in this model
|
|
847
|
-
// IMPORTANT: Use ALL entities from data store, not just meshes
|
|
848
|
-
// Spatial containers (IfcProject, IfcSite, etc.) don't have geometry but need valid globalId resolution
|
|
849
|
-
const maxExpressId = getMaxExpressId(parsedDataStore, parsedGeometry.meshes);
|
|
850
|
-
|
|
851
|
-
// Step 2: Register with federation registry to get unique offset
|
|
852
|
-
const idOffset = registerModelOffset(modelId, maxExpressId);
|
|
147
|
+
// THE canonical load path. loadFile acquires bytes, detects format
|
|
148
|
+
// (IFC / IFCX / GLB / point cloud), produces geometry through the single
|
|
149
|
+
// GeometryProcessor pipeline, parses the data store, and — because the
|
|
150
|
+
// target is federated — finalizeModel aligns to the anchor, offsets ids,
|
|
151
|
+
// builds the spatial index, and registers the model via addModel. loadFile
|
|
152
|
+
// awaits that finalize, so on return the model is already in the map.
|
|
153
|
+
await loadFile(file, {
|
|
154
|
+
kind: 'federated',
|
|
155
|
+
modelId,
|
|
156
|
+
name: options?.name,
|
|
157
|
+
visible: options?.visible,
|
|
158
|
+
collapsed: options?.collapsed,
|
|
159
|
+
loadedAt: options?.loadedAt,
|
|
160
|
+
sharedRtcOffset,
|
|
161
|
+
});
|
|
853
162
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
for (const mesh of parsedGeometry.meshes) {
|
|
859
|
-
mesh.expressId = mesh.expressId + idOffset;
|
|
860
|
-
}
|
|
861
|
-
// Point clouds need the same offset so picking / isolation /
|
|
862
|
-
// property lookup resolve through the FederationRegistry's
|
|
863
|
-
// global ID space — otherwise two pointcloud models with the
|
|
864
|
-
// same local expressId collide.
|
|
865
|
-
for (const asset of parsedGeometry.pointClouds ?? []) {
|
|
866
|
-
asset.expressId = asset.expressId + idOffset;
|
|
867
|
-
}
|
|
163
|
+
if (loadSessionRef.current !== currentSession) return null;
|
|
164
|
+
const registered = useViewerStore.getState().models.has(modelId);
|
|
165
|
+
if (registered) {
|
|
166
|
+
console.log(`[ifc-lite] Added model ${file.name} (${fileSizeForGateMB.toFixed(1)}MB) in ${(performance.now() - addStart).toFixed(0)}ms`);
|
|
868
167
|
}
|
|
869
|
-
|
|
870
|
-
// local expressId. After registerModelOffset() hands us an
|
|
871
|
-
// idOffset, the renderer needs to emit the post-offset globalId
|
|
872
|
-
// in picking + selection outputs — otherwise picks resolve to
|
|
873
|
-
// the local id and collide across federated models. The shader
|
|
874
|
-
// reads expressId from a per-asset uniform (`flags.x`) so this
|
|
875
|
-
// is just a metadata update; no GPU buffer rewrite.
|
|
876
|
-
if (idOffset > 0 && pointCloudHandleId !== undefined) {
|
|
877
|
-
const renderer = getGlobalRenderer();
|
|
878
|
-
if (renderer && parsedGeometry.pointClouds && parsedGeometry.pointClouds.length > 0) {
|
|
879
|
-
// Use the asset that's already had idOffset folded in above
|
|
880
|
-
// as the source of truth for the global id.
|
|
881
|
-
const asset = parsedGeometry.pointClouds[0];
|
|
882
|
-
renderer.relabelPointCloudAsset({ id: pointCloudHandleId }, asset.expressId);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// =========================================================================
|
|
887
|
-
// COORDINATE ALIGNMENT: All federated models use the same shared RTC offset
|
|
888
|
-
// (passed to WASM during parsing above), so no post-processing vertex
|
|
889
|
-
// adjustment is needed. All models are already in the same coordinate space.
|
|
890
|
-
// =========================================================================
|
|
891
|
-
|
|
892
|
-
// Build spatial index AFTER ID offset + RTC alignment so it stores
|
|
893
|
-
// correct globalIds and final world-space positions.
|
|
894
|
-
buildSpatialIndexGuarded(parsedGeometry.meshes, parsedDataStore, setIfcDataStore);
|
|
895
|
-
|
|
896
|
-
// Create the federated model with offset info
|
|
897
|
-
const federatedModel: FederatedModel = {
|
|
898
|
-
id: modelId,
|
|
899
|
-
name: options?.name ?? file.name,
|
|
900
|
-
ifcDataStore: parsedDataStore,
|
|
901
|
-
geometryResult: parsedGeometry,
|
|
902
|
-
visible: options?.visible ?? true,
|
|
903
|
-
collapsed: options?.collapsed ?? hasModels(), // Collapse if not first model
|
|
904
|
-
schemaVersion,
|
|
905
|
-
loadedAt: options?.loadedAt ?? Date.now(),
|
|
906
|
-
fileSize: buffer.byteLength,
|
|
907
|
-
sourceFile: file,
|
|
908
|
-
idOffset,
|
|
909
|
-
maxExpressId,
|
|
910
|
-
pointCloudHandleId,
|
|
911
|
-
preAlignmentPositions,
|
|
912
|
-
preAlignmentNormals,
|
|
913
|
-
preAlignmentCoordinateInfo,
|
|
914
|
-
federationAlignmentStatus,
|
|
915
|
-
};
|
|
916
|
-
|
|
917
|
-
// Add to store
|
|
918
|
-
storeAddModel(federatedModel);
|
|
919
|
-
|
|
920
|
-
// Don't touch the legacy top-level setters for added models. When this
|
|
921
|
-
// is the first model, modelSlice.addModel already mirrored it into the
|
|
922
|
-
// top-level fields. When subsequent models are added, activeModelId
|
|
923
|
-
// stays on the first model — writing here would alias the new model's
|
|
924
|
-
// data into the active (first) model's per-model entry and cause both
|
|
925
|
-
// viewport slots to render the same mesh (issue #661, PR #792).
|
|
926
|
-
//
|
|
927
|
-
// An earlier draft of this branch called `setActiveModel(modelId)`
|
|
928
|
-
// here, which also fixed #661 but had the side-effect of stealing
|
|
929
|
-
// focus to every added model — confusing UX. The main-branch fix
|
|
930
|
-
// (drop the legacy calls; keep activeModelId on the first model)
|
|
931
|
-
// is preferred and was kept on merge.
|
|
932
|
-
|
|
933
|
-
setProgress({ phase: 'Complete', percent: 100 });
|
|
934
|
-
setLoading(false);
|
|
935
|
-
console.log(`[ifc-lite] Added model ${file.name} (${fileSizeMB.toFixed(1)}MB) in ${(performance.now() - addStart).toFixed(0)}ms`);
|
|
936
|
-
|
|
937
|
-
return modelId;
|
|
168
|
+
return registered ? modelId : null;
|
|
938
169
|
|
|
939
170
|
} catch (err) {
|
|
940
171
|
// Only mutate shared loading/error/progress state if our session
|
|
@@ -963,7 +194,7 @@ export function useIfcFederation() {
|
|
|
963
194
|
} finally {
|
|
964
195
|
releaseFederationLoadSlot(gateSlot);
|
|
965
196
|
}
|
|
966
|
-
}, [setLoading, setError, setProgress
|
|
197
|
+
}, [loadFile, setLoading, setError, setProgress]);
|
|
967
198
|
|
|
968
199
|
/**
|
|
969
200
|
* Re-apply federation alignment using the currently selected anchor
|