@ifc-lite/viewer 1.25.2 → 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 (57) hide show
  1. package/.turbo/turbo-build.log +30 -27
  2. package/CHANGELOG.md +81 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
  10. package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
  13. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
  21. package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +7 -6
  26. package/src/components/extensions/FlavorDialog.tsx +18 -2
  27. package/src/components/extensions/FlavorListView.tsx +12 -3
  28. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  29. package/src/components/viewer/ClashPanel.tsx +370 -0
  30. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  31. package/src/components/viewer/CommandPalette.tsx +14 -15
  32. package/src/components/viewer/MainToolbar.tsx +155 -175
  33. package/src/components/viewer/ViewerLayout.tsx +5 -0
  34. package/src/components/viewer/Viewport.tsx +49 -9
  35. package/src/components/viewer/ViewportContainer.tsx +45 -3
  36. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  37. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  38. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  39. package/src/hooks/ingest/streamCleanup.ts +45 -0
  40. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  41. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  42. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  43. package/src/hooks/useAlignmentLines3D.ts +164 -0
  44. package/src/hooks/useClash.ts +420 -0
  45. package/src/hooks/useIfcFederation.ts +16 -2
  46. package/src/hooks/useIfcLoader.ts +5 -7
  47. package/src/lib/clash/persistence.ts +308 -0
  48. package/src/lib/geo/effective-georef.test.ts +66 -0
  49. package/src/services/extensions/host.ts +13 -0
  50. package/src/store/constants.ts +33 -25
  51. package/src/store/index.ts +29 -8
  52. package/src/store/slices/clashSlice.ts +251 -0
  53. package/src/store/slices/visibilitySlice.test.ts +23 -5
  54. package/src/store/slices/visibilitySlice.ts +18 -8
  55. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  56. package/dist/assets/index-Bws3UAkj.css +0 -1
  57. package/dist/assets/raw-R2QfzPAR.js +0 -1
@@ -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,107 +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 / IfcGrid have no body mesh, so they can't be detected via
631
- // the mesh scan. Look up the entity table directly. byType keys are
632
- // uppercase STEP names but cache loads sometimes preserve PascalCase.
633
- //
634
- // Issue #862 split these into separate visibility toggles — files that
635
- // ship only one of the two need only that menu entry. Some files ship
636
- // only grids (Snowdon Towers Structural — no IfcAnnotation) so probing
637
- // each independently is required.
638
- const hasIfcEntities = useMemo(() => {
639
- const probe = (store: typeof ifcDataStore | undefined) => {
640
- const byType = store?.entityIndex?.byType;
641
- if (!byType) return { annotations: false, grid: false };
642
- return {
643
- annotations: (byType.get('IFCANNOTATION')?.length ?? 0) > 0
644
- || (byType.get('IfcAnnotation')?.length ?? 0) > 0,
645
- grid: (byType.get('IFCGRID')?.length ?? 0) > 0
646
- || (byType.get('IfcGrid')?.length ?? 0) > 0,
647
- };
648
- };
649
- let annotations = false;
650
- let grid = false;
651
- if (models.size > 0) {
652
- for (const [, m] of models) {
653
- const p = probe(m.ifcDataStore);
654
- annotations ||= p.annotations;
655
- grid ||= p.grid;
656
- }
657
- } else {
658
- const p = probe(ifcDataStore);
659
- annotations = p.annotations;
660
- grid = p.grid;
661
- }
662
- return { annotations, grid };
663
- }, [models, ifcDataStore]);
664
- const hasIfcAnnotations = hasIfcEntities.annotations;
665
- const hasIfcGrid = hasIfcEntities.grid;
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.
666
616
 
667
617
  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
668
618
  const files = e.target.files;
@@ -811,7 +761,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
811
761
  setScriptPanelVisible,
812
762
  ]);
813
763
 
