@ifc-lite/viewer 1.25.2 → 1.27.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 (116) hide show
  1. package/.turbo/turbo-build.log +40 -30
  2. package/CHANGELOG.md +110 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +13 -9
  38. package/src/components/extensions/FlavorDialog.tsx +18 -2
  39. package/src/components/extensions/FlavorListView.tsx +12 -3
  40. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  41. package/src/components/mcp/data.ts +6 -0
  42. package/src/components/mcp/playground-dispatcher.ts +277 -0
  43. package/src/components/mcp/types.ts +2 -1
  44. package/src/components/ui/combo-input.tsx +163 -0
  45. package/src/components/ui/tabs.tsx +1 -1
  46. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  47. package/src/components/viewer/ClashPanel.tsx +370 -0
  48. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  49. package/src/components/viewer/CommandPalette.tsx +14 -15
  50. package/src/components/viewer/MainToolbar.tsx +155 -175
  51. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  52. package/src/components/viewer/SearchInline.tsx +62 -2
  53. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  54. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  55. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  56. package/src/components/viewer/SearchModal.tsx +19 -6
  57. package/src/components/viewer/ViewerLayout.tsx +5 -0
  58. package/src/components/viewer/Viewport.tsx +64 -9
  59. package/src/components/viewer/ViewportContainer.tsx +45 -3
  60. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  61. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  62. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  63. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  64. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  65. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  66. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  67. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  68. package/src/generated/mcp-catalog.json +4 -0
  69. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  70. package/src/hooks/ingest/streamCleanup.ts +45 -0
  71. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  72. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  73. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  74. package/src/hooks/source-key.ts +35 -0
  75. package/src/hooks/useAlignmentLines3D.ts +139 -0
  76. package/src/hooks/useClash.ts +420 -0
  77. package/src/hooks/useGridLines3D.ts +140 -0
  78. package/src/hooks/useIfcFederation.ts +16 -2
  79. package/src/hooks/useIfcLoader.ts +5 -7
  80. package/src/lib/clash/persistence.ts +308 -0
  81. package/src/lib/geo/effective-georef.test.ts +66 -0
  82. package/src/lib/length-unit-scale.ts +41 -0
  83. package/src/lib/lists/adapter.ts +136 -11
  84. package/src/lib/lists/export/csv.ts +47 -0
  85. package/src/lib/lists/export/index.ts +49 -0
  86. package/src/lib/lists/export/model.ts +111 -0
  87. package/src/lib/lists/export/pdf.ts +67 -0
  88. package/src/lib/lists/export/xlsx.ts +83 -0
  89. package/src/lib/lists/index.ts +2 -0
  90. package/src/lib/search/filter-evaluate.test.ts +81 -0
  91. package/src/lib/search/filter-evaluate.ts +59 -87
  92. package/src/lib/search/filter-match.ts +167 -0
  93. package/src/lib/search/filter-rules.test.ts +25 -0
  94. package/src/lib/search/filter-rules.ts +75 -2
  95. package/src/lib/search/filter-schema.ts +0 -0
  96. package/src/lib/slab-edit.test.ts +72 -0
  97. package/src/lib/slab-edit.ts +159 -19
  98. package/src/sdk/adapters/export-adapter.ts +3 -3
  99. package/src/sdk/adapters/query-adapter.ts +3 -3
  100. package/src/services/extensions/host.ts +13 -0
  101. package/src/store/constants.ts +33 -25
  102. package/src/store/index.ts +29 -8
  103. package/src/store/slices/clashSlice.ts +251 -0
  104. package/src/store/slices/listSlice.ts +6 -0
  105. package/src/store/slices/mutationSlice.ts +14 -6
  106. package/src/store/slices/searchSlice.ts +29 -3
  107. package/src/store/slices/visibilitySlice.test.ts +23 -5
  108. package/src/store/slices/visibilitySlice.ts +18 -8
  109. package/src/utils/nativeSpatialDataStore.ts +6 -0
  110. package/src/utils/serverDataModel.test.ts +6 -0
  111. package/src/utils/serverDataModel.ts +7 -0
  112. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  113. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  114. package/dist/assets/index-Bws3UAkj.css +0 -1
  115. package/dist/assets/raw-R2QfzPAR.js +0 -1
  116. package/dist/assets/server-client-Ctk8_Bof.js +0 -626
@@ -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
 
