@ifc-lite/viewer 1.25.1 → 1.25.2

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 (53) hide show
  1. package/.turbo/turbo-build.log +79 -84
  2. package/CHANGELOG.md +23 -0
  3. package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-CTgyKI3U.js} +6 -6
  4. package/dist/assets/{bcf-DP2AK1-_.js → bcf-7jQby1qi.js} +1 -1
  5. package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cfp9t1Df.js} +1 -1
  6. package/dist/assets/exporters-DfSvJPi4.js +4660 -0
  7. package/dist/assets/geometry.worker-Cyn5BybV.js +1 -0
  8. package/dist/assets/{geotiff-By06vdeL.js → geotiff-xZoE8BkO.js} +10 -10
  9. package/dist/assets/{ids-DDkkb4mo.js → ids-Cu73hD0Y.js} +21 -21
  10. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  11. package/dist/assets/{index-CqBdDOAZ.js → index-WSbA5iy6.js} +34803 -34679
  12. package/dist/assets/{jpeg-B4IBTphL.js → jpeg-DhwFEbqb.js} +1 -1
  13. package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Dz6BXOVb.js} +1 -1
  14. package/dist/assets/{lzw-CtdH775t.js → lzw-C9z0fG2o.js} +1 -1
  15. package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-RvDmzO-2.js} +1 -1
  16. package/dist/assets/{packbits-DG3zn49C.js → packbits-jfwifz7C.js} +1 -1
  17. package/dist/assets/parser.worker-C594dWxH.js +182 -0
  18. package/dist/assets/raw-R2QfzPAR.js +1 -0
  19. package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-DDSZ7rek.js} +2450 -2260
  20. package/dist/assets/{server-client-D9xO_8yX.js → server-client-Ctk8_Bof.js} +1 -1
  21. package/dist/assets/{webimage-_-qCDjkn.js → webimage-XFHVyVtC.js} +1 -1
  22. package/dist/assets/{zstd-DlfgC8gA.js → zstd-3q5qcl5V.js} +1 -1
  23. package/dist/index.html +6 -6
  24. package/package.json +22 -21
  25. package/src/App.tsx +4 -0
  26. package/src/components/viewer/CommandPalette.tsx +5 -1
  27. package/src/components/viewer/MainToolbar.tsx +41 -19
  28. package/src/components/viewer/Viewport.tsx +48 -3
  29. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  30. package/src/components/viewer/useGeometryStreaming.ts +113 -18
  31. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  32. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  33. package/src/hooks/ingest/viewerModelIngest.ts +55 -11
  34. package/src/hooks/useIfcCache.ts +44 -18
  35. package/src/hooks/useIfcLoader.ts +1 -23
  36. package/src/hooks/useSymbolicAnnotations.ts +170 -35
  37. package/src/store/constants.ts +19 -3
  38. package/src/store/index.ts +1 -0
  39. package/src/store/slices/visibilitySlice.ts +2 -1
  40. package/src/store/types.ts +9 -0
  41. package/src/utils/serverDataModel.test.ts +51 -1
  42. package/src/utils/serverDataModel.ts +2 -26
  43. package/vite.config.ts +0 -5
  44. package/dist/assets/exporters-CZe0D8N-.js +0 -5957
  45. package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
  46. package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
  47. package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
  48. package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
  49. package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
  50. package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
  51. package/dist/assets/raw-DY7Y_acr.js +0 -1
  52. package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
  53. package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
@@ -13,10 +13,11 @@ import { useCallback } from 'react';
13
13
  import {
14
14
  BinaryCacheWriter,
15
15
  BinaryCacheReader,
16
+ type CachedEntityIndexColumns,
16
17
  type IfcDataStore as CacheDataStore,
17
18
  type GeometryData,
18
19
  } from '@ifc-lite/cache';