814
- const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'addElement' | 'extensions') => {
764
+ const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'addElement' | 'extensions') => {
815
765
  if (activeAnalysisExtension?.placement !== 'bottom') {
816
766
  closeActiveAnalysisExtension();
817
767
  }
@@ -828,6 +778,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
828
778
  const nextBcfVisible = panel === 'bcf' ? !bcfPanelVisible : false;
829
779
  const nextIdsVisible = panel === 'ids' ? !idsPanelVisible : false;
830
780
  const nextLensVisible = panel === 'lens' ? !lensPanelVisible : false;
781
+ const nextClashVisible = panel === 'clash' ? !clashPanelVisible : false;
831
782
  const nextExtensionsVisible = panel === 'extensions' ? !extensionsPanelVisible : false;
832
783
  const isAddElementActive = activeTool === 'addElement';
833
784
  const nextAddElementActive = panel === 'addElement' ? !isAddElementActive : false;
@@ -835,6 +786,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
835
786
  setBcfPanelVisible(nextBcfVisible);
836
787
  setIdsPanelVisible(nextIdsVisible);
837
788
  setLensPanelVisible(nextLensVisible);
789
+ setClashPanelVisible(nextClashVisible);
838
790
  setExtensionsPanelVisible(nextExtensionsVisible);
839
791
 
840
792
  if (panel === 'addElement') {
@@ -843,19 +795,21 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
843
795
  setActiveTool('select');
844
796
  }
845
797
 
846
- if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextExtensionsVisible || nextAddElementActive) {
798
+ if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextClashVisible || nextExtensionsVisible || nextAddElementActive) {
847
799
  setRightPanelCollapsed(false);
848
800
  }
