@ifc-lite/viewer 1.25.0 → 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 (58) hide show
  1. package/.turbo/turbo-build.log +79 -84
  2. package/CHANGELOG.md +60 -0
  3. package/dist/assets/{basketViewActivator-CU8_toGq.js → basketViewActivator-CTgyKI3U.js} +6 -6
  4. package/dist/assets/{bcf-DXGDhw56.js → bcf-7jQby1qi.js} +1 -1
  5. package/dist/assets/{deflate-Bb1_H2Yf.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-y0ZxbRJd.js → geotiff-xZoE8BkO.js} +10 -10
  9. package/dist/assets/{ids-DruUNtfD.js → ids-Cu73hD0Y.js} +21 -21
  10. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  11. package/dist/assets/{index-Dr88ZlSY.js → index-WSbA5iy6.js} +31959 -31608
  12. package/dist/assets/{jpeg-B3_loqFe.js → jpeg-DhwFEbqb.js} +1 -1
  13. package/dist/assets/{lerc-nkwS8ZUe.js → lerc-Dz6BXOVb.js} +1 -1
  14. package/dist/assets/{lzw-D3cW5Wpg.js → lzw-C9z0fG2o.js} +1 -1
  15. package/dist/assets/{native-bridge-BcYJooq8.js → native-bridge-RvDmzO-2.js} +1 -1
  16. package/dist/assets/{packbits-DDN4xzB5.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-DETNEyQb.js → sandbox-DDSZ7rek.js} +2450 -2260
  20. package/dist/assets/{server-client-CmzJOeS7.js → server-client-Ctk8_Bof.js} +1 -1
  21. package/dist/assets/{webimage-CBjgg4up.js → webimage-XFHVyVtC.js} +1 -1
  22. package/dist/assets/{zstd-C8oQ6qdS.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/BCFPanel.tsx +8 -1
  27. package/src/components/viewer/CommandPalette.tsx +5 -1
  28. package/src/components/viewer/MainToolbar.tsx +41 -19
  29. package/src/components/viewer/Section2DPanel.tsx +6 -2
  30. package/src/components/viewer/Viewport.tsx +48 -3
  31. package/src/components/viewer/bcf/BCFTopicDetail.tsx +24 -0
  32. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  33. package/src/components/viewer/useGeometryStreaming.ts +113 -18
  34. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  35. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  36. package/src/hooks/ingest/viewerModelIngest.ts +55 -11
  37. package/src/hooks/useBCF.ts +98 -16
  38. package/src/hooks/useDrawingGeneration.ts +149 -3
  39. package/src/hooks/useIfcCache.ts +44 -18
  40. package/src/hooks/useIfcLoader.ts +1 -23
  41. package/src/hooks/useSymbolicAnnotations.ts +240 -61
  42. package/src/store/constants.ts +19 -3
  43. package/src/store/index.ts +1 -0
  44. package/src/store/slices/visibilitySlice.ts +2 -1
  45. package/src/store/types.ts +9 -0
  46. package/src/utils/serverDataModel.test.ts +51 -1
  47. package/src/utils/serverDataModel.ts +2 -26
  48. package/vite.config.ts +0 -5
  49. package/dist/assets/exporters-DZhLN0ux.js +0 -5957
  50. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +0 -7
  51. package/dist/assets/geometry.worker-B62e03Ao.js +0 -1
  52. package/dist/assets/ifc-lite-Ch2T9pP9.js +0 -7
  53. package/dist/assets/ifc-lite_bg-D7O1WHgP.wasm +0 -0
  54. package/dist/assets/ifc-lite_bg-iH_07wf8.wasm +0 -0
  55. package/dist/assets/parser.worker-BW1IMUed.js +0 -182
  56. package/dist/assets/raw-CoIXstQ-.js +0 -1
  57. package/dist/assets/wasm-bridge-CT7mK9W0.js +0 -1
  58. package/dist/assets/workerHelpers-IEQDo8r3.js +0 -36
@@ -60,10 +60,9 @@ export interface AnnotationText2D {
60
60
  /**
61
61
  * When true, the renderer rebuilds the glyph quad in screen-aligned
62
62
  * (cameraRight, cameraUp) basis so the text always faces the camera.
63
- * Set for IfcGridAxis tags they must stay readable in top-down/ground
64
- * views where the authored world-Y up axis collapses to zero on-screen.
65
- * Defaults to false (authored, in-plane text matches BIMvision for
66
- * dimension/leader annotations that lie flat on the floor).
63
+ * Set for every annotation literal (grid bubbles, dimension callouts,
64
+ * leader labels) so they stay legible in any view flat-in-plane text
65
+ * collapses to a sliver at oblique angles (issue #812). Defaults to false.
67
66
  */
68
67
  billboard?: boolean;
69
68
  /** sRGB straight-alpha tint (0..1). Defaults to renderer near-black. */
@@ -92,13 +91,31 @@ export interface AnnotationFill2D {
92
91
  };
93
92
  }
94
93
 
95
- /** 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
+ */
96
107
  interface ParseResult {
108
+ // IfcAnnotation buckets
97
109
  byStorey: Map<number, AnnotationsForStorey>;
98
- /** Annotations with no resolvable storey — shown on every floor as a fallback. */
99
110
  loose: DrawingLine2D[];
100
111
  looseTexts: AnnotationText2D[];
101
112
  looseFills: AnnotationFill2D[];
113
+
114
+ // IfcGridAxis buckets (issue #862)
115
+ gridByStorey: Map<number, AnnotationsForStorey>;
116
+ gridLoose: DrawingLine2D[];
117
+ gridLooseTexts: AnnotationText2D[];
118
+ gridLooseFills: AnnotationFill2D[];
102
119
  }
103
120
 
104
121
  const CIRCLE_SEGMENTS_FULL = 32;
@@ -213,6 +230,10 @@ async function parseAnnotations(
213
230
  loose: [],
214
231
  looseTexts: [],
215
232
  looseFills: [],
233
+ gridByStorey: new Map(),
234
+ gridLoose: [],
235
+ gridLooseTexts: [],
236
+ gridLooseFills: [],
216
237
  };
217
238
  const source = store.source;
218
239
  if (!source || source.byteLength === 0) {
@@ -257,6 +278,7 @@ async function parseAnnotations(
257
278
  const ensureBucket = (
258
279
  expressId: number,
259
280
  primitiveWorldY: number,
281
+ ifcType: string,
260
282
  ): AnnotationsForStorey | null => {
261
283
  let effectiveY: number | null = null;
262
284
  if (Number.isFinite(primitiveWorldY) && primitiveWorldY !== 0) {
@@ -270,7 +292,11 @@ async function parseAnnotations(
270
292
  }
271
293
  if (effectiveY === null) return null;
272
294
  const key = Math.round(effectiveY * 1000);
273
- 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);
274
300
  if (!bucket) {
275
301
  bucket = {
276
302
  storeyId: key,
@@ -279,7 +305,7 @@ async function parseAnnotations(
279
305
  texts: [],
280
306
  fills: [],
281
307
  };
282
- result.byStorey.set(key, bucket);
308
+ storeyMap.set(key, bucket);
283
309
  }
284
310
  return bucket;
285
311
  };
@@ -288,8 +314,9 @@ async function parseAnnotations(
288
314
  const poly = collection.getPolyline(i);
289
315
  if (!poly) continue;
290
316
  if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue;
291
- const bucket = ensureBucket(poly.expressId, poly.worldY);
292
- 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;
293
320
  polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out);
294
321
  }
295
322
 
@@ -297,8 +324,9 @@ async function parseAnnotations(
297
324
  const circle = collection.getCircle(i);
298
325
  if (!circle) continue;
299
326
  if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue;
300
- const bucket = ensureBucket(circle.expressId, circle.worldY);
301
- 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;
302
330
  circleToSegments(
303
331
  circle.centerX,
304
332
  circle.centerY,
@@ -336,13 +364,15 @@ async function parseAnnotations(
336
364
  // Industry-standard line-spacing (CSS line-height ≈ 1.2). Picks up
337
365
  // a little air between rows so descenders don't kiss the next cap.
338
366
  const lineSpacing = perLineHeight * 1.2;
339
- const bucket = ensureBucket(text.expressId, text.worldY);
340
- // IfcGridAxis bubble tags must stay readable in any view orientation
341
- // (top-down, eye-level, oblique). Tag them as billboard so the text
342
- // shader rebuilds the quad in screen-aligned basis at render time.
343
- // Other annotation text (dimensions, leader labels) keeps authored
344
- // orientation those are meant to lie flat in the floor plane.
345
- const isGridTag = text.ifcType === 'IfcGridAxis';
367
+ const bucket = ensureBucket(text.expressId, text.worldY, text.ifcType);
368
+ const looseTextTarget = text.ifcType === 'IfcGridAxis' ? result.gridLooseTexts : result.looseTexts;
369
+ // All annotation text grid bubbles, dimension callouts, leader labels —
370
+ // billboards to the camera so it stays legible in any view orientation
371
+ // (top-down, eye-level, oblique). The shader rebuilds the quad in the
372
+ // screen-aligned basis at render time. Authored orientation is intentionally
373
+ // dropped: at oblique viewing angles, flat-in-plane text becomes a smeared
374
+ // sliver of pixels (issue #812). Anchor + alignment are preserved, so each
375
+ // label still sits at its authored insertion point.
346
376
  // Read per-instance style metadata. WASM emits these for grid
347
377
  // bubble parts (● fill / ○ outline / tag) and reserves them for
348
378
  // future IfcTextStyle resolution on regular annotation text.
@@ -362,11 +392,11 @@ async function parseAnnotations(
362
392
  content: lines[li],
363
393
  alignment: text.alignment,
364
394
  lineYOffset: -li * lineSpacing,
365
- billboard: isGridTag,
395
+ billboard: true,
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
+ );
552
614
  }
553
615
 
554
- for (const bucket of cached.byStorey.values()) {
555
- liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
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);
621
+ }
622
+
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
  /**
@@ -658,43 +747,96 @@ export function useSymbolicAnnotationsForDrawing(params: {
658
747
  if (axis !== 'down') return EMPTY_DRAWING_ANNOTATIONS;
659
748
  void version;
660
749
 
661
- // Section view range in world Y. Matches the convention used by
662
- // `profile-projector.isInProjectionRange`:
663
- // not flipped [sectionPos, sectionPos + viewDepth]
664
- // flipped → [sectionPos - viewDepth, sectionPos]
665
- // We expand the range by a small tolerance so annotations sitting
666
- // exactly on the cut plane still match (storey elevations are
667
- // typically the cut Y).
750
+ // Section view range in world Y.
751
+ //
752
+ // For a floor-plan cut at axis='down' the camera looks DOWN through the
753
+ // cut. "In front of the camera" is therefore the side BELOW the cut —
754
+ // where the floor and authored dimensions sit (IFC convention places
755
+ // dimension annotations at the storey's floor elevation, not at the
756
+ // cut height). The user's complaint: with the slab on the +normal
757
+ // side, you had to scrub the section DOWN into the floor before
758
+ // anything showed, and then the dimensions appeared one storey BELOW
759
+ // the cut. Mirror that — keep the slab on the −normal side for the
760
+ // unflipped down section, and flip it for the reflected-ceiling case.
761
+ //
762
+ // Note this DIVERGES from `profile-projector.isInProjectionRange`,
763
+ // which projects above the cut by default. Annotations live with the
764
+ // storey floor, the projection lives with the upper-storey volume —
765
+ // they're naturally on opposite sides of the cut plane.
766
+ //
767
+ // Tolerance lets annotations authored exactly on the cut plane (e.g.
768
+ // a storey at Z=0 with a section right at the storey datum) survive.
668
769
  const TOL = 1e-3;
669
- const rangeMin = (flipped ? sectionPosWorld - viewDepth : sectionPosWorld) - TOL;
670
- const rangeMax = (flipped ? sectionPosWorld : sectionPosWorld + viewDepth) + TOL;
770
+ const rangeMin = (flipped ? sectionPosWorld : sectionPosWorld - viewDepth) - TOL;
771
+ const rangeMax = (flipped ? sectionPosWorld + viewDepth : sectionPosWorld) + TOL;
671
772
 
672
773
  const lines: DrawingLine2D[] = [];
673
774
  const texts: AnnotationText2D[] = [];
674
775
  const fills: AnnotationFill2D[] = [];
675
776
 
777
+ // The drawing-2d cutter negates the 2D U axis on flipped cardinal cuts
778
+ // (see `projectTo2D` in @ifc-lite/drawing-2d/math.ts and `flipU` in the
779
+ // GPU cutter). Annotation primitives come out of WASM in the cutter's
780
+ // UNFLIPPED basis, so on a flipped section they'd sit beside the model
781
+ // (mirrored across X=0) instead of on top of it — exactly the
782
+ // "dimensions floating to the right of the floor plan" symptom. Mirror
783
+ // X for lines/texts/fills here so they line up with the section cut
784
+ // output drawn underneath. Y stays put (the cutter only flips U).
785
+ const pushLine = flipped
786
+ ? (ln: DrawingLine2D) => lines.push({
787
+ line: {
788
+ start: { x: -ln.line.start.x, y: ln.line.start.y },
789
+ end: { x: -ln.line.end.x, y: ln.line.end.y },
790
+ },
791
+ category: ln.category,
792
+ })
793
+ : (ln: DrawingLine2D) => lines.push(ln);
794
+ const pushText = flipped
795
+ ? (t: AnnotationText2D) => texts.push({ ...t, x: -t.x, dirX: -t.dirX })
796
+ : (t: AnnotationText2D) => texts.push(t);
797
+ const pushFill = flipped
798
+ ? (f: AnnotationFill2D) => {
799
+ const src = f.points;
800
+ const dst = new Float32Array(src.length);
801
+ for (let i = 0; i < src.length; i += 2) {
802
+ dst[i] = -src[i];
803
+ dst[i + 1] = src[i + 1];
804
+ }
805
+ fills.push({ ...f, points: dst });
806
+ }
807
+ : (f: AnnotationFill2D) => fills.push(f);
808
+
676
809
  for (const store of stores) {
677
810
  const key = sourceKey(store);
678
811
  if (!key) continue;
679
812
  const cached = PARSE_CACHE.get(key);
680
813
  if (!cached) continue;
681
814
 
682
- 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) => {
683
820
  const bucketY = resolveBucketY(bucket.storeyElevation, fallbackY);
684
- if (bucketY < rangeMin || bucketY > rangeMax) continue;
685
- for (const ln of bucket.lines) lines.push(ln);
686
- for (const t of bucket.texts) texts.push(t);
687
- for (const f of bucket.fills) fills.push(f);
688
- }
821
+ if (bucketY < rangeMin || bucketY > rangeMax) return;
822
+ for (const ln of bucket.lines) pushLine(ln);
823
+ for (const t of bucket.texts) pushText(t);
824
+ for (const f of bucket.fills) pushFill(f);
825
+ };
826
+ for (const bucket of cached.byStorey.values()) collectBucket(bucket);
827
+ for (const bucket of cached.gridByStorey.values()) collectBucket(bucket);
689
828
 
690
829
  // Loose annotations have no resolvable storey — include them if the
691
830
  // fallback Y lands in the view range. That keeps malformed exports
692
831
  // (e.g. 3DEXPERIENCE files with orphaned storeys) usable when the
693
832
  // user is looking at the storey the fallback resolves to.
694
833
  if (fallbackY >= rangeMin && fallbackY <= rangeMax) {
695
- for (const ln of cached.loose) lines.push(ln);
696
- for (const t of cached.looseTexts) texts.push(t);
697
- for (const f of cached.looseFills) fills.push(f);
834
+ for (const ln of cached.loose) pushLine(ln);
835
+ for (const t of cached.looseTexts) pushText(t);
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);
698
840
  }
699
841
  }
700
842
 
@@ -713,14 +855,24 @@ export function useSymbolicAnnotationsForDrawing(params: {
713
855
  */
714
856
  export function useSymbolicAnnotationsRichData(params: {
715
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;
716
864
  fallbackY?: number;
717
865
  }): { texts: readonly AnnotationText3D[]; fills: readonly AnnotationFill3D[] } {
718
- const { enabled, fallbackY = 0 } = params;
866
+ const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params;
867
+ const effectiveGridEnabled = gridEnabled ?? enabled;
719
868
  const stores = useActiveStores();
720
- 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;
721
873
 
722
874
  return useMemo(() => {
723
- if (!enabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
875
+ if (!enabled && !effectiveGridEnabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
724
876
  void version;
725
877
 
726
878
  const texts: AnnotationText3D[] = [];
@@ -759,18 +911,45 @@ export function useSymbolicAnnotationsRichData(params: {
759
911
  });
760
912
  };
761
913
 
762
- for (const bucket of cached.byStorey.values()) {
763
- const y = resolveBucketY(bucket.storeyElevation, fallbackY);
764
- for (const t of bucket.texts) pushText(t, y);
765
- 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
+ }
766
947
  }
767
- for (const t of cached.looseTexts) pushText(t, fallbackY);
768
- for (const f of cached.looseFills) pushFill(f, fallbackY);
769
948
  }
770
949
 
771
950
  return {
772
951
  texts: texts.length ? texts : EMPTY_TEXTS,
773
952
  fills: fills.length ? fills : EMPTY_FILLS,
774
953
  };
775
- }, [enabled, stores, version, fallbackY]);
954
+ }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]);
776
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
  });