@ifc-lite/viewer 1.17.1 → 1.17.3

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 (63) hide show
  1. package/.turbo/turbo-build.log +30 -28
  2. package/.turbo/turbo-typecheck.log +1 -41
  3. package/CHANGELOG.md +23 -0
  4. package/dist/assets/arrow-DJf2ErbF.js +20 -0
  5. package/dist/assets/basketViewActivator-aojwdomq.js +1 -0
  6. package/dist/assets/bcf-D5-QWGO9.js +281 -0
  7. package/dist/assets/{browser-BQdwnOUt.js → browser-CKs-FY1P.js} +1 -1
  8. package/dist/assets/drawing-2d-gWfpdfYe.js +257 -0
  9. package/dist/assets/epsg-index.generated-BjJrt_0S.js +1 -0
  10. package/dist/assets/exporters-C_6J153K.js +79896 -0
  11. package/dist/assets/geometry.worker-Nz9_YIqh.js +1 -0
  12. package/dist/assets/ids-B4jTqB1O.js +1 -0
  13. package/dist/assets/ifc-lite_bg-eSkBTizQ.wasm +0 -0
  14. package/dist/assets/index-jhBr1wbn.js +101666 -0
  15. package/dist/assets/index-pbE7itQS.css +1 -0
  16. package/dist/assets/lens-CSASnhAL.js +1 -0
  17. package/dist/assets/maplibre-gl-BpvwNKKy.js +811 -0
  18. package/dist/assets/{native-bridge-CN0ZMR2t.js → native-bridge-DSIyEYXG.js} +6 -4
  19. package/dist/assets/{arrow2-bb-jcVEo.js → parquet-CEXmQNRO.js} +2 -2
  20. package/dist/assets/sandbox-B79eavQ3.js +5933 -0
  21. package/dist/assets/server-client-D3bUPJJc.js +626 -0
  22. package/dist/assets/wasm-bridge-B0J07fZZ.js +1 -0
  23. package/dist/assets/zip-B-jFFAGa.js +12 -0
  24. package/dist/index.html +11 -2
  25. package/package.json +24 -19
  26. package/src/components/viewer/ExportChangesButton.tsx +18 -3
  27. package/src/components/viewer/ExportDialog.tsx +16 -3
  28. package/src/components/viewer/HierarchyPanel.tsx +6 -6
  29. package/src/components/viewer/PropertiesPanel.tsx +96 -60
  30. package/src/components/viewer/Section2DPanel.tsx +3 -2
  31. package/src/components/viewer/ViewportContainer.tsx +5 -4
  32. package/src/components/viewer/hierarchy/treeDataBuilder.ts +2 -1
  33. package/src/components/viewer/properties/EpsgLookupDialog.tsx +418 -0
  34. package/src/components/viewer/properties/GeoreferencingPanel.tsx +591 -0
  35. package/src/components/viewer/properties/LocationMap.tsx +289 -0
  36. package/src/components/viewer/properties/ModelMetadataPanel.tsx +3 -70
  37. package/src/hooks/bcfIdLookup.ts +13 -11
  38. package/src/hooks/ids/idsColorSystem.ts +3 -8
  39. package/src/hooks/useIDS.ts +31 -16
  40. package/src/hooks/useIfcFederation.ts +2 -2
  41. package/src/lib/geo/kmz-exporter.ts +112 -0
  42. package/src/lib/geo/reproject.ts +244 -0
  43. package/src/lib/lens/adapter.ts +3 -1
  44. package/src/main.tsx +1 -0
  45. package/src/sdk/adapters/export-adapter.ts +14 -1
  46. package/src/sdk/adapters/viewer-adapter.ts +5 -9
  47. package/src/sdk/adapters/visibility-adapter.ts +6 -9
  48. package/src/store/basketVisibleSet.ts +3 -4
  49. package/src/store/globalId.ts +79 -0
  50. package/src/store/index.ts +1 -0
  51. package/src/store/slices/mutationSlice.ts +178 -0
  52. package/src/store/slices/pinboardSlice.ts +4 -8
  53. package/vite.config.ts +17 -0
  54. package/dist/assets/Arrow.dom-DuPUrOxJ.js +0 -20
  55. package/dist/assets/arrow2_bg-BlXl-cSQ.js +0 -1
  56. package/dist/assets/basketViewActivator-DetjPnvt.js +0 -1
  57. package/dist/assets/geometry.worker-Bjm-ukng.js +0 -1
  58. package/dist/assets/ifc-lite_bg-DD0A7Yow.wasm +0 -0
  59. package/dist/assets/index-B3X21yXA.js +0 -229
  60. package/dist/assets/index-Ba4eoTe7.css +0 -1
  61. package/dist/assets/index-BybGZJTW.js +0 -189478
  62. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +0 -6
  63. package/dist/assets/wasm-bridge-D0bALkma.js +0 -1