19
- import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndexBuilder, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
20
+ import { SpatialHierarchyBuilder, StepTokenizer, CompactEntityIndex, CompactEntityIndexBuilder, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
20
21
  import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
21
22
  import type { MeshData } from '@ifc-lite/geometry';
22
23
 
@@ -30,6 +31,27 @@ import { calculateStoreyHeights } from '../utils/localParsingUtils.js';
30
31
  export type { CacheResult } from '../services/cacheService.js';
31
32
  export { getCached, setCached, deleteCached } from '../services/cacheService.js';
32
33
 
34
+ function buildEntityIndexFromCachedColumns(columns: CachedEntityIndexColumns): IfcDataStore['entityIndex'] {
35
+ const byId = new CompactEntityIndex(
36
+ columns.ids,
37
+ columns.byteOffsets,
38
+ columns.byteLengths,
39
+ columns.typeIndices,
40
+ columns.typeNames,
41
+ );
42
+ const byType = new Map<string, number[]>();
43
+ for (let i = 0; i < columns.ids.length; i++) {
44
+ const type = columns.typeNames[columns.typeIndices[i]];
45
+ let ids = byType.get(type);
46
+ if (!ids) {
47
+ ids = [];
48
+ byType.set(type, ids);
49
+ }
50
+ ids.push(columns.ids[i]);
51
+ }
52
+ return { byId, byType };
53
+ }
54
+
33
55
  // ============================================================================
34
56
  // Types
35
57
  // ============================================================================
@@ -107,25 +129,28 @@ export function useIfcCache() {
107
129
  if (cacheResult.sourceBuffer) {
108
130
  dataStore.source = new Uint8Array(cacheResult.sourceBuffer);
109
131
 
110
- // Quick scan to rebuild entity index with byte offsets (needed for on-demand extraction).
111
- // Uses CompactEntityIndexBuilder to fill typed arrays directly during the scan,
112
- // avoiding a temporary array of 4.4M+ objects (~350MB for large files).
113
- const tokenizer = new StepTokenizer(dataStore.source);
114
- const estimatedCount = dataStore.entities?.count ?? 100_000;
115
- const indexBuilder = new CompactEntityIndexBuilder(estimatedCount);
116
- const byType = new Map<string, number[]>();
117
-
118
- for (const ref of tokenizer.scanEntitiesFast()) {
119
- indexBuilder.add(ref.expressId, ref.type, ref.offset, ref.length);
120
- let typeList = byType.get(ref.type);
121
- if (!typeList) {
122
- typeList = [];
123
- byType.set(ref.type, typeList);
132
+ if (result.entityIndex) {
133
+ dataStore.entityIndex = buildEntityIndexFromCachedColumns(result.entityIndex);
134
+ } else {
135
+ // Backward compatibility for v3 caches: rebuild byte offsets from the
136
+ // source once, then future v4 writes persist this section.
137
+ const tokenizer = new StepTokenizer(dataStore.source);
138
+ const estimatedCount = dataStore.entities?.count ?? 100_000;
139
+ const indexBuilder = new CompactEntityIndexBuilder(estimatedCount);
140
+ const byType = new Map<string, number[]>();
141
+
142
+ for (const ref of tokenizer.scanEntitiesFast()) {
143
+ indexBuilder.add(ref.expressId, ref.type, ref.offset, ref.length);
144
+ let typeList = byType.get(ref.type);
145
+ if (!typeList) {
146
+ typeList = [];
147
+ byType.set(ref.type, typeList);
148
+ }
149
+ typeList.push(ref.expressId);
124
150
  }
125
- typeList.push(ref.expressId);
151
+ const compactByIdIndex = indexBuilder.build();
152
+ dataStore.entityIndex = { byId: compactByIdIndex, byType };
126
153
  }
127
- const compactByIdIndex = indexBuilder.build();
128
- dataStore.entityIndex = { byId: compactByIdIndex, byType };
129
154
 
130
155
  // Rebuild on-demand maps from relationships
131
156
  // Pass entityIndex which contains ALL entity types including IfcPropertySet/IfcElementQuantity
@@ -254,6 +279,7 @@ export function useIfcCache() {
254
279
  quantities: dataStore.quantities,
255
280
  relationships: dataStore.relationships,
256
281
  spatialHierarchy: dataStore.spatialHierarchy,
282
+ entityIndex: dataStore.entityIndex,
257
283
  };
258
284
 
259
285
  console.log('[useIfcCache] Writing cache buffer...');
@@ -1905,7 +1905,7 @@ export function useIfcLoader() {
1905
1905
  // risking corruption.
1906
1906
  const parserWasmApi = isNativeFileHandle(file) ? undefined : geometryProcessor.getApi();
1907
1907
  return new IfcParser().parseColumnar(buffer, {
1908
- wasmApi: parserWasmApi,
1908
+ wasmApi: parserWasmApi ?? undefined,
1909
1909
  onSpatialReady: onPartialDataStore,
1910
1910
  });
1911
1911
  };
@@ -2032,34 +2032,12 @@ export function useIfcLoader() {
2032
2032
  // When the parser worker is in use, hand the geometry workers the
2033
2033
  // same SAB so we don't pay the file-bytes copy twice.
2034
2034
  const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(buffer);
2035
- // Phase 2 of single-controller-rayon-design.md — opt-in via
2036
- // localStorage so we can A/B compare against the N-worker
2037
- // baseline without rolling out for everyone. Users (and the
2038
- // benchmark harness) flip this with:
2039
- // localStorage.setItem('ifc-lite:single-controller', '1')
2040
- // and reload. Set to anything else (or unset) for the legacy
2041
- // N-worker path. Safe: if the threaded WASM bundle fails to
2042
- // load (no COI, Safari, etc.) the controller worker falls back
2043
- // to per-task serial execution within the controller itself
2044
- // (par_iter without an initialized pool).
2045
- const useSingleController = (() => {
2046
- try {
2047
- return typeof localStorage !== 'undefined'
2048
- && localStorage.getItem('ifc-lite:single-controller') === '1';
2049
- } catch {
2050
- return false;
2051
- }
2052
- })();
2053
- if (useSingleController) {
2054
- console.log('[useIfc] single-controller path enabled (Phase 2)');
2055
- }
2056
2035
  const geometryEvents = shouldUseDesktopStableWasmGeometry
2057
2036
  ? geometryProcessor.processStreaming(geometryView, undefined, dynamicBatchConfig)
2058
2037
  : geometryProcessor.processAdaptive(geometryView, {
2059
2038
  sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
2060
2039
  batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
2061
2040
  existingSab: sharedSource ?? undefined,
2062
- useSingleController,
2063
2041
  // Hand the streaming pre-pass's entity index to the parser
2064
2042
  // worker so it skips a duplicate ~10 s WASM scan. Safe even
2065
2043
  // when the parser falls back to main-thread (instance is
@@ -91,13 +91,31 @@ export interface AnnotationFill2D {
91
91
  };
92
92
  }
93
93
 
94
- /** Cached parse result keyed by source identity. */
94
+ /** Cached parse result keyed by source identity.
95
+ *
96
+ * IfcAnnotation and IfcGridAxis primitives are stored in PARALLEL bucket
97
+ * collections (issue #862). They share the same parse pass and the same
98
+ * storey-resolution logic, but the renderer treats them differently:
99
+ *
100
+ * - Annotation buckets always lift every storey (memory
101
+ * `feedback_3d_annotation_overlay_no_section_filter.md`: the user
102
+ * expects every storey's dimensions to be visible in 3D).
103
+ * - Grid buckets get optional section-plane filtering and an
104
+ * independent visibility toggle, so dense-grid models can hide
105
+ * grids per storey without losing dimensions.
106
+ */
95
107
  interface ParseResult {
108
+ // IfcAnnotation buckets
96
109
  byStorey: Map<number, AnnotationsForStorey>;
97
- /** Annotations with no resolvable storey — shown on every floor as a fallback. */
98
110
  loose: DrawingLine2D[];
99
111
  looseTexts: AnnotationText2D[];
100
112
  looseFills: AnnotationFill2D[];
113
+
114
+ // IfcGridAxis buckets (issue #862)
115
+ gridByStorey: Map<number, AnnotationsForStorey>;
116
+ gridLoose: DrawingLine2D[];
117
+ gridLooseTexts: AnnotationText2D[];
118
+ gridLooseFills: AnnotationFill2D[];
101
119
  }
102
120
 
103
121
  const CIRCLE_SEGMENTS_FULL = 32;
@@ -212,6 +230,10 @@ async function parseAnnotations(
212
230
  loose: [],
213
231
  looseTexts: [],
214
232
  looseFills: [],
233
+ gridByStorey: new Map(),
234
+ gridLoose: [],
235
+ gridLooseTexts: [],
236
+ gridLooseFills: [],
215
237
  };
216
238
  const source = store.source;
217
239
  if (!source || source.byteLength === 0) {
@@ -256,6 +278,7 @@ async function parseAnnotations(
256
278
  const ensureBucket = (
257
279
  expressId: number,
258
280
  primitiveWorldY: number,
281
+ ifcType: string,
259
282
  ): AnnotationsForStorey | null => {
260
283
  let effectiveY: number | null = null;
261
284
  if (Number.isFinite(primitiveWorldY) && primitiveWorldY !== 0) {
@@ -269,7 +292,11 @@ async function parseAnnotations(
269
292
  }
270
293
  if (effectiveY === null) return null;
271
294
  const key = Math.round(effectiveY * 1000);
272
- let bucket = result.byStorey.get(key);
295
+ // Issue #862: IfcGridAxis primitives land in a parallel bucket
296
+ // collection so the renderer can section-clip + visibility-toggle
297
+ // them independently of IfcAnnotation (text/dimension symbols).
298
+ const storeyMap = ifcType === 'IfcGridAxis' ? result.gridByStorey : result.byStorey;
299
+ let bucket = storeyMap.get(key);
273
300
  if (!bucket) {
274
301
  bucket = {
275
302
  storeyId: key,
@@ -278,7 +305,7 @@ async function parseAnnotations(
278
305
  texts: [],
279
306
  fills: [],
280
307
  };
281
- result.byStorey.set(key, bucket);
308
+ storeyMap.set(key, bucket);
282
309
  }
283
310
  return bucket;
284
311
  };
@@ -287,8 +314,9 @@ async function parseAnnotations(
287
314
  const poly = collection.getPolyline(i);
288
315
  if (!poly) continue;
289
316
  if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue;
290
- const bucket = ensureBucket(poly.expressId, poly.worldY);
291
- const out = bucket ? bucket.lines : result.loose;
317
+ const bucket = ensureBucket(poly.expressId, poly.worldY, poly.ifcType);
318
+ const looseTarget = poly.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
319
+ const out = bucket ? bucket.lines : looseTarget;
292
320
  polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out);
293
321
  }
294
322
 
@@ -296,8 +324,9 @@ async function parseAnnotations(
296
324
  const circle = collection.getCircle(i);
297
325
  if (!circle) continue;
298
326
  if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue;
299
- const bucket = ensureBucket(circle.expressId, circle.worldY);
300
- const out = bucket ? bucket.lines : result.loose;
327
+ const bucket = ensureBucket(circle.expressId, circle.worldY, circle.ifcType);
328
+ const looseTarget = circle.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
329
+ const out = bucket ? bucket.lines : looseTarget;
301
330
  circleToSegments(
302
331
  circle.centerX,
303
332
  circle.centerY,
@@ -335,7 +364,8 @@ async function parseAnnotations(
335
364
  // Industry-standard line-spacing (CSS line-height ≈ 1.2). Picks up
336
365
  // a little air between rows so descenders don't kiss the next cap.
337
366
  const lineSpacing = perLineHeight * 1.2;
338
- const bucket = ensureBucket(text.expressId, text.worldY);
367
+ const bucket = ensureBucket(text.expressId, text.worldY, text.ifcType);
368
+ const looseTextTarget = text.ifcType === 'IfcGridAxis' ? result.gridLooseTexts : result.looseTexts;
339
369
  // All annotation text — grid bubbles, dimension callouts, leader labels —
340
370
  // billboards to the camera so it stays legible in any view orientation
341
371
  // (top-down, eye-level, oblique). The shader rebuilds the quad in the
@@ -366,7 +396,7 @@ async function parseAnnotations(
366
396
  color: textColor,
367
397
  targetPx,
368
398
  };
369
- (bucket ? bucket.texts : result.looseTexts).push(t2d);
399
+ (bucket ? bucket.texts : looseTextTarget).push(t2d);
370
400
  }
371
401
  }
372
402
 
@@ -389,8 +419,9 @@ async function parseAnnotations(
389
419
  }
390
420
  : undefined,
391
421
  };
392
- const bucket = ensureBucket(fill.expressId, fill.worldY);
393
- (bucket ? bucket.fills : result.looseFills).push(f2d);
422
+ const bucket = ensureBucket(fill.expressId, fill.worldY, fill.ifcType);
423
+ const looseFillTarget = fill.ifcType === 'IfcGridAxis' ? result.gridLooseFills : result.looseFills;
424
+ (bucket ? bucket.fills : looseFillTarget).push(f2d);
394
425
  }
395
426
  } finally {
396
427
  processor.dispose();
@@ -521,17 +552,48 @@ function resolveBucketY(elevation: number | null, fallbackY: number): number {
521
552
  return elevation === null ? fallbackY : elevation;
522
553
  }
523
554
 
555
+ /** Section-clip parameters for grid lines (issue #862). Grids ARE clipped
556
+ * by the active section plane; IfcAnnotation overlays are NOT (per the
557
+ * feedback_3d_annotation_overlay_no_section_filter memory). When
558
+ * `enabled === false` no clipping happens — grids lift to every storey
559
+ * same as annotations.
560
+ */
561
+ export interface SectionClipForGrid {
562
+ enabled: boolean;
563
+ /** World coord on the cut axis (e.g. world-Y for axis='down'). */
564
+ posWorld: number;
565
+ /** Half-thickness of the visible band around the cut, world units. */
566
+ viewDepth: number;
567
+ /** Cut axis. Only `'down'` performs vertical clipping; other axes pass through unfiltered (grid lines are vertical and don't project meaningfully onto elevation cuts). */
568
+ axis: 'down' | 'front' | 'side';
569
+ }
570
+
524
571
  export function useSymbolicAnnotations(params: {
572
+ /** Enable IfcAnnotation lift (the existing default behaviour). */
525
573
  enabled: boolean;
574
+ /**
575
+ * Enable IfcGrid lift. Independent of `enabled` so a user can hide
576
+ * annotations while keeping grids, or vice versa (issue #862).
577
+ * Defaults to `enabled` so existing call sites that don't set it
578
+ * keep the legacy combined behaviour.
579
+ */
580
+ gridEnabled?: boolean;
581
+ /** Section clipping for grids only — see [`SectionClipForGrid`]. */
582
+ gridSectionClip?: SectionClipForGrid;
526
583
  /** World Y to use for annotations with no resolvable storey. Defaults to 0. */
527
584
  fallbackY?: number;
528
585
  }): Float32Array {
529
- const { enabled, fallbackY = 0 } = params;
586
+ const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params;
587
+ const effectiveGridEnabled = gridEnabled ?? enabled;
530
588
  const stores = useActiveStores();
531
- const version = useAnnotationParseTrigger(enabled, stores);
589
+ // Trigger parse if EITHER subset is enabled — the parse pass is shared.
590
+ const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores);
591
+ const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down';
592
+ const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0;
593
+ const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0;
532
594
 
533
595
  return useMemo(() => {
534
- if (!enabled) return EMPTY_F32;
596
+ if (!enabled && !effectiveGridEnabled) return EMPTY_F32;
535
597
  void version; // depend on parse-completion ticks
536
598
 
537
599
  const verts: number[] = [];
@@ -546,22 +608,49 @@ export function useSymbolicAnnotations(params: {
546
608
  continue;
547
609
  }
548
610
  if (debugEnabled()) {
549
- const buckets = cached.byStorey.size;
550
- const looseLines = cached.loose.length;
551
- console.log(`[annotations] store ${storeIdx}: lifting ${buckets} storey buckets + ${looseLines} loose lines (key=${key}, fallbackY=${fallbackY})`);
611
+ console.log(
612
+ `[annotations] store ${storeIdx}: annotation buckets=${cached.byStorey.size}+${cached.loose.length}loose, grid buckets=${cached.gridByStorey.size}+${cached.gridLoose.length}loose (annot=${enabled}, grid=${effectiveGridEnabled}, clip=${clipEnabled})`,
613
+ );
614
+ }
615
+
616
+ if (enabled) {
617
+ for (const bucket of cached.byStorey.values()) {
618
+ liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
619
+ }
620
+ liftTo3DLineList(cached.loose, fallbackY, verts);
552
621
  }
553
622
 
554
- for (const bucket of cached.byStorey.values()) {
555
- liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
623
+ if (effectiveGridEnabled) {
624
+ // Issue #862: section-clip grid buckets only — IfcAnnotation
625
+ // intentionally bypasses this per the feedback memory ("the
626
+ // user expects every storey's dimensions/grid bubbles to lift
627
+ // into the viewport when [the annotation toggle is] on, even
628
+ // while a section cut is active").
629
+ if (clipEnabled) {
630
+ const lo = clipPos - clipDepth;
631
+ const hi = clipPos + clipDepth;
632
+ for (const bucket of cached.gridByStorey.values()) {
633
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
634
+ if (y < lo || y > hi) continue;
635
+ liftTo3DLineList(bucket.lines, y, verts);
636
+ }
637
+ if (fallbackY >= lo && fallbackY <= hi) {
638
+ liftTo3DLineList(cached.gridLoose, fallbackY, verts);
639
+ }
640
+ } else {
641
+ for (const bucket of cached.gridByStorey.values()) {
642
+ liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
643
+ }
644
+ liftTo3DLineList(cached.gridLoose, fallbackY, verts);
645
+ }
556
646
  }
557
- liftTo3DLineList(cached.loose, fallbackY, verts);
558
647
  storeIdx++;
559
648
  }
560
649
 
561
650
  if (debugEnabled()) console.log(`[annotations] total 3D line vertices: ${verts.length / 3} from ${stores.length} stores`);
562
651
  if (verts.length === 0) return EMPTY_F32;
563
652
  return new Float32Array(verts);
564
- }, [enabled, stores, version, fallbackY]);
653
+ }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]);
565
654
  }
566
655
 
567
656
  /**
@@ -723,13 +812,19 @@ export function useSymbolicAnnotationsForDrawing(params: {
723
812
  const cached = PARSE_CACHE.get(key);
724
813
  if (!cached) continue;
725
814
 
726
- for (const bucket of cached.byStorey.values()) {
815
+ // Drawing-2D pulls BOTH annotation and grid buckets (issue #862
816
+ // split them at parse time so the 3D viewport can clip them
817
+ // separately — the 2D Section panel still wants the combined
818
+ // overlay).
819
+ const collectBucket = (bucket: AnnotationsForStorey) => {
727
820
  const bucketY = resolveBucketY(bucket.storeyElevation, fallbackY);
728
- if (bucketY < rangeMin || bucketY > rangeMax) continue;
821
+ if (bucketY < rangeMin || bucketY > rangeMax) return;
729
822
  for (const ln of bucket.lines) pushLine(ln);
730
823
  for (const t of bucket.texts) pushText(t);
731
824
  for (const f of bucket.fills) pushFill(f);
732
- }
825
+ };
826
+ for (const bucket of cached.byStorey.values()) collectBucket(bucket);
827
+ for (const bucket of cached.gridByStorey.values()) collectBucket(bucket);
733
828
 
734
829
  // Loose annotations have no resolvable storey — include them if the
735
830
  // fallback Y lands in the view range. That keeps malformed exports
@@ -739,6 +834,9 @@ export function useSymbolicAnnotationsForDrawing(params: {
739
834
  for (const ln of cached.loose) pushLine(ln);
740
835
  for (const t of cached.looseTexts) pushText(t);
741
836
  for (const f of cached.looseFills) pushFill(f);
837
+ for (const ln of cached.gridLoose) pushLine(ln);
838
+ for (const t of cached.gridLooseTexts) pushText(t);
839
+ for (const f of cached.gridLooseFills) pushFill(f);
742
840
  }
743
841
  }
744
842
 
@@ -757,14 +855,24 @@ export function useSymbolicAnnotationsForDrawing(params: {
757
855
  */
758
856
  export function useSymbolicAnnotationsRichData(params: {
759
857
  enabled: boolean;
858
+ /** Lift grid-bubble texts + fills. Independent of `enabled` (issue #862).
859
+ * Defaults to `enabled` for legacy callers. */
860
+ gridEnabled?: boolean;
861
+ /** Section clipping for grid texts/fills only — same semantics as
862
+ * [`useSymbolicAnnotations`]. */
863
+ gridSectionClip?: SectionClipForGrid;
760
864
  fallbackY?: number;
761
865
  }): { texts: readonly AnnotationText3D[]; fills: readonly AnnotationFill3D[] } {
762
- const { enabled, fallbackY = 0 } = params;
866
+ const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params;
867
+ const effectiveGridEnabled = gridEnabled ?? enabled;
763
868
  const stores = useActiveStores();
764
- const version = useAnnotationParseTrigger(enabled, stores);
869
+ const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores);
870
+ const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down';
871
+ const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0;
872
+ const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0;
765
873
 
766
874
  return useMemo(() => {
767
- if (!enabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
875
+ if (!enabled && !effectiveGridEnabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
768
876
  void version;
769
877
 
770
878
  const texts: AnnotationText3D[] = [];
@@ -803,18 +911,45 @@ export function useSymbolicAnnotationsRichData(params: {
803
911
  });
804
912
  };
805
913
 
806
- for (const bucket of cached.byStorey.values()) {
807
- const y = resolveBucketY(bucket.storeyElevation, fallbackY);
808
- for (const t of bucket.texts) pushText(t, y);
809
- for (const f of bucket.fills) pushFill(f, y);
914
+ if (enabled) {
915
+ for (const bucket of cached.byStorey.values()) {
916
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
917
+ for (const t of bucket.texts) pushText(t, y);
918
+ for (const f of bucket.fills) pushFill(f, y);
919
+ }
920
+ for (const t of cached.looseTexts) pushText(t, fallbackY);
921
+ for (const f of cached.looseFills) pushFill(f, fallbackY);
922
+ }
923
+
924
+ if (effectiveGridEnabled) {
925
+ if (clipEnabled) {
926
+ const lo = clipPos - clipDepth;
927
+ const hi = clipPos + clipDepth;
928
+ for (const bucket of cached.gridByStorey.values()) {
929
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
930
+ if (y < lo || y > hi) continue;
931
+ for (const t of bucket.texts) pushText(t, y);
932
+ for (const f of bucket.fills) pushFill(f, y);
933
+ }
934
+ if (fallbackY >= lo && fallbackY <= hi) {
935
+ for (const t of cached.gridLooseTexts) pushText(t, fallbackY);
936
+ for (const f of cached.gridLooseFills) pushFill(f, fallbackY);
937
+ }
938
+ } else {
939
+ for (const bucket of cached.gridByStorey.values()) {
940
+ const y = resolveBucketY(bucket.storeyElevation, fallbackY);
941
+ for (const t of bucket.texts) pushText(t, y);
942
+ for (const f of bucket.fills) pushFill(f, y);
943
+ }
944
+ for (const t of cached.gridLooseTexts) pushText(t, fallbackY);
945
+ for (const f of cached.gridLooseFills) pushFill(f, fallbackY);
946
+ }
810
947
  }
811
- for (const t of cached.looseTexts) pushText(t, fallbackY);
812
- for (const f of cached.looseFills) pushFill(f, fallbackY);
813
948
  }
814
949
 
815
950
  return {
816
951
  texts: texts.length ? texts : EMPTY_TEXTS,
817
952
  fills: fills.length ? fills : EMPTY_FILLS,
818
953
  };
819
- }, [enabled, stores, version, fallbackY]);
954
+ }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]);
820
955
  }
@@ -159,6 +159,7 @@ export const TYPE_VISIBILITY_STORAGE_KEYS = {
159
159
  openings: 'ifc-lite-ifc-openings-visible',
160
160
  site: 'ifc-lite-ifc-site-visible',
161
161
  ifcAnnotations: 'ifc-lite-ifc-annotations-visible',
162
+ ifcGrid: 'ifc-lite-ifc-grid-visible',
162
163
  } as const;
163
164
 
164
165
  /** Legacy alias — kept until external callers migrate. */
@@ -178,13 +179,16 @@ function readPersistedBool(key: string, fallback: boolean): boolean {
178
179
 
179
180
  // Semantic defaults applied when no localStorage preference is set.
180
181
  // IfcSpace / IfcOpeningElement off — they cover walls and confuse novices
181
- // on first load. IfcSite + IfcAnnotation/IfcGrid on — both convey
182
- // design intent users expect to see by default.
182
+ // on first load. IfcSite + IfcAnnotation + IfcGrid on — all three convey
183
+ // design intent users expect to see by default. (Issue #862 split grid
184
+ // into its own toggle so dense-grid models can hide grids without losing
185
+ // dimensions/labels.)
183
186
  const SEMANTIC_DEFAULTS = {
184
187
  spaces: false,
185
188
  openings: false,
186
189
  site: true,
187
190
  ifcAnnotations: true,
191
+ ifcGrid: true,
188
192
  } as const;
189
193
 
190
194
  export const TYPE_VISIBILITY_DEFAULTS = {
@@ -194,8 +198,20 @@ export const TYPE_VISIBILITY_DEFAULTS = {
194
198
  OPENINGS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, SEMANTIC_DEFAULTS.openings),
195
199
  /** IfcSite visibility — persisted across reloads. */
196
200
  SITE: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, SEMANTIC_DEFAULTS.site),
197
- /** IfcAnnotation + IfcGrid visibility — persisted across reloads. */
201
+ /** IfcAnnotation visibility (text, dimensions, leaders) — persisted. */
198
202
  IFC_ANNOTATIONS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcAnnotations),
203
+ /**
204
+ * IfcGrid visibility (axis lines + bubble tags) — persisted. Issue
205
+ * #862. Migration: if the new key isn't set yet, fall back to the
206
+ * legacy combined `ifcAnnotations` preference. That way a user who
207
+ * previously turned the combined "Annotations & Grids" toggle off
208
+ * keeps grids hidden after upgrade, instead of grids silently
209
+ * reappearing (PR #868 review, chatgpt-codex P2).
210
+ */
211
+ IFC_GRID: readPersistedBool(
212
+ TYPE_VISIBILITY_STORAGE_KEYS.ifcGrid,
213
+ readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcGrid),
214
+ ),
199
215
  } as const;
200
216
 
201
217
  // ============================================================================
@@ -208,6 +208,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
208
208
  openings: TYPE_VISIBILITY_DEFAULTS.OPENINGS,
209
209
  site: TYPE_VISIBILITY_DEFAULTS.SITE,
210
210
  ifcAnnotations: TYPE_VISIBILITY_DEFAULTS.IFC_ANNOTATIONS,
211
+ ifcGrid: TYPE_VISIBILITY_DEFAULTS.IFC_GRID,
211
212
  },
