@ifc-lite/viewer 1.27.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 (162) hide show
  1. package/.turbo/turbo-build.log +35 -42
  2. package/CHANGELOG.md +74 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
  5. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  6. package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
  7. package/dist/assets/drawing-2d-DW98umlt.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
  10. package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
  14. package/dist/assets/index-E9wB0zWt.css +1 -0
  15. package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
  24. package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
  26. package/dist/assets/raw-C0ZJYGmN.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
  28. package/dist/assets/server-client-DVZ2huNS.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/App.tsx +1 -3
  35. package/src/components/mcp/playground-dispatcher.ts +3 -0
  36. package/src/components/mcp/playground-files.ts +33 -1
  37. package/src/components/viewer/BCFPanel.tsx +1 -16
  38. package/src/components/viewer/ChatPanel.tsx +11 -46
  39. package/src/components/viewer/CommandPalette.tsx +6 -1
  40. package/src/components/viewer/ComparePanel.tsx +420 -0
  41. package/src/components/viewer/HierarchyPanel.tsx +48 -183
  42. package/src/components/viewer/IDSPanel.tsx +1 -26
  43. package/src/components/viewer/MainToolbar.tsx +94 -187
  44. package/src/components/viewer/MobileToolbar.tsx +1 -9
  45. package/src/components/viewer/PropertiesPanel.tsx +98 -127
  46. package/src/components/viewer/ScriptPanel.tsx +8 -34
  47. package/src/components/viewer/Section2DPanel.tsx +32 -1
  48. package/src/components/viewer/ViewerLayout.tsx +5 -2
  49. package/src/components/viewer/Viewport.tsx +3 -0
  50. package/src/components/viewer/ViewportContainer.tsx +24 -42
  51. package/src/components/viewer/ViewportOverlays.tsx +1 -4
  52. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  53. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  54. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  55. package/src/components/viewer/hierarchy/types.ts +1 -0
  56. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  57. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  58. package/src/components/viewer/useGeometryStreaming.ts +0 -2
  59. package/src/hooks/federationLoadGate.test.ts +12 -2
  60. package/src/hooks/federationLoadGate.ts +9 -2
  61. package/src/hooks/ingest/federationAlign.ts +488 -0
  62. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  63. package/src/hooks/useCompare.ts +0 -0
  64. package/src/hooks/useCompareOverlay.ts +119 -0
  65. package/src/hooks/useDrawingGeneration.ts +234 -14
  66. package/src/hooks/useIfc.ts +1 -1
  67. package/src/hooks/useIfcCache.ts +100 -24
  68. package/src/hooks/useIfcFederation.ts +42 -811
  69. package/src/hooks/useIfcLoader.ts +349 -1517
  70. package/src/hooks/useIfcServer.ts +3 -0
  71. package/src/hooks/useLens.ts +5 -1
  72. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  73. package/src/lib/compare/buildFingerprints.ts +173 -0
  74. package/src/lib/compare/describeChange.ts +0 -0
  75. package/src/lib/compare/geometricData.test.ts +54 -0
  76. package/src/lib/compare/geometricData.ts +37 -0
  77. package/src/lib/compare/overlay.test.ts +99 -0
  78. package/src/lib/compare/overlay.ts +91 -0
  79. package/src/lib/geo/cesium-placement.ts +1 -1
  80. package/src/lib/geo/reproject.ts +4 -1
  81. package/src/lib/llm/script-edit-ops.ts +23 -0
  82. package/src/lib/llm/stream-client.ts +8 -1
  83. package/src/lib/search/result-export.ts +7 -1
  84. package/src/sdk/adapters/export-adapter.ts +6 -1
  85. package/src/services/cacheService.ts +9 -25
  86. package/src/services/desktop-export.ts +2 -59
  87. package/src/services/file-dialog.ts +8 -142
  88. package/src/store/constants.ts +23 -0
  89. package/src/store/globalId.ts +15 -13
  90. package/src/store/index.ts +19 -6
  91. package/src/store/slices/cesiumSlice.ts +8 -1
  92. package/src/store/slices/compareSlice.ts +96 -0
  93. package/src/store/slices/drawing2DSlice.ts +8 -0
  94. package/src/store/slices/lensSlice.ts +8 -0
  95. package/src/store/slices/visibilitySlice.ts +22 -1
  96. package/src/store/types.ts +1 -71
  97. package/src/utils/acquireFileBuffer.test.ts +12 -4
  98. package/src/utils/ifcConfig.ts +0 -12
  99. package/src/utils/loadingUtils.ts +32 -0
  100. package/src/utils/spatialHierarchy.test.ts +53 -1
  101. package/src/utils/spatialHierarchy.ts +42 -2
  102. package/src/vite-env.d.ts +2 -0
  103. package/vite.config.ts +6 -3
  104. package/DESKTOP_CONTRACT_VERSION +0 -1
  105. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  106. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  107. package/dist/assets/event-B0kAzHa-.js +0 -1
  108. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  109. package/dist/assets/index-ajK6D32J.css +0 -1
  110. package/dist/assets/lens-PYsLu_MA.js +0 -1
  111. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  112. package/dist/assets/raw-D9iw0tmc.js +0 -1
  113. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  114. package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
  115. package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
  116. package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
  117. package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
  118. package/src/components/viewer/SettingsPage.tsx +0 -581
  119. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  120. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  121. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  122. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
  123. package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
  124. package/src/lib/desktop-entitlement.ts +0 -43
  125. package/src/lib/desktop-product.ts +0 -130
  126. package/src/lib/platform.ts +0 -23
  127. package/src/services/desktop-cache.ts +0 -186
  128. package/src/services/desktop-harness.ts +0 -196
  129. package/src/services/desktop-logger.ts +0 -20
  130. package/src/services/desktop-native-metadata.ts +0 -230
  131. package/src/services/desktop-panel-actions.ts +0 -43
  132. package/src/services/desktop-preferences.ts +0 -44
  133. package/src/services/fs-cache.ts +0 -212
  134. package/src/services/tauri-core-stub.ts +0 -7
  135. package/src/services/tauri-dialog-stub.ts +0 -7
  136. package/src/services/tauri-fs-stub.ts +0 -7
  137. package/src/services/tauri-modules.d.ts +0 -50
  138. package/src/store/slices/desktopEntitlementSlice.ts +0 -86
  139. package/src/utils/desktopModelSnapshot.ts +0 -358
  140. package/src/utils/nativeSpatialDataStore.ts +0 -277
  141. package/src-tauri/Cargo.toml +0 -29
  142. package/src-tauri/build.rs +0 -7
  143. package/src-tauri/capabilities/default.json +0 -18
  144. package/src-tauri/icons/128x128.png +0 -0
  145. package/src-tauri/icons/128x128@2x.png +0 -0
  146. package/src-tauri/icons/32x32.png +0 -0
  147. package/src-tauri/icons/Square107x107Logo.png +0 -0
  148. package/src-tauri/icons/Square142x142Logo.png +0 -0
  149. package/src-tauri/icons/Square150x150Logo.png +0 -0
  150. package/src-tauri/icons/Square284x284Logo.png +0 -0
  151. package/src-tauri/icons/Square30x30Logo.png +0 -0
  152. package/src-tauri/icons/Square310x310Logo.png +0 -0
  153. package/src-tauri/icons/Square44x44Logo.png +0 -0
  154. package/src-tauri/icons/Square71x71Logo.png +0 -0
  155. package/src-tauri/icons/Square89x89Logo.png +0 -0
  156. package/src-tauri/icons/StoreLogo.png +0 -0
  157. package/src-tauri/icons/icon.icns +0 -0
  158. package/src-tauri/icons/icon.ico +0 -0
  159. package/src-tauri/icons/icon.png +0 -0
  160. package/src-tauri/src/lib.rs +0 -21
  161. package/src-tauri/src/main.rs +0 -10
  162. 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;