@@ -0,0 +1,112 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * KMZ Exporter — packages a GLB model with a KML file into a KMZ archive
7
+ * so Google Earth can display the 3D model at its correct geolocation.
8
+ *
9
+ * KMZ is just a ZIP archive containing:
10
+ * doc.kml — KML document with a <Model> positioned at lat/lon/alt
11
+ * model.glb — the 3D model in glTF binary format
12
+ *
13
+ * The KML <Model> element uses:
14
+ * <Location> — latitude, longitude, altitude from reprojected georef
15
+ * <Orientation> — heading derived from the angle-to-grid-north
16
+ * <Scale> — uniform scale (1:1)
17
+ * <Link> — relative path to the GLB inside the archive
18
+ */
19
+
20
+ import { zipSync } from 'fflate';
21
+ import type { LatLon } from './reproject';
22
+
23
+ export interface KmzOptions {
24
+ /** WGS84 coordinates of the model origin */
25
+ latLon: LatLon;
26
+ /** Orthogonal height (elevation) in metres */
27
+ altitude: number;
28
+ /** Heading in degrees clockwise from north (0 = north, 90 = east) */
29
+ heading: number;
30
+ /** GLB model binary data */
31
+ glb: Uint8Array;
32
+ /** Display name for the placemark */
33
+ name?: string;
34
+ }
35
+
36
+ /**
37
+ * Convert the IFC angle-to-grid-north (counterclockwise from east) into
38
+ * a KML heading (clockwise from north).
39
+ *
40
+ * IFC convention: atan2(XAxisOrdinate, XAxisAbscissa) gives the CCW angle
41
+ * from the map east axis to the local X axis.
42
+ *
43
+ * KML convention: heading is CW from north (0=N, 90=E, 180=S, 270=W).
44
+ */
45
+ export function ifcAngleToKmlHeading(
46
+ xAxisAbscissa?: number,
47
+ xAxisOrdinate?: number,
48
+ ): number {
49
+ if (xAxisAbscissa === undefined || xAxisOrdinate === undefined) return 0;
50
+ const angleFromEastCcw = Math.atan2(xAxisOrdinate, xAxisAbscissa) * (180 / Math.PI);
51
+ // Convert: heading = 90 - angle (CCW from east → CW from north)
52
+ const heading = 90 - angleFromEastCcw;
53
+ // Normalize to [0, 360)
54
+ return ((heading % 360) + 360) % 360;
55
+ }
56
+
57
+ function buildKml(opts: KmzOptions): string {
58
+ const name = escapeXml(opts.name ?? 'IFC Model');
59
+ return `<?xml version="1.0" encoding="UTF-8"?>
60
+ <kml xmlns="http://www.opengis.net/kml/2.2">
61
+ <Document>
62
+ <name>${name}</name>
63
+ <Placemark>
64
+ <name>${name}</name>
65
+ <Model id="model">
66
+ <altitudeMode>relativeToGround</altitudeMode>
67
+ <Location>
68
+ <longitude>${opts.latLon.lon}</longitude>
69
+ <latitude>${opts.latLon.lat}</latitude>
70
+ <altitude>${opts.altitude}</altitude>
71
+ </Location>
72
+ <Orientation>
73
+ <heading>${opts.heading}</heading>
74
+ <tilt>0</tilt>
75
+ <roll>0</roll>
76
+ </Orientation>
77
+ <Scale>
78
+ <x>1</x>
79
+ <y>1</y>
80
+ <z>1</z>
81
+ </Scale>
82
+ <Link>
83
+ <href>model.glb</href>
84
+ </Link>
85
+ </Model>
86
+ </Placemark>
87
+ </Document>
88
+ </kml>`;
89
+ }
90
+
91
+ function escapeXml(s: string): string {
92
+ return s
93
+ .replace(/&/g, '&amp;')
94
+ .replace(/</g, '&lt;')
95
+ .replace(/>/g, '&gt;')
96
+ .replace(/"/g, '&quot;')
97
+ .replace(/'/g, '&apos;');
98
+ }
99
+
100
+ /**
101
+ * Build a KMZ file (ZIP archive) containing doc.kml + model.glb.
102
+ * Returns the KMZ as a Uint8Array ready for download.
103
+ */
104
+ export function buildKmz(opts: KmzOptions): Uint8Array {
105
+ const kml = buildKml(opts);
106
+ const kmlBytes = new TextEncoder().encode(kml);
107
+
108
+ return zipSync({
109
+ 'doc.kml': kmlBytes,
110
+ 'model.glb': opts.glb,
111
+ });
112
+ }
@@ -0,0 +1,244 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Coordinate reprojection utilities.
7
+ *
8
+ * Converts projected coordinates (e.g. UTM eastings/northings) from an
9
+ * IfcMapConversion + IfcProjectedCRS pair into WGS84 longitude/latitude
10
+ * so they can be displayed on a web map.
11
+ *
12
+ * proj4 definitions are resolved from:
13
+ * 1. The bundled EPSG index (@ifc-lite/data) — covers all 7000+ codes
14
+ * 2. Programmatically constructed (UTM zones, well-known codes)
15
+ * 3. Fetched from epsg.io at runtime as last resort
16
+ */
17
+
18
+ import proj4 from 'proj4';
19
+ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
20
+ import type { CoordinateInfo } from '@ifc-lite/geometry';
21
+ import { lookupProj4 } from '@ifc-lite/data';
22
+
23
+ export interface LatLon {
24
+ lat: number;
25
+ lon: number;
26
+ }
27
+
28
+ // Cache resolved projection definitions (from any source).
29
+ const projDefCache = new Map<string, string | null>();
30
+
31
+ /**
32
+ * Extract EPSG numeric code from a CRS name like "EPSG:32632" or "EPSG 2056".
33
+ */
34
+ function extractEpsgCode(crs: ProjectedCRS): string | null {
35
+ const match = crs.name?.match(/EPSG[:\s]*(\d+)/i);
36
+ return match ? match[1] : null;
37
+ }
38
+
39
+ /**
40
+ * Build a proj4 definition string for a UTM zone.
41
+ */
42
+ function utmProj4String(zone: string): string | null {
43
+ const match = zone.match(/^(\d{1,2})([NS])$/i);
44
+ if (!match) return null;
45
+ const zoneNum = parseInt(match[1], 10);
46
+ const isNorth = match[2].toUpperCase() === 'N';
47
+ if (zoneNum < 1 || zoneNum > 60) return null;
48
+ return `+proj=utm +zone=${zoneNum}${isNorth ? '' : ' +south'} +datum=WGS84 +units=m +no_defs`;
49
+ }
50
+
51
+ /**
52
+ * Well-known +towgs84 approximations for datums that normally use grid files.
53
+ * These are accurate to ~1-5m, which is sufficient for map display.
54
+ * Grid files (like OSTN15_NTv2_OSGBtoETRS.gsb) cannot run in the browser.
55
+ */
56
+ const DATUM_TOWGS84: Record<string, string> = {
57
+ 'airy': '+towgs84=446.448,-125.157,542.06,0.15,0.247,0.842,-20.489', // OSGB36 (UK)
58
+ 'clrk66': '+towgs84=-8,160,176,0,0,0,0', // NAD27 (approx)
59
+ 'GRS80': '+towgs84=0,0,0,0,0,0,0', // GRS80-based (NAD83≈WGS84)
60
+ 'bessel': '+towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.7', // DHDN (Germany)
61
+ 'intl': '+towgs84=-87,-98,-121,0,0,0,0', // NZGD49 (NZ)
62
+ 'aust_SA': '+towgs84=-134,-48,149,0,0,0,0', // AGD84 (Australia)
63
+ };
64
+
65
+ /**
66
+ * Strip +nadgrids=... from a proj4 string and add a +towgs84 approximation
67
+ * based on the ellipsoid. Grid files cannot be loaded in the browser.
68
+ */
69
+ function sanitizeProj4(def: string): string {
70
+ if (!def.includes('+nadgrids') || def.includes('+nadgrids=@null')) return def;
71
+
72
+ // Extract the ellipsoid to find the right towgs84 approximation
73
+ const ellpsMatch = def.match(/\+ellps=(\S+)/);
74
+ const ellps = ellpsMatch?.[1] ?? '';
75
+ const towgs84 = DATUM_TOWGS84[ellps] ?? '+towgs84=0,0,0,0,0,0,0';
76
+
77
+ // Remove +nadgrids=... and add +towgs84
78
+ return def.replace(/\+nadgrids=\S+/g, '').replace(/\s+/g, ' ').trim() + ' ' + towgs84;
79
+ }
80
+
81
+ /**
82
+ * Fetch a proj4 definition string from epsg.io (last-resort fallback).
83
+ */
84
+ async function fetchProj4Def(epsgCode: string): Promise<string | null> {
85
+ try {
86
+ const resp = await fetch(`https://epsg.io/${epsgCode}.proj4`);
87
+ if (!resp.ok) return null;
88
+ const text = (await resp.text()).trim();
89
+ if (!text || text.startsWith('<') || text.startsWith('{') || !text.includes('+')) {
90
+ return null;
91
+ }
92
+ return text;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Resolve a proj4 definition for the given ProjectedCRS.
100
+ *
101
+ * Resolution order:
102
+ * 1. Cache hit
103
+ * 2. Bundled EPSG index (7000+ codes with proj4 strings)
104
+ * 3. UTM zone heuristic (from CRS metadata)
105
+ * 4. Fetch from epsg.io (network fallback)
106
+ */
107
+ export async function resolveProjection(crs: ProjectedCRS): Promise<string | null> {
108
+ const code = extractEpsgCode(crs);
109
+
110
+ // 1. Check cache
111
+ if (code && projDefCache.has(code)) {
112
+ return projDefCache.get(code) ?? null;
113
+ }
114
+
115
+ // 2. Bundled EPSG index (primary source — all 7000+ codes)
116
+ if (code) {
117
+ try {
118
+ const bundled = await lookupProj4(code);
119
+ if (bundled) {
120
+ const sanitized = sanitizeProj4(bundled);
121
+ projDefCache.set(code, sanitized);
122
+ return sanitized;
123
+ }
124
+ } catch {
125
+ // EPSG index not loaded yet, continue to fallbacks
126
+ }
127
+ }
128
+
129
+ // 3. UTM zone heuristic
130
+ if (crs.mapZone) {
131
+ const def = utmProj4String(crs.mapZone);
132
+ if (def) {
133
+ if (code) projDefCache.set(code, def);
134
+ return def;
135
+ }
136
+ }
137
+ const name = crs.name?.toUpperCase() ?? '';
138
+ const utmMatch = name.match(/UTM\s+ZONE\s+(\d{1,2}[NS])/i)
139
+ ?? crs.description?.match(/UTM\s+zone\s+(\d{1,2}[NS])/i);
140
+ if (utmMatch) {
141
+ const def = utmProj4String(utmMatch[1]);
142
+ if (def) {
143
+ if (code) projDefCache.set(code, def);
144
+ return def;
145
+ }
146
+ }
147
+
148
+ // 4. Network fallback — fetch from epsg.io
149
+ if (code) {
150
+ const raw = await fetchProj4Def(code);
151
+ const fetched = raw ? sanitizeProj4(raw) : null;
152
+ projDefCache.set(code, fetched);
153
+ return fetched;
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Compute the model center in the projected CRS (easting, northing).
161
+ *
162
+ * The coordinate pipeline is:
163
+ * 1. WASM extracts IFC positions (Z-up) and may apply RTC offset (wasmRtcOffset, Z-up)
164
+ * 2. Mesh collector converts Z-up → Y-up: viewerX = ifcX, viewerY = ifcZ, viewerZ = -ifcY
165
+ * 3. CoordinateHandler may apply originShift (Y-up)
166
+ *
167
+ * To recover IFC world coordinates (Z-up) from the viewer bounds:
168
+ * world_yup = bounds_center + originShift + wasmRtcOffset_as_yup
169
+ * ifc_x = world_yup.x, ifc_y = -world_yup.z, ifc_z = world_yup.y
170
+ *
171
+ * Then the projected CRS coordinates are:
172
+ * easting = mapConversion.eastings + scale * (cos*ifc_x - sin*ifc_y)
173
+ * northing = mapConversion.northings + scale * (sin*ifc_x + cos*ifc_y)
174
+ */
175
+ function computeProjectedCenter(
176
+ conversion: MapConversion,
177
+ coordinateInfo?: CoordinateInfo,
178
+ ): { easting: number; northing: number } {
179
+ let ifcX = 0;
180
+ let ifcY = 0;
181
+
182
+ if (coordinateInfo) {
183
+ const bounds = coordinateInfo.originalBounds;
184
+ const shift = coordinateInfo.originShift;
185
+ const rtc = coordinateInfo.wasmRtcOffset;
186
+
187
+ // Convert WASM RTC offset from IFC Z-up to viewer Y-up
188
+ const rtcYup = rtc
189
+ ? { x: rtc.x, y: rtc.z, z: -rtc.y }
190
+ : { x: 0, y: 0, z: 0 };
191
+
192
+ // Bounds center in viewer Y-up (scene-local)
193
+ const cx = (bounds.min.x + bounds.max.x) / 2;
194
+ const cz = (bounds.min.z + bounds.max.z) / 2;
195
+
196
+ // World Y-up = scene_local + originShift + wasmRtcOffset_yup
197
+ const worldYupX = cx + shift.x + rtcYup.x;
198
+ const worldYupZ = cz + shift.z + rtcYup.z;
199
+
200
+ // Convert Y-up to IFC Z-up: ifc_x = viewer_x, ifc_y = -viewer_z
201
+ ifcX = worldYupX;
202
+ ifcY = -worldYupZ;
203
+ }
204
+
205
+ // Apply MapConversion rotation + scale + offset
206
+ const scale = conversion.scale ?? 1.0;
207
+ const abscissa = conversion.xAxisAbscissa ?? 1.0;
208
+ const ordinate = conversion.xAxisOrdinate ?? 0.0;
209
+
210
+ const easting = conversion.eastings + scale * (abscissa * ifcX - ordinate * ifcY);
211
+ const northing = conversion.northings + scale * (ordinate * ifcX + abscissa * ifcY);
212
+
213
+ return { easting, northing };
214
+ }
215
+
216
+ /**
217
+ * Reproject the model center from the projected CRS to WGS84 lat/lon.
218
+ *
219
+ * Uses the model's actual geometry bounds + RTC offset to determine where
220
+ * the model sits in the projected coordinate system, then reprojects to WGS84.
221
+ *
222
+ * @param conversion IfcMapConversion (offset, rotation, scale)
223
+ * @param crs IfcProjectedCRS (EPSG code)
224
+ * @param coordinateInfo Geometry coordinate info with bounds and RTC offset
225
+ */
226
+ export async function reprojectToLatLon(
227
+ conversion: MapConversion,
228
+ crs: ProjectedCRS,
229
+ coordinateInfo?: CoordinateInfo,
230
+ ): Promise<LatLon | null> {
231
+ const projDef = await resolveProjection(crs);
232
+ if (!projDef) return null;
233
+
234
+ const { easting, northing } = computeProjectedCenter(conversion, coordinateInfo);
235
+
236
+ try {
237
+ const [lon, lat] = proj4(projDef, 'WGS84', [easting, northing]);
238
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
239
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
240
+ return { lat, lon };
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
@@ -18,6 +18,7 @@ import {
18
18
  extractClassificationsOnDemand,
19
19
  extractMaterialsOnDemand,
20
20
  } from '@ifc-lite/parser';
21
+ import { toGlobalIdFromModels } from '@/store/globalId';
21
22
  import type { FederatedModel } from '@/store/types';
22
23
 
23
24
  interface ModelEntry {
@@ -80,12 +81,13 @@ export function createLensDataProvider(
80
81
  },
81
82
 
82
83
  forEachEntity(callback: (globalId: number, modelId: string) => void): void {
84
+ const models = new Map(entries.map((entry) => [entry.id, { idOffset: entry.idOffset }]));
83
85
  for (const entry of entries) {
84
86
  const entities = entry.ifcDataStore.entities;
85
87
  if (!entities) continue;
86
88
  for (let i = 0; i < entities.count; i++) {
87
89
  const expressId = entities.expressId[i];
88
- callback(expressId + entry.idOffset, entry.id);
90
+ callback(toGlobalIdFromModels(models, entry.id, expressId), entry.id);
89
91
  }
90
92
  }
91
93
  },
package/src/main.tsx CHANGED
@@ -11,6 +11,7 @@ import ReactDOM from 'react-dom/client';
11
11
  import { ClerkProvider } from '@clerk/clerk-react';
12
12
  import { App } from './App';
13
13
  import './index.css';
14
+ import 'maplibre-gl/dist/maplibre-gl.css';
14
15
 
15
16
  const clerkPublishableKey = (import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined)?.trim();
16
17
 
@@ -119,6 +119,13 @@ function escapeCsv(value: string, sep: string): string {
119
119
  * on the same LocalBackend, providing full export support for both
120
120
  * direct dispatch calls and SDK namespace usage.
121
121
  */
122
+ function toBlobPart(content: string | Uint8Array): BlobPart {
123
+ if (typeof content === 'string') return content;
124
+ const bytes = new Uint8Array(content.byteLength);
125
+ bytes.set(content);
126
+ return bytes;
127
+ }
128
+
122
129
  export function createExportAdapter(store: StoreApi): ExportBackendMethods {
123
130
  /** Resolve entity data via the query subsystem */
124
131
  function getEntityData(ref: EntityRef): EntityData | null {
@@ -354,6 +361,11 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
354
361
  model.ifcDataStore,
355
362
  options.includeMutations === false ? undefined : getMutationViewForModel(store, modelId) ?? undefined,
356
363
  );
364
+ // Include georeferencing mutations if present
365
+ const georefMutations = options.includeMutations !== false
366
+ ? state.georefMutations?.get(modelId) ?? undefined
367
+ : undefined;
368
+
357
369
  const exportOptions: StepExportOptions = {
358
370
  schema: options.schema ?? model.ifcDataStore.schemaVersion,
359
371
  includeGeometry: true,
@@ -364,6 +376,7 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
364
376
  visibleOnly,
365
377
  hiddenEntityIds,
366
378
  isolatedEntityIds,
379
+ georefMutations,
367
380
  };
368
381
 
369
382
  return exporter.export(exportOptions).content;
@@ -381,7 +394,7 @@ function triggerDownload(content: string | Uint8Array, filename: string, mimeTyp
381
394
  if (typeof document === 'undefined') {
382
395
  throw new Error('download() requires a browser environment (document is unavailable)');
383
396
  }
384
- const blob = new Blob([content], { type: mimeType });
397
+ const blob = new Blob([toBlobPart(content)], { type: mimeType });
385
398
  const url = URL.createObjectURL(blob);
386
399
  const a = document.createElement('a');
387
400
  a.href = url;
@@ -5,6 +5,7 @@
5
5
  import type { EntityRef, SectionPlane, CameraState, ViewerBackendMethods } from '@ifc-lite/sdk';
6
6
  import type { StoreApi } from './types.js';
7
7
  import { getModelForRef } from './model-compat.js';
8
+ import { toGlobalIdForRef } from '../../store/globalId.js';
8
9
 
9
10
  const AXIS_TO_STORE: Record<string, 'down' | 'front' | 'side'> = {
10
11
  x: 'side',
@@ -25,11 +26,8 @@ export function createViewerAdapter(store: StoreApi): ViewerBackendMethods {
25
26
  const existing = state.pendingColorUpdates;
26
27
  const colorMap = existing ? new Map(existing) : new Map<number, [number, number, number, number]>();
27
28
  for (const ref of refs) {
28
- const model = getModelForRef(state, ref.modelId);
29
- if (model) {
30
- const globalId = ref.expressId + model.idOffset;
31
- colorMap.set(globalId, color);
32
- }
29
+ if (!getModelForRef(state, ref.modelId)) continue;
30
+ colorMap.set(toGlobalIdForRef(state.models, ref), color);
33
31
  }
34
32
  state.setPendingColorUpdates(colorMap);
35
33
  return undefined;
@@ -41,10 +39,8 @@ export function createViewerAdapter(store: StoreApi): ViewerBackendMethods {
41
39
  const batchMap = new Map<number, [number, number, number, number]>();
42
40
  for (const batch of batches) {
43
41
  for (const ref of batch.refs) {
44
- const model = getModelForRef(state, ref.modelId);
45
- if (model) {
46
- batchMap.set(ref.expressId + model.idOffset, batch.color);
47
- }
42
+ if (!getModelForRef(state, ref.modelId)) continue;
43
+ batchMap.set(toGlobalIdForRef(state.models, ref), batch.color);
48
44
  }
49
45
  }
50
46
  state.setPendingColorUpdates(batchMap);
@@ -6,6 +6,7 @@ import type { EntityRef, VisibilityBackendMethods } from '@ifc-lite/sdk';
6
6
  import type { StoreApi } from './types.js';
7
7
  import { getModelForRef, type ModelLike } from './model-compat.js';
8
8
  import { collectSpatialSubtreeElementsWithIfcSpace } from '../../store/basketVisibleSet.js';
9
+ import { toGlobalIdForRef, toGlobalIdFromModels } from '../../store/globalId.js';
9
10
  import { isSpaceLikeSpatialTypeName, isSpatialStructureTypeName, type SpatialNode } from '@ifc-lite/data';
10
11
 
11
12
  function findDescendantNode(root: SpatialNode, expressId: number): SpatialNode | null {
@@ -49,10 +50,8 @@ export function createVisibilityAdapter(store: StoreApi): VisibilityBackendMetho
49
50
  // hiddenEntities set (global IDs), not hiddenEntitiesByModel.
50
51
  const globalIds: number[] = [];
51
52
  for (const ref of refs) {
52
- const model = getModelForRef(state, ref.modelId);
53
- if (model) {
54
- globalIds.push(ref.expressId + model.idOffset);
55
- }
53
+ if (!getModelForRef(state, ref.modelId)) continue;
54
+ globalIds.push(toGlobalIdForRef(state.models, ref));
56
55
  }
57
56
  if (globalIds.length > 0) {
58
57
  state.hideEntities(globalIds);
@@ -63,10 +62,8 @@ export function createVisibilityAdapter(store: StoreApi): VisibilityBackendMetho
63
62
  const state = store.getState();
64
63
  const globalIds: number[] = [];
65
64
  for (const ref of refs) {
66
- const model = getModelForRef(state, ref.modelId);
67
- if (model) {
68
- globalIds.push(ref.expressId + model.idOffset);
69
- }
65
+ if (!getModelForRef(state, ref.modelId)) continue;
66
+ globalIds.push(toGlobalIdForRef(state.models, ref));
70
67
  }
71
68
  if (globalIds.length > 0) {
72
69
  state.showEntities(globalIds);
@@ -81,7 +78,7 @@ export function createVisibilityAdapter(store: StoreApi): VisibilityBackendMetho
81
78
  if (model) {
82
79
  const expanded = expandSpatialRef(ref, model);
83
80
  for (const id of expanded) {
84
- globalIds.push(id + model.idOffset);
81
+ globalIds.push(toGlobalIdFromModels(state.models, ref.modelId, id));
85
82
  }
86
83
  }
87
84
  }
@@ -14,6 +14,7 @@ import type { IfcDataStore } from '@ifc-lite/parser';
14
14
  import type { EntityRef } from './types.js';
15
15
  import { entityRefToString, stringToEntityRef } from './types.js';
16
16
  import { useViewerStore } from './index.js';
17
+ import { toGlobalIdFromModels } from './globalId.js';
17
18
 
18
19
  type ViewerStateSnapshot = ReturnType<typeof useViewerStore.getState>;
19
20
 
@@ -159,9 +160,7 @@ function expandRefToElements(state: ViewerStateSnapshot, ref: EntityRef): Entity
159
160
  }
160
161
 
161
162
  function toGlobalId(modelId: string, expressId: number, state: ViewerStateSnapshot): number {
162
- if (modelId === 'legacy') return expressId;
163
- const model = state.models.get(modelId);
164
- return expressId + (model?.idOffset ?? 0);
163
+ return toGlobalIdFromModels(state.models, modelId, expressId);
165
164
  }
166
165
 
167
166
  function globalIdToRef(state: ViewerStateSnapshot, globalId: number): EntityRef | null {
@@ -318,7 +317,7 @@ function computeStoreyIsolation(state: ViewerStateSnapshot): Set<number> | null
318
317
  const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, localStoreyId);
319
318
  if (!storeyElementIds) continue;
320
319
  for (const localId of storeyElementIds) {
321
- ids.add(localId + offset);
320
+ ids.add(toGlobalIdFromModels(state.models, model.id, localId));
322
321
  }
323
322
  }
324
323
  }
@@ -0,0 +1,79 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import type { EntityRef, FederatedModel } from './types.js';
6
+
7
+ type ForwardModelMapLike = ReadonlyMap<string, { idOffset?: number }>;
8
+ type ReverseModelMapLike = ReadonlyMap<string, Pick<FederatedModel, 'idOffset' | 'maxExpressId'>>;
9
+
10
+ /**
11
+ * Convert a local expressId to the renderer/global ID space.
12
+ *
13
+ * This is the viewer-level single source of truth for modelId + expressId →
14
+ * globalId conversion outside Zustand hooks. It preserves single-model legacy
15
+ * behavior by falling back to the original expressId when no federated model
16
+ * entry exists.
17
+ */
18
+ export function toGlobalIdFromModels(
19
+ models: ForwardModelMapLike,
20
+ modelId: string,
21
+ expressId: number,
22
+ ): number {
23
+ if (modelId === 'legacy' || modelId === 'default' || modelId === '__legacy__') {
24
+ return expressId;
25
+ }
26
+
27
+ const model = models.get(modelId);
28
+ if (!model) {
29
+ return expressId;
30
+ }
31
+
32
+ return expressId + (model.idOffset ?? 0);
33
+ }
34
+
35
+ /**
36
+ * Resolve a renderer/global ID back to the source model and local expressId.
37
+ *
38
+ * This mirrors toGlobalIdFromModels and preserves legacy single-model behavior.
39
+ */
40
+ export function fromGlobalIdFromModels(
41
+ models: ReverseModelMapLike,
42
+ globalId: number,
43
+ ): EntityRef | undefined {
44
+ if (models.size <= 1) {
45
+ const firstModelId = models.keys().next().value;
46
+ if (firstModelId) {
47
+ return {
48
+ modelId: firstModelId,
49
+ expressId: globalId,
50
+ };
51
+ }
52
+ return {
53
+ modelId: 'legacy',
54
+ expressId: globalId,
55
+ };
56
+ }
57
+
58
+ for (const [modelId, model] of models.entries()) {
59
+ const localExpressId = globalId - model.idOffset;
60
+ if (localExpressId > 0 && localExpressId <= model.maxExpressId) {
61
+ return {
62
+ modelId,
63
+ expressId: localExpressId,
64
+ };
65
+ }
66
+ }
67
+
68
+ return undefined;
69
+ }
70
+
71
+ /**
72
+ * Convert an EntityRef to the renderer/global ID space.
73
+ */
74
+ export function toGlobalIdForRef(
75
+ models: ForwardModelMapLike,
76
+ ref: EntityRef,
77
+ ): number {
78
+ return toGlobalIdFromModels(models, ref.modelId, ref.expressId);
79
+ }
@@ -48,6 +48,7 @@ export { entityRefToString, stringToEntityRef, entityRefEquals, isIfcxDataStore
48
48
 
49
49
  // Re-export single source of truth for globalId → EntityRef resolution
50
50
  export { resolveEntityRef } from './resolveEntityRef.js';
51
+ export { fromGlobalIdFromModels, toGlobalIdFromModels, toGlobalIdForRef } from './globalId.js';
51
52
 
52
53
  // Re-export Drawing2D types
53
54
  export type { Drawing2DState, Drawing2DStatus, Annotation2DTool, PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D, SelectedAnnotation2D } from './slices/drawing2DSlice.js';