@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.
- package/.turbo/turbo-build.log +30 -27
- package/CHANGELOG.md +81 -0
- package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
- package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
- package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
- package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
- package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
- package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
- package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
- package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
- package/dist/assets/index-B9Ug2EqU.css +1 -0
- package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
- package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
- package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
- package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
- package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
- package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
- package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
- package/dist/assets/raw-BQrAgxwT.js +1 -0
- package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
- package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
- package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
- package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +7 -6
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +14 -15
- package/src/components/viewer/MainToolbar.tsx +155 -175
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +49 -9
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/useGeometryStreaming.ts +21 -1
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +64 -42
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/useAlignmentLines3D.ts +164 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +5 -7
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +33 -25
- package/src/store/index.ts +29 -8
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +18 -8
- package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
- package/dist/assets/index-Bws3UAkj.css +0 -1
- 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
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
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.
|
|
1538
|
-
//
|
|
1539
|
-
aria-label={mergeLayers ? '
|
|
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 ? '
|
|
1522
|
+
{mergeLayers ? 'Visibility · Merge Multilayer Walls is on' : 'Visibility'}
|
|
1557
1523
|
</TooltipContent>
|
|
1558
1524
|
</Tooltip>
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
<
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
>
|
|
1574
|
-
<
|
|
1575
|
-
|
|
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
|
-
</
|
|
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
|
|
656
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
146
|
-
|
|
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,
|