@ifc-lite/viewer 1.17.4 → 1.17.6
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 +16 -16
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +117 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-86rgogji.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-CcPS9MK5.js} +2274 -2227
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-BFUYA08u.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-Bfms9I4A.js} +35160 -33084
- package/dist/assets/index-_bfZsDCC.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-DUyLCMZS.js} +104 -104
- package/dist/assets/{sandbox-DZiNLNMk.js → sandbox-C8575tul.js} +4340 -4322
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-BuZK7OST.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-JsqEGDV8.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +7 -7
- package/src/App.tsx +16 -2
- package/src/components/viewer/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +195 -91
- package/src/components/viewer/MainToolbar.tsx +4 -3
- package/src/components/viewer/PropertiesPanel.tsx +16 -2
- package/src/components/viewer/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ViewerLayout.tsx +1 -0
- package/src/components/viewer/Viewport.tsx +14 -2
- package/src/components/viewer/ViewportContainer.tsx +49 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -2
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +1 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +484 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -0
- package/src/lib/llm/types.ts +14 -2
- package/src/main.tsx +1 -10
- package/src/services/api-keys.ts +73 -0
- package/src/store/constants.ts +20 -2
- package/src/store/index.ts +12 -5
- package/src/store/slices/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- package/src/store/slices/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- package/src/lib/llm/clerk-auth.ts +0 -62
|
@@ -13,8 +13,15 @@
|
|
|
13
13
|
import { useCallback } from 'react';
|
|
14
14
|
import { useShallow } from 'zustand/react/shallow';
|
|
15
15
|
import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store.js';
|
|
16
|
-
import {
|
|
17
|
-
|
|
16
|
+
import {
|
|
17
|
+
detectFormat,
|
|
18
|
+
parseFederatedIfcx,
|
|
19
|
+
type IfcDataStore,
|
|
20
|
+
type FederatedIfcxParseResult,
|
|
21
|
+
type MapConversion,
|
|
22
|
+
type ProjectedCRS,
|
|
23
|
+
} from '@ifc-lite/parser';
|
|
24
|
+
import type { CoordinateInfo, MeshData } from '@ifc-lite/geometry';
|
|
18
25
|
import { IfcQuery } from '@ifc-lite/query';
|
|
19
26
|
import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
|
|
20
27
|
import { getDynamicBatchConfig } from '../utils/ifcConfig.js';
|
|
@@ -27,6 +34,7 @@ import {
|
|
|
27
34
|
parseStepBufferViewerModel,
|
|
28
35
|
} from './ingest/viewerModelIngest.js';
|
|
29
36
|
import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
|
|
37
|
+
import { getEffectiveGeoreference, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
|
|
30
38
|
|
|
31
39
|
function isNativeFileHandle(file: File | NativeFileHandle): file is NativeFileHandle {
|
|
32
40
|
return typeof (file as NativeFileHandle).path === 'string';
|
|
@@ -39,6 +47,271 @@ function toExactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
|
39
47
|
return bytes.slice().buffer;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
type FederatedGeometryResult = NonNullable<FederatedModel['geometryResult']>;
|
|
51
|
+
|
|
52
|
+
interface ModelGeoref {
|
|
53
|
+
mapConversion: MapConversion;
|
|
54
|
+
projectedCRS: ProjectedCRS;
|
|
55
|
+
lengthUnitScale: number;
|
|
56
|
+
coordinateInfo?: CoordinateInfo;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface AffineTransform3D {
|
|
60
|
+
m00: number;
|
|
61
|
+
m01: number;
|
|
62
|
+
m02: number;
|
|
63
|
+
tx: number;
|
|
64
|
+
m10: number;
|
|
65
|
+
m11: number;
|
|
66
|
+
m12: number;
|
|
67
|
+
ty: number;
|
|
68
|
+
m20: number;
|
|
69
|
+
m21: number;
|
|
70
|
+
m22: number;
|
|
71
|
+
tz: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getMapUnitScale(georef: ModelGeoref): number {
|
|
75
|
+
return georef.projectedCRS.mapUnitScale ?? georef.lengthUnitScale ?? 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getAxis(conversion: MapConversion): { a: number; o: number; scale: number; denom: number } {
|
|
79
|
+
const a = conversion.xAxisAbscissa ?? 1;
|
|
80
|
+
const o = conversion.xAxisOrdinate ?? 0;
|
|
81
|
+
const scale = conversion.scale ?? 1;
|
|
82
|
+
const denom = Math.max(a * a + o * o, 1e-12);
|
|
83
|
+
return { a, o, scale, denom };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractModelGeoref(
|
|
87
|
+
dataStore: IfcDataStore,
|
|
88
|
+
coordinateInfo?: CoordinateInfo,
|
|
89
|
+
mutations?: GeorefMutationDataLike,
|
|
90
|
+
): ModelGeoref | null {
|
|
91
|
+
const georef = getEffectiveGeoreference(dataStore, coordinateInfo, mutations);
|
|
92
|
+
if (!georef?.mapConversion || !georef.projectedCRS?.name) return null;
|
|
93
|
+
return {
|
|
94
|
+
mapConversion: georef.mapConversion,
|
|
95
|
+
projectedCRS: georef.projectedCRS,
|
|
96
|
+
lengthUnitScale: georef.lengthUnitScale,
|
|
97
|
+
coordinateInfo,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function crsKey(crs: ProjectedCRS): string {
|
|
102
|
+
return `${crs.name ?? ''}|${crs.geodeticDatum ?? ''}|${crs.mapProjection ?? ''}|${crs.mapZone ?? ''}`.toUpperCase();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function canAlignInSameProjectedCrs(a: ModelGeoref, b: ModelGeoref): boolean {
|
|
106
|
+
return crsKey(a.projectedCRS) === crsKey(b.projectedCRS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function totalYupOffset(coordinateInfo?: CoordinateInfo): { x: number; y: number; z: number } {
|
|
110
|
+
const shift = coordinateInfo?.originShift ?? { x: 0, y: 0, z: 0 };
|
|
111
|
+
const rtc = coordinateInfo?.wasmRtcOffset;
|
|
112
|
+
const rtcYup = rtc ? { x: rtc.x, y: rtc.z, z: -rtc.y } : { x: 0, y: 0, z: 0 };
|
|
113
|
+
return {
|
|
114
|
+
x: shift.x + rtcYup.x,
|
|
115
|
+
y: shift.y + rtcYup.y,
|
|
116
|
+
z: shift.z + rtcYup.z,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function emptyBounds() {
|
|
121
|
+
return {
|
|
122
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
123
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function zeroBounds() {
|
|
128
|
+
return {
|
|
129
|
+
min: { x: 0, y: 0, z: 0 },
|
|
130
|
+
max: { x: 0, y: 0, z: 0 },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updateBounds(bounds: ReturnType<typeof emptyBounds>, x: number, y: number, z: number): boolean {
|
|
135
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false;
|
|
136
|
+
bounds.min.x = Math.min(bounds.min.x, x);
|
|
137
|
+
bounds.min.y = Math.min(bounds.min.y, y);
|
|
138
|
+
bounds.min.z = Math.min(bounds.min.z, z);
|
|
139
|
+
bounds.max.x = Math.max(bounds.max.x, x);
|
|
140
|
+
bounds.max.y = Math.max(bounds.max.y, y);
|
|
141
|
+
bounds.max.z = Math.max(bounds.max.z, z);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildGeorefAlignmentTransform(source: ModelGeoref, reference: ModelGeoref): AffineTransform3D | null {
|
|
146
|
+
const sourceConv = source.mapConversion;
|
|
147
|
+
const refConv = reference.mapConversion;
|
|
148
|
+
const sourceAxis = getAxis(sourceConv);
|
|
149
|
+
const refAxis = getAxis(refConv);
|
|
150
|
+
const refDenom = refAxis.scale * refAxis.denom;
|
|
151
|
+
if (Math.abs(refDenom) < 1e-12) return null;
|
|
152
|
+
|
|
153
|
+
const sourceMapUnitScale = getMapUnitScale(source);
|
|
154
|
+
const refMapUnitScale = getMapUnitScale(reference);
|
|
155
|
+
const sourceOffset = totalYupOffset(source.coordinateInfo);
|
|
156
|
+
const refOffset = totalYupOffset(reference.coordinateInfo);
|
|
157
|
+
|
|
158
|
+
const eVx = sourceAxis.scale * sourceAxis.a;
|
|
159
|
+
const eVz = sourceAxis.scale * sourceAxis.o;
|
|
160
|
+
const eC = sourceConv.eastings * sourceMapUnitScale
|
|
161
|
+
+ sourceAxis.scale * (sourceAxis.a * sourceOffset.x + sourceAxis.o * sourceOffset.z)
|
|
162
|
+
- refConv.eastings * refMapUnitScale;
|
|
163
|
+
|
|
164
|
+
const nVx = sourceAxis.scale * sourceAxis.o;
|
|
165
|
+
const nVz = -sourceAxis.scale * sourceAxis.a;
|
|
166
|
+
const nC = sourceConv.northings * sourceMapUnitScale
|
|
167
|
+
+ sourceAxis.scale * (sourceAxis.o * sourceOffset.x - sourceAxis.a * sourceOffset.z)
|
|
168
|
+
- refConv.northings * refMapUnitScale;
|
|
169
|
+
|
|
170
|
+
const hC = sourceConv.orthogonalHeight * sourceMapUnitScale
|
|
171
|
+
+ sourceOffset.y
|
|
172
|
+
- refConv.orthogonalHeight * refMapUnitScale;
|
|
173
|
+
|
|
174
|
+
const invRefDenom = 1 / refDenom;
|
|
175
|
+
const xVx = (refAxis.a * eVx + refAxis.o * nVx) * invRefDenom;
|
|
176
|
+
const xVz = (refAxis.a * eVz + refAxis.o * nVz) * invRefDenom;
|
|
177
|
+
const xC = (refAxis.a * eC + refAxis.o * nC) * invRefDenom - refOffset.x;
|
|
178
|
+
|
|
179
|
+
const yVx = (-refAxis.o * eVx + refAxis.a * nVx) * invRefDenom;
|
|
180
|
+
const yVz = (-refAxis.o * eVz + refAxis.a * nVz) * invRefDenom;
|
|
181
|
+
const yC = (-refAxis.o * eC + refAxis.a * nC) * invRefDenom;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
m00: xVx,
|
|
185
|
+
m01: 0,
|
|
186
|
+
m02: xVz,
|
|
187
|
+
tx: xC,
|
|
188
|
+
m10: 0,
|
|
189
|
+
m11: 1,
|
|
190
|
+
m12: 0,
|
|
191
|
+
ty: hC - refOffset.y,
|
|
192
|
+
m20: -yVx,
|
|
193
|
+
m21: 0,
|
|
194
|
+
m22: -yVz,
|
|
195
|
+
tz: -yC - refOffset.z,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isIdentityTransform(transform: AffineTransform3D): boolean {
|
|
200
|
+
const eps = 1e-7;
|
|
201
|
+
return Math.abs(transform.m00 - 1) < eps
|
|
202
|
+
&& Math.abs(transform.m01) < eps
|
|
203
|
+
&& Math.abs(transform.m02) < eps
|
|
204
|
+
&& Math.abs(transform.tx) < eps
|
|
205
|
+
&& Math.abs(transform.m10) < eps
|
|
206
|
+
&& Math.abs(transform.m11 - 1) < eps
|
|
207
|
+
&& Math.abs(transform.m12) < eps
|
|
208
|
+
&& Math.abs(transform.ty) < eps
|
|
209
|
+
&& Math.abs(transform.m20) < eps
|
|
210
|
+
&& Math.abs(transform.m21) < eps
|
|
211
|
+
&& Math.abs(transform.m22 - 1) < eps
|
|
212
|
+
&& Math.abs(transform.tz) < eps;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function applyAlignmentTransformAndUpdateBounds(
|
|
216
|
+
geometry: FederatedGeometryResult,
|
|
217
|
+
transform: AffineTransform3D,
|
|
218
|
+
referenceInfo?: CoordinateInfo,
|
|
219
|
+
): void {
|
|
220
|
+
const bounds = emptyBounds();
|
|
221
|
+
let found = false;
|
|
222
|
+
|
|
223
|
+
for (const mesh of geometry.meshes) {
|
|
224
|
+
const positions = mesh.positions;
|
|
225
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
226
|
+
const x = positions[i];
|
|
227
|
+
const y = positions[i + 1];
|
|
228
|
+
const z = positions[i + 2];
|
|
229
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const alignedX = transform.m00 * x + transform.m01 * y + transform.m02 * z + transform.tx;
|
|
234
|
+
const alignedY = transform.m10 * x + transform.m11 * y + transform.m12 * z + transform.ty;
|
|
235
|
+
const alignedZ = transform.m20 * x + transform.m21 * y + transform.m22 * z + transform.tz;
|
|
236
|
+
positions[i] = alignedX;
|
|
237
|
+
positions[i + 1] = alignedY;
|
|
238
|
+
positions[i + 2] = alignedZ;
|
|
239
|
+
found = updateBounds(bounds, alignedX, alignedY, alignedZ) || found;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Rotate normals by the transform's 3×3 linear part (translation omitted)
|
|
243
|
+
// and renormalize. CRS alignment is a rigid rotation, so the linear part
|
|
244
|
+
// itself is the correct transform for normals; degenerate results from
|
|
245
|
+
// zero-length or non-finite inputs are left in place.
|
|
246
|
+
const normals = mesh.normals;
|
|
247
|
+
if (normals && normals.length >= 3) {
|
|
248
|
+
for (let i = 0; i < normals.length; i += 3) {
|
|
249
|
+
const nx = normals[i];
|
|
250
|
+
const ny = normals[i + 1];
|
|
251
|
+
const nz = normals[i + 2];
|
|
252
|
+
if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const rx = transform.m00 * nx + transform.m01 * ny + transform.m02 * nz;
|
|
256
|
+
const ry = transform.m10 * nx + transform.m11 * ny + transform.m12 * nz;
|
|
257
|
+
const rz = transform.m20 * nx + transform.m21 * ny + transform.m22 * nz;
|
|
258
|
+
const len = Math.sqrt(rx * rx + ry * ry + rz * rz);
|
|
259
|
+
if (!Number.isFinite(len) || len < 1e-12) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
normals[i] = rx / len;
|
|
263
|
+
normals[i + 1] = ry / len;
|
|
264
|
+
normals[i + 2] = rz / len;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
geometry.coordinateInfo = {
|
|
270
|
+
originShift: referenceInfo?.originShift ?? { x: 0, y: 0, z: 0 },
|
|
271
|
+
originalBounds: found ? bounds : zeroBounds(),
|
|
272
|
+
shiftedBounds: found ? bounds : zeroBounds(),
|
|
273
|
+
hasLargeCoordinates: referenceInfo?.hasLargeCoordinates ?? false,
|
|
274
|
+
wasmRtcOffset: referenceInfo?.wasmRtcOffset,
|
|
275
|
+
buildingRotation: referenceInfo?.buildingRotation,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function alignGeometryToReferenceGeoref(
|
|
280
|
+
geometry: FederatedGeometryResult,
|
|
281
|
+
source: ModelGeoref,
|
|
282
|
+
reference: ModelGeoref,
|
|
283
|
+
): boolean {
|
|
284
|
+
if (!canAlignInSameProjectedCrs(source, reference)) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const transform = buildGeorefAlignmentTransform(source, reference);
|
|
289
|
+
if (!transform) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!isIdentityTransform(transform)) {
|
|
294
|
+
applyAlignmentTransformAndUpdateBounds(geometry, transform, reference.coordinateInfo);
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function findReferenceGeorefModel(): ModelGeoref | null {
|
|
300
|
+
const state = useViewerStore.getState();
|
|
301
|
+
const modelEntries = Array.from(state.models.entries()) as Array<[string, FederatedModel]>;
|
|
302
|
+
const sorted = [...modelEntries].sort(([, a], [, b]) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
|
|
303
|
+
for (const [modelId, model] of sorted) {
|
|
304
|
+
if (!model.ifcDataStore || !model.geometryResult) continue;
|
|
305
|
+
const georef = extractModelGeoref(
|
|
306
|
+
model.ifcDataStore,
|
|
307
|
+
model.geometryResult.coordinateInfo,
|
|
308
|
+
state.georefMutations.get(modelId),
|
|
309
|
+
);
|
|
310
|
+
if (georef) return georef;
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
42
315
|
/**
|
|
43
316
|
* Extended data store type for IFCX (IFC5) files.
|
|
44
317
|
* IFCX uses schemaVersion 'IFC5' and may include federated composition metadata.
|
|
@@ -100,9 +373,15 @@ export function useIfcFederation() {
|
|
|
100
373
|
*/
|
|
101
374
|
const addModel = useCallback(async (
|
|
102
375
|
file: File | NativeFileHandle,
|
|
103
|
-
options?: {
|
|
376
|
+
options?: {
|
|
377
|
+
name?: string;
|
|
378
|
+
modelId?: string;
|
|
379
|
+
loadedAt?: number;
|
|
380
|
+
visible?: boolean;
|
|
381
|
+
collapsed?: boolean;
|
|
382
|
+
}
|
|
104
383
|
): Promise<string | null> => {
|
|
105
|
-
const modelId = crypto.randomUUID();
|
|
384
|
+
const modelId = options?.modelId ?? crypto.randomUUID();
|
|
106
385
|
const addStart = performance.now();
|
|
107
386
|
try {
|
|
108
387
|
// IMPORTANT: Before adding a new model, check if there's a legacy model
|
|
@@ -143,6 +422,7 @@ export function useIfcFederation() {
|
|
|
143
422
|
schemaVersion: 'IFC4',
|
|
144
423
|
loadedAt: Date.now() - 1000,
|
|
145
424
|
fileSize: 0,
|
|
425
|
+
sourceFile: undefined,
|
|
146
426
|
idOffset: legacyOffset,
|
|
147
427
|
maxExpressId: legacyMaxExpressId,
|
|
148
428
|
};
|
|
@@ -190,12 +470,26 @@ export function useIfcFederation() {
|
|
|
190
470
|
schemaVersion = result.schemaVersion;
|
|
191
471
|
} else {
|
|
192
472
|
setProgress({ phase: 'Starting geometry streaming', percent: 10 });
|
|
473
|
+
|
|
474
|
+
// For federated models: use the first model's RTC offset so all models
|
|
475
|
+
// share the same coordinate origin. This ensures pixel-perfect alignment
|
|
476
|
+
// without error-prone delta adjustments.
|
|
477
|
+
let sharedRtcOffset: { x: number; y: number; z: number } | undefined;
|
|
478
|
+
const existingModelsForRtc = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
|
|
479
|
+
if (existingModelsForRtc.length > 0) {
|
|
480
|
+
const sorted = [...existingModelsForRtc].sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
|
|
481
|
+
sharedRtcOffset = sorted.find(
|
|
482
|
+
(model) => model.geometryResult?.coordinateInfo?.wasmRtcOffset != null,
|
|
483
|
+
)?.geometryResult?.coordinateInfo?.wasmRtcOffset;
|
|
484
|
+
}
|
|
485
|
+
|
|
193
486
|
const result = await parseStepBufferViewerModel({
|
|
194
487
|
fileName: file.name,
|
|
195
488
|
buffer,
|
|
196
489
|
fileSizeMB,
|
|
197
490
|
getDynamicBatchSize: getDynamicBatchConfig,
|
|
198
491
|
onProgress: setProgress,
|
|
492
|
+
sharedRtcOffset,
|
|
199
493
|
});
|
|
200
494
|
parsedDataStore = result.dataStore;
|
|
201
495
|
parsedGeometry = result.geometryResult;
|
|
@@ -206,6 +500,27 @@ export function useIfcFederation() {
|
|
|
206
500
|
throw new Error('Failed to parse file');
|
|
207
501
|
}
|
|
208
502
|
|
|
503
|
+
const referenceGeoref = findReferenceGeorefModel();
|
|
504
|
+
// Include any georef edits the user has already saved for this model so
|
|
505
|
+
// that a reload after editing reflects the new placement. Without this,
|
|
506
|
+
// extractModelGeoref reads only the raw parsed metadata and mutations
|
|
507
|
+
// are silently ignored.
|
|
508
|
+
const parsedGeorefMutations = useViewerStore.getState().georefMutations.get(modelId);
|
|
509
|
+
const parsedGeoref = extractModelGeoref(
|
|
510
|
+
parsedDataStore,
|
|
511
|
+
parsedGeometry.coordinateInfo,
|
|
512
|
+
parsedGeorefMutations,
|
|
513
|
+
);
|
|
514
|
+
if (referenceGeoref && parsedGeoref) {
|
|
515
|
+
setProgress({ phase: 'Aligning georeferenced model', percent: 90 });
|
|
516
|
+
const aligned = alignGeometryToReferenceGeoref(parsedGeometry, parsedGeoref, referenceGeoref);
|
|
517
|
+
if (!aligned) {
|
|
518
|
+
console.warn(
|
|
519
|
+
`[ifc-lite] Skipped georeferenced federation alignment for "${file.name}" because CRS differs from the reference model.`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
209
524
|
// =========================================================================
|
|
210
525
|
// FEDERATION REGISTRY: Transform expressIds to globally unique IDs
|
|
211
526
|
// This is the BULLETPROOF fix for multi-model ID collisions
|
|
@@ -229,71 +544,10 @@ export function useIfcFederation() {
|
|
|
229
544
|
}
|
|
230
545
|
|
|
231
546
|
// =========================================================================
|
|
232
|
-
// COORDINATE ALIGNMENT:
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// RTC offset is in IFC coordinates (Z-up). After Z-up to Y-up conversion:
|
|
237
|
-
// - IFC X → WebGL X
|
|
238
|
-
// - IFC Y → WebGL -Z
|
|
239
|
-
// - IFC Z → WebGL Y (vertical)
|
|
547
|
+
// COORDINATE ALIGNMENT: All federated models use the same shared RTC offset
|
|
548
|
+
// (passed to WASM during parsing above), so no post-processing vertex
|
|
549
|
+
// adjustment is needed. All models are already in the same coordinate space.
|
|
240
550
|
// =========================================================================
|
|
241
|
-
const existingModels = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
|
|
242
|
-
if (existingModels.length > 0) {
|
|
243
|
-
const firstModel = existingModels[0];
|
|
244
|
-
const firstRtc = firstModel.geometryResult?.coordinateInfo?.wasmRtcOffset;
|
|
245
|
-
const newRtc = parsedGeometry.coordinateInfo?.wasmRtcOffset;
|
|
246
|
-
|
|
247
|
-
// If both models have RTC offsets, use RTC delta for precise alignment
|
|
248
|
-
if (firstRtc && newRtc) {
|
|
249
|
-
// Calculate what adjustment is needed to align new model with first model
|
|
250
|
-
// First model: pos = original - firstRtc
|
|
251
|
-
// New model: pos = original - newRtc
|
|
252
|
-
// To align: newPos + adjustment = firstPos (assuming same original)
|
|
253
|
-
// adjustment = firstRtc - newRtc (add back new's RTC, subtract first's RTC)
|
|
254
|
-
const adjustX = firstRtc.x - newRtc.x; // IFC X adjustment
|
|
255
|
-
const adjustY = firstRtc.y - newRtc.y; // IFC Y adjustment
|
|
256
|
-
const adjustZ = firstRtc.z - newRtc.z; // IFC Z adjustment (vertical)
|
|
257
|
-
|
|
258
|
-
// Convert to WebGL coordinates:
|
|
259
|
-
// IFC X → WebGL X (no change)
|
|
260
|
-
// IFC Y → WebGL -Z (swap and negate)
|
|
261
|
-
// IFC Z → WebGL Y (vertical)
|
|
262
|
-
const webglAdjustX = adjustX;
|
|
263
|
-
const webglAdjustY = adjustZ; // IFC Z is WebGL Y (vertical)
|
|
264
|
-
const webglAdjustZ = -adjustY; // IFC Y is WebGL -Z
|
|
265
|
-
|
|
266
|
-
const hasSignificantAdjust = Math.abs(webglAdjustX) > 0.01 ||
|
|
267
|
-
Math.abs(webglAdjustY) > 0.01 ||
|
|
268
|
-
Math.abs(webglAdjustZ) > 0.01;
|
|
269
|
-
|
|
270
|
-
if (hasSignificantAdjust) {
|
|
271
|
-
// Apply adjustment to all mesh vertices
|
|
272
|
-
// SUBTRACT adjustment: if firstRtc > newRtc, first was shifted MORE,
|
|
273
|
-
// so new model needs to be shifted in same direction (subtract more)
|
|
274
|
-
for (const mesh of parsedGeometry.meshes) {
|
|
275
|
-
const positions = mesh.positions;
|
|
276
|
-
for (let i = 0; i < positions.length; i += 3) {
|
|
277
|
-
positions[i] -= webglAdjustX;
|
|
278
|
-
positions[i + 1] -= webglAdjustY;
|
|
279
|
-
positions[i + 2] -= webglAdjustZ;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Update coordinate info bounds
|
|
284
|
-
if (parsedGeometry.coordinateInfo) {
|
|
285
|
-
parsedGeometry.coordinateInfo.shiftedBounds.min.x -= webglAdjustX;
|
|
286
|
-
parsedGeometry.coordinateInfo.shiftedBounds.max.x -= webglAdjustX;
|
|
287
|
-
parsedGeometry.coordinateInfo.shiftedBounds.min.y -= webglAdjustY;
|
|
288
|
-
parsedGeometry.coordinateInfo.shiftedBounds.max.y -= webglAdjustY;
|
|
289
|
-
parsedGeometry.coordinateInfo.shiftedBounds.min.z -= webglAdjustZ;
|
|
290
|
-
parsedGeometry.coordinateInfo.shiftedBounds.max.z -= webglAdjustZ;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
} else {
|
|
294
|
-
// No RTC info - can't align reliably. This happens with old cache entries.
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
551
|
|
|
298
552
|
// Build spatial index AFTER ID offset + RTC alignment so it stores
|
|
299
553
|
// correct globalIds and final world-space positions.
|
|
@@ -305,11 +559,12 @@ export function useIfcFederation() {
|
|
|
305
559
|
name: options?.name ?? file.name,
|
|
306
560
|
ifcDataStore: parsedDataStore,
|
|
307
561
|
geometryResult: parsedGeometry,
|
|
308
|
-
visible: true,
|
|
309
|
-
collapsed: hasModels(), // Collapse if not first model
|
|
562
|
+
visible: options?.visible ?? true,
|
|
563
|
+
collapsed: options?.collapsed ?? hasModels(), // Collapse if not first model
|
|
310
564
|
schemaVersion,
|
|
311
|
-
loadedAt: Date.now(),
|
|
565
|
+
loadedAt: options?.loadedAt ?? Date.now(),
|
|
312
566
|
fileSize: buffer.byteLength,
|
|
567
|
+
sourceFile: file,
|
|
313
568
|
idOffset,
|
|
314
569
|
maxExpressId,
|
|
315
570
|
};
|
|
@@ -43,6 +43,7 @@ function useViewControls({
|
|
|
43
43
|
const [viewTransform, setViewTransform] = useState({ x: 0, y: 0, scale: 1 });
|
|
44
44
|
const [needsFit, setNeedsFit] = useState(true); // Force fit on first open and axis change
|
|
45
45
|
const prevAxisRef = useRef(sectionPlane.axis); // Track axis changes
|
|
46
|
+
const prevFlippedRef = useRef(sectionPlane.flipped); // Track flip changes
|
|
46
47
|
|
|
47
48
|
// Wheel zoom handler
|
|
48
49
|
useEffect(() => {
|
|
@@ -158,14 +159,21 @@ function useViewControls({
|
|
|
158
159
|
// Track axis changes for forced fit-to-view
|
|
159
160
|
const lastFitAxisRef = useRef(sectionPlane.axis);
|
|
160
161
|
|
|
161
|
-
// Set needsFit when axis changes
|
|
162
|
+
// Set needsFit when axis OR flip changes. Flip mirrors the projection's U
|
|
163
|
+
// axis (see `projectTo2D` in @ifc-lite/drawing-2d), so the polygon bounds
|
|
164
|
+
// jump from positive X into negative X (or vice versa). Without re-fitting
|
|
165
|
+
// the new bounds end up off-screen and the user sees "empty 2D panel after
|
|
166
|
+
// I pressed Flip" — that was the bug behind the recent screenshot report.
|
|
162
167
|
useEffect(() => {
|
|
163
|
-
|
|
168
|
+
const axisChanged = sectionPlane.axis !== prevAxisRef.current;
|
|
169
|
+
const flipChanged = sectionPlane.flipped !== prevFlippedRef.current;
|
|
170
|
+
if (axisChanged || flipChanged) {
|
|
164
171
|
prevAxisRef.current = sectionPlane.axis;
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
prevFlippedRef.current = sectionPlane.flipped;
|
|
173
|
+
setNeedsFit(true);
|
|
174
|
+
cachedSheetTransformRef.current = null;
|
|
167
175
|
}
|
|
168
|
-
}, [sectionPlane.axis]);
|
|
176
|
+
}, [sectionPlane.axis, sectionPlane.flipped]);
|
|
169
177
|
|
|
170
178
|
// Track previous sheet mode to detect toggle
|
|
171
179
|
const prevSheetEnabledRef = useRef(sheetEnabled);
|