212
213
 
213
214
  // Visibility (multi-model)
@@ -43,7 +43,7 @@ export interface VisibilitySlice {
43
43
  clearAllFilters: () => void;
44
44
  showAll: () => void;
45
45
  isEntityVisible: (id: number) => boolean;
46
- toggleTypeVisibility: (type: 'spaces' | 'openings' | 'site' | 'ifcAnnotations') => void;
46
+ toggleTypeVisibility: (type: 'spaces' | 'openings' | 'site' | 'ifcAnnotations' | 'ifcGrid') => void;
47
47
  /** Set all hidden entities at once (for BCF viewpoint application) */
48
48
  setHiddenEntities: (ids: Set<number>) => void;
49
49
  /** Set all isolated entities at once (for BCF viewpoint with defaultVisibility=false) */
@@ -80,6 +80,7 @@ export const createVisibilitySlice: StateCreator<VisibilitySlice, [], [], Visibi
80
80
  openings: TYPE_VISIBILITY_DEFAULTS.OPENINGS,
81
81
  site: TYPE_VISIBILITY_DEFAULTS.SITE,
82
82
  ifcAnnotations: TYPE_VISIBILITY_DEFAULTS.IFC_ANNOTATIONS,
83
+ ifcGrid: TYPE_VISIBILITY_DEFAULTS.IFC_GRID,
83
84
  },
