@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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +16 -16
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +117 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-86rgogji.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-CcPS9MK5.js} +2274 -2227
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-BFUYA08u.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-Bfms9I4A.js} +35160 -33084
  12. package/dist/assets/index-_bfZsDCC.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-DUyLCMZS.js} +104 -104
  14. package/dist/assets/{sandbox-DZiNLNMk.js → sandbox-C8575tul.js} +4340 -4322
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-BuZK7OST.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-JsqEGDV8.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +7 -7
  20. package/src/App.tsx +16 -2
  21. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  22. package/src/components/viewer/ChatPanel.tsx +195 -91
  23. package/src/components/viewer/MainToolbar.tsx +4 -3
  24. package/src/components/viewer/PropertiesPanel.tsx +16 -2
  25. package/src/components/viewer/SettingsPage.tsx +252 -101
  26. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  27. package/src/components/viewer/ViewerLayout.tsx +1 -0
  28. package/src/components/viewer/Viewport.tsx +14 -2
  29. package/src/components/viewer/ViewportContainer.tsx +49 -64
  30. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  31. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  32. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  33. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  34. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  35. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  36. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  37. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  38. package/src/components/viewer/useAnimationLoop.ts +9 -1
  39. package/src/components/viewer/useRenderUpdates.ts +1 -1
  40. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  41. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  42. package/src/hooks/useIfcFederation.ts +326 -71
  43. package/src/hooks/useIfcLoader.ts +1 -0
  44. package/src/hooks/useViewControls.ts +13 -5
  45. package/src/index.css +484 -10
  46. package/src/lib/desktop-entitlement.ts +2 -4
  47. package/src/lib/geo/cesium-bridge.ts +15 -7
  48. package/src/lib/geo/effective-georef.test.ts +73 -0
  49. package/src/lib/geo/effective-georef.ts +111 -0
  50. package/src/lib/geo/reproject.ts +105 -19
  51. package/src/lib/llm/byok-guard.test.ts +77 -0
  52. package/src/lib/llm/byok-guard.ts +39 -0
  53. package/src/lib/llm/free-models.test.ts +0 -6
  54. package/src/lib/llm/models.ts +104 -42
  55. package/src/lib/llm/stream-client.ts +74 -110
  56. package/src/lib/llm/stream-direct.test.ts +130 -0
  57. package/src/lib/llm/stream-direct.ts +316 -0
  58. package/src/lib/llm/types.ts +14 -2
  59. package/src/main.tsx +1 -10
  60. package/src/services/api-keys.ts +73 -0
  61. package/src/store/constants.ts +20 -2
  62. package/src/store/index.ts +12 -5
  63. package/src/store/slices/cesiumSlice.ts +5 -0
  64. package/src/store/slices/chatSlice.test.ts +6 -76
  65. package/src/store/slices/chatSlice.ts +17 -58
  66. package/src/store/slices/sectionSlice.test.ts +87 -7
  67. package/src/store/slices/sectionSlice.ts +151 -5
  68. package/src/store/slices/uiSlice.ts +28 -5
  69. package/src/store/types.ts +26 -0
  70. package/src/utils/nativeSpatialDataStore.ts +4 -1
  71. package/src/utils/viewportUtils.ts +7 -2
  72. package/src/vite-env.d.ts +0 -4
  73. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  74. package/dist/assets/ids-B4jTqB1O.js +0 -1
  75. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  76. package/dist/assets/index-DckuDqlv.css +0 -1
  77. package/src/components/viewer/UpgradePage.tsx +0 -71
  78. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  79. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  80. 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 { detectFormat, parseFederatedIfcx, type IfcDataStore, type FederatedIfcxParseResult } from '@ifc-lite/parser';
17
- import type { MeshData } from '@ifc-lite/geometry';
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?: { name?: string }
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: Align new model with existing models using RTC delta
233
- // WASM applies per-model RTC offsets. To align models from the same project,
234
- // we calculate the difference in RTC offsets and apply it to the new model.
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
  };
@@ -231,6 +231,7 @@ export function useIfcLoader() {
231
231
  schemaVersion: 'IFC4',
232
232
  loadedAt: Date.now(),
233
233
  fileSize,
234
+ sourceFile: file,
234
235
  idOffset: 0,
235
236
  maxExpressId: 0,
236
237
  loadState: 'pending',
@@ -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
- if (sectionPlane.axis !== prevAxisRef.current) {
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
- setNeedsFit(true); // Force fit when axis changes
166
- cachedSheetTransformRef.current = null; // Clear cached transform for new axis
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);