@@ -179,18 +214,29 @@ export function useDrawingGeneration({
179
214
  try {
180
215
  await processor.init();
181
216
 
217
+ // SymbolicRepresentationCollection and each getPolyline/getCircle
218
+ // item are wasm-bindgen handles owning WASM memory — free them
219
+ // deterministically (AGENTS.md §7). Leaking them to GC lets the
220
+ // FinalizationRegistry free them later against an already-grown/
221
+ // reused shared dlmalloc heap, corrupting the allocator free-list.
182
222
  const symbolicCollection = processor.parseSymbolicRepresentations(ifcDataStore!.source);
183
223
  // For single-model (legacy) mode, model index is always 0
184
224
  // Multi-model symbolic parsing would require iterating over each model separately
185
225
  const symbolicModelIndex = 0;
186
226
 
187
- if (symbolicCollection && !symbolicCollection.isEmpty) {
227
+ if (symbolicCollection) {
228
+ try {
229
+ if (!symbolicCollection.isEmpty) {
188
230
  // Process polylines
189
231
  for (let i = 0; i < symbolicCollection.polylineCount; i++) {
190
232
  const poly = symbolicCollection.getPolyline(i);
191
233
  if (!poly) continue;
234
+ try {
192
235
 
193
236
  entitiesWithSymbols.add(poly.expressId);
237
+ // poly.points is consumed synchronously within this iteration
238
+ // (centroid sum + segment pushes read scalar values out of it);
239
+ // the array itself is never stored, so no copy is needed.
194
240
  const points = poly.points;
195
241
  const pointCount = poly.pointCount;
196
242
  // WASM exposes `worldY` on every symbolic primitive — the
@@ -249,12 +295,16 @@ export function useDrawingGeneration({
249
295
  worldZ: polyWorldZ,
250
296
  });
251
297
  }
298
+ } finally {
299
+ poly.free();
300
+ }
252
301
  }
253
302
 
254
303
  // Process circles/arcs
255
304
  for (let i = 0; i < symbolicCollection.circleCount; i++) {
256
305
  const circle = symbolicCollection.getCircle(i);
257
306
  if (!circle) continue;
307
+ try {
258
308
 
259
309
  entitiesWithSymbols.add(circle.expressId);
260
310
  const numSegments = circle.isFullCircle ? 32 : 16;
@@ -293,6 +343,13 @@ export function useDrawingGeneration({
293
343
  worldZ: circleWorldZ,
294
344
  });
295
345
  }
346
+ } finally {
347
+ circle.free();
348
+ }
349
+ }
350
+ }
351
+ } finally {
352
+ symbolicCollection.free();
296
353
  }
297
354
  }
298
355
  } finally {
@@ -319,6 +376,96 @@ export function useDrawingGeneration({
319
376
  }
320
377
  }
321
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
+
322
469
  let generator: Drawing2DGenerator | null = null;
323
470
  try {
324
471
  generator = new Drawing2DGenerator();
@@ -337,6 +484,17 @@ export function useDrawingGeneration({
337
484
  // Calculate max depth as half the model extent
338
485
  const maxDepth = (axisMax - axisMin) * 0.5;
339
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
+
340
498
  // Adjust progress to account for symbolic parsing phase (0-20%)
341
499
  const progressOffset = symbolicLines.length > 0 ? 20 : 0;
342
500
  const progressScale = symbolicLines.length > 0 ? 0.8 : 1;
@@ -347,6 +505,8 @@ export function useDrawingGeneration({
347
505
  // Create section config
348
506
  const config: SectionConfig = createSectionConfig(axis, position, {
349
507
  projectionDepth: maxDepth,
508
+ projectionBelowDepth: fullExtent,
509
+ projectionAboveDepth: fullExtent,
350
510
  includeHiddenLines: displayOptions.showHiddenLines,
351
511
  scale: displayOptions.scale,
352
512
  });
@@ -412,13 +572,76 @@ export function useDrawingGeneration({
412
572
  return;
413
573
  }
414
574
 
415
- const result = await generator.generate(meshesToProcess, config, {
416
- includeHiddenLines: false, // Disable - causes internal mesh edges
417
- includeProjection: false, // Disable - causes triangulation lines
418
- includeEdges: false, // Disable - causes triangulation lines
419
- mergeLines: true,
420
- onProgress: progressCallback,
421
- });
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
+ );
422
645
 
423
646
  // If we have symbolic representations, create a hybrid drawing
424
647
  if (symbolicLines.length > 0 && entitiesWithSymbols.size > 0) {
@@ -480,10 +703,7 @@ export function useDrawingGeneration({
480
703
  // The kept window is `−ANNOTATION_VIEW_DEPTH ≤ signedDist ≤ 0` on
481
704
  // the −normal side — the side BELOW a down-looking camera, where
482
705
  // IFC dimensions live (authored at the storey's floor elevation,
483
- // not at the cut height). This DIVERGES from
484
- // `EdgeExtractor.filterEdgesByDepth`, which projects above the
485
- // cut: annotations and projection edges are naturally on opposite
486
- // 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
487
707
  // from the opposite side, so the slab mirrors to
488
708
  // `0 ≤ signedDist ≤ ANNOTATION_VIEW_DEPTH`.
489
709
  //
@@ -82,7 +82,7 @@ export function useIfc() {
82
82
  findModelForEntity,
83
83
  resolveGlobalId,
84
84
  realignFederation,
85
- } = useIfcFederation();
85
+ } = useIfcFederation(loadFile);
86
86
 
87
87
  // Memoize query to prevent recreation on every render
88
88
  // For single-model backward compatibility
@@ -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, 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
  // ============================================================================
@@ -109,7 +167,8 @@ export function useIfcCache() {
109
167
  const loadFromCache = useCallback(async (
110
168
  cacheResult: CacheResult,
111
169
  fileName: string,
112
- cacheKey?: string
170
+ cacheKey?: string,
171
+ fallbackSourceBuffer?: ArrayBufferLike,
113
172
  ): Promise<CacheLoadResult> => {
114
173
  try {
115
174
  const cacheLoadStart = performance.now();
@@ -122,20 +181,29 @@ export function useIfcCache() {
122
181
  const result = await reader.read(cacheResult.buffer);
123
182
  const cacheReadTime = performance.now() - cacheLoadStart;
124
183
 
125
- // Convert cache data store to viewer data store format
126
- const dataStore = result.dataStore as any;
127
-
128
- // Restore source buffer for on-demand property extraction
129
- if (cacheResult.sourceBuffer) {
130
- dataStore.source = new Uint8Array(cacheResult.sourceBuffer);
184
+ // Restore the source buffer required for on-demand property extraction
185
+ // AND the lazy entity accessors (getEntity/getProperties/...). The web
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;
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
+
197
+ if (sourceBuffer) {
198
+ source = new Uint8Array(sourceBuffer);
131
199
 
132
200
  if (result.entityIndex) {
133
- dataStore.entityIndex = buildEntityIndexFromCachedColumns(result.entityIndex);
201
+ entityIndex = buildEntityIndexFromCachedColumns(result.entityIndex);
134
202
  } else {
135
203
  // Backward compatibility for v3 caches: rebuild byte offsets from the
136
204
  // source once, then future v4 writes persist this section.
137
- const tokenizer = new StepTokenizer(dataStore.source);
138
- const estimatedCount = dataStore.entities?.count ?? 100_000;
205
+ const tokenizer = new StepTokenizer(source);
206
+ const estimatedCount = cacheStore.entities?.count ?? 100_000;
139
207
  const indexBuilder = new CompactEntityIndexBuilder(estimatedCount);
140
208
  const byType = new Map<string, number[]>();
141
209
 
@@ -148,25 +216,33 @@ export function useIfcCache() {
148
216
  }
149
217
  typeList.push(ref.expressId);
150
218
  }
151
- const compactByIdIndex = indexBuilder.build();
152
- dataStore.entityIndex = { byId: compactByIdIndex, byType };
219
+ entityIndex = { byId: indexBuilder.build(), byType };
153
220
  }
154
221
 
155
- // Rebuild on-demand maps from relationships
222
+ // Rebuild on-demand maps from relationships.
156
223
  // Pass entityIndex which contains ALL entity types including IfcPropertySet/IfcElementQuantity
157
- // (the entity table may not include these since they're filtered during fresh parse)
158
- const { onDemandPropertyMap, onDemandQuantityMap } = rebuildOnDemandMaps(
159
- dataStore.entities,
160
- dataStore.relationships,
161
- dataStore.entityIndex
162
- );
163
- dataStore.onDemandPropertyMap = onDemandPropertyMap;
164
- dataStore.onDemandQuantityMap = onDemandQuantityMap;
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
+ ));
165
230
  } else {
166
231
  console.warn('[useIfcCache] No source buffer in cache - on-demand property extraction disabled');
167
- dataStore.source = new Uint8Array(0);
168
232
  }
169
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
+
170
246
  // Rebuild spatial hierarchy from cache data (cache doesn't serialize it)
171
247
  // Use SpatialHierarchyBuilder to extract elevations from source buffer
172
248
  if (!dataStore.spatialHierarchy && dataStore.entities && dataStore.relationships) {