849
801
  }, [
850
802
  activeAnalysisExtension?.placement,
851
803
  activeTool,
852
804
  bcfPanelVisible,
805
+ clashPanelVisible,
853
806
  extensionsPanelVisible,
854
807
  idsPanelVisible,
855
808
  lensPanelVisible,
856
809
  requireDesktopFeature,
857
810
  setActiveTool,
858
811
  setBcfPanelVisible,
812
+ setClashPanelVisible,
859
813
  setExtensionsPanelVisible,
860
814
  setIdsPanelVisible,
861
815
  setLensPanelVisible,
@@ -889,6 +843,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
889
843
  setBcfPanelVisible(false);
890
844
  setIdsPanelVisible(false);
891
845
  setLensPanelVisible(false);
846
+ setClashPanelVisible(false);
892
847
  setExtensionsPanelVisible(false);
893
848
  // The right slot is single-tenant: when an analysis extension takes
894
849
  // it over, the AddElement tool must release it too, otherwise its 3D
@@ -903,6 +858,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
903
858
  analysisExtensionState.extensions,
904
859
  setActiveTool,
905
860
  setBcfPanelVisible,
861
+ setClashPanelVisible,
906
862
  setExtensionsPanelVisible,
907
863
  setGanttPanelVisible,
908
864
  setIdsPanelVisible,
@@ -920,6 +876,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
920
876
  if (bcfPanelVisible) panels.add('bcf');
921
877
  if (idsPanelVisible) panels.add('ids');
922
878
  if (lensPanelVisible) panels.add('lens');
879
+ if (clashPanelVisible) panels.add('clash');
923
880
  if (extensionsPanelVisible) panels.add('extensions');
924
881
  if (activeTool === 'addElement') panels.add('addElement');
925
882
  if (analysisExtensionState.activeId) panels.add(analysisExtensionState.activeId);
@@ -928,6 +885,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
928
885
  activeTool,
929
886
  analysisExtensionState.activeId,
930
887
  bcfPanelVisible,
888
+ clashPanelVisible,
931
889
  extensionsPanelVisible,
932
890
  ganttPanelVisible,
933
891
  idsPanelVisible,
@@ -945,6 +903,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
945
903
  if (activeWorkspacePanels.has('bcf')) return 'BCF Issues';
946
904
  if (activeWorkspacePanels.has('ids')) return 'IDS Validation';
947
905
  if (activeWorkspacePanels.has('lens')) return 'Lens Rules';
906
+ if (activeWorkspacePanels.has('clash')) return 'Clash Detection';
948
907
  if (activeWorkspacePanels.has('extensions')) return 'Extensions';
949
908
  if (activeWorkspacePanels.has('addElement')) return 'Add Element';
950
909
  return activeAnalysisExtension?.label ?? 'Analysis';
@@ -1299,6 +1258,13 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1299
1258
  <Palette className="h-4 w-4 mr-2" />
1300
1259
  Lens Rules
1301
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>
1302
1268
  <DropdownMenuSeparator />
1303
1269
  <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1304
1270
  Author
@@ -1534,9 +1500,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1534
1500
  // Stay enabled even with no model loaded — the dropdown
1535
1501
  // also exposes load-time settings (Merge Multilayer
1536
1502
  // Walls) that the user should be able to set BEFORE
1537
- // opening a file. Runtime items inside self-gate via
1538
- // typeGeometryExists.
1539
- 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'}
1540
1506
  className="relative"
1541
1507
  >
1542
1508
  <Filter className="h-4 w-4" />
@@ -1553,79 +1519,93 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1553
1519
  </DropdownMenuTrigger>
1554
1520
  </TooltipTrigger>
1555
1521
  <TooltipContent>
1556
- {mergeLayers ? 'Class Visibility · Merge Multilayer Walls is on' : 'Class Visibility'}
1522
+ {mergeLayers ? 'Visibility · Merge Multilayer Walls is on' : 'Visibility'}
1557
1523
  </TooltipContent>
1558
1524
  </Tooltip>
1559
- <DropdownMenuContent className="w-72">
1560
- {typeGeometryExists.spaces && (
1561
- <DropdownMenuCheckboxItem
1562
- checked={typeVisibility.spaces}
1563
- onCheckedChange={() => toggleTypeVisibility('spaces')}
1564
- >
1565
- <Box className="h-4 w-4 mr-2" style={{ color: '#33d9ff' }} />
1566
- Show Spaces
1567
- </DropdownMenuCheckboxItem>
1568
- )}
1569
- {typeGeometryExists.openings && (
1570
- <DropdownMenuCheckboxItem
1571
- checked={typeVisibility.openings}
1572
- onCheckedChange={() => toggleTypeVisibility('openings')}
1573
- >
1574
- <SquareX className="h-4 w-4 mr-2" style={{ color: '#ff6b4a' }} />
1575
- Show Openings
1576
- </DropdownMenuCheckboxItem>
1577
- )}
1578
- {typeGeometryExists.site && (
1579
- <DropdownMenuCheckboxItem
1580
- checked={typeVisibility.site}
1581
- onCheckedChange={() => toggleTypeVisibility('site')}
1582
- >
1583
- <Building2 className="h-4 w-4 mr-2" style={{ color: '#66cc4d' }} />
1584
- Show Site
1585
- </DropdownMenuCheckboxItem>
1586
- )}
1587
- {hasIfcAnnotations && (
1588
- <DropdownMenuCheckboxItem
1589
- checked={typeVisibility.ifcAnnotations}
1590
- onCheckedChange={() => toggleTypeVisibility('ifcAnnotations')}
1591
- >
1592
- <Pencil className="h-4 w-4 mr-2" style={{ color: '#e4b400' }} />
1593
- Show Annotations
1594
- </DropdownMenuCheckboxItem>
1595
- )}
1596
- {hasIfcGrid && (
1597
- <DropdownMenuCheckboxItem
1598
- checked={typeVisibility.ifcGrid}
1599
- onCheckedChange={() => toggleTypeVisibility('ifcGrid')}
1600
- >
1601
- <Pencil className="h-4 w-4 mr-2" style={{ color: '#e4b400' }} />
1602
- Show Grids
1603
- </DropdownMenuCheckboxItem>
1604
- )}
1605
-
1606
- {/* Load-time toggles live below the runtime visibility
1607
- switches — they apply on next model open rather than
1608
- affecting the current scene. The subheader makes that
1609
- boundary visible at a glance. */}
1610
- <DropdownMenuSeparator />
1611
- <DropdownMenuLabel className="px-2 pt-1 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
1612
- Load Settings
1613
- </DropdownMenuLabel>
1614
- <DropdownMenuCheckboxItem
1615
- checked={mergeLayers}
1616
- onCheckedChange={(next) => setMergeLayers(next === true)}
1617
- // Use items-start so the checkmark and icon line up with
1618
- // the primary label while the description wraps below.
1619
- className="items-start gap-2 py-2"
1620
- >
1621
- <Layers2 className="h-4 w-4 mr-2 mt-0.5 shrink-0 text-primary" />
1622
- <div className="flex flex-col gap-0.5 min-w-0">
1623
- <span className="text-sm font-medium leading-tight">Merge Multilayer Walls</span>
1624
- <span className="text-[11px] leading-tight text-muted-foreground">
1625
- 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
1626
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>
1627
1551
  </div>
1628
- </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>
1629
1609
  </DropdownMenuContent>
1630
1610
  </DropdownMenu>
1631
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 ? (
@@ -45,6 +45,7 @@ import {
45
45
  useSymbolicAnnotationsRichData,
46
46
  type SectionClipForGrid,
47
47
  } from '../../hooks/useSymbolicAnnotations.js';
48
+ import { useAlignmentLines3D } from '../../hooks/useAlignmentLines3D.js';
48
49
 
49
50
  interface ViewportProps {
50
51
  geometry: MeshData[] | null;
@@ -652,19 +653,43 @@ export function Viewport({
652
653
  calculateScale();
653
654
  },
654
655
  frameSelection: () => {
655
- // Frame selection - zoom to fit selected element
656
- 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.
657
661
  const geom = geometryRef.current;
658
- if (selectedId !== null && geom) {
659
- const bounds = getEntityBounds(geom, selectedId);
660
- if (bounds) {
661
- camera.frameBounds(bounds.min, bounds.max, 300);
662
- 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 };
663
679
  } else {
664
- 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);
665
686
  }
687
+ }
688
+ if (min && max) {
689
+ camera.frameBounds(min, max, 300);
690
+ calculateScale();
666
691
  } else {
667
- console.warn('[Viewport] frameSelection: No selection or geometry');
692
+ console.warn('[Viewport] frameSelection: Could not get bounds for selected element');
668
693
  }
669
694
  },
670
695
  orbit: (deltaX: number, deltaY: number) => {
@@ -862,6 +887,20 @@ export function Viewport({
862
887
  }
863
888
  }, [annotationVertices3D, isInitialized]);
864
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
+
865
904
  // Upload IfcAnnotation text + fill data for the WebGPU symbolic overlay
866
905
  // pipelines. Map the hook's per-annotation records into the SymbolicFillInput
867
906
  // / SymbolicTextInput shape the renderer expects. Empty arrays clear cleanly.
@@ -1056,6 +1095,7 @@ export function Viewport({
1056
1095
  geometryContentVersion,
1057
1096
  coordinateInfo,
1058
1097
  isStreaming,
1098
+ modelCount: modelIdToIndex?.size ?? 0,
1059
1099
  geometryBoundsRef,
1060
1100
  pendingColorUpdates,
1061
1101
  pendingMeshColorUpdates,
@@ -39,6 +39,26 @@ const DEFAULT_COORDINATE_INFO: CoordinateInfo = {
39
39
  hasLargeCoordinates: false,
40
40
  };
41
41
 
42
+ type Vec3Bounds = { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } };
43
+
44
+ /** True for a real (non-placeholder, non-degenerate) bounds box. */
45
+ function isUsableBounds(b: Vec3Bounds | undefined): b is Vec3Bounds {
46
+ if (!b) return false;
47
+ return (
48
+ b.max.x > b.min.x || b.max.y > b.min.y || b.max.z > b.min.z
49
+ );
50
+ }
51
+
52
+ /** Axis-aligned union of two bounds boxes (either may be undefined). */
53
+ function unionBounds(acc: Vec3Bounds | undefined, b: Vec3Bounds | undefined): Vec3Bounds | undefined {
54
+ if (!isUsableBounds(b)) return acc;
55
+ if (!acc) return { min: { ...b.min }, max: { ...b.max } };
56
+ return {
57
+ min: { x: Math.min(acc.min.x, b.min.x), y: Math.min(acc.min.y, b.min.y), z: Math.min(acc.min.z, b.min.z) },
58
+ max: { x: Math.max(acc.max.x, b.max.x), y: Math.max(acc.max.y, b.max.y), z: Math.max(acc.max.z, b.max.z) },
59
+ };
60
+ }
61
+
42
62
  export function ViewportContainer() {
43
63
  // Drive Stacked / Solo / Exploded level display from the slice.
44
64
  // Mount-once hook — it self-gates on mode + gap + model changes.
@@ -121,7 +141,16 @@ export function ViewportContainer() {
121
141
  if (storeModels.size > 1) {
122
142
  let totalVertices = 0;
123
143
  let totalTriangles = 0;
124
- let mergedCoordinateInfo: CoordinateInfo | undefined;
144
+ // The merged coordinateInfo must cover ALL visible models, not just the
145
+ // first one — the renderer fits the camera to `shiftedBounds`, so a
146
+ // first-wins box left every model after the first off-screen (it only
147
+ // showed its 2D grid overlay). Union the bounds across visible models;
148
+ // keep the first model's frame metadata (originShift / RTC) since
149
+ // federated models share a coordinate frame.
150
+ let baseCoordInfo: CoordinateInfo | undefined;
151
+ let unionedShifted: Vec3Bounds | undefined;
152
+ let unionedOriginal: Vec3Bounds | undefined;
153
+ let anyLargeCoords = false;
125
154
  let shouldRebuild = false;
126
155
 
127
156
  if (mergedLengthsRef.current.size !== storeModels.size) {
@@ -142,8 +171,12 @@ export function ViewportContainer() {
142
171
  const meshCount = model.visible ? (modelGeometry?.meshes.length ?? 0) : 0;
143
172
  totalVertices += model.visible ? (modelGeometry?.totalVertices ?? 0) : 0;
144
173
  totalTriangles += model.visible ? (modelGeometry?.totalTriangles ?? 0) : 0;
145
- if (!mergedCoordinateInfo && model.visible && modelGeometry?.coordinateInfo) {
146
- mergedCoordinateInfo = modelGeometry.coordinateInfo;
174
+ if (model.visible && modelGeometry?.coordinateInfo) {
175
+ const ci = modelGeometry.coordinateInfo;
176
+ if (!baseCoordInfo) baseCoordInfo = ci;
177
+ anyLargeCoords = anyLargeCoords || !!ci.hasLargeCoordinates;
178
+ unionedShifted = unionBounds(unionedShifted, ci.shiftedBounds);
179
+ unionedOriginal = unionBounds(unionedOriginal, ci.originalBounds);
147
180
  }
148
181
 
149
182
  if (
@@ -187,6 +220,15 @@ export function ViewportContainer() {
187
220
  }
188
221
  }
189
222
 
223
+ const mergedCoordinateInfo: CoordinateInfo | undefined = baseCoordInfo
224
+ ? {
225
+ ...baseCoordInfo,
226
+ originalBounds: unionedOriginal ?? baseCoordInfo.originalBounds,
227
+ shiftedBounds: unionedShifted ?? baseCoordInfo.shiftedBounds,
228
+ hasLargeCoordinates: anyLargeCoords,
229
+ }
230
+ : undefined;
231
+
190
232
  return {
191
233
  meshes: mergedCacheRef.current,
192
234
  totalVertices,