@ifc-lite/viewer 1.17.2 → 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 (62) hide show
  1. package/.turbo/turbo-build.log +30 -29
  2. package/.turbo/turbo-typecheck.log +1 -42
  3. package/CHANGELOG.md +9 -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-BDShTXzi.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-FNRmpSvM.wasm → 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-Crsb7TKz.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-BhOg9lpn.js +0 -20
  55. package/dist/assets/arrow2_bg-BlXl-cSQ.js +0 -1
  56. package/dist/assets/basketViewActivator-BRG5DBmM.js +0 -1
  57. package/dist/assets/geometry.worker-kgiT_Qhh.js +0 -1
  58. package/dist/assets/index-B1Ecw4AU.js +0 -189756
  59. package/dist/assets/index-Ba4eoTe7.css +0 -1
  60. package/dist/assets/index-CrgYBjTn.js +0 -229
  61. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +0 -6
  62. package/dist/assets/wasm-bridge-mJUhb7uk.js +0 -1
@@ -0,0 +1,289 @@
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
+ * LocationMap — a compact MapLibre GL JS minimap that shows the model's
7
+ * real-world position derived from IfcMapConversion + IfcProjectedCRS.
8
+ *
9
+ * Renders as a collapsible panel below the georeferencing fields.
10
+ * Includes links to Google Maps, OpenStreetMap, and Google Earth (KMZ export).
11
+ */
12
+
13
+ import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
14
+ import { Map as MapIcon, ExternalLink, Loader2, MapPinOff, Globe2 } from 'lucide-react';
15
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
16
+ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
17
+ import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
18
+ import { GLTFExporter } from '@ifc-lite/export';
19
+ import { reprojectToLatLon, type LatLon } from '@/lib/geo/reproject';
20
+ import { buildKmz, ifcAngleToKmlHeading } from '@/lib/geo/kmz-exporter';
21
+
22
+ // Lazy-load maplibre-gl to avoid bloating the initial bundle
23
+ let maplibrePromise: Promise<typeof import('maplibre-gl')> | null = null;
24
+ function loadMaplibre() {
25
+ if (!maplibrePromise) {
26
+ maplibrePromise = import('maplibre-gl');
27
+ }
28
+ return maplibrePromise;
29
+ }
30
+
31
+ export interface LocationMapProps {
32
+ mapConversion?: MapConversion;
33
+ projectedCRS?: ProjectedCRS;
34
+ /** Coordinate info from the model's GeometryResult (includes bounds and RTC offset) */
35
+ coordinateInfo?: CoordinateInfo;
36
+ /** Geometry result for KMZ export (optional — KMZ button hidden if not provided) */
37
+ geometryResult?: GeometryResult | null;
38
+ }
39
+
40
+ type MapState = 'idle' | 'loading' | 'ready' | 'error';
41
+
42
+ export function LocationMap({ mapConversion, projectedCRS, coordinateInfo, geometryResult }: LocationMapProps) {
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+ const mapRef = useRef<InstanceType<typeof import('maplibre-gl').Map> | null>(null);
45
+ const markerRef = useRef<InstanceType<typeof import('maplibre-gl').Marker> | null>(null);
46
+
47
+ const [mapState, setMapState] = useState<MapState>('idle');
48
+ const [latLon, setLatLon] = useState<LatLon | null>(null);
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ useEffect(() => {
52
+ if (!mapConversion || !projectedCRS) {
53
+ setLatLon(null);
54
+ setError(null);
55
+ return;
56
+ }
57
+
58
+ let cancelled = false;
59
+ setMapState('loading');
60
+ setError(null);
61
+
62
+ reprojectToLatLon(mapConversion, projectedCRS, coordinateInfo).then(result => {
63
+ if (cancelled) return;
64
+ if (result) {
65
+ setLatLon(result);
66
+ setMapState('ready');
67
+ } else {
68
+ setLatLon(null);
69
+ setError('Could not resolve projection — EPSG code may be unsupported');
70
+ setMapState('error');
71
+ }
72
+ });
73
+
74
+ return () => { cancelled = true; };
75
+ }, [mapConversion, projectedCRS, coordinateInfo]);
76
+
77
+ // Initialize/update the map when we have a valid lat/lon
78
+ useEffect(() => {
79
+ if (!latLon || !containerRef.current) return;
80
+
81
+ let cancelled = false;
82
+
83
+ loadMaplibre().then(maplibregl => {
84
+ if (cancelled || !containerRef.current) return;
85
+
86
+ // If map already exists, just fly to new position
87
+ if (mapRef.current) {
88
+ mapRef.current.flyTo({ center: [latLon.lon, latLon.lat], zoom: 15, duration: 1200 });
89
+ if (markerRef.current) {
90
+ markerRef.current.setLngLat([latLon.lon, latLon.lat]);
91
+ }
92
+ return;
93
+ }
94
+
95
+ // Create new map
96
+ const map = new maplibregl.Map({
97
+ container: containerRef.current,
98
+ style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
99
+ center: [latLon.lon, latLon.lat],
100
+ zoom: 15,
101
+ attributionControl: false,
102
+ interactive: true,
103
+ });
104
+
105
+ map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
106
+ map.addControl(new maplibregl.AttributionControl({ compact: false }), 'bottom-right');
107
+
108
+ // Add marker at model location
109
+ const marker = new maplibregl.Marker({ color: '#14b8a6' })
110
+ .setLngLat([latLon.lon, latLon.lat])
111
+ .addTo(map);
112
+
113
+ mapRef.current = map;
114
+ markerRef.current = marker;
115
+ });
116
+
117
+ return () => {
118
+ cancelled = true;
119
+ };
120
+ }, [latLon]);
121
+
122
+ // Cleanup on unmount
123
+ useEffect(() => {
124
+ return () => {
125
+ markerRef.current?.remove();
126
+ markerRef.current = null;
127
+ mapRef.current?.remove();
128
+ mapRef.current = null;
129
+ };
130
+ }, []);
131
+
132
+ const googleMapsUrl = useMemo(() => {
133
+ if (!latLon) return null;
134
+ return `https://www.google.com/maps?q=${latLon.lat},${latLon.lon}`;
135
+ }, [latLon]);
136
+
137
+ const openStreetMapUrl = useMemo(() => {
138
+ if (!latLon) return null;
139
+ return `https://www.openstreetmap.org/?mlat=${latLon.lat}&mlon=${latLon.lon}#map=17/${latLon.lat}/${latLon.lon}`;
140
+ }, [latLon]);
141
+
142
+ const handleExportKmz = useCallback(() => {
143
+ if (!latLon || !geometryResult || !mapConversion) return;
144
+ try {
145
+ const exporter = new GLTFExporter(geometryResult);
146
+ const glb = new Uint8Array(exporter.exportGLB({ includeMetadata: true }));
147
+ const heading = ifcAngleToKmlHeading(mapConversion.xAxisAbscissa, mapConversion.xAxisOrdinate);
148
+ const kmz = buildKmz({
149
+ latLon,
150
+ altitude: mapConversion.orthogonalHeight,
151
+ heading,
152
+ glb,
153
+ name: 'IFC Model',
154
+ });
155
+ const blob = new Blob([kmz], { type: 'application/vnd.google-earth.kmz' });
156
+ const url = URL.createObjectURL(blob);
157
+ const a = document.createElement('a');
158
+ a.href = url;
159
+ a.download = 'model.kmz';
160
+ a.click();
161
+ URL.revokeObjectURL(url);
162
+ } catch (err) {
163
+ console.error('KMZ export failed:', err);
164
+ }
165
+ }, [latLon, geometryResult, mapConversion]);
166
+
167
+ const isDarkRef = useRef(false);
168
+
169
+ const handleStyleToggle = useCallback(() => {
170
+ if (!mapRef.current) return;
171
+ isDarkRef.current = !isDarkRef.current;
172
+ mapRef.current.setStyle(
173
+ isDarkRef.current
174
+ ? 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'
175
+ : 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
176
+ );
177
+ // Re-add marker after style fully loads
178
+ if (markerRef.current && mapRef.current) {
179
+ mapRef.current.once('style.load', () => {
180
+ if (markerRef.current && mapRef.current) {
181
+ markerRef.current.addTo(mapRef.current);
182
+ }
183
+ });
184
+ }
185
+ }, []);
186
+
187
+ // Nothing to show if no georeferencing data
188
+ if (!mapConversion || !projectedCRS) {
189
+ return null;
190
+ }
191
+
192
+ return (
193
+ <div className="border-t border-zinc-100 dark:border-zinc-900">
194
+ {/* Header */}
195
+ <div className="flex items-center gap-2 px-3 py-1.5">
196
+ <MapIcon className="h-3 w-3 text-teal-500 shrink-0" />
197
+ <span className="font-bold text-[11px] text-zinc-700 dark:text-zinc-300 uppercase tracking-wide flex-1">
198
+ Location
199
+ </span>
200
+ {latLon && (
201
+ <span className="text-[10px] font-mono text-teal-600/70 dark:text-teal-500/60">
202
+ {latLon.lat.toFixed(5)}, {latLon.lon.toFixed(5)}
203
+ </span>
204
+ )}
205
+ </div>
206
+
207
+ {/* Map container */}
208
+ {mapState === 'loading' && (
209
+ <div className="flex items-center justify-center h-[180px] bg-zinc-50 dark:bg-zinc-900/50">
210
+ <Loader2 className="h-4 w-4 text-teal-500 animate-spin" />
211
+ <span className="text-[10px] text-zinc-400 ml-2">Resolving coordinates...</span>
212
+ </div>
213
+ )}
214
+
215
+ {mapState === 'error' && (
216
+ <div className="flex items-center justify-center h-[60px] bg-zinc-50 dark:bg-zinc-900/50 gap-2 px-3">
217
+ <MapPinOff className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
218
+ <span className="text-[10px] text-zinc-400">{error}</span>
219
+ </div>
220
+ )}
221
+
222
+ {(mapState === 'ready' || (mapState === 'loading' && latLon)) && (
223
+ <>
224
+ <div
225
+ ref={containerRef}
226
+ className="h-[180px] w-full [&_.maplibregl-ctrl-attrib]:!text-[7px] [&_.maplibregl-ctrl-attrib]:!bg-white/40 [&_.maplibregl-ctrl-attrib]:dark:!bg-black/30 [&_.maplibregl-ctrl-attrib]:!py-0 [&_.maplibregl-ctrl-attrib]:!px-1 [&_.maplibregl-ctrl-attrib]:!shadow-none [&_.maplibregl-ctrl-attrib]:!text-zinc-400/70 [&_.maplibregl-ctrl-attrib_a]:!text-zinc-400/70 [&_.maplibregl-ctrl-attrib]:!leading-normal"
227
+ style={{ minHeight: 180 }}
228
+ />
229
+
230
+ {/* Action links */}
231
+ <div className="flex items-center gap-3 px-3 py-1.5 border-t border-zinc-100 dark:border-zinc-900">
232
+ {googleMapsUrl && (
233
+ <Tooltip>
234
+ <TooltipTrigger asChild>
235
+ <a
236
+ href={googleMapsUrl}
237
+ target="_blank"
238
+ rel="noopener noreferrer"
239
+ className="flex items-center gap-1 text-[10px] text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 transition-colors"
240
+ >
241
+ <ExternalLink className="h-2.5 w-2.5" />
242
+ Google Maps
243
+ </a>
244
+ </TooltipTrigger>
245
+ <TooltipContent>Open model location in Google Maps</TooltipContent>
246
+ </Tooltip>
247
+ )}
248
+ {openStreetMapUrl && (
249
+ <Tooltip>
250
+ <TooltipTrigger asChild>
251
+ <a
252
+ href={openStreetMapUrl}
253
+ target="_blank"
254
+ rel="noopener noreferrer"
255
+ className="flex items-center gap-1 text-[10px] text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 transition-colors"
256
+ >
257
+ <ExternalLink className="h-2.5 w-2.5" />
258
+ OpenStreetMap
259
+ </a>
260
+ </TooltipTrigger>
261
+ <TooltipContent>Open model location in OpenStreetMap</TooltipContent>
262
+ </Tooltip>
263
+ )}
264
+ {geometryResult && (
265
+ <Tooltip>
266
+ <TooltipTrigger asChild>
267
+ <button
268
+ onClick={handleExportKmz}
269
+ className="flex items-center gap-1 text-[10px] text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 transition-colors"
270
+ >
271
+ <Globe2 className="h-2.5 w-2.5" />
272
+ Google Earth
273
+ </button>
274
+ </TooltipTrigger>
275
+ <TooltipContent>Download KMZ to open in Google Earth with model at correct position</TooltipContent>
276
+ </Tooltip>
277
+ )}
278
+ <button
279
+ onClick={handleStyleToggle}
280
+ className="ml-auto text-[10px] text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
281
+ >
282
+ Toggle style
283
+ </button>
284
+ </div>
285
+ </>
286
+ )}
287
+ </div>
288
+ );
289
+ }
@@ -18,11 +18,11 @@ import {
18
18
  Hash,
19
19
  Database,
20
20
  Building2,
21
- Globe,
22
21
  Ruler,
23
22
  } from 'lucide-react';
