@ifc-lite/viewer 1.25.1 → 1.26.0

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 (75) hide show
  1. package/.turbo/turbo-build.log +83 -85
  2. package/CHANGELOG.md +104 -0
  3. package/dist/assets/{basketViewActivator-Dkn92C04.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-DP2AK1-_.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-BYqYwhkl.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/exporters-DSq76AVM.js +4687 -0
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-By06vdeL.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-DDkkb4mo.js → ids-DiLcGTer.js} +4 -4
  10. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-CqBdDOAZ.js → index-BAH8IJVR.js} +39550 -36936
  13. package/dist/assets/{jpeg-B4IBTphL.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-DQ3jI0Ke.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-CtdH775t.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-DA8wxaN_.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-DG3zn49C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/parser.worker-8md211IW.js +182 -0
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-D1pQT-5R.js → sandbox-CsRXlgCO.js} +4715 -3081
  21. package/dist/assets/{server-client-D9xO_8yX.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-_-qCDjkn.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-DlfgC8gA.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +23 -21
  26. package/src/App.tsx +4 -0
  27. package/src/components/extensions/FlavorDialog.tsx +18 -2
  28. package/src/components/extensions/FlavorListView.tsx +12 -3
  29. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  30. package/src/components/viewer/ClashPanel.tsx +370 -0
  31. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  32. package/src/components/viewer/CommandPalette.tsx +19 -16
  33. package/src/components/viewer/MainToolbar.tsx +155 -153
  34. package/src/components/viewer/ViewerLayout.tsx +5 -0
  35. package/src/components/viewer/Viewport.tsx +97 -12
  36. package/src/components/viewer/ViewportContainer.tsx +45 -3
  37. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  38. package/src/components/viewer/hierarchy/ifc-icons.ts +60 -0
  39. package/src/components/viewer/useGeometryStreaming.ts +134 -19
  40. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +61 -0
  41. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +28 -0
  42. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  43. package/src/hooks/ingest/streamCleanup.ts +45 -0
  44. package/src/hooks/ingest/viewerModelIngest.ts +118 -52
  45. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  46. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  47. package/src/hooks/useAlignmentLines3D.ts +164 -0
  48. package/src/hooks/useClash.ts +420 -0
  49. package/src/hooks/useIfcCache.ts +44 -18
  50. package/src/hooks/useIfcFederation.ts +16 -2
  51. package/src/hooks/useIfcLoader.ts +6 -30
  52. package/src/hooks/useSymbolicAnnotations.ts +170 -35
  53. package/src/lib/clash/persistence.ts +308 -0
  54. package/src/lib/geo/effective-georef.test.ts +66 -0
  55. package/src/services/extensions/host.ts +13 -0
  56. package/src/store/constants.ts +38 -14
  57. package/src/store/index.ts +29 -7
  58. package/src/store/slices/clashSlice.ts +251 -0
  59. package/src/store/slices/visibilitySlice.test.ts +23 -5
  60. package/src/store/slices/visibilitySlice.ts +19 -8
  61. package/src/store/types.ts +9 -0
  62. package/src/utils/serverDataModel.test.ts +51 -1
  63. package/src/utils/serverDataModel.ts +2 -26
  64. package/vite.config.ts +0 -5
  65. package/dist/assets/exporters-CZe0D8N-.js +0 -5957
  66. package/dist/assets/geometry-controller.worker-pD49_fH6.js +0 -7
  67. package/dist/assets/geometry.worker-D4c-06r5.js +0 -1
  68. package/dist/assets/ifc-lite-DxGqDbjO.js +0 -7
  69. package/dist/assets/ifc-lite_bg-BNeu7R_V.wasm +0 -0
  70. package/dist/assets/ifc-lite_bg-DuxUZomW.wasm +0 -0
  71. package/dist/assets/index-Bws3UAkj.css +0 -1
  72. package/dist/assets/parser.worker-BZZcO7DB.js +0 -182
  73. package/dist/assets/raw-DY7Y_acr.js +0 -1
  74. package/dist/assets/wasm-bridge-DMX8Acuf.js +0 -1
  75. package/dist/assets/workerHelpers-Crstj4Oa.js +0 -36
@@ -52,6 +52,7 @@ import {
52
52
  Redo2,
53
53
  } from 'lucide-react';
54
54
  import { Button } from '@/components/ui/button';
55
+ import { Switch } from '@/components/ui/switch';
55
56
  import { Separator } from '@/components/ui/separator';
56
57
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
57
58
  import {
@@ -158,6 +159,39 @@ function ToolButton({
158
159
  );
159
160
  }
160
161
 
162
+ interface ClassVisibilityRowProps {
163
+ /** Colored class glyph (caller sets the tint). */
164
+ icon: React.ReactNode;
165
+ label: string;
166
+ /** One-line plain-language hint about what the IFC class covers. */
167
+ description: string;
168
+ checked: boolean;
169
+ onChange: (next: boolean) => void;
170
+ }
171
+
172
+ /**
173
+ * One row of the Visibility panel: colored class icon + label/description
174
+ * on the left, a Switch on the right. The whole row is a <label>, so a
175
+ * click anywhere toggles the switch and — because it isn't a menu item —
176
+ * the dropdown stays open for flipping several classes in a row. The left
177
+ * cluster dims when off so on/off reads from saturation as well as the
178
+ * switch position.
179
+ */
180
+ function ClassVisibilityRow({ icon, label, description, checked, onChange }: ClassVisibilityRowProps) {
181
+ return (
182
+ <label className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 cursor-pointer hover:bg-muted/50 transition-colors">
183
+ <span className={cn('flex items-center gap-2.5 min-w-0 transition-opacity', !checked && 'opacity-50')}>
184
+ {icon}
185
+ <span className="grid gap-0.5 min-w-0">
186
+ <span className="text-sm leading-tight truncate">{label}</span>
187
+ <span className="text-[10px] leading-tight text-muted-foreground truncate">{description}</span>
188
+ </span>
189
+ </span>
190
+ <Switch checked={checked} onCheckedChange={onChange} />
191
+ </label>
192
+ );
193
+ }
194
+
161
195
  /**
162
196
  * Stacked / Exploded / Solo level display dropdown. Pinned next
163
197
  * to the Quick Floorplan dropdown so storey-related controls
@@ -505,6 +539,16 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
505
539
  const toggleHoverTooltips = useViewerStore((state) => state.toggleHoverTooltips);
506
540
  const typeVisibility = useViewerStore((state) => state.typeVisibility);
507
541
  const toggleTypeVisibility = useViewerStore((state) => state.toggleTypeVisibility);
542
+ const resetTypeVisibility = useViewerStore((state) => state.resetTypeVisibility);
543
+ // How many of the five class toggles are on — surfaced in the menu
544
+ // header so the user sees scene state at a glance.
545
+ const visibleClassCount = [
546
+ typeVisibility.spaces,
547
+ typeVisibility.openings,
548
+ typeVisibility.site,
549
+ typeVisibility.ifcAnnotations,
550
+ typeVisibility.ifcGrid,
551
+ ].filter(Boolean).length;
508
552
  // Issue #540: load-time toggle that asks the WASM bridge to merge
509
553
  // Revit-style multilayer walls. We surface this in the Class
510
554
  // Visibility dropdown so users discover it next to the other
@@ -516,6 +560,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
516
560
  const setBcfPanelVisible = useViewerStore((state) => state.setBcfPanelVisible);
517
561
  const idsPanelVisible = useViewerStore((state) => state.idsPanelVisible);
518
562
  const setIdsPanelVisible = useViewerStore((state) => state.setIdsPanelVisible);
563
+ const clashPanelVisible = useViewerStore((state) => state.clashPanelVisible);
564
+ const setClashPanelVisible = useViewerStore((state) => state.setClashPanelVisible);
519
565
  const listPanelVisible = useViewerStore((state) => state.listPanelVisible);
520
566
  const setListPanelVisible = useViewerStore((state) => state.setListPanelVisible);
521
567
  const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
@@ -562,94 +608,11 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
562
608
  );
563
609
  const desktopShell = isTauri();
564
610
 
565
- // Check which type geometries exist across ALL loaded models (federation-aware).
566
- // PERF: Use meshes.length as dep proxy instead of full geometryResult, and
567
- // scan incrementally once a type is found it stays found, so we only scan
568
- // NEW meshes since the last check. Per-model cursors ensure federated models
569
- // each track their own scan position independently.
570
- const typeGeomScanRef = useRef({
571
- spaces: false, openings: false, site: false,
572
- legacyLastLen: 0,
573
- modelLastLen: new Map<string | number, number>(),
574
- });
575
- const meshLen = geometryResult?.meshes.length ?? 0;
576
- const typeGeometryExists = useMemo(() => {
577
- const scan = typeGeomScanRef.current;
578
-
579
- // Reset if legacy meshes array shrunk (new file loaded)
580
- if (meshLen < scan.legacyLastLen) {
581
- scan.spaces = false;
582
- scan.openings = false;
583
- scan.site = false;
584
- scan.legacyLastLen = 0;
585
- scan.modelLastLen.clear();
586
- }
587
-
588
- // Already found all types — nothing to do
589
- if (scan.spaces && scan.openings && scan.site) {
590
- return { spaces: scan.spaces, openings: scan.openings, site: scan.site };
591
- }
592
-
593
- // Check federated models (scan only new meshes per model)
594
- if (models.size > 0) {
595
- for (const [modelId, model] of models) {
596
- const meshes = model.geometryResult?.meshes;
597
- if (!meshes) continue;
598
- const modelStart = scan.modelLastLen.get(modelId) ?? 0;
599
- // Reset cursor if model was reloaded (mesh array shrunk)
600
- const start = meshes.length < modelStart ? 0 : modelStart;
601
- for (let i = start; i < meshes.length; i++) {
602
- const t = meshes[i].ifcType;
603
- if (t === 'IfcSpace') scan.spaces = true;
604
- else if (t === 'IfcOpeningElement') scan.openings = true;
605
- else if (t === 'IfcSite') scan.site = true;
606
- if (scan.spaces && scan.openings && scan.site) break;
607
- }
608
- scan.modelLastLen.set(modelId, meshes.length);
609
- if (scan.spaces && scan.openings && scan.site) break;
610
- }
611
- }
612
-
613
- // Legacy single-model path (scan only new meshes)
614
- if (geometryResult?.meshes) {
615
- const meshes = geometryResult.meshes;
616
- for (let i = scan.legacyLastLen; i < meshes.length; i++) {
617
- const t = meshes[i].ifcType;
618
- if (t === 'IfcSpace') scan.spaces = true;
619
- else if (t === 'IfcOpeningElement') scan.openings = true;
620
- else if (t === 'IfcSite') scan.site = true;
621
- if (scan.spaces && scan.openings && scan.site) break;
622
- }
623
- }
624
-
625
- scan.legacyLastLen = meshLen;
626
- return { spaces: scan.spaces, openings: scan.openings, site: scan.site };
627
- // eslint-disable-next-line react-hooks/exhaustive-deps -- meshLen is a stable proxy for geometryResult
628
- }, [models, meshLen]);
629
-
630
- // IfcAnnotation has no body mesh, so it can't be detected via the mesh scan.
631
- // Look up the entity table directly. byType keys are uppercase STEP names
632
- // ('IFCANNOTATION') but cache loads sometimes preserve PascalCase too.
633
- // Symbolic 2D overlays cover BOTH IfcAnnotation (text, dimensions, leader
634
- // lines, filled regions) AND IfcGrid (axis lines + synthesized bubble +
635
- // tag). Some files ship only grids (Snowdon Towers Structural is the
636
- // canonical example — no IfcAnnotation at all), so the toggle must
637
- // surface for either entity type or grid-only models get no way to hide
638
- // the overlay.
639
- const hasIfcAnnotations = useMemo(() => {
640
- const has = (store: typeof ifcDataStore | undefined) => {
641
- const byType = store?.entityIndex?.byType;
642
- if (!byType) return false;
643
- return (byType.get('IFCANNOTATION')?.length ?? 0) > 0
644
- || (byType.get('IfcAnnotation')?.length ?? 0) > 0
645
- || (byType.get('IFCGRID')?.length ?? 0) > 0
646
- || (byType.get('IfcGrid')?.length ?? 0) > 0;
647
- };
648
- if (models.size > 0) {
649
- for (const [, m] of models) if (has(m.ifcDataStore)) return true;
650
- }
651
- return has(ifcDataStore);
652
- }, [models, ifcDataStore]);
611
+ // NOTE: The Class Visibility dropdown used to gate each toggle on whether
612
+ // the loaded model actually contained that class (scanning meshes for
613
+ // Spaces/Openings/Site and probing the entity table for Annotations/Grids).
614
+ // That gating was removed: the toggles are persisted user preferences, so
615
+ // they now render unconditionally and stay sticky across models and reloads.
653
616
 
654
617
  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
655
618
  const files = e.target.files;
@@ -798,7 +761,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
798
761
  setScriptPanelVisible,
799
762
  ]);
800
763
 
801
- const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'addElement' | 'extensions') => {
764
+ const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'addElement' | 'extensions') => {
802
765
  if (activeAnalysisExtension?.placement !== 'bottom') {
803
766
  closeActiveAnalysisExtension();
804
767
  }
@@ -815,6 +778,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
815
778
  const nextBcfVisible = panel === 'bcf' ? !bcfPanelVisible : false;
816
779
  const nextIdsVisible = panel === 'ids' ? !idsPanelVisible : false;
817
780
  const nextLensVisible = panel === 'lens' ? !lensPanelVisible : false;
781
+ const nextClashVisible = panel === 'clash' ? !clashPanelVisible : false;
818
782
  const nextExtensionsVisible = panel === 'extensions' ? !extensionsPanelVisible : false;
819
783
  const isAddElementActive = activeTool === 'addElement';
820
784
  const nextAddElementActive = panel === 'addElement' ? !isAddElementActive : false;
@@ -822,6 +786,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
822
786
  setBcfPanelVisible(nextBcfVisible);
823
787
  setIdsPanelVisible(nextIdsVisible);
824
788
  setLensPanelVisible(nextLensVisible);
789
+ setClashPanelVisible(nextClashVisible);
825
790
  setExtensionsPanelVisible(nextExtensionsVisible);
826
791
 
827
792
  if (panel === 'addElement') {
@@ -830,19 +795,21 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
830
795
  setActiveTool('select');
831
796
  }
832
797
 
833
- if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextExtensionsVisible || nextAddElementActive) {
798
+ if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextClashVisible || nextExtensionsVisible || nextAddElementActive) {
834
799
  setRightPanelCollapsed(false);
835
800
  }
836
801
  }, [
837
802
  activeAnalysisExtension?.placement,
838
803
  activeTool,
839
804
  bcfPanelVisible,
805
+ clashPanelVisible,
840
806
  extensionsPanelVisible,
841
807
  idsPanelVisible,
842
808
  lensPanelVisible,
843
809
  requireDesktopFeature,
844
810
  setActiveTool,
845
811
  setBcfPanelVisible,
812
+ setClashPanelVisible,
846
813
  setExtensionsPanelVisible,
847
814
  setIdsPanelVisible,
848
815
  setLensPanelVisible,
@@ -876,6 +843,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
876
843
  setBcfPanelVisible(false);
877
844
  setIdsPanelVisible(false);
878
845
  setLensPanelVisible(false);
846
+ setClashPanelVisible(false);
879
847
  setExtensionsPanelVisible(false);
880
848
  // The right slot is single-tenant: when an analysis extension takes
881
849
  // it over, the AddElement tool must release it too, otherwise its 3D
@@ -890,6 +858,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
890
858
  analysisExtensionState.extensions,
891
859
  setActiveTool,
892
860
  setBcfPanelVisible,
861
+ setClashPanelVisible,
893
862
  setExtensionsPanelVisible,
894
863
  setGanttPanelVisible,
895
864
  setIdsPanelVisible,
@@ -907,6 +876,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
907
876
  if (bcfPanelVisible) panels.add('bcf');
908
877
  if (idsPanelVisible) panels.add('ids');
909
878
  if (lensPanelVisible) panels.add('lens');
879
+ if (clashPanelVisible) panels.add('clash');
910
880
  if (extensionsPanelVisible) panels.add('extensions');
911
881
  if (activeTool === 'addElement') panels.add('addElement');
912
882
  if (analysisExtensionState.activeId) panels.add(analysisExtensionState.activeId);
@@ -915,6 +885,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
915
885
  activeTool,
916
886
  analysisExtensionState.activeId,
917
887
  bcfPanelVisible,
888
+ clashPanelVisible,
918
889
  extensionsPanelVisible,
919
890
  ganttPanelVisible,
920
891
  idsPanelVisible,
@@ -932,6 +903,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
932
903
  if (activeWorkspacePanels.has('bcf')) return 'BCF Issues';
933
904
  if (activeWorkspacePanels.has('ids')) return 'IDS Validation';
934
905
  if (activeWorkspacePanels.has('lens')) return 'Lens Rules';
906
+ if (activeWorkspacePanels.has('clash')) return 'Clash Detection';
935
907
  if (activeWorkspacePanels.has('extensions')) return 'Extensions';
936
908
  if (activeWorkspacePanels.has('addElement')) return 'Add Element';
937
909
  return activeAnalysisExtension?.label ?? 'Analysis';
@@ -1286,6 +1258,13 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1286
1258
  <Palette className="h-4 w-4 mr-2" />
1287
1259
  Lens Rules
1288
1260
  </DropdownMenuCheckboxItem>
1261
+ <DropdownMenuCheckboxItem
1262
+ checked={activeWorkspacePanels.has('clash')}
1263
+ onCheckedChange={() => handleToggleRightPanel('clash')}
1264
+ >
1265
+ <Crosshair className="h-4 w-4 mr-2" />
1266
+ Clash Detection
1267
+ </DropdownMenuCheckboxItem>
1289
1268
  <DropdownMenuSeparator />
1290
1269
  <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1291
1270
  Author
@@ -1521,9 +1500,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1521
1500
  // Stay enabled even with no model loaded — the dropdown
1522
1501
  // also exposes load-time settings (Merge Multilayer
1523
1502
  // Walls) that the user should be able to set BEFORE
1524
- // opening a file. Runtime items inside self-gate via
1525
- // typeGeometryExists.
1526
- aria-label={mergeLayers ? 'Class Visibility (Merge Multilayer Walls is on)' : 'Class Visibility'}
1503
+ // opening a file. The class toggles are persisted
1504
+ // preferences, so they always render too.
1505
+ aria-label={mergeLayers ? 'Visibility (Merge Multilayer Walls is on)' : 'Visibility'}
1527
1506
  className="relative"
1528
1507
  >
1529
1508
  <Filter className="h-4 w-4" />
@@ -1540,70 +1519,93 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1540
1519
  </DropdownMenuTrigger>
1541
1520
  </TooltipTrigger>
1542
1521
  <TooltipContent>
1543
- {mergeLayers ? 'Class Visibility · Merge Multilayer Walls is on' : 'Class Visibility'}
1522
+ {mergeLayers ? 'Visibility · Merge Multilayer Walls is on' : 'Visibility'}
1544
1523
  </TooltipContent>
1545
1524
  </Tooltip>
1546
- <DropdownMenuContent className="w-72">
1547
- {typeGeometryExists.spaces && (
1548
- <DropdownMenuCheckboxItem
1549
- checked={typeVisibility.spaces}
1550
- onCheckedChange={() => toggleTypeVisibility('spaces')}
1551
- >
1552
- <Box className="h-4 w-4 mr-2" style={{ color: '#33d9ff' }} />
1553
- Show Spaces
1554
- </DropdownMenuCheckboxItem>
1555
- )}
1556
- {typeGeometryExists.openings && (
1557
- <DropdownMenuCheckboxItem
1558
- checked={typeVisibility.openings}
1559
- onCheckedChange={() => toggleTypeVisibility('openings')}
1560
- >
1561
- <SquareX className="h-4 w-4 mr-2" style={{ color: '#ff6b4a' }} />
1562
- Show Openings
1563
- </DropdownMenuCheckboxItem>
1564
- )}
1565
- {typeGeometryExists.site && (
1566
- <DropdownMenuCheckboxItem
1567
- checked={typeVisibility.site}
1568
- onCheckedChange={() => toggleTypeVisibility('site')}
1569
- >
1570
- <Building2 className="h-4 w-4 mr-2" style={{ color: '#66cc4d' }} />
1571
- Show Site
1572
- </DropdownMenuCheckboxItem>
1573
- )}
1574
- {hasIfcAnnotations && (
1575
- <DropdownMenuCheckboxItem
1576
- checked={typeVisibility.ifcAnnotations}
1577
- onCheckedChange={() => toggleTypeVisibility('ifcAnnotations')}
1578
- >
1579
- <Pencil className="h-4 w-4 mr-2" style={{ color: '#e4b400' }} />
1580
- Show Annotations & Grids
1581
- </DropdownMenuCheckboxItem>
1582
- )}
1583
-
1584
- {/* Load-time toggles live below the runtime visibility
1585
- switches — they apply on next model open rather than
1586
- affecting the current scene. The subheader makes that
1587
- boundary visible at a glance. */}
1588
- <DropdownMenuSeparator />
1589
- <DropdownMenuLabel className="px-2 pt-1 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
1590
- Load Settings
1591
- </DropdownMenuLabel>
1592
- <DropdownMenuCheckboxItem
1593
- checked={mergeLayers}
1594
- onCheckedChange={(next) => setMergeLayers(next === true)}
1595
- // Use items-start so the checkmark and icon line up with
1596
- // the primary label while the description wraps below.
1597
- className="items-start gap-2 py-2"
1598
- >
1599
- <Layers2 className="h-4 w-4 mr-2 mt-0.5 shrink-0 text-primary" />
1600
- <div className="flex flex-col gap-0.5 min-w-0">
1601
- <span className="text-sm font-medium leading-tight">Merge Multilayer Walls</span>
1602
- <span className="text-[11px] leading-tight text-muted-foreground">
1603
- Render walls as 1 solid · Applies on reload
1525
+ {/*
1526
+ Settings-style panel (not a list of menu-items): each row is a
1527
+ plain <label> wrapping a right-aligned Switch, so toggling does
1528
+ NOT close the menu — users routinely flip several classes in one
1529
+ pass. State reads two ways: the switch position and the row
1530
+ dimming when off. All five render unconditionally (persisted
1531
+ preferences, sticky across models/reloads); toggling a class the
1532
+ model lacks is a no-op.
1533
+ */}
1534
+ <DropdownMenuContent align="start" className="w-[300px] p-1.5">
1535
+ <div className="flex items-center justify-between gap-2 px-1.5 pb-1 pt-0.5">
1536
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
1537
+ Visibility
1538
+ </span>
1539
+ <div className="flex items-center gap-1">
1540
+ <span className="text-[11px] tabular-nums text-muted-foreground/80">
1541
+ {visibleClassCount}/5
1604
1542
  </span>
1543
+ <Button
1544
+ variant="ghost"
1545
+ size="sm"
1546
+ className="h-6 px-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground"
1547
+ onClick={resetTypeVisibility}
1548
+ >
1549
+ Reset
1550
+ </Button>
1605
1551
  </div>
1606
- </DropdownMenuCheckboxItem>
1552
+ </div>
1553
+
1554
+ <ClassVisibilityRow
1555
+ icon={<Box className="h-4 w-4 shrink-0" style={{ color: '#33d9ff' }} />}
1556
+ label="Spaces"
1557
+ description="Room & zone volumes"
1558
+ checked={typeVisibility.spaces}
1559
+ onChange={() => toggleTypeVisibility('spaces')}
1560
+ />
1561
+ <ClassVisibilityRow
1562
+ icon={<SquareX className="h-4 w-4 shrink-0" style={{ color: '#ff6b4a' }} />}
1563
+ label="Openings"
1564
+ description="Door & window voids"
1565
+ checked={typeVisibility.openings}
1566
+ onChange={() => toggleTypeVisibility('openings')}
1567
+ />
1568
+ <ClassVisibilityRow
1569
+ icon={<Building2 className="h-4 w-4 shrink-0" style={{ color: '#66cc4d' }} />}
1570
+ label="Site"
1571
+ description="Terrain & context"
1572
+ checked={typeVisibility.site}
1573
+ onChange={() => toggleTypeVisibility('site')}
1574
+ />
1575
+ <ClassVisibilityRow
1576
+ icon={<Pencil className="h-4 w-4 shrink-0" style={{ color: '#e4b400' }} />}
1577
+ label="Annotations"
1578
+ description="Text, dimensions, leaders"
1579
+ checked={typeVisibility.ifcAnnotations}
1580
+ onChange={() => toggleTypeVisibility('ifcAnnotations')}
1581
+ />
1582
+ <ClassVisibilityRow
1583
+ icon={<Grid3x3 className="h-4 w-4 shrink-0" style={{ color: '#e4b400' }} />}
1584
+ label="Grids"
1585
+ description="Structural axes"
1586
+ checked={typeVisibility.ifcGrid}
1587
+ onChange={() => toggleTypeVisibility('ifcGrid')}
1588
+ />
1589
+
1590
+ <DropdownMenuSeparator className="my-1" />
1591
+
1592
+ {/* Merge multilayer walls rebuilds geometry, so unlike the live
1593
+ toggles above it only takes effect on the next model load.
1594
+ The "· on reload" suffix carries that nuance inline — keeps
1595
+ the row identical in shape to the others (no header, no chip
1596
+ crowding the long label). */}
1597
+ <label className="group flex items-center justify-between gap-3 rounded-md px-2 py-1.5 cursor-pointer hover:bg-muted/50 transition-colors">
1598
+ <span className={cn('flex items-center gap-2.5 min-w-0 transition-opacity', !mergeLayers && 'opacity-50')}>
1599
+ <Layers2 className="h-4 w-4 shrink-0 text-primary" />
1600
+ <span className="grid gap-0.5 min-w-0">
1601
+ <span className="text-sm leading-tight truncate">Merge multilayer walls</span>
1602
+ <span className="text-[10px] leading-tight text-muted-foreground truncate">
1603
+ Render walls as one solid · on reload
1604
+ </span>
1605
+ </span>
1606
+ </span>
1607
+ <Switch checked={mergeLayers} onCheckedChange={(next) => setMergeLayers(next === true)} />
1608
+ </label>
1607
1609
  </DropdownMenuContent>
1608
1610
  </DropdownMenu>
1609
1611
 
@@ -28,6 +28,7 @@ import { HoverTooltip } from './HoverTooltip';
28
28
  import { BCFPanel } from './BCFPanel';
29
29
  import { IDSPanel } from './IDSPanel';
30
30
  import { LensPanel } from './LensPanel';
31
+ import { ClashPanel } from './ClashPanel';
31
32
  import { ListPanel } from './lists/ListPanel';
32
33
  import { ScriptPanel } from './ScriptPanel';
33
34
  import { GanttPanel } from './schedule/GanttPanel';
@@ -132,6 +133,8 @@ export function ViewerLayout() {
132
133
  const setListPanelVisible = useViewerStore((s) => s.setListPanelVisible);
133
134
  const lensPanelVisible = useViewerStore((s) => s.lensPanelVisible);
134
135
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
136
+ const clashPanelVisible = useViewerStore((s) => s.clashPanelVisible);
137
+ const setClashPanelVisible = useViewerStore((s) => s.setClashPanelVisible);
135
138
  const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
136
139
  const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
137
140
  const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
@@ -342,6 +345,8 @@ export function ViewerLayout() {
342
345
  <AddElementPanel onClose={() => setActiveTool('select')} />
343
346
  ) : lensPanelVisible ? (
344
347
  <LensPanel onClose={() => setLensPanelVisible(false)} />
348
+ ) : clashPanelVisible ? (
349
+ <ClashPanel onClose={() => setClashPanelVisible(false)} />
345
350
  ) : idsPanelVisible ? (
346
351
  <IDSPanel onClose={() => setIdsPanelVisible(false)} />
347
352
  ) : bcfPanelVisible ? (
@@ -40,7 +40,12 @@ import { useGeometryStreaming } from './useGeometryStreaming.js';
40
40
  import { usePointCloudSync } from './usePointCloudSync.js';
41
41
  import { usePointCloudLifecycle } from './usePointCloudLifecycle.js';
42
42
  import { useRenderUpdates } from './useRenderUpdates.js';
43
- import { useSymbolicAnnotations, useSymbolicAnnotationsRichData } from '../../hooks/useSymbolicAnnotations.js';
43
+ import {
44
+ useSymbolicAnnotations,
45
+ useSymbolicAnnotationsRichData,
46
+ type SectionClipForGrid,
47
+ } from '../../hooks/useSymbolicAnnotations.js';
48
+ import { useAlignmentLines3D } from '../../hooks/useAlignmentLines3D.js';
44
49
 
45
50
  interface ViewportProps {
46
51
  geometry: MeshData[] | null;
@@ -622,8 +627,19 @@ export function Viewport({
622
627
  calculateScale();
623
628
  },
624
629
  home: () => {
625
- // Reset to isometric view
626
- camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
630
+ // Adaptive home: compact buildings get the historical SE isometric
631
+ // pose (1:1 with the old behaviour), linear infrastructure gets a
632
+ // side-on view at a distance where signals / referents are visible
633
+ // instead of receding to sub-pixel. The policy is computed from
634
+ // the current bbox shape so a federation that swaps from one
635
+ // building to a railway picks the right pose on Home press.
636
+ // See packages/renderer/src/camera-fit-policy.ts.
637
+ const canvas = rendererRef.current?.getCanvas();
638
+ const canvasShort = Math.min(canvas?.height ?? 0, canvas?.width ?? 0);
639
+ camera.fitBoundsAdaptive(
640
+ { min: geometryBoundsRef.current.min, max: geometryBoundsRef.current.max },
641
+ { animate: true, duration: 500, viewportShortPx: canvasShort > 0 ? canvasShort : undefined },
642
+ );
627
643
  calculateScale();
628
644
  },
629
645
  zoomIn: () => {
@@ -637,19 +653,43 @@ export function Viewport({
637
653
  calculateScale();
638
654
  },
639
655
  frameSelection: () => {
640
- // Frame selection - zoom to fit selected element
641
- const selectedId = selectedEntityIdRef.current;
656
+ // Frame the current selection. Prefer the full multi-selection set
657
+ // (Ctrl-click, box-select, a clash pair) so the camera encloses EVERY
658
+ // selected element; fall back to the single primary id. The set is
659
+ // kept in sync with selection (cleared on a plain click), so the
660
+ // union is always an accurate frame of what's highlighted.
642
661
  const geom = geometryRef.current;
643
- if (selectedId !== null && geom) {
644
- const bounds = getEntityBounds(geom, selectedId);
645
- if (bounds) {
646
- camera.frameBounds(bounds.min, bounds.max, 300);
647
- calculateScale();
662
+ const set = selectedEntityIdsRef.current;
663
+ const single = selectedEntityIdRef.current;
664
+ const ids = set && set.size > 0
665
+ ? Array.from(set)
666
+ : single !== null ? [single] : [];
667
+ if (!geom || ids.length === 0) {
668
+ console.warn('[Viewport] frameSelection: No selection or geometry');
669
+ return;
670
+ }
671
+ let min: { x: number; y: number; z: number } | null = null;
672
+ let max: { x: number; y: number; z: number } | null = null;
673
+ for (const id of ids) {
674
+ const b = getEntityBounds(geom, id);
675
+ if (!b) continue;
676
+ if (!min || !max) {
677
+ min = { x: b.min.x, y: b.min.y, z: b.min.z };
678
+ max = { x: b.max.x, y: b.max.y, z: b.max.z };
648
679
  } else {
649
- console.warn('[Viewport] frameSelection: Could not get bounds for selected element');
680
+ min.x = Math.min(min.x, b.min.x);
681
+ min.y = Math.min(min.y, b.min.y);
682
+ min.z = Math.min(min.z, b.min.z);
683
+ max.x = Math.max(max.x, b.max.x);
684
+ max.y = Math.max(max.y, b.max.y);
685
+ max.z = Math.max(max.z, b.max.z);
650
686
  }
687
+ }
688
+ if (min && max) {
689
+ camera.frameBounds(min, max, 300);
690
+ calculateScale();
651
691
  } else {
652
- console.warn('[Viewport] frameSelection: No selection or geometry');
692
+ console.warn('[Viewport] frameSelection: Could not get bounds for selected element');
653
693
  }
654
694
  },
655
695
  orbit: (deltaX: number, deltaY: number) => {
@@ -787,6 +827,11 @@ export function Viewport({
787
827
  // storey model shows all storeys' annotations layered correctly in 3D
788
828
  // (issue #653). Parsing is lazy and only runs while the toggle is on.
789
829
  const ifcAnnotationsVisible = useViewerStore((s) => s.typeVisibility.ifcAnnotations);
830
+ // Issue #862: IfcGrid is a separate toggle from IfcAnnotation. Default
831
+ // is on so existing users see no change; when the user disables it the
832
+ // grid axes + bubble tags drop out without affecting dimension/leader
833
+ // annotation rendering.
834
+ const ifcGridVisible = useViewerStore((s) => s.typeVisibility.ifcGrid);
790
835
  // For annotations whose storey can't be resolved (or whose authored
791
836
  // elevation is 0 because the storey Z lives on the placement instead),
792
837
  // lift to the middle of the model's vertical span so they don't end up
@@ -799,12 +844,37 @@ export function Viewport({
799
844
  if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return 0;
800
845
  return (min + max) * 0.5;
801
846
  }, [coordinateInfo]);
847
+
848
+ // Issue #862: section-clip grid lines so dense-grid models stay
849
+ // readable when a horizontal cut is active. Use a 1.5 m band on each
850
+ // side of the cut so the cut storey's grids are visible but storeys
851
+ // 1.5 m+ away are hidden (matches typical residential floor heights).
852
+ // Only applies to the floor-plan axis (`'down'`) — vertical cuts
853
+ // don't clip grids since grid lines are inherently vertical.
854
+ const gridSectionClip = useMemo<SectionClipForGrid | undefined>(() => {
855
+ if (!sectionPlane.enabled || sectionPlane.axis !== 'down' || !sectionRange) {
856
+ return undefined;
857
+ }
858
+ const posWorld = sectionRange.min + (sectionPlane.position / 100) * (sectionRange.max - sectionRange.min);
859
+ const GRID_CLIP_HALF_BAND_M = 1.5;
860
+ return {
861
+ enabled: true,
862
+ posWorld,
863
+ viewDepth: GRID_CLIP_HALF_BAND_M,
864
+ axis: sectionPlane.axis,
865
+ };
866
+ }, [sectionPlane.enabled, sectionPlane.axis, sectionPlane.position, sectionRange]);
867
+
802
868
  const annotationVertices3D = useSymbolicAnnotations({
803
869
  enabled: ifcAnnotationsVisible,
870
+ gridEnabled: ifcGridVisible,
871
+ gridSectionClip,
804
872
  fallbackY: annotationFallbackY,
805
873
  });
806
874
  const { texts: annotationTexts3D, fills: annotationFills3D } = useSymbolicAnnotationsRichData({
807
875
  enabled: ifcAnnotationsVisible,
876
+ gridEnabled: ifcGridVisible,
877
+ gridSectionClip,
808
878
  fallbackY: annotationFallbackY,
809
879
  });
810
880
  useEffect(() => {
@@ -817,6 +887,20 @@ export function Viewport({
817
887
  }
818
888
  }, [annotationVertices3D, isInitialized]);
819
889
 
890
+ // IfcAlignment centerlines render as thin lines (not a ribbon mesh), always
891
+ // on — see useAlignmentLines3D. Upload/clear mirrors the annotation overlay;
892
+ // a separate renderer buffer keeps alignment visibility independent.
893
+ const alignmentVertices3D = useAlignmentLines3D();
894
+ useEffect(() => {
895
+ const renderer = rendererRef.current;
896
+ if (!renderer || !isInitialized) return;
897
+ if (alignmentVertices3D.length === 0) {
898
+ renderer.clearAlignmentLines3D();
899
+ } else {
900
+ renderer.uploadAlignmentLines3D(alignmentVertices3D);
901
+ }
902
+ }, [alignmentVertices3D, isInitialized]);
903
+
820
904
  // Upload IfcAnnotation text + fill data for the WebGPU symbolic overlay
821
905
  // pipelines. Map the hook's per-annotation records into the SymbolicFillInput
822
906
  // / SymbolicTextInput shape the renderer expects. Empty arrays clear cleanly.
@@ -1011,6 +1095,7 @@ export function Viewport({
1011
1095
  geometryContentVersion,
1012
1096
  coordinateInfo,
1013
1097
  isStreaming,
1098
+ modelCount: modelIdToIndex?.size ?? 0,
1014
1099
  geometryBoundsRef,
1015
1100
  pendingColorUpdates,
1016
1101
  pendingMeshColorUpdates,