@ifc-lite/viewer 1.28.0 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.turbo/turbo-build.log +34 -41
  2. package/CHANGELOG.md +10 -0
  3. package/dist/assets/{basketViewActivator-BNRDNuUJ.js → basketViewActivator-Ce38DhXd.js} +7 -7
  4. package/dist/assets/{bcf-DCwCuP7n.js → bcf-Cv_O3JfD.js} +1 -1
  5. package/dist/assets/{deflate-DNGgs8Ur.js → deflate-HbyMq59o.js} +1 -1
  6. package/dist/assets/drawing-2d-DW98umlt.js +257 -0
  7. package/dist/assets/{exporters-B9v81gi9.js → exporters-BuD3XRzB.js} +463 -416
  8. package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
  9. package/dist/assets/{geotiff-D-YCLS4g.js → geotiff-B2HA8Bwm.js} +10 -10
  10. package/dist/assets/{ids-CCpq-5d3.js → ids-DYUFMd5f.js} +4 -4
  11. package/dist/assets/{ifc-lite_bg-DbgS5EUA.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
  12. package/dist/assets/index-E9wB0zWt.css +1 -0
  13. package/dist/assets/{index-Bgb3_Pu_.js → index-n5O1QJMM.js} +36808 -39415
  14. package/dist/assets/{index.es-CWfqZyyr.js → index.es-BKVIpZgL.js} +8 -8
  15. package/dist/assets/{jpeg-DGOAeUqU.js → jpeg-C7hjKjPX.js} +1 -1
  16. package/dist/assets/{jspdf.es.min-XPLU2Wkq.js → jspdf.es.min-oWlFc42Y.js} +4 -4
  17. package/dist/assets/{lerc-1PMSCHwX.js → lerc-BfIOGhQz.js} +1 -1
  18. package/dist/assets/{lzw-C65U9lNM.js → lzw-B0jRuuW5.js} +1 -1
  19. package/dist/assets/{native-bridge-XxXos6yI.js → native-bridge-DpB-dtEn.js} +5 -2
  20. package/dist/assets/{packbits-BdMWXC3m.js → packbits-DVvBTC39.js} +1 -1
  21. package/dist/assets/{parser.worker-Ddwo3_06.js → parser.worker-BDsWQ6rc.js} +1 -1
  22. package/dist/assets/{pdf-CRwaZf3s.js → pdf-dVIqI5ac.js} +9 -9
  23. package/dist/assets/raw-C0ZJYGmN.js +1 -0
  24. package/dist/assets/{sandbox-0sDo3g3m.js → sandbox-qpJlrNN0.js} +8 -8
  25. package/dist/assets/{server-client-cTCJ-853.js → server-client-DVZ2huNS.js} +1 -1
  26. package/dist/assets/{webimage-BtakWX7W.js → webimage-B394g0Tw.js} +1 -1
  27. package/dist/assets/{xlsx-B1YOg2QB.js → xlsx-D-oHO76J.js} +7 -7
  28. package/dist/assets/{zstd-CmwsbxmM.js → zstd-Bf38MwV2.js} +1 -1
  29. package/dist/index.html +8 -8
  30. package/package.json +5 -5
  31. package/src/App.tsx +1 -3
  32. package/src/components/viewer/BCFPanel.tsx +1 -16
  33. package/src/components/viewer/ChatPanel.tsx +11 -46
  34. package/src/components/viewer/HierarchyPanel.tsx +2 -176
  35. package/src/components/viewer/IDSPanel.tsx +1 -26
  36. package/src/components/viewer/MainToolbar.tsx +75 -185
  37. package/src/components/viewer/MobileToolbar.tsx +1 -9
  38. package/src/components/viewer/PropertiesPanel.tsx +28 -126
  39. package/src/components/viewer/ScriptPanel.tsx +8 -34
  40. package/src/components/viewer/Section2DPanel.tsx +32 -1
  41. package/src/components/viewer/ViewerLayout.tsx +0 -2
  42. package/src/components/viewer/ViewportContainer.tsx +24 -42
  43. package/src/components/viewer/ViewportOverlays.tsx +1 -4
  44. package/src/components/viewer/useGeometryStreaming.ts +0 -2
  45. package/src/hooks/ingest/federationAlign.ts +7 -0
  46. package/src/hooks/useDrawingGeneration.ts +211 -13
  47. package/src/hooks/useIfcCache.ts +94 -41
  48. package/src/hooks/useIfcFederation.ts +2 -3
  49. package/src/hooks/useIfcLoader.ts +10 -1051
  50. package/src/services/cacheService.ts +9 -25
  51. package/src/services/desktop-export.ts +2 -59
  52. package/src/services/file-dialog.ts +8 -142
  53. package/src/store/constants.ts +23 -0
  54. package/src/store/index.ts +3 -5
  55. package/src/store/slices/drawing2DSlice.ts +8 -0
  56. package/src/store/slices/visibilitySlice.ts +22 -1
  57. package/src/store/types.ts +1 -71
  58. package/src/utils/ifcConfig.ts +0 -12
  59. package/vite.config.ts +6 -3
  60. package/DESKTOP_CONTRACT_VERSION +0 -1
  61. package/dist/assets/drawing-2d-D0dDf6Lh.js +0 -257
  62. package/dist/assets/event-B0kAzHa-.js +0 -1
  63. package/dist/assets/geometry.worker-Bpa3115V.js +0 -1
  64. package/dist/assets/index-BtbXFKsX.css +0 -1
  65. package/dist/assets/raw-CJgQdyuZ.js +0 -1
  66. package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
  67. package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
  68. package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
  69. package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
  70. package/src/components/viewer/SettingsPage.tsx +0 -581
  71. package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
  72. package/src/lib/desktop-entitlement.ts +0 -43
  73. package/src/lib/desktop-product.ts +0 -130
  74. package/src/lib/platform.ts +0 -23
  75. package/src/services/desktop-cache.ts +0 -186
  76. package/src/services/desktop-harness.ts +0 -196
  77. package/src/services/desktop-logger.ts +0 -20
  78. package/src/services/desktop-native-metadata.ts +0 -230
  79. package/src/services/desktop-panel-actions.ts +0 -43
  80. package/src/services/desktop-preferences.ts +0 -44
  81. package/src/services/fs-cache.ts +0 -212
  82. package/src/services/tauri-core-stub.ts +0 -7
  83. package/src/services/tauri-dialog-stub.ts +0 -7
  84. package/src/services/tauri-fs-stub.ts +0 -7
  85. package/src/services/tauri-modules.d.ts +0 -50
  86. package/src/store/slices/desktopEntitlementSlice.ts +0 -86
  87. package/src/utils/desktopModelSnapshot.ts +0 -359
  88. package/src/utils/nativeSpatialDataStore.ts +0 -277
  89. package/src-tauri/Cargo.toml +0 -29
  90. package/src-tauri/build.rs +0 -7
  91. package/src-tauri/capabilities/default.json +0 -18
  92. package/src-tauri/icons/128x128.png +0 -0
  93. package/src-tauri/icons/128x128@2x.png +0 -0
  94. package/src-tauri/icons/32x32.png +0 -0
  95. package/src-tauri/icons/Square107x107Logo.png +0 -0
  96. package/src-tauri/icons/Square142x142Logo.png +0 -0
  97. package/src-tauri/icons/Square150x150Logo.png +0 -0
  98. package/src-tauri/icons/Square284x284Logo.png +0 -0
  99. package/src-tauri/icons/Square30x30Logo.png +0 -0
  100. package/src-tauri/icons/Square310x310Logo.png +0 -0
  101. package/src-tauri/icons/Square44x44Logo.png +0 -0
  102. package/src-tauri/icons/Square71x71Logo.png +0 -0
  103. package/src-tauri/icons/Square89x89Logo.png +0 -0
  104. package/src-tauri/icons/StoreLogo.png +0 -0
  105. package/src-tauri/icons/icon.icns +0 -0
  106. package/src-tauri/icons/icon.ico +0 -0
  107. package/src-tauri/icons/icon.png +0 -0
  108. package/src-tauri/src/lib.rs +0 -21
  109. package/src-tauri/src/main.rs +0 -10
  110. package/src-tauri/tauri.conf.json +0 -39
@@ -21,10 +21,34 @@ import {
21
21
  type Drawing2D,
22
22
  type DrawingLine,
23
23
  type SectionConfig,
24
+ type ProfileEntry,
25
+ type MeshOutline2D,
24
26
  } from '@ifc-lite/drawing-2d';
25
27
  import { GeometryProcessor, type GeometryResult } from '@ifc-lite/geometry';
28
+ import * as IfcWasm from '@ifc-lite/wasm';
26
29
  import { customPlaneCenter } from '@/store';
27
30
 
31
+ // The winding-robust Rust `meshOutline2d` binding (issue #979) is gitignored →
32
+ // CI-built, so reference it defensively: against an older wasm bundle it's
33
+ // undefined and projection falls back to the TS mesh silhouette. The wasm
34
+ // module is already initialised (the model loaded through it), so the free
35
+ // function can be called without a GeometryProcessor instance.
36
+ interface MeshOutlineHandle {
37
+ readonly axisMin: number;
38
+ readonly axisMax: number;
39
+ readonly contourCount: number;
40
+ contour(index: number): Float32Array | undefined;
41
+ free(): void;
42
+ }
43
+ type MeshOutline2dFn = (
44
+ positions: Float32Array,
45
+ indices: Uint32Array,
46
+ axis: number,
47
+ flipped: boolean,
48
+ ) => MeshOutlineHandle | undefined;
49
+ const meshOutline2dFn = (IfcWasm as unknown as { meshOutline2d?: MeshOutline2dFn }).meshOutline2d;
50
+ const AXIS_CODE: Record<'x' | 'y' | 'z', number> = { x: 0, y: 1, z: 2 };
51
+
28
52
  // Axis conversion from semantic (down/front/side) to geometric (x/y/z)
29
53
  export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
30
54
  down: 'y',
@@ -61,7 +85,7 @@ interface UseDrawingGenerationParams {
61
85
  bitangent: [number, number, number];
62
86
  };
63
87
  };