@@ -512,7 +512,7 @@ export function PropertiesPanel() {
512
512
  if (!entityNode) return [];
513
513
 
514
514
  const rawProps = entityNode.properties();
515
- let result = rawProps.map(pset => ({
515
+ let result: DisplayPropertySet[] = rawProps.map(pset => ({
516
516
  name: pset.name,
517
517
  properties: pset.properties.map(p => ({ name: p.name, value: p.value, isMutated: false })),
518
518
  isNewPset: false,
@@ -970,21 +970,28 @@ export function PropertiesPanel() {
970
970
  }));
971
971
  }, [nativeDetails]);
972
972
 
973
+ // Overlay (authored) entities — split halves, duplicates, scripted
974
+ // adds — live only in the StoreEditor overlay, NOT the parsed store.
975
+ // `modelQuery.entity()` always returns a node, and its getters fall
976
+ // back to the 'Unknown'/'' sentinels for ids absent from the parsed
977
+ // table (entity-table.ts#getTypeName). Those non-null sentinels would
978
+ // shadow the overlay record in an `entityNode ?? overlay` chain, so
979
+ // when an overlay record exists it MUST take precedence.
973
980
  const renderedEntityType = isNativeLazySelection
974
981
  ? (nativeDetails?.summary.type ?? 'Loading...')
975
- : (entityNode?.type ?? overlayEntity?.type ?? 'Unknown');
982
+ : (overlayEntity?.type ?? entityNode?.type ?? 'Unknown');
976
983
  const renderedEntityName = isNativeLazySelection
977
984
  ? (nativeDetails?.summary.name ?? `#${selectedEntity?.expressId ?? ''}`)
978
- : (entityNode?.name ?? overlayAttr(2) ?? undefined);
985
+ : (overlayAttr(2) ?? entityNode?.name ?? undefined);
979
986
  const renderedEntityGlobalId = isNativeLazySelection
980
987
  ? (nativeDetails?.summary.globalId ?? null)
981
- : (entityNode?.globalId ?? overlayAttr(0));
988
+ : (overlayAttr(0) ?? entityNode?.globalId);
982
989
  const renderedEntityDescription = isNativeLazySelection
983
990
  ? undefined
984
- : (entityNode?.description ?? overlayAttr(3) ?? undefined);
991
+ : (overlayAttr(3) ?? entityNode?.description ?? undefined);
985
992
  const renderedEntityObjectType = isNativeLazySelection
986
993
  ? undefined
987
- : (entityNode?.objectType ?? overlayAttr(4) ?? undefined);
994
+ : (overlayAttr(4) ?? entityNode?.objectType ?? undefined);
988
995
  const renderedSpatialInfo = isNativeLazySelection ? nativeSpatialInfo : spatialInfo;
989
996
  const renderedOccurrenceProperties = isNativeLazySelection ? nativeOccurrenceProperties : occurrenceProperties;
990
997
  const renderedInheritedTypeProperties = isNativeLazySelection ? [] : inheritedTypeProperties;
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
28
- import { Search, Clock, X } from 'lucide-react';
28
+ import { Search, Clock, X, SlidersHorizontal } from 'lucide-react';
29
29
  import { useShallow } from 'zustand/react/shallow';
30
30
  import { Input } from '@/components/ui/input';
31
31
  import { useViewerStore } from '@/store';
@@ -79,6 +79,9 @@ export function SearchInline() {
79
79
  exitVimCycle,
80
80
  stepVimCycle,
81
81
  setSearchModalOpen,
82
+ setSearchModalTab,
83
+ activeRuleCount,
84
+ clearFilterRules,
82
85
  models,
83
86
  setSelectedEntity,
84
87
  setSelectedEntityId,
@@ -99,6 +102,9 @@ export function SearchInline() {
99
102
  exitVimCycle: s.exitVimCycle,
100
103
  stepVimCycle: s.stepVimCycle,
101
104
  setSearchModalOpen: s.setSearchModalOpen,
105
+ setSearchModalTab: s.setSearchModalTab,
106
+ activeRuleCount: s.searchFilter.rules.length,
107
+ clearFilterRules: s.clearFilterRules,
102
108
  models: s.models,
103
109
  setSelectedEntity: s.setSelectedEntity,
104
110
  setSelectedEntityId: s.setSelectedEntityId,
@@ -395,8 +401,10 @@ export function SearchInline() {
395
401
  e.preventDefault();
396
402
  // ⌘↵ / Ctrl+↵ opens the advanced modal instead of committing — the
397
403
  // inline query is preserved so the modal opens already populated.
404
+ // Text-search entry point, so land on the Search tab.
398
405
  if (e.metaKey || e.ctrlKey) {
399
406
  setSearchOpen(false);
407
+ setSearchModalTab('search');
400
408
  setSearchModalOpen(true);
401
409
  return;
402
410
  }
@@ -416,9 +424,19 @@ export function SearchInline() {
416
424
  if (target) commitResult(target, idx, e.shiftKey, liveResults, live);
417
425
  }
418
426
  },
419
- [commitResult, results, searchHighlightIndex, searchOpen, setSearchHighlightIndex, setSearchModalOpen, setSearchOpen],
427
+ [commitResult, results, searchHighlightIndex, searchOpen, setSearchHighlightIndex, setSearchModalOpen, setSearchModalTab, setSearchOpen],
420
428
  );
421
429
 
430
+ const hasFilters = activeRuleCount > 0;
431
+
432
+ /** Open the advanced modal straight to the Filter builder — the
433
+ * always-visible entry point to structured filtering. */
434
+ const openAdvancedFilter = useCallback(() => {
435
+ setSearchOpen(false);
436
+ setSearchModalTab('filter');
437
+ setSearchModalOpen(true);
438
+ }, [setSearchOpen, setSearchModalTab, setSearchModalOpen]);
439
+
422
440
  const queryTrimmedLen = searchQuery.trim().length;
423
441
  const showPopover = searchOpen && (results.length > 0 || queryTrimmedLen > 0 || recents.length > 0);
424
442
  const showRecents = searchOpen && queryTrimmedLen === 0 && recents.length > 0;
@@ -437,11 +455,52 @@ export function SearchInline() {
437
455
  }}
438
456
  onFocus={() => setSearchOpen(true)}
439
457
  onKeyDown={handleInputKeyDown}
458
+ className={cn(hasFilters ? 'pr-[4.5rem]' : 'pr-9')}
440
459
  aria-label="Search entities"
441
460
  aria-autocomplete="list"
442
461
  aria-expanded={showPopover}
443
462
  aria-controls="search-inline-popover"
444
463
  />
464
+ {/* Advanced-filter affordance — always visible so structured
465
+ filtering is discoverable without the ⌘⇧F shortcut. Shows the
466
+ active rule count and a quick-clear when a filter is applied. */}
467
+ <div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
468
+ {hasFilters && (
469
+ <button
470
+ type="button"
471
+ aria-label="Clear filters"
472
+ title="Clear filters"
473
+ onMouseDown={(e) => {
474
+ e.preventDefault();
475
+ clearFilterRules();
476
+ }}
477
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800"
478
+ >
479
+ <X className="h-3.5 w-3.5" />
480
+ </button>
481
+ )}
482
+ <button
483
+ type="button"
484
+ aria-label={hasFilters ? `Advanced filter — ${activeRuleCount} active` : 'Advanced filter'}
485
+ aria-pressed={hasFilters}
486
+ title="Advanced filter (⌘⇧F)"
487
+ onMouseDown={(e) => {
488
+ e.preventDefault();
489
+ openAdvancedFilter();
490
+ }}
491
+ className={cn(
492
+ 'flex items-center gap-1 rounded px-1.5 py-1 text-xs transition-colors',
493
+ hasFilters
494
+ ? 'bg-primary/10 text-primary hover:bg-primary/15'
495
+ : 'text-muted-foreground hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800',
496
+ )}
497
+ >
498
+ <SlidersHorizontal className="h-3.5 w-3.5" />
499
+ {hasFilters && (
500
+ <span className="font-mono text-[10px] font-semibold leading-none">{activeRuleCount}</span>
501
+ )}
502
+ </button>
503
+ </div>
445
504
  {/* Vim cycle hint — shows below the input whenever a cycle is active
446
505
  and the popover is closed. Clicking it exits the cycle. */}
447
506
  {searchVimCycle && !showPopover && (
@@ -475,6 +534,7 @@ export function SearchInline() {
475
534
  onHover={(i) => setSearchHighlightIndex(i)}
476
535
  onOpenAdvanced={() => {
477
536
  setSearchOpen(false);
537
+ setSearchModalTab('search');
478
538
  setSearchModalOpen(true);
479
539
  }}
480
540
  />