84
85
 
85
86
  // Initial state (multi-model)
@@ -199,6 +199,15 @@ export interface TypeVisibility {
199
199
  site: boolean;
200
200
  /** IfcAnnotation (2D symbolic curves) - on by default when present */
201
201
  ifcAnnotations: boolean;
202
+ /**
203
+ * IfcGrid axis lines + bubble tags — split from `ifcAnnotations`
204
+ * (issue #862). Default true to match the legacy combined behaviour;
205
+ * users with dense grids that obscure components can hide grids while
206
+ * keeping annotations on. Unlike `ifcAnnotations`, grids are also
207
+ * section-clipped when a 3D section plane is active so each storey's
208
+ * grid lines only show for storeys near the cut.
209
+ */
210
+ ifcGrid: boolean;
202
211
  }
203
212
 
204
213
  // ============================================================================
@@ -5,7 +5,7 @@
5
5
  import assert from 'node:assert/strict';
6
6
  import { describe, it } from 'node:test';
7
7
  import type { DataModel } from '@ifc-lite/server-client';
8
- import { IfcTypeEnum } from '@ifc-lite/data';
8
+ import { IfcTypeEnum, RelationshipType } from '@ifc-lite/data';
9
9
  import { convertServerDataModel, type ServerParseResult } from './serverDataModel';