24
23
  import { ScrollArea } from '@/components/ui/scroll-area';
25
24
  import { PropertySetCard } from './PropertySetCard';
25
+ import { GeoreferencingPanel } from './GeoreferencingPanel';
26
26
  import type { PropertySet } from './encodingUtils';
27
27
  import type { FederatedModel } from '@/store/types';
28
28
  import { extractGeoreferencingOnDemand, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
@@ -147,7 +147,7 @@ export function ModelMetadataPanel({ model }: { model: FederatedModel }) {
147
147
  {formatDate(model.loadedAt)}
148
148
  </span>
149
149
  </div>
150
- {dataStore && (
150
+ {dataStore && dataStore.parseTime != null && (
151
151
  <div className="flex items-center gap-3 px-3 py-2">
152
152
  <Clock className="h-3.5 w-3.5 text-zinc-400 shrink-0" />
153
153
  <span className="text-xs text-zinc-500">Parse Time</span>
@@ -212,74 +212,7 @@ export function ModelMetadataPanel({ model }: { model: FederatedModel }) {
212
212
  </div>
213
213
 
214
214
  {/* Georeferencing */}
215
- {georef && (
216
- <div className="border-b border-zinc-200 dark:border-zinc-800">
217
- <div className="p-3 bg-teal-50/50 dark:bg-teal-950/20">
218
- <div className="flex items-center gap-2">
219
- <Globe className="h-3.5 w-3.5 text-teal-600 dark:text-teal-400" />
220
- <h4 className="font-bold text-xs uppercase tracking-wide text-teal-700 dark:text-teal-300">
221
- Georeferencing
222
- </h4>
223
- </div>
224
- </div>
225
- <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
226
- {georef.projectedCRS?.name && (
227
- <div className="flex items-center gap-3 px-3 py-2">
228
- <span className="text-xs text-zinc-500 shrink-0">CRS</span>
229
- <span className="text-xs font-mono text-teal-700 dark:text-teal-400 ml-auto truncate max-w-[65%]">
230
- {georef.projectedCRS.name}
231
- </span>
232
- </div>
233
- )}
234
- {georef.projectedCRS?.geodeticDatum && (
235
- <div className="flex items-center gap-3 px-3 py-2">
236
- <span className="text-xs text-zinc-500 shrink-0">Geodetic Datum</span>
237
- <span className="text-xs font-mono text-teal-700 dark:text-teal-400 ml-auto">
238
- {georef.projectedCRS.geodeticDatum}
239
- </span>
240
- </div>
241
- )}
242
- {georef.projectedCRS?.mapProjection && (
243
- <div className="flex items-center gap-3 px-3 py-2">
244
- <span className="text-xs text-zinc-500 shrink-0">Projection</span>
245
- <span className="text-xs font-mono text-teal-700 dark:text-teal-400 ml-auto truncate max-w-[65%]">
246
- {georef.projectedCRS.mapProjection}
247
- </span>
248
- </div>
249
- )}
250
- {georef.mapConversion && (
251
- <>
252
- <div className="flex items-center gap-3 px-3 py-2">
253
- <span className="text-xs text-zinc-500 shrink-0">Eastings</span>
254
- <span className="text-xs font-mono text-teal-700 dark:text-teal-400 ml-auto tabular-nums">
255
- {georef.mapConversion.eastings.toFixed(3)}
256
- </span>
257
- </div>
258
- <div className="flex items-center gap-3 px-3 py-2">
259
- <span className="text-xs text-zinc-500 shrink-0">Northings</span>
260
- <span className="text-xs font-mono text-teal-700 dark:text-teal-400 ml-auto tabular-nums">
261
- {georef.mapConversion.northings.toFixed(3)}
262
- </span>
263
- </div>
264
- <div className="flex items-center gap-3 px-3 py-2">
265
- <span className="text-xs text-zinc-500 shrink-0">Height</span>
266
- <span className="text-xs font-mono text-teal-700 dark:text-teal-400 ml-auto tabular-nums">
267
- {georef.mapConversion.orthogonalHeight.toFixed(3)}
268
- </span>
269
- </div>
270
- {georef.mapConversion.scale != null && georef.mapConversion.scale !== 1.0 && (
271
- <div className="flex items-center gap-3 px-3 py-2">
272
- <span className="text-xs text-zinc-500 shrink-0">Scale</span>
273
- <span className="text-xs font-mono text-teal-700 dark:text-teal-400 ml-auto">
274
- {georef.mapConversion.scale}
275
- </span>
276
- </div>
277
- )}
278
- </>
279
- )}
280
- </div>
281
- </div>
282
- )}
215
+ <GeoreferencingPanel georef={georef} modelId={model.id} enableEditing schemaVersion={model.schemaVersion} coordinateInfo={model.geometryResult?.coordinateInfo} geometryResult={model.geometryResult} />
283
216
 
284
217
  {/* IfcProject Data */}
285
218
  {projectData && (
@@ -9,7 +9,9 @@
9
9
  * accounting for multi-model federation offsets and single-model fallback.
10
10
  */
11
11
 
12
- import type { FederatedModel, IfcDataStore } from '@/store/types';
12
+ import type { FederatedModel } from '@/store/types';
13
+ import type { IfcDataStore } from '@ifc-lite/parser';
14
+ import { fromGlobalIdFromModels, toGlobalIdFromModels } from '@/store/globalId';
13
15
 
14
16
  export interface IdLookupResult {
15
17
  expressId: number;
@@ -29,8 +31,10 @@ export function globalIdToExpressId(
29
31
  for (const [modelId, model] of models.entries()) {
30
32
  const localExpressId = model.ifcDataStore?.entities?.getExpressIdByGlobalId(globalIdString);
31
33
  if (localExpressId !== undefined && localExpressId > 0) {
32
- const offset = model.idOffset ?? 0;
33
- return { expressId: localExpressId + offset, modelId };
34
+ return {
35
+ expressId: toGlobalIdFromModels(models, modelId, localExpressId),
36
+ modelId,
37
+ };
34
38
  }
35
39
  }
36
40
  // Single-model fallback
@@ -52,14 +56,12 @@ export function expressIdToGlobalId(
52
56
  models: Map<string, FederatedModel>,
53
57
  ifcDataStore: IfcDataStore | null | undefined,
54
58
  ): string | null {
55
- // Multi-model path: search federated models
56
- for (const model of models.values()) {
57
- const offset = model.idOffset ?? 0;
58
- const localExpressId = expressId - offset;
59
- if (localExpressId > 0 && localExpressId <= (model.maxExpressId ?? Infinity)) {
60
- const globalIdString = model.ifcDataStore?.entities?.getGlobalId(localExpressId);
61
- if (globalIdString) return globalIdString;
62
- }
59
+ // Multi-model path: resolve through the centralized reverse conversion helper
60
+ const resolved = fromGlobalIdFromModels(models, expressId);
61
+ if (resolved && resolved.modelId !== 'legacy') {
62
+ const model = models.get(resolved.modelId);
63
+ const globalIdString = model?.ifcDataStore?.entities?.getGlobalId(resolved.expressId);
64
+ if (globalIdString) return globalIdString;
63
65
  }
64
66
  // Single-model fallback: use legacy ifcDataStore directly
65
67
  if (models.size === 0 && ifcDataStore?.entities) {
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { IDSValidationReport } from '@ifc-lite/ids';
13
13
  import type { GeometryResult } from '@ifc-lite/geometry';
14
+ import { toGlobalIdFromModels } from '../../store/globalId.js';
14
15
 
15
16
  /** RGBA color tuple in 0-1 range */
16
17
  export type ColorTuple = [number, number, number, number];
@@ -66,10 +67,7 @@ export function buildValidationColorUpdates(
66
67
  const globalIdsToUpdate = new Set<number>();
67
68
  for (const specResult of report.specificationResults) {
68
69
  for (const entityResult of specResult.entityResults) {
69
- const model = models.get(entityResult.modelId);
70
- const globalId = model
71
- ? entityResult.expressId + (model.idOffset ?? 0)
72
- : entityResult.expressId;
70
+ const globalId = toGlobalIdFromModels(models, entityResult.modelId, entityResult.expressId);
73
71
  globalIdsToUpdate.add(globalId);
74
72
  }
75
73
  }
@@ -86,10 +84,7 @@ export function buildValidationColorUpdates(
86
84
  // Process all entity results
87
85
  for (const specResult of report.specificationResults) {
88
86
  for (const entityResult of specResult.entityResults) {
89
- const model = models.get(entityResult.modelId);
90
- const globalId = model
91
- ? entityResult.expressId + (model.idOffset ?? 0)
92
- : entityResult.expressId;
87
+ const globalId = toGlobalIdFromModels(models, entityResult.modelId, entityResult.expressId);
93
88
 
94
89
  if (entityResult.passed && displayOptions.highlightPassed) {
95
90
  colorUpdates.set(globalId, passedClr);
@@ -213,12 +213,28 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
213
213
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
214
214
  const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
215
215
  const setIsolatedEntities = useViewerStore((s) => s.setIsolatedEntities);
216
+ const toGlobalId = useViewerStore((s) => s.toGlobalId);
216
217
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
217
218
  const geometryResult = useViewerStore((s) => s.geometryResult);
218
219
 
219
220
  // Ref to store original colors before IDS color overrides
220
221
  const originalColorsRef = useRef<Map<number, ColorTuple>>(new Map());
221
222
 
223
+ const toViewerGlobalId = useCallback((modelId: string, expressId: number): number | undefined => {
224
+ if (
225
+ modelId === '__legacy__'
226
+ || modelId === 'legacy'
227
+ || models.size === 0
228
+ || (models.size === 1 && !models.has(modelId))
229
+ ) {
230
+ return expressId;
231
+ }
232
+ if (!models.has(modelId)) {
233
+ return undefined;
234
+ }
235
+ return toGlobalId(modelId, expressId);
236
+ }, [models, toGlobalId]);
237
+
222
238
  // Ref to access geometryResult without creating callback dependencies (prevents infinite loops)
223
239
  const geometryResultRef = useRef(geometryResult);
224
240
  geometryResultRef.current = geometryResult;
@@ -357,7 +373,7 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
357
373
 
358
374
  // Sync to viewer selection
359
375
  // Handle legacy mode vs federation mode
360
- const isLegacyMode = modelId === '__legacy__' || models.size === 0;
376
+ const isLegacyMode = modelId === '__legacy__' || modelId === 'legacy' || models.size === 0;
361
377
 
362
378
  if (isLegacyMode) {
363
379
  // Legacy mode: globalId equals expressId, use 'legacy' for selection
@@ -365,9 +381,9 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
365
381
  // Use 'legacy' as the modelId for PropertiesPanel compatibility
366
382
  setSelectedEntity({ modelId: 'legacy', expressId });
367
383
  } else {
368
- // Federation mode: convert to globalId using model offset
369
- const model = models.get(modelId);
370
- const globalId = model ? expressId + (model.idOffset ?? 0) : expressId;
384
+ // Federation mode: use the store helper so ID resolution stays centralized.
385
+ const globalId = toViewerGlobalId(modelId, expressId);
386
+ if (globalId == null) return;
371
387
  setSelectedEntityId(globalId);
372
388
  setSelectedEntity({ modelId, expressId });
373
389
  }
@@ -378,7 +394,7 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
378
394
  cameraCallbacks.frameSelection?.();
379
395
  }, 50);
380
396
  }
381
- }, [setIdsActiveEntity, setSelectedEntityId, setSelectedEntity, models, cameraCallbacks]);
397
+ }, [setIdsActiveEntity, setSelectedEntityId, setSelectedEntity, models, cameraCallbacks, toViewerGlobalId]);
382
398
 
383
399
  const clearEntitySelection = useCallback(() => {
384
400
  setIdsActiveEntity(null);
@@ -462,15 +478,14 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
462
478
  const modelId = key.substring(0, lastColonIndex);
463
479
  const expressIdStr = key.substring(lastColonIndex + 1);
464
480
  const expressId = parseInt(expressIdStr, 10);
465
- const model = models.get(modelId);
466
- const globalId = model ? expressId + (model.idOffset ?? 0) : expressId;
467
- failedIds.add(globalId);
481
+ const globalId = toViewerGlobalId(modelId, expressId);
482
+ if (globalId != null) failedIds.add(globalId);
468
483
  }
469
484
 
470
485
  if (failedIds.size > 0) {
471
486
  setIsolatedEntities(failedIds);
472
487
  }
473
- }, [idsFailedEntityIds, models, setIsolatedEntities]);
488
+ }, [idsFailedEntityIds, setIsolatedEntities, toViewerGlobalId]);
474
489
 
475
490
  const isolatePassed = useCallback(() => {
476
491
  const passedIds = new Set<number>();
@@ -480,15 +495,14 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
480
495
  const modelId = key.substring(0, lastColonIndex);
481
496
  const expressIdStr = key.substring(lastColonIndex + 1);
482
497
  const expressId = parseInt(expressIdStr, 10);
483
- const model = models.get(modelId);
484
- const globalId = model ? expressId + (model.idOffset ?? 0) : expressId;
485
- passedIds.add(globalId);
498
+ const globalId = toViewerGlobalId(modelId, expressId);
499
+ if (globalId != null) passedIds.add(globalId);
486
500
  }
487
501
 
488
502
  if (passedIds.size > 0) {
489
503
  setIsolatedEntities(passedIds);
490
504
  }
491
- }, [idsPassedEntityIds, models, setIsolatedEntities]);
505
+ }, [idsPassedEntityIds, setIsolatedEntities, toViewerGlobalId]);
492
506
 
493
507
  const clearIsolation = useCallback(() => {
494
508
  setIsolatedEntities(null);
@@ -632,7 +646,8 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
632
646
  // Find matching model geometry
633
647
  for (const modelData of allMeshData) {
634
648
  if (modelData.modelId === entity.modelId || allMeshData.length === 1) {
635
- const globalExpressId = entity.expressId + modelData.idOffset;
649
+ const globalExpressId = toViewerGlobalId(entity.modelId, entity.expressId);
650
+ if (globalExpressId == null) break;
636
651
  const bounds = getEntityBounds(
637
652
  modelData.meshes as Parameters<typeof getEntityBounds>[0],
638
653
  globalExpressId,
@@ -700,8 +715,8 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
700
715
  if (!bounds) continue;
701
716
 
702
717
  // Find the global expressId for isolation (direct Map lookup)
703
- const model = models.get(entity.modelId);
704
- const globalExpressId = entity.expressId + (model?.idOffset ?? 0);
718
+ const globalExpressId = toViewerGlobalId(entity.modelId, entity.expressId);
719
+ if (globalExpressId == null) continue;
705
720
 
706
721
  // Frame the entity bounds directly via camera (properly centers the object)
707
722
  // duration=1 (not 0) because the animator skips updates when duration===0,
@@ -38,7 +38,7 @@ export interface IfcxDataStore extends IfcDataStore {
38
38
  /** Original buffers for re-composition when adding overlays */
39
39
  _federatedBuffers?: Array<{ buffer: ArrayBuffer; name: string }>;
40
40
  /** Composition statistics */
41
- _compositionStats?: { totalNodes: number; layersUsed: number; inheritanceResolutions: number; crossLayerReferences: number };
41
+ _compositionStats?: { layersUsed: number; inheritanceResolutions: number; crossLayerReferences: number };
42
42
  /** Layer info for display */
43
43
  _layerInfo?: Array<{ id: string; name: string; meshCount: number }>;
44
44
  }
@@ -630,7 +630,7 @@ export function useIfcFederation() {
630
630
  name: b.name,
631
631
  })),
632
632
  _compositionStats: result.compositionStats,
633
- } as IfcxDataStore;
633
+ } as unknown as IfcxDataStore;
634
634
 
635
635
  // IfcxDataStore extends IfcDataStore (with schemaVersion: 'IFC5'), so this is safe
636
636
  setIfcDataStore(dataStore);