64
- displayOptions: { showHiddenLines: boolean; useSymbolicRepresentations: boolean; show3DOverlay: boolean; scale: number };
88
+ displayOptions: { showHiddenLines: boolean; useSymbolicRepresentations: boolean; show3DOverlay: boolean; scale: number; showConstructionProjection: boolean };
65
89
  combinedHiddenIds: Set<number>;
66
90
  combinedIsolatedIds: Set<number> | null;
67
91
  computedIsolatedIds?: Set<number> | null;
@@ -127,6 +151,17 @@ export function useDrawingGeneration({
127
151
  useSymbolic: boolean;
128
152
  } | null>(null);
129
153
 
154
+ // Cache for extracted extruded-solid profiles (issue #979 construction
155
+ // projection). Like symbolic reps these are section-position-independent, so
156
+ // they're parsed once per model and reused across section moves. Every typed
157
+ // array is copied off the WASM heap (`.slice()`) and the WASM handles freed
158
+ // deterministically before caching — caching a live view would dangle once
159
+ // the shared dlmalloc heap grows/reuses (AGENTS.md §7).
160
+ const profileCacheRef = useRef<{
161
+ profiles: ProfileEntry[];
162
+ sourceId: string | null;
163
+ } | null>(null);
164
+
130
165
  // Generate drawing when panel opens
131
166
  const generateDrawing = useCallback(async (isRegenerate = false) => {
132
167
  if (!geometryResult?.meshes || geometryResult.meshes.length === 0) {
@@ -152,7 +187,7 @@ export function useDrawingGeneration({
152
187
  // For multi-model: create cache key from model count and visible model IDs
153
188
  // For single-model: use source byteLength as before
154
189
  const modelCacheKey = models.size > 0
155
- ? `${models.size}-${[...models.values()].filter(m => m.visible).map(m => m.id).sort().join(',')}`
190
+ ? `${models.size}-${[...models.values()].filter(m => m.visible).map(m => m.id).sort().join('|')}`
156
191
  : (ifcDataStore?.source ? String(ifcDataStore.source.byteLength) : null);
157
192
 
158
193
  const useSymbolic = displayOptions.useSymbolicRepresentations && !!ifcDataStore?.source;
@@ -341,6 +376,96 @@ export function useDrawingGeneration({
341
376
  }
342
377
  }
343
378
 
379
+ // Construction projection is plan-only (issue #979): the cut must be the
380
+ // cardinal 'down' axis and not a face-picked custom plane. The UI disables
381
+ // the toggle off-plan, but the persisted flag can stay true when the user
382
+ // switches axis — so gate generation here too, otherwise front/side/custom
383
+ // sections keep emitting projection the user can't turn off.
384
+ const projectionSupported = sectionPlane.axis === 'down' && !sectionPlane.custom;
385
+ const projectionOn = projectionSupported && displayOptions.showConstructionProjection;
386
+
387
+ // ── Construction projection profiles (issue #979) ────────────────────────
388
+ // Extract extruded-area-solid profiles for the clean projection path. Only
389
+ // when projection is on; cached per model since they don't move with the
390
+ // section. Single-model (modelIndex 0) for now, mirroring the symbolic
391
+ // path's federation limitation.
392
+ let profiles: ProfileEntry[] = [];
393
+ if (projectionOn && ifcDataStore?.source) {
394
+ const pcache = profileCacheRef.current;
395
+ if (pcache && pcache.sourceId === modelCacheKey) {
396
+ profiles = pcache.profiles;
397
+ } else {
398
+ if (!isRegenerate) {
399
+ setDrawingProgress(10, 'Extracting profiles...');
400
+ }
401
+ try {
402
+ const processor = new GeometryProcessor();
403
+ try {
404
+ await processor.init();
405
+ // ProfileCollection + each ProfileEntryJs are WASM-bindgen handles
406
+ // owning WASM memory. Copy every typed array off the heap with
407
+ // `.slice()` and free each handle deterministically before caching
408
+ // (AGENTS.md §7 — leaking to GC corrupts the shared dlmalloc heap).
409
+ const collection = processor.extractProfiles(ifcDataStore.source, 0);
410
+ if (collection) {
411
+ try {
412
+ // Profiles come back in UNSHIFTED WebGL world space, but the
413
+ // meshes and the section position live in the render frame
414
+ // (issue #945 RTC / large-coordinate shift). Subtract the same
415
+ // shift so projection lines land on the cut geometry for
416
+ // georeferenced models — a no-op for small-coordinate models
417
+ // (AC20). The WASM mesh path subtracts the RTC offset in IFC
418
+ // Z-up then converts to Y-up via (x,y,z)→(x,z,−y), so the Y-up
419
+ // shift is (rtc.x, rtc.z, −rtc.y); the TS path instead
420
+ // subtracts `originShift`, already in Y-up.
421
+ const ci = geometryResult.coordinateInfo;
422
+ const rtc = ci.wasmRtcOffset;
423
+ const shift = rtc
424
+ ? { x: rtc.x, y: rtc.z, z: -rtc.y }
425
+ : ci.originShift;
426
+ const len = collection.length;
427
+ for (let i = 0; i < len; i++) {
428
+ const entry = collection.get(i);
429
+ if (!entry) continue;
430
+ try {
431
+ const transform = entry.transform.slice();
432
+ transform[12] -= shift.x;
433
+ transform[13] -= shift.y;
434
+ transform[14] -= shift.z;
435
+ profiles.push({
436
+ expressId: entry.expressId,
437
+ ifcType: entry.ifcType,
438
+ outerPoints: entry.outerPoints.slice(),
439
+ holeCounts: entry.holeCounts.slice(),
440
+ holePoints: entry.holePoints.slice(),
441
+ transform,
442
+ extrusionDir: entry.extrusionDir.slice(),
443
+ extrusionDepth: entry.extrusionDepth,
444
+ modelIndex: 0,
445
+ });
446
+ } finally {
447
+ entry.free();
448
+ }
449
+ }
450
+ } finally {
451
+ collection.free();
452
+ }
453
+ }
454
+ profileCacheRef.current = { profiles, sourceId: modelCacheKey };
455
+ } finally {
456
+ processor.dispose();
457
+ }
458
+ } catch (error) {
459
+ // Degrade gracefully: the drawing still renders without projection.
460
+ console.warn('Profile extraction failed:', error);
461
+ profiles = [];
462
+ }
463
+ }
464
+ } else if (profileCacheRef.current) {
465
+ // Toggle off: drop the cache so a re-enable re-extracts cleanly.
466
+ profileCacheRef.current = null;
467
+ }
468
+
344
469
  let generator: Drawing2DGenerator | null = null;
345
470
  try {
346
471
  generator = new Drawing2DGenerator();
@@ -359,6 +484,17 @@ export function useDrawingGeneration({
359
484
  // Calculate max depth as half the model extent
360
485
  const maxDepth = (axisMax - axisMin) * 0.5;
361
486
 
487
+ // Construction-projection bands (issue #979). Project the full model
488
+ // extent on each side of the cut and let the band classifier split by
489
+ // side (below → solid, above → dashed). Full extent makes single-storey
490
+ // models with an overhead roof (e.g. AC20) "just work"; multi-storey
491
+ // bleed is naturally scoped when the user isolates a storey (the meshes
492
+ // are already filtered to it below). Flip-invariant: the classifier
493
+ // applies the flip sign itself. Floor at 1mm so a degenerate zero-extent
494
+ // model (or a storey collapsed to a single slab) doesn't yield 0-width
495
+ // bands that cull every element sitting on the plane.
496
+ const fullExtent = Math.max(axisMax - axisMin, 1e-3);
497
+
362
498
  // Adjust progress to account for symbolic parsing phase (0-20%)
363
499
  const progressOffset = symbolicLines.length > 0 ? 20 : 0;
364
500
  const progressScale = symbolicLines.length > 0 ? 0.8 : 1;
@@ -369,6 +505,8 @@ export function useDrawingGeneration({
369
505
  // Create section config
370
506
  const config: SectionConfig = createSectionConfig(axis, position, {
371
507
  projectionDepth: maxDepth,
508
+ projectionBelowDepth: fullExtent,
509
+ projectionAboveDepth: fullExtent,
372
510
  includeHiddenLines: displayOptions.showHiddenLines,
373
511
  scale: displayOptions.scale,
374
512
  });
@@ -434,13 +572,76 @@ export function useDrawingGeneration({
434
572
  return;
435
573
  }
436
574
 
437
- const result = await generator.generate(meshesToProcess, config, {
438
- includeHiddenLines: false, // Disable - causes internal mesh edges
439
- includeProjection: false, // Disable - causes triangulation lines
440
- includeEdges: false, // Disable - causes triangulation lines
441
- mergeLines: true,
442
- onProgress: progressCallback,
443
- });
575
+ // Construction projection (issue #979): when enabled, project geometry
576
+ // beyond the cut. The clean profile path handles extruded solids; the
577
+ // silhouette path (includeEdges) covers non-extruded geometry roofs,
578
+ // stairs, site that has no profile. The below/above band split drives
579
+ // solid vs dashed; hidden-line removal (below `includeHiddenLines`) is an
580
+ // additional occlusion pass the user controls via "show hidden lines".
581
+
582
+ // Apply the SAME hiding/isolation filters to the profiles as to the
583
+ // meshes, so projection respects 3D hiding and storey isolation —
584
+ // otherwise other storeys' profiles project through the plan and the
585
+ // dedup keys (built from profiles) would suppress silhouettes for
586
+ // entities that aren't actually drawn.
587
+ let projectionProfiles = profiles;
588
+ if (projectionOn && profiles.length > 0) {
589
+ if (combinedHiddenIds.size > 0) {
590
+ projectionProfiles = projectionProfiles.filter((p) => !combinedHiddenIds.has(p.expressId));
591
+ }
592
+ if (combinedIsolatedIds !== null) {
593
+ projectionProfiles = projectionProfiles.filter((p) => combinedIsolatedIds.has(p.expressId));
594
+ }
595
+ if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) {
596
+ const isolatedSet = computedIsolatedIds;
597
+ projectionProfiles = projectionProfiles.filter((p) => isolatedSet.has(p.expressId));
598
+ }
599
+ }
600
+
601
+ // Winding-robust outline provider for non-extruded geometry (roofs,
602
+ // stairs, site). Calls the Rust meshOutline2d binding per mesh; each call
603
+ // copies the contour data off the WASM heap and frees the handle inline.
604
+ // Undefined when projection is off or the binding isn't in this wasm
605
+ // build → the generator falls back to the TS mesh silhouette.
606
+ const outlineProvider =
607
+ projectionOn && typeof meshOutline2dFn === 'function'
608
+ ? (mesh: { positions: Float32Array; indices: Uint32Array }, axis: 'x' | 'y' | 'z', flipped: boolean): MeshOutline2D | null => {
609
+ try {
610
+ const handle = meshOutline2dFn(mesh.positions, mesh.indices, AXIS_CODE[axis], flipped);
611
+ if (!handle) return null;
612
+ try {
613
+ const contours: Float32Array[] = [];
614
+ for (let i = 0; i < handle.contourCount; i++) {
615
+ const ring = handle.contour(i);
616
+ if (ring) contours.push(ring.slice()); // copy off the WASM heap
617
+ }
618
+ if (contours.length === 0) return null;
619
+ return { contours, axisMin: handle.axisMin, axisMax: handle.axisMax };
620
+ } finally {
621
+ handle.free();
622
+ }
623
+ } catch {
624
+ return null; // binding unavailable/failed → silhouette fallback
625
+ }
626
+ }
627
+ : undefined;
628
+
629
+ const result = await generator.generate(
630
+ meshesToProcess,
631
+ config,
632
+ {
633
+ // Respect the "show hidden lines" toggle: occlusion can downgrade
634
+ // visible (below-cut) projection lines to dashed. Overhead lines stay
635
+ // dashed regardless (the generator passes them through unchanged).
636
+ includeHiddenLines: projectionOn ? displayOptions.showHiddenLines : false,
637
+ includeProjection: projectionOn,
638
+ includeEdges: projectionOn,
639
+ mergeLines: true,
640
+ outlineProvider,
641
+ onProgress: progressCallback,
642
+ },
643
+ projectionOn ? projectionProfiles : undefined,
644
+ );
444
645
 
445
646
  // If we have symbolic representations, create a hybrid drawing
446
647
  if (symbolicLines.length > 0 && entitiesWithSymbols.size > 0) {
@@ -502,10 +703,7 @@ export function useDrawingGeneration({
502
703
  // The kept window is `−ANNOTATION_VIEW_DEPTH ≤ signedDist ≤ 0` on
503
704
  // the −normal side — the side BELOW a down-looking camera, where
504
705
  // IFC dimensions live (authored at the storey's floor elevation,
505
- // not at the cut height). This DIVERGES from
506
- // `EdgeExtractor.filterEdgesByDepth`, which projects above the
507
- // cut: annotations and projection edges are naturally on opposite
508
- // sides of the cut plane. Flipped sections look at the same world
706
+ // not at the cut height). Flipped sections look at the same world
509
707
  // from the opposite side, so the slab mirrors to
510
708
  // `0 ≤ signedDist ≤ ANNOTATION_VIEW_DEPTH`.
511
709
  //
@@ -13,11 +13,12 @@ import { useCallback } from 'react';
13
13
  import {
14
14
  BinaryCacheWriter,
15
15
  BinaryCacheReader,
16
+ SchemaVersion,
16
17
  type CachedEntityIndexColumns,
17
- type IfcDataStore as CacheDataStore,
18
+ type CacheDataStore,
18
19
  type GeometryData,
19
20
  } from '@ifc-lite/cache';
20
- import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndex, CompactEntityIndexBuilder, extractLengthUnitScale, attachDataStoreAccessors, type IfcDataStore } from '@ifc-lite/parser';
21
+ import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndex, CompactEntityIndexBuilder, extractLengthUnitScale, attachDataStoreAccessors, type IfcDataStore, type IfcStoreData } from '@ifc-lite/parser';
21
22
  import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
22
23
  import type { MeshData } from '@ifc-lite/geometry';
23
24
 
@@ -52,6 +53,63 @@ function buildEntityIndexFromCachedColumns(columns: CachedEntityIndexColumns): I
52
53
  return { byId, byType };
53
54
  }
54
55
 
56
+ /**
57
+ * Build the viewer's runtime {@link IfcDataStore} from a deserialized
58
+ * {@link CacheDataStore}. This is the typed cache→runtime adapter (#952): the
59
+ * data tables (strings/entities/properties/quantities/relationships/
60
+ * spatialHierarchy) are the same `@ifc-lite/data` types in both stores, so the
61
+ * mapping is compiler-checked — there is no `as unknown as IfcDataStore` escape
62
+ * hatch, and a future required store member becomes a compile error here instead
63
+ * of a silent runtime crash. The only field that differs is `schema` (cache) →
64
+ * `schemaVersion` (runtime). Lazy entity/property/quantity accessors are wired
65
+ * via {@link attachDataStoreAccessors}; with a `source` + `entityIndex` they
66
+ * read live, otherwise they fall back to the pre-built cache tables.
67
+ */
68
+ /**
69
+ * Map the cache's numeric {@link SchemaVersion} enum to the runtime store's
70
+ * string schema union. The cache format predates IFC5 (it stores IFC2X3/IFC4/
71
+ * IFC4X3 only), so anything else round-trips as IFC2X3 — matching the inverse
72
+ * mapping the save path uses.
73
+ */
74
+ function cacheSchemaToVersion(schema: SchemaVersion): IfcDataStore['schemaVersion'] {
75
+ switch (schema) {
76
+ case SchemaVersion.IFC4: return 'IFC4';
77
+ case SchemaVersion.IFC4X3: return 'IFC4X3';
78
+ default: return 'IFC2X3';
79
+ }
80
+ }
81
+
82
+ function hydrateCacheStore(
83
+ cacheStore: CacheDataStore,
84
+ extras: {
85
+ source: Uint8Array;
86
+ fileSize: number;
87
+ entityIndex: IfcDataStore['entityIndex'];
88
+ onDemandPropertyMap?: Map<number, number[]>;
89
+ onDemandQuantityMap?: Map<number, number[]>;
90
+ onDemandMaterialMap?: Map<number, number>;
91
+ },
92
+ ): IfcDataStore {
93
+ const storeData: IfcStoreData = {
94
+ schemaVersion: cacheSchemaToVersion(cacheStore.schema),
95
+ entityCount: cacheStore.entityCount,
96
+ fileSize: extras.fileSize,
97
+ parseTime: 0,
98
+ source: extras.source,
99
+ strings: cacheStore.strings,
100
+ entities: cacheStore.entities,
101
+ properties: cacheStore.properties,
102
+ quantities: cacheStore.quantities,
103
+ relationships: cacheStore.relationships,
104
+ entityIndex: extras.entityIndex,
105
+ spatialHierarchy: cacheStore.spatialHierarchy,
106
+ onDemandPropertyMap: extras.onDemandPropertyMap,
107
+ onDemandQuantityMap: extras.onDemandQuantityMap,
108
+ onDemandMaterialMap: extras.onDemandMaterialMap,
109
+ };
110
+ return attachDataStoreAccessors(storeData);
111
+ }
112
+
55
113
  // ============================================================================
56
114
  // Types
57
115
  // ============================================================================
@@ -123,31 +181,29 @@ export function useIfcCache() {
123
181
  const result = await reader.read(cacheResult.buffer);
124
182
  const cacheReadTime = performance.now() - cacheLoadStart;
125
183
 
126
- // Convert cache data store to viewer data store format.
127
- // The cache reader emits a cache-shaped store; this function mutates it
128
- // in place into the parser `IfcDataStore` the viewer requires (adding
129
- // source, entityIndex, on-demand maps, spatialHierarchy). Cast through
130
- // `unknown` to the target shape so the subsequent property writes stay
131
- // type-checked against the parser types.
132
- const dataStore = result.dataStore as unknown as IfcDataStore;
133
-
134
184
  // Restore the source buffer — required for on-demand property extraction
135
185
  // AND the lazy entity accessors (getEntity/getProperties/...). The web
136
- // cache persists `sourceBuffer`; the desktop cache does not, so fall back
137
- // to the freshly read file buffer when the caller provides it. Without a
138
- // source, the accessors can't be attached and a cache hit would crash the
139
- // Properties panel with "store.getEntity is not a function".
186
+ // cache persists `sourceBuffer`; fall back to the freshly read file buffer
187
+ // when the caller provides it. Without a source the accessors return empty
188
+ // (and getProperties falls back to the pre-built cache tables).
189
+ const cacheStore = result.dataStore;
140
190
  const sourceBuffer = cacheResult.sourceBuffer ?? fallbackSourceBuffer;
191
+ let source: Uint8Array = new Uint8Array(0);
192
+ let entityIndex: IfcDataStore['entityIndex'] = { byId: new Map(), byType: new Map() };
193
+ let onDemandPropertyMap: Map<number, number[]> | undefined;
194
+ let onDemandQuantityMap: Map<number, number[]> | undefined;
195
+ let onDemandMaterialMap: Map<number, number> | undefined;
196
+
141
197
  if (sourceBuffer) {
142
- dataStore.source = new Uint8Array(sourceBuffer);
198
+ source = new Uint8Array(sourceBuffer);
143
199
 
144
200
  if (result.entityIndex) {
145
- dataStore.entityIndex = buildEntityIndexFromCachedColumns(result.entityIndex);
201
+ entityIndex = buildEntityIndexFromCachedColumns(result.entityIndex);
146
202
  } else {
147
203
  // Backward compatibility for v3 caches: rebuild byte offsets from the
148
204
  // source once, then future v4 writes persist this section.
149
- const tokenizer = new StepTokenizer(dataStore.source);
150
- const estimatedCount = dataStore.entities?.count ?? 100_000;
205
+ const tokenizer = new StepTokenizer(source);
206
+ const estimatedCount = cacheStore.entities?.count ?? 100_000;
151
207
  const indexBuilder = new CompactEntityIndexBuilder(estimatedCount);
152
208
  const byType = new Map<string, number[]>();
153
209
 
@@ -160,36 +216,33 @@ export function useIfcCache() {
160
216
  }
161
217
  typeList.push(ref.expressId);
162
218
  }
163
- const compactByIdIndex = indexBuilder.build();
164
- dataStore.entityIndex = { byId: compactByIdIndex, byType };
219
+ entityIndex = { byId: indexBuilder.build(), byType };
165
220
  }
166
221
 
167
- // Rebuild on-demand maps from relationships
222
+ // Rebuild on-demand maps from relationships.
168
223
  // Pass entityIndex which contains ALL entity types including IfcPropertySet/IfcElementQuantity
169
- // (the entity table may not include these since they're filtered during fresh parse)
170
- const { onDemandPropertyMap, onDemandQuantityMap, onDemandMaterialMap } = rebuildOnDemandMaps(
171
- dataStore.entities,
172
- dataStore.relationships,
173
- dataStore.entityIndex
174
- );
175
- dataStore.onDemandPropertyMap = onDemandPropertyMap;
176
- dataStore.onDemandQuantityMap = onDemandQuantityMap;
177
- // Materials tab + per-material totals read onDemandMaterialMap; without
178
- // this a cache hit left the Materials grouping empty (#982 follow-up).
179
- dataStore.onDemandMaterialMap = onDemandMaterialMap;
180
-
181
- // Reattach the lazy entity/property/quantity accessors. A freshly parsed
182
- // store carries these (wired by attachDataStoreAccessors), but the cache
183
- // format only serialises data — so a cache-restored store would be
184
- // missing getEntity()/getProperties()/etc. and crash any query path
185
- // (e.g. the Properties panel: "store.getEntity is not a function").
186
- // Safe here: source, entityIndex and the on-demand maps are all set.
187
- attachDataStoreAccessors(dataStore as IfcDataStore);
224
+ // (the entity table may not include these since they're filtered during fresh parse).
225
+ ({ onDemandPropertyMap, onDemandQuantityMap, onDemandMaterialMap } = rebuildOnDemandMaps(
226
+ cacheStore.entities,
227
+ cacheStore.relationships,
228
+ entityIndex
229
+ ));
188
230
  } else {
189
231
  console.warn('[useIfcCache] No source buffer in cache - on-demand property extraction disabled');
190
- dataStore.source = new Uint8Array(0);
191
232
  }
192
233
 
234
+ // Typed cache→runtime hydration (#952): builds the parser-shaped
235
+ // IfcDataStore with compiler-checked field mapping (no `as unknown` cast)
236
+ // and wires the lazy accessors via attachDataStoreAccessors.
237
+ const dataStore = hydrateCacheStore(cacheStore, {
238
+ source,
239
+ fileSize: sourceBuffer?.byteLength ?? 0,
240
+ entityIndex,
241
+ onDemandPropertyMap,
242
+ onDemandQuantityMap,
243
+ onDemandMaterialMap,
244
+ });
245
+
193
246
  // Rebuild spatial hierarchy from cache data (cache doesn't serialize it)
194
247
  // Use SpatialHierarchyBuilder to extract elevations from source buffer
195
248
  if (!dataStore.spatialHierarchy && dataStore.entities && dataStore.relationships) {
@@ -28,7 +28,6 @@ import {
28
28
  convertIfcxMeshes,
29
29
  } from './ingest/viewerModelIngest.js';
30
30
  import { extractModelGeoref, alignGeometryToReference, findReferenceGeorefModel } from './ingest/federationAlign.js';
31
- import { type NativeFileHandle } from '../services/file-dialog.js';
32
31
  import { toast } from '../components/ui/toast.js';
33
32
  import { acquireFederationLoadSlot, releaseFederationLoadSlot } from './federationLoadGate.js';
34
33
 
@@ -56,7 +55,7 @@ export interface IfcxDataStore extends IfcDataStore {
56
55
  export function useIfcFederation(
57
56
  // The ONE canonical loader. Federated adds route through it (target
58
57
  // 'federated') so model #1 and model #N share an identical pipeline.
59
- loadFile: (file: File | NativeFileHandle, target?: import('./useIfcLoader.js').LoadTarget) => Promise<void>,
58
+ loadFile: (file: File, target?: import('./useIfcLoader.js').LoadTarget) => Promise<void>,
60
59
  ) {
61
60
  const {
62
61
  setLoading,
@@ -103,7 +102,7 @@ export function useIfcFederation(
103
102
  * Returns the model ID on success, null on failure
104
103
  */
105
104
  const addModel = useCallback(async (
106
- file: File | NativeFileHandle,
105
+ file: File,
107
106
  options?: {
108
107
  name?: string;
109
108
  modelId?: string;