10
10
 
11
11
  const parseResult: ServerParseResult = {
@@ -87,4 +87,54 @@ describe('convertServerDataModel', () => {
87
87
  assert.deepEqual(dataStore.spatialHierarchy?.getPath(4).map((node) => node.expressId), [1, 2, 3]);
88
88
  assert.deepEqual(dataStore.spatialHierarchy?.byBuilding.get(2), []);
89
89
  });
90
+
91
+ it('uses the canonical parser relationship map for server relationships', () => {
92
+ const dataModel: DataModel = {
93
+ entities: new Map([
94
+ [1, { entity_id: 1, type_name: 'IFCPROJECT', global_id: '0', name: 'Project', has_geometry: false }],
95
+ [2, { entity_id: 2, type_name: 'IFCBUILDING', global_id: '1', name: 'Building', has_geometry: false }],
96
+ [3, { entity_id: 3, type_name: 'IFCDOCUMENTREFERENCE', global_id: '', name: 'Spec', has_geometry: false }],
97
+ ]),
98
+ propertySets: new Map(),
99
+ quantitySets: new Map(),
100
+ relationships: [
101
+ { rel_type: 'IFCRELNESTS', relating_id: 1, related_id: 2 },
102
+ { rel_type: 'IFCRELASSOCIATESDOCUMENT', relating_id: 3, related_id: 2 },
103
+ ],
104
+ spatialHierarchy: {
105
+ nodes: [
106
+ {
107
+ entity_id: 1,
108
+ parent_id: 0,
109
+ level: 0,
110
+ path: 'Project',
111
+ type_name: 'IFCPROJECT',
112
+ name: 'Project',
113
+ children_ids: [2],
114
+ element_ids: [],
115
+ },
116
+ {
117
+ entity_id: 2,
118
+ parent_id: 1,
119
+ level: 1,
120
+ path: 'Project/Building',
121
+ type_name: 'IFCBUILDING',
122
+ name: 'Building',
123
+ children_ids: [],
124
+ element_ids: [],
125
+ },
126
+ ],
127
+ project_id: 1,
128
+ element_to_storey: new Map(),
129
+ element_to_building: new Map(),
130
+ element_to_site: new Map(),
131
+ element_to_space: new Map(),
132
+ },
133
+ };
134
+
135
+ const dataStore = convertServerDataModel(dataModel, parseResult, { size: 1 }, []);
136
+
137
+ assert.deepEqual(dataStore.relationships.getRelated(1, RelationshipType.Aggregates, 'forward'), [2]);
138
+ assert.deepEqual(dataStore.relationships.getRelated(3, RelationshipType.AssociatesDocument, 'forward'), [2]);
139
+ });
90
140
  });