@ifc-lite/viewer 1.27.0 → 1.28.1
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 +35 -42
- package/CHANGELOG.md +74 -0
- package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
- package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
- package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
- package/dist/assets/drawing-2d-DW98umlt.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
- package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
- package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
- package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
- package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
- package/dist/assets/index-E9wB0zWt.css +1 -0
- package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
- package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
- package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
- package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
- package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
- package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
- package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
- package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
- package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
- package/dist/assets/raw-C0ZJYGmN.js +1 -0
- package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
- package/dist/assets/server-client-DVZ2huNS.js +719 -0
- package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
- package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
- package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
- package/dist/index.html +9 -9
- package/package.json +24 -23
- package/src/App.tsx +1 -3
- package/src/components/mcp/playground-dispatcher.ts +3 -0
- package/src/components/mcp/playground-files.ts +33 -1
- package/src/components/viewer/BCFPanel.tsx +1 -16
- package/src/components/viewer/ChatPanel.tsx +11 -46
- package/src/components/viewer/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +48 -183
- package/src/components/viewer/IDSPanel.tsx +1 -26
- package/src/components/viewer/MainToolbar.tsx +94 -187
- package/src/components/viewer/MobileToolbar.tsx +1 -9
- package/src/components/viewer/PropertiesPanel.tsx +98 -127
- package/src/components/viewer/ScriptPanel.tsx +8 -34
- package/src/components/viewer/Section2DPanel.tsx +32 -1
- package/src/components/viewer/ViewerLayout.tsx +5 -2
- package/src/components/viewer/Viewport.tsx +3 -0
- package/src/components/viewer/ViewportContainer.tsx +24 -42
- package/src/components/viewer/ViewportOverlays.tsx +1 -4
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/components/viewer/useGeometryStreaming.ts +0 -2
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +488 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +234 -14
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +100 -24
- package/src/hooks/useIfcFederation.ts +42 -811
- package/src/hooks/useIfcLoader.ts +349 -1517
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- package/src/lib/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- package/src/lib/search/result-export.ts +7 -1
- package/src/sdk/adapters/export-adapter.ts +6 -1
- package/src/services/cacheService.ts +9 -25
- package/src/services/desktop-export.ts +2 -59
- package/src/services/file-dialog.ts +8 -142
- package/src/store/constants.ts +23 -0
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +19 -6
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/lensSlice.ts +8 -0
- package/src/store/slices/visibilitySlice.ts +22 -1
- package/src/store/types.ts +1 -71
- package/src/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/ifcConfig.ts +0 -12
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/vite.config.ts +6 -3
- package/DESKTOP_CONTRACT_VERSION +0 -1
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- package/dist/assets/event-B0kAzHa-.js +0 -1
- package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
- package/dist/assets/index-ajK6D32J.css +0 -1
- package/dist/assets/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-D591Zu_-.js +0 -182
- package/dist/assets/raw-D9iw0tmc.js +0 -1
- package/dist/assets/server-client-Cjwnm7il.js +0 -706
- package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
- package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
- package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
- package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
- package/src/components/viewer/SettingsPage.tsx +0 -581
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
- package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
- package/src/lib/desktop-entitlement.ts +0 -43
- package/src/lib/desktop-product.ts +0 -130
- package/src/lib/platform.ts +0 -23
- package/src/services/desktop-cache.ts +0 -186
- package/src/services/desktop-harness.ts +0 -196
- package/src/services/desktop-logger.ts +0 -20
- package/src/services/desktop-native-metadata.ts +0 -230
- package/src/services/desktop-panel-actions.ts +0 -43
- package/src/services/desktop-preferences.ts +0 -44
- package/src/services/fs-cache.ts +0 -212
- package/src/services/tauri-core-stub.ts +0 -7
- package/src/services/tauri-dialog-stub.ts +0 -7
- package/src/services/tauri-fs-stub.ts +0 -7
- package/src/services/tauri-modules.d.ts +0 -50
- package/src/store/slices/desktopEntitlementSlice.ts +0 -86
- package/src/utils/desktopModelSnapshot.ts +0 -358
- package/src/utils/nativeSpatialDataStore.ts +0 -277
- package/src-tauri/Cargo.toml +0 -29
- package/src-tauri/build.rs +0 -7
- package/src-tauri/capabilities/default.json +0 -18
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +0 -21
- package/src-tauri/src/main.rs +0 -10
- package/src-tauri/tauri.conf.json +0 -39
|
@@ -19,10 +19,7 @@ import { toGlobalIdFromModels } from '@/store/globalId';
|
|
|
19
19
|
import { collectIfcBuildingStoreyElementsWithIfcSpace } from '@/store/basketVisibleSet';
|
|
20
20
|
import { useIfc } from '@/hooks/useIfc';
|
|
21
21
|
import { useWebGPU } from '@/hooks/useWebGPU';
|
|
22
|
-
import { openIfcFileDialog } from '@/services/file-dialog';
|
|
23
|
-
import { logToDesktopTerminal } from '@/services/desktop-logger';
|
|
24
22
|
import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRecentFiles, type RecentFileEntry } from '@/lib/recent-files';
|
|
25
|
-
import { isTauri } from '@/lib/platform';
|
|
26
23
|
import { toast } from '@/components/ui/toast';
|
|
27
24
|
import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest';
|
|
28
25
|
import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3, Sparkles, ArrowUpRight, PackagePlus } from 'lucide-react';
|
|
@@ -69,6 +66,7 @@ export function ViewportContainer() {
|
|
|
69
66
|
const releaseGeometryMemory = useViewerStore((s) => s.releaseGeometryMemory);
|
|
70
67
|
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
71
68
|
const typeVisibility = useViewerStore((s) => s.typeVisibility);
|
|
69
|
+
const typeViewMode = useViewerStore((s) => s.typeViewMode);
|
|
72
70
|
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
73
71
|
const classFilter = useViewerStore((s) => s.classFilter);
|
|
74
72
|
const resetViewerState = useViewerStore((s) => s.resetViewerState);
|
|
@@ -392,20 +390,6 @@ export function ViewportContainer() {
|
|
|
392
390
|
setCesiumSourceModelId(georef?.sourceModelId ?? null);
|
|
393
391
|
}, [georef?.sourceModelId, setCesiumSourceModelId]);
|
|
394
392
|
|
|
395
|
-
useEffect(() => {
|
|
396
|
-
// Recent files are a desktop-only feature — the web viewer should not
|
|
397
|
-
// show previously opened files in the landing page empty state.
|
|
398
|
-
if (!isTauri()) return;
|
|
399
|
-
|
|
400
|
-
const refreshRecentFiles = () => {
|
|
401
|
-
setRecentFiles(getRecentFiles().slice(0, 3));
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
refreshRecentFiles();
|
|
405
|
-
window.addEventListener('focus', refreshRecentFiles);
|
|
406
|
-
return () => window.removeEventListener('focus', refreshRecentFiles);
|
|
407
|
-
}, []);
|
|
408
|
-
|
|
409
393
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
410
394
|
e.preventDefault();
|
|
411
395
|
e.stopPropagation();
|
|
@@ -504,7 +488,6 @@ export function ViewportContainer() {
|
|
|
504
488
|
|
|
505
489
|
const handleStartBlank = useCallback(async () => {
|
|
506
490
|
if (!webgpu.supported) return;
|
|
507
|
-
void logToDesktopTerminal('info', '[ViewportContainer] Start blank IFC clicked');
|
|
508
491
|
const file = createBlankIfcFile();
|
|
509
492
|
// Must await: loadFile() calls resetViewerState() internally which
|
|
510
493
|
// resets activeTool back to 'select'. Setting addElement before that
|
|
@@ -526,6 +509,7 @@ export function ViewportContainer() {
|
|
|
526
509
|
const filteredSourceLenRef = useRef(0);
|
|
527
510
|
const filteredSourceRef = useRef<MeshData[] | null>(null);
|
|
528
511
|
const filteredTypeVisRef = useRef(typeVisibility);
|
|
512
|
+
const filteredTypeModeRef = useRef(typeViewMode);
|
|
529
513
|
const filteredVersionRef = useRef(0);
|
|
530
514
|
|
|
531
515
|
const filteredGeometry = useMemo(() => {
|
|
@@ -540,18 +524,21 @@ export function ViewportContainer() {
|
|
|
540
524
|
const allMeshes = mergedGeometryResult.meshes;
|
|
541
525
|
const cache = filteredCacheRef.current;
|
|
542
526
|
|
|
543
|
-
// Full rebuild if: type visibility changed,
|
|
527
|
+
// Full rebuild if: type visibility changed, view mode changed, source shrunk
|
|
528
|
+
// (new file), or empty cache
|
|
544
529
|
const prevVis = filteredTypeVisRef.current;
|
|
545
530
|
const typeVisChanged =
|
|
546
531
|
prevVis.spaces !== typeVisibility.spaces ||
|
|
547
532
|
prevVis.openings !== typeVisibility.openings ||
|
|
548
|
-
prevVis.site !== typeVisibility.site
|
|
533
|
+
prevVis.site !== typeVisibility.site ||
|
|
534
|
+
filteredTypeModeRef.current !== typeViewMode;
|
|
549
535
|
const sourceChanged = filteredSourceRef.current !== allMeshes;
|
|
550
536
|
if (typeVisChanged || sourceChanged || allMeshes.length < filteredSourceLenRef.current) {
|
|
551
537
|
cache.length = 0;
|
|
552
538
|
filteredSourceLenRef.current = 0;
|
|
553
539
|
filteredSourceRef.current = allMeshes;
|
|
554
540
|
filteredTypeVisRef.current = typeVisibility;
|
|
541
|
+
filteredTypeModeRef.current = typeViewMode;
|
|
555
542
|
}
|
|
556
543
|
|
|
557
544
|
const needsFilter = !typeVisibility.spaces || !typeVisibility.openings || !typeVisibility.site;
|
|
@@ -562,6 +549,18 @@ export function ViewportContainer() {
|
|
|
562
549
|
const mesh = allMeshes[i];
|
|
563
550
|
const ifcType = mesh.ifcType;
|
|
564
551
|
|
|
552
|
+
// Model/Types view switch (#957 follow-up). geometryClass: 0 = occurrence,
|
|
553
|
+
// 1 = orphan type (no occurrence — shown in BOTH modes since it's the only
|
|
554
|
+
// geometry), 2 = instanced type-library shape. In 'model' mode hide class 2
|
|
555
|
+
// (else the AC20 duplicate boxes at the MappingOrigin reappear); in 'types'
|
|
556
|
+
// mode hide occurrences (class 0) so only the type library shows.
|
|
557
|
+
const geometryClass = mesh.geometryClass ?? 0;
|
|
558
|
+
if (typeViewMode === 'types') {
|
|
559
|
+
if (geometryClass === 0) continue;
|
|
560
|
+
} else if (geometryClass === 2) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
|
|
565
564
|
if (needsFilter) {
|
|
566
565
|
if (ifcType === 'IfcSpace' && !typeVisibility.spaces) continue;
|
|
567
566
|
if (ifcType === 'IfcOpeningElement' && !typeVisibility.openings) continue;
|
|
@@ -588,7 +587,7 @@ export function ViewportContainer() {
|
|
|
588
587
|
// Return the same array reference — downstream change detection uses
|
|
589
588
|
// geometryVersion (which increments each batch) instead of array identity.
|
|
590
589
|
return cache;
|
|
591
|
-
}, [mergedGeometryResult, typeVisibility]);
|
|
590
|
+
}, [mergedGeometryResult, typeVisibility, typeViewMode]);
|
|
592
591
|
|
|
593
592
|
// Version counter that changes every batch — triggers useGeometryStreaming
|
|
594
593
|
// without requiring a new geometry array reference.
|
|
@@ -882,27 +881,10 @@ export function ViewportContainer() {
|
|
|
882
881
|
*/}
|
|
883
882
|
{/* Track 1 — open / drag */}
|
|
884
883
|
<button
|
|
885
|
-
onClick={
|
|
884
|
+
onClick={() => {
|
|
886
885
|
if (!webgpu.supported) {
|
|
887
886
|
return;
|
|
888
887
|
}
|
|
889
|
-
|
|
890
|
-
void logToDesktopTerminal('info', '[ViewportContainer] Empty-state open button clicked');
|
|
891
|
-
const file = await openIfcFileDialog();
|
|
892
|
-
if (file) {
|
|
893
|
-
void logToDesktopTerminal('info', `[ViewportContainer] Native dialog selected ${file.path}`);
|
|
894
|
-
recordRecentFiles([{
|
|
895
|
-
name: file.name,
|
|
896
|
-
size: file.size,
|
|
897
|
-
path: file.path,
|
|
898
|
-
modifiedMs: file.modifiedMs ?? null,
|
|
899
|
-
}]);
|
|
900
|
-
setRecentFiles(getRecentFiles().slice(0, 3));
|
|
901
|
-
loadFile(file);
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
void logToDesktopTerminal('info', '[ViewportContainer] Falling back to browser file input');
|
|
906
888
|
fileInputRef.current?.click();
|
|
907
889
|
}}
|
|
908
890
|
disabled={!webgpu.supported || webgpu.checking}
|
|
@@ -1064,7 +1046,7 @@ export function ViewportContainer() {
|
|
|
1064
1046
|
)}
|
|
1065
1047
|
|
|
1066
1048
|
{/* Cesium 3D world context overlay — rendered behind the WebGPU canvas (web only) */}
|
|
1067
|
-
{cesiumEnabled && georef &&
|
|
1049
|
+
{cesiumEnabled && georef && (
|
|
1068
1050
|
<CesiumOverlay
|
|
1069
1051
|
mapConversion={georef.mapConversion}
|
|
1070
1052
|
cameraMapConversion={georef.baseMapConversion}
|
|
@@ -1075,7 +1057,7 @@ export function ViewportContainer() {
|
|
|
1075
1057
|
storeyElevations={georef.storeyElevations}
|
|
1076
1058
|
/>
|
|
1077
1059
|
)}
|
|
1078
|
-
{cesiumEnabled && georef?.mapConversion &&
|
|
1060
|
+
{cesiumEnabled && georef?.mapConversion && georef.baseMapConversion && (
|
|
1079
1061
|
<CesiumPlacementEditor
|
|
1080
1062
|
modelId={georef.sourceModelId}
|
|
1081
1063
|
mapConversion={georef.mapConversion}
|
|
@@ -1094,7 +1076,7 @@ export function ViewportContainer() {
|
|
|
1094
1076
|
coordinateInfo={mergedGeometryResult?.coordinateInfo}
|
|
1095
1077
|
computedIsolatedIds={computedIsolatedIds}
|
|
1096
1078
|
modelIdToIndex={modelIdToIndex}
|
|
1097
|
-
cesiumActive={cesiumEnabled && georef !== null
|
|
1079
|
+
cesiumActive={cesiumEnabled && georef !== null}
|
|
1098
1080
|
releaseGeometryAfterStream={false}
|
|
1099
1081
|
onGeometryReleased={releaseGeometryMemory}
|
|
1100
1082
|
/>
|
|
@@ -15,15 +15,12 @@ import { useViewerStore } from '@/store';
|
|
|
15
15
|
import { goHomeFromStore } from '@/store/homeView';
|
|
16
16
|
import { useIfc } from '@/hooks/useIfc';
|
|
17
17
|
import { cn } from '@/lib/utils';
|
|
18
|
-
import { isTauri } from '@/lib/platform';
|
|
19
18
|
import { ViewCube, type ViewCubeRef } from './ViewCube';
|
|
20
19
|
import { AxisHelper, type AxisHelperRef } from './AxisHelper';
|
|
21
20
|
import { BasepointOverlay } from './BasepointOverlay';
|
|
22
21
|
import { PointCloudPanel } from './PointCloudPanel';
|
|
23
22
|
import { Crosshair } from 'lucide-react';
|
|
24
23
|
|
|
25
|
-
const isDesktop = isTauri();
|
|
26
|
-
|
|
27
24
|
export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: boolean } = {}) {
|
|
28
25
|
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
29
26
|
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
@@ -144,7 +141,7 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
|
|
|
144
141
|
<>
|
|
145
142
|
<PointCloudPanelMount />
|
|
146
143
|
{/* Bottom-right: Navigation controls (hidden when Cesium active — Cesium is web-only) */}
|
|
147
|
-
{!
|
|
144
|
+
{!cesiumEnabled && (
|
|
148
145
|
<div
|
|
149
146
|
className={cn(
|
|
150
147
|
'absolute flex flex-col gap-1 bg-background/90 backdrop-blur-sm border p-1',
|
|
@@ -275,7 +275,7 @@ export function HierarchyNode({
|
|
|
275
275
|
{/* Name */}
|
|
276
276
|
<span className={cn(
|
|
277
277
|
'flex-1 text-sm truncate ml-1.5',
|
|
278
|
-
isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' || node.type === 'type-group'
|
|
278
|
+
isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' || node.type === 'type-group' || node.type === 'material-group'
|
|
279
279
|
? 'font-medium text-zinc-900 dark:text-zinc-100'
|
|
280
280
|
: 'text-zinc-700 dark:text-zinc-300',
|
|
281
281
|
nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
|
|
@@ -306,11 +306,11 @@ export function HierarchyNode({
|
|
|
306
306
|
<Tooltip>
|
|
307
307
|
<TooltipTrigger asChild>
|
|
308
308
|
<span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-950 px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-none">
|
|
309
|
-
{node.elementCount}
|
|
309
|
+
{node.elementCount.toLocaleString()}
|
|
310
310
|
</span>
|
|
311
311
|
</TooltipTrigger>
|
|
312
312
|
<TooltipContent>
|
|
313
|
-
<p className="text-xs">{node.elementCount} {node.elementCount === 1 ? 'element' : 'elements'}</p>
|
|
313
|
+
<p className="text-xs">{node.elementCount.toLocaleString()} {node.elementCount === 1 ? 'element' : 'elements'}</p>
|
|
314
314
|
</TooltipContent>
|
|
315
315
|
</Tooltip>
|
|
316
316
|
)}
|
|
@@ -140,6 +140,15 @@ export const IFC_ICON_CODEPOINTS: Record<string, string> = {
|
|
|
140
140
|
IfcTrackElement: '\ue260', // "linear_scale"
|
|
141
141
|
IfcVehicle: '\ue531', // "directions_car"
|
|
142
142
|
|
|
143
|
+
// Materials (Materials hierarchy tab)
|
|
144
|
+
IfcMaterial: '\ue4f4', // "texture"
|
|
145
|
+
IfcMaterialLayerSet: '\ue8fe', // "layers"
|
|
146
|
+
IfcMaterialLayerSetUsage: '\ue8fe',
|
|
147
|
+
IfcMaterialProfileSet: '\ue8fe',
|
|
148
|
+
IfcMaterialProfileSetUsage: '\ue8fe',
|
|
149
|
+
IfcMaterialConstituentSet: '\ue4f4',
|
|
150
|
+
IfcMaterialList: '\ue4f4',
|
|
151
|
+
|
|
143
152
|
// Proxy / generic fallback
|
|
144
153
|
IfcProduct: '\ue047',
|
|
145
154
|
IfcBuildingElementProxy: '\ue047',
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type SpatialNode,
|
|
13
13
|
} from '@ifc-lite/data';
|
|
14
14
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
15
|
+
import { buildMaterialUsageIndex } from '@ifc-lite/parser';
|
|
15
16
|
import { useViewerStore, type FederatedModel } from '@/store';
|
|
16
17
|
import { toGlobalIdFromModels } from '@/store/globalId';
|
|
17
18
|
import type { TreeNode, NodeType, StoreyData, UnifiedStorey } from './types';
|
|
@@ -712,6 +713,92 @@ export function buildIfcTypeTree(
|
|
|
712
713
|
return nodes;
|
|
713
714
|
}
|
|
714
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Build a flat "By Material" tree: one row per base material (IfcMaterial),
|
|
718
|
+
* grouped by name so the same-named material across federated models merges.
|
|
719
|
+
* Each row carries the using elements' global ids for click-to-isolate and the
|
|
720
|
+
* representative material express id for the properties panel. Mirrors
|
|
721
|
+
* {@link buildIfcTypeTree} but keyed on the parser's material usage index.
|
|
722
|
+
*/
|
|
723
|
+
export function buildMaterialTree(
|
|
724
|
+
models: Map<string, FederatedModel>,
|
|
725
|
+
ifcDataStore: IfcDataStore | null | undefined,
|
|
726
|
+
_expandedNodes: Set<string>,
|
|
727
|
+
_isMultiModel: boolean,
|
|
728
|
+
geometricIds?: Set<number>,
|
|
729
|
+
): TreeNode[] {
|
|
730
|
+
interface MatEntry {
|
|
731
|
+
name: string;
|
|
732
|
+
ifcClass: string;
|
|
733
|
+
materialId: number; // representative material express id
|
|
734
|
+
modelIds: Set<string>; // contributing models (insertion order)
|
|
735
|
+
elements: Map<number, number>; // globalId -> expressId (deduped)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const byName = new Map<string, MatEntry>();
|
|
739
|
+
const applyGeomFilter = !!geometricIds && geometricIds.size > 0;
|
|
740
|
+
|
|
741
|
+
const processDataStore = (dataStore: IfcDataStore, modelId: string) => {
|
|
742
|
+
const usage = buildMaterialUsageIndex(dataStore);
|
|
743
|
+
for (const u of usage.values()) {
|
|
744
|
+
let entry = byName.get(u.name);
|
|
745
|
+
if (!entry) {
|
|
746
|
+
// Invariant: the representative `materialId` and the first entry in
|
|
747
|
+
// `modelIds` come from the SAME (first-contributing) model, so the click
|
|
748
|
+
// handler's `node.modelIds[0]` + `node.entityExpressId` always resolve a
|
|
749
|
+
// valid (model, material) pair. Sets preserve insertion order.
|
|
750
|
+
entry = {
|
|
751
|
+
name: u.name,
|
|
752
|
+
ifcClass: u.ifcClass,
|
|
753
|
+
materialId: u.id,
|
|
754
|
+
modelIds: new Set([modelId]),
|
|
755
|
+
elements: new Map(),
|
|
756
|
+
};
|
|
757
|
+
byName.set(u.name, entry);
|
|
758
|
+
} else {
|
|
759
|
+
entry.modelIds.add(modelId);
|
|
760
|
+
}
|
|
761
|
+
for (const { entityId } of u.entries) {
|
|
762
|
+
const globalId = resolveTreeGlobalId(modelId, entityId, models);
|
|
763
|
+
if (applyGeomFilter && !geometricIds!.has(globalId)) continue;
|
|
764
|
+
entry.elements.set(globalId, entityId);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
if (models.size > 0) {
|
|
770
|
+
for (const [modelId, model] of models) {
|
|
771
|
+
if (model.ifcDataStore) processDataStore(model.ifcDataStore, modelId);
|
|
772
|
+
}
|
|
773
|
+
} else if (ifcDataStore) {
|
|
774
|
+
processDataStore(ifcDataStore, 'legacy');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const nodes: TreeNode[] = [];
|
|
778
|
+
const names = Array.from(byName.keys()).sort((a, b) => a.localeCompare(b));
|
|
779
|
+
for (const name of names) {
|
|
780
|
+
const entry = byName.get(name)!;
|
|
781
|
+
if (entry.elements.size === 0) continue; // skip materials with no visible elements (dead clicks)
|
|
782
|
+
nodes.push({
|
|
783
|
+
id: `material-${name}`,
|
|
784
|
+
expressIds: Array.from(entry.elements.values()),
|
|
785
|
+
globalIds: Array.from(entry.elements.keys()),
|
|
786
|
+
entityExpressId: entry.materialId,
|
|
787
|
+
modelIds: Array.from(entry.modelIds),
|
|
788
|
+
name,
|
|
789
|
+
type: 'material-group',
|
|
790
|
+
ifcType: entry.ifcClass,
|
|
791
|
+
depth: 0,
|
|
792
|
+
hasChildren: false,
|
|
793
|
+
isExpanded: false,
|
|
794
|
+
isVisible: true,
|
|
795
|
+
elementCount: entry.elements.size,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return nodes;
|
|
800
|
+
}
|
|
801
|
+
|
|
715
802
|
/** Filter nodes based on search query */
|
|
716
803
|
export function filterNodes(nodes: TreeNode[], searchQuery: string): TreeNode[] {
|
|
717
804
|
if (!searchQuery.trim()) return nodes;
|
|
@@ -22,6 +22,7 @@ export type NodeType =
|
|
|
22
22
|
| 'IfcSpace' // Space node
|
|
23
23
|
| 'type-group' // IFC class grouping header (e.g., "IfcWall (47)")
|
|
24
24
|
| 'ifc-type' // IFC type entity node (e.g., "IfcWallType/W01")
|
|
25
|
+
| 'material-group' // Material grouping (e.g., "Concrete (47)") from the Materials tab
|
|
25
26
|
| 'element'; // Individual element
|
|
26
27
|
|
|
27
28
|
export interface TreeNode {
|
|
@@ -13,11 +13,12 @@ import {
|
|
|
13
13
|
buildTreeData,
|
|
14
14
|
buildTypeTree,
|
|
15
15
|
buildIfcTypeTree,
|
|
16
|
+
buildMaterialTree,
|
|
16
17
|
filterNodes,
|
|
17
18
|
splitNodes,
|
|
18
19
|
} from './treeDataBuilder';
|
|
19
20
|
|
|
20
|
-
export type GroupingMode = 'spatial' | 'type' | 'ifc-type';
|
|
21
|
+
export type GroupingMode = 'spatial' | 'type' | 'ifc-type' | 'material';
|
|
21
22
|
|
|
22
23
|
interface UseHierarchyTreeParams {
|
|
23
24
|
models: Map<string, FederatedModel>;
|
|
@@ -192,6 +193,9 @@ export function useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryR
|
|
|
192
193
|
if (groupingMode === 'ifc-type') {
|
|
193
194
|
return buildIfcTypeTree(models, ifcDataStore, expandedNodes, isMultiModel, geometricIds);
|
|
194
195
|
}
|
|
196
|
+
if (groupingMode === 'material') {
|
|
197
|
+
return buildMaterialTree(models, ifcDataStore, expandedNodes, isMultiModel, geometricIds);
|
|
198
|
+
}
|
|
195
199
|
return buildTreeData(models, ifcDataStore, expandedNodes, isMultiModel, unifiedStoreys);
|
|
196
200
|
},
|
|
197
201
|
[models, ifcDataStore, expandedNodes, isMultiModel, unifiedStoreys, groupingMode, geometricIds]
|
|
@@ -223,7 +227,7 @@ export function useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryR
|
|
|
223
227
|
|
|
224
228
|
// Get all elements for a node (handles type groups, ifc-type, unified storeys, single storeys, model contributions, and elements)
|
|
225
229
|
const getNodeElements = useCallback((node: TreeNode): number[] => {
|
|
226
|
-
if (node.type === 'type-group' || node.type === 'ifc-type') {
|
|
230
|
+
if (node.type === 'type-group' || node.type === 'ifc-type' || node.type === 'material-group') {
|
|
227
231
|
// GlobalIds are pre-stored on the node during tree construction — O(1)
|
|
228
232
|
return node.globalIds;
|
|
229
233
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Material totals panel — shown when a material is selected from the
|
|
7
|
+
* "Materials" hierarchy tab. Surfaces the material's own property sets
|
|
8
|
+
* (IfcMaterialProperties) plus quantities aggregated across every element that
|
|
9
|
+
* uses the material. Volumes/areas are apportioned by each element's material
|
|
10
|
+
* share (layer thickness / constituent fraction), so a layered wall's volume is
|
|
11
|
+
* split between its concrete and insulation rather than double-counted.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useMemo } from 'react';
|
|
15
|
+
import { Layers, Calculator, Boxes, Info } from 'lucide-react';
|
|
16
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
17
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
18
|
+
import {
|
|
19
|
+
buildMaterialUsageIndex,
|
|
20
|
+
getMaterialDisplay,
|
|
21
|
+
extractMaterialPropertiesForMaterialId,
|
|
22
|
+
extractQuantitiesOnDemand,
|
|
23
|
+
type IfcDataStore,
|
|
24
|
+
} from '@ifc-lite/parser';
|
|
25
|
+
import { QuantityType } from '@ifc-lite/data';
|
|
26
|
+
import { PropertySetCard } from './PropertySetCard';
|
|
27
|
+
import type { PropertySet } from './encodingUtils';
|
|
28
|
+
|
|
29
|
+
interface MaterialTotals {
|
|
30
|
+
/** Number of elements using this material (across all loaded models). */
|
|
31
|
+
elementCount: number;
|
|
32
|
+
/** Elements that contributed at least one volume quantity. */
|
|
33
|
+
elementsWithVolume: number;
|
|
34
|
+
volume: number;
|
|
35
|
+
hasVolume: boolean;
|
|
36
|
+
area: number;
|
|
37
|
+
hasArea: boolean;
|
|
38
|
+
weight: number;
|
|
39
|
+
hasWeight: boolean;
|
|
40
|
+
/** Element count per IFC class, sorted desc. */
|
|
41
|
+
byClass: Array<{ ifcClass: string; count: number }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pick a quantity value by candidate names (case-insensitive), else by type. */
|
|
45
|
+
function pickQuantity(
|
|
46
|
+
byName: Map<string, number>,
|
|
47
|
+
candidates: string[],
|
|
48
|
+
): number | undefined {
|
|
49
|
+
for (const c of candidates) {
|
|
50
|
+
const v = byName.get(c);
|
|
51
|
+
if (v !== undefined) return v;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Format an aggregated quantity with magnitude-appropriate precision. */
|
|
57
|
+
function formatNumber(value: number): string {
|
|
58
|
+
if (value === 0) return '0';
|
|
59
|
+
if (Math.abs(value) >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
|
60
|
+
if (Math.abs(value) >= 1) return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
61
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function MaterialTotalsPanel({ materialId, modelId }: { materialId: number; modelId: string }) {
|
|
65
|
+
const { ifcDataStore, models } = useIfc();
|
|
66
|
+
|
|
67
|
+
// The store the selected material lives in, plus every loaded store (so the
|
|
68
|
+
// totals merge same-named materials across a federation).
|
|
69
|
+
const { selectedStore, allStores } = useMemo(() => {
|
|
70
|
+
const stores: IfcDataStore[] = [];
|
|
71
|
+
if (models.size > 0) {
|
|
72
|
+
for (const [, m] of models) {
|
|
73
|
+
if (m.ifcDataStore) stores.push(m.ifcDataStore as IfcDataStore);
|
|
74
|
+
}
|
|
75
|
+
} else if (ifcDataStore) {
|
|
76
|
+
stores.push(ifcDataStore as IfcDataStore);
|
|
77
|
+
}
|
|
78
|
+
const sel = modelId !== 'legacy'
|
|
79
|
+
? (models.get(modelId)?.ifcDataStore as IfcDataStore | undefined) ?? (ifcDataStore as IfcDataStore | null) ?? undefined
|
|
80
|
+
: (ifcDataStore as IfcDataStore | null) ?? undefined;
|
|
81
|
+
return { selectedStore: sel, allStores: stores.length > 0 ? stores : (sel ? [sel] : []) };
|
|
82
|
+
}, [models, ifcDataStore, modelId]);
|
|
83
|
+
|
|
84
|
+
const display = useMemo(() => {
|
|
85
|
+
if (!selectedStore) return { name: `Material #${materialId}`, type: 'IfcMaterial' };
|
|
86
|
+
return getMaterialDisplay(selectedStore, materialId);
|
|
87
|
+
}, [selectedStore, materialId]);
|
|
88
|
+
|
|
89
|
+
// The material's own property sets (Pset_Material*).
|
|
90
|
+
const psetGroups = useMemo(() => {
|
|
91
|
+
if (!selectedStore) return [];
|
|
92
|
+
return extractMaterialPropertiesForMaterialId(selectedStore, materialId);
|
|
93
|
+
}, [selectedStore, materialId]);
|
|
94
|
+
|
|
95
|
+
// Aggregate quantities across all elements using a material of this name.
|
|
96
|
+
const totals = useMemo<MaterialTotals>(() => {
|
|
97
|
+
const result: MaterialTotals = {
|
|
98
|
+
elementCount: 0,
|
|
99
|
+
elementsWithVolume: 0,
|
|
100
|
+
volume: 0,
|
|
101
|
+
hasVolume: false,
|
|
102
|
+
area: 0,
|
|
103
|
+
hasArea: false,
|
|
104
|
+
weight: 0,
|
|
105
|
+
hasWeight: false,
|
|
106
|
+
byClass: [],
|
|
107
|
+
};
|
|
108
|
+
const classCounts = new Map<string, number>();
|
|
109
|
+
const targetName = display.name;
|
|
110
|
+
|
|
111
|
+
for (const store of allStores) {
|
|
112
|
+
const usageIndex = buildMaterialUsageIndex(store);
|
|
113
|
+
// Forward map of entity -> quantity-set ids (when on-demand parsing is
|
|
114
|
+
// active). Used to skip the per-element extractor allocation for elements
|
|
115
|
+
// that carry no quantities — the common case in large models, so a
|
|
116
|
+
// material used by thousands of elements only pays the parse cost for the
|
|
117
|
+
// subset that actually has Qto data.
|
|
118
|
+
const qMap = store.onDemandQuantityMap;
|
|
119
|
+
for (const usage of usageIndex.values()) {
|
|
120
|
+
if (usage.name !== targetName) continue;
|
|
121
|
+
for (const { entityId, weight } of usage.entries) {
|
|
122
|
+
result.elementCount += 1;
|
|
123
|
+
|
|
124
|
+
const ifcClass = store.entityIndex.byId.get(entityId)?.type || usage.ifcClass;
|
|
125
|
+
classCounts.set(ifcClass, (classCounts.get(ifcClass) ?? 0) + 1);
|
|
126
|
+
|
|
127
|
+
if (qMap && !qMap.get(entityId)?.length) continue; // no quantities — skip extraction
|
|
128
|
+
const qsets = extractQuantitiesOnDemand(store, entityId);
|
|
129
|
+
if (qsets.length === 0) continue;
|
|
130
|
+
const volByName = new Map<string, number>();
|
|
131
|
+
const areaByName = new Map<string, number>();
|
|
132
|
+
const weightByName = new Map<string, number>();
|
|
133
|
+
for (const qset of qsets) {
|
|
134
|
+
for (const q of qset.quantities) {
|
|
135
|
+
const key = q.name.toLowerCase();
|
|
136
|
+
if (q.type === QuantityType.Volume) volByName.set(key, q.value);
|
|
137
|
+
else if (q.type === QuantityType.Area) areaByName.set(key, q.value);
|
|
138
|
+
else if (q.type === QuantityType.Weight) weightByName.set(key, q.value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const vol = pickQuantity(volByName, ['netvolume', 'grossvolume', 'volume'])
|
|
143
|
+
?? (volByName.size > 0 ? [...volByName.values()][0] : undefined);
|
|
144
|
+
if (vol !== undefined) {
|
|
145
|
+
result.volume += vol * weight;
|
|
146
|
+
result.hasVolume = true;
|
|
147
|
+
result.elementsWithVolume += 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const area = pickQuantity(areaByName, ['netarea', 'grossarea', 'netsidearea', 'grosssidearea', 'netfloorarea', 'grossfloorarea', 'area']);
|
|
151
|
+
if (area !== undefined) {
|
|
152
|
+
result.area += area * weight;
|
|
153
|
+
result.hasArea = true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const wt = pickQuantity(weightByName, ['netweight', 'grossweight', 'weight']);
|
|
157
|
+
if (wt !== undefined) {
|
|
158
|
+
result.weight += wt * weight;
|
|
159
|
+
result.hasWeight = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
result.byClass = [...classCounts.entries()]
|
|
166
|
+
.map(([ifcClass, count]) => ({ ifcClass, count }))
|
|
167
|
+
.sort((a, b) => b.count - a.count);
|
|
168
|
+
return result;
|
|
169
|
+
}, [allStores, display.name]);
|
|
170
|
+
|
|
171
|
+
const psetCount = psetGroups.reduce((sum, g) => sum + g.psets.length, 0);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className="h-full flex flex-col border-l-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
|
|
175
|
+
{/* Header */}
|
|
176
|
+
<div className="p-4 border-b-2 border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20 space-y-2">
|
|
177
|
+
<div className="flex items-start gap-3">
|
|
178
|
+
<div className="p-2 border-2 border-amber-200 dark:border-amber-800 bg-white dark:bg-zinc-950 shrink-0">
|
|
179
|
+
<Layers className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
|
180
|
+
</div>
|
|
181
|
+
<div className="flex-1 min-w-0 pt-0.5">
|
|
182
|
+
<h3 className="font-bold text-sm truncate uppercase tracking-tight text-zinc-900 dark:text-zinc-100 min-w-0">
|
|
183
|
+
{display.name}
|
|
184
|
+
</h3>
|
|
185
|
+
<p className="text-xs font-mono text-amber-600/80 dark:text-amber-400/80">{display.type}</p>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<ScrollArea className="flex-1 bg-white dark:bg-black">
|
|
191
|
+
<div className="p-3 space-y-3 w-full overflow-hidden">
|
|
192
|
+
{/* Totals */}
|
|
193
|
+
<div className="border-2 border-amber-200 dark:border-amber-800 bg-amber-50/20 dark:bg-amber-950/20">
|
|
194
|
+
<div className="flex items-center gap-2 px-2.5 py-2 border-b-2 border-amber-200 dark:border-amber-800">
|
|
195
|
+
<Calculator className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
|
|
196
|
+
<span className="font-bold text-xs text-amber-700 dark:text-amber-400 uppercase tracking-wide">Totals</span>
|
|
197
|
+
</div>
|
|
198
|
+
<div className="divide-y divide-amber-100 dark:divide-amber-900/30">
|
|
199
|
+
<TotalRow label="Elements" value={totals.elementCount.toLocaleString()} />
|
|
200
|
+
{totals.hasVolume && (
|
|
201
|
+
<TotalRow label="Volume" value={`${formatNumber(totals.volume)} m³`} />
|
|
202
|
+
)}
|
|
203
|
+
{totals.hasArea && (
|
|
204
|
+
<TotalRow label="Area" value={`${formatNumber(totals.area)} m²`} />
|
|
205
|
+
)}
|
|
206
|
+
{totals.hasWeight && (
|
|
207
|
+
<TotalRow label="Weight" value={`${formatNumber(totals.weight)} kg`} />
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
{totals.elementCount > 0 && !totals.hasVolume && (
|
|
211
|
+
<div className="flex items-start gap-1.5 px-2.5 py-2 text-[10px] text-zinc-500 dark:text-zinc-400 border-t border-amber-100 dark:border-amber-900/30">
|
|
212
|
+
<Info className="h-3 w-3 shrink-0 mt-px" />
|
|
213
|
+
<span>No volume quantities (Qto_*) found on these elements.</span>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
{totals.hasVolume && totals.elementsWithVolume < totals.elementCount && (
|
|
217
|
+
<div className="flex items-start gap-1.5 px-2.5 py-2 text-[10px] text-zinc-500 dark:text-zinc-400 border-t border-amber-100 dark:border-amber-900/30">
|
|
218
|
+
<Info className="h-3 w-3 shrink-0 mt-px" />
|
|
219
|
+
<span>
|
|
220
|
+
Volume from {totals.elementsWithVolume.toLocaleString()} of {totals.elementCount.toLocaleString()} elements with reported quantities;
|
|
221
|
+
multi-material elements are split by layer thickness / constituent fraction.
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Breakdown by class */}
|
|
228
|
+
{totals.byClass.length > 0 && (
|
|
229
|
+
<div className="border border-zinc-200 dark:border-zinc-800">
|
|
230
|
+
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-zinc-200 dark:border-zinc-800">
|
|
231
|
+
<Boxes className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
|
|
232
|
+
<span className="font-bold text-xs text-zinc-600 dark:text-zinc-300 uppercase tracking-wide">By Class</span>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="divide-y divide-zinc-100 dark:divide-zinc-900">
|
|
235
|
+
{totals.byClass.map((c) => (
|
|
236
|
+
<div key={c.ifcClass} className="flex items-center justify-between px-2.5 py-1.5 text-xs">
|
|
237
|
+
<span className="font-mono text-zinc-600 dark:text-zinc-400 truncate">{c.ifcClass}</span>
|
|
238
|
+
<span className="font-mono text-zinc-900 dark:text-zinc-100">{c.count.toLocaleString()}</span>
|
|
239
|
+
</div>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{/* Material property sets */}
|
|
246
|
+
{psetCount > 0 && (
|
|
247
|
+
<div className="space-y-3">
|
|
248
|
+
<div className="flex items-center gap-2 px-1 pt-1 pb-0.5 text-[11px] text-amber-600/70 dark:text-amber-400/60 uppercase tracking-wider font-semibold">
|
|
249
|
+
<Layers className="h-3 w-3 shrink-0" />
|
|
250
|
+
<span className="truncate">Material Properties</span>
|
|
251
|
+
</div>
|
|
252
|
+
{psetGroups.map((group) =>
|
|
253
|
+
group.psets.map((pset) => {
|
|
254
|
+
const psetView: PropertySet = {
|
|
255
|
+
name: pset.name,
|
|
256
|
+
properties: pset.properties.map((p) => ({ name: p.name, value: p.value, isMutated: false })),
|
|
257
|
+
};
|
|
258
|
+
return <PropertySetCard key={`${group.materialId}-${pset.name}`} pset={psetView} />;
|
|
259
|
+
}),
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{psetCount === 0 && totals.elementCount === 0 && (
|
|
265
|
+
<p className="text-sm text-zinc-500 dark:text-zinc-500 text-center py-8 font-mono">
|
|
266
|
+
No data for this material
|
|
267
|
+
</p>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</ScrollArea>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A single label/value row in the material totals card. */
|
|
276
|
+
function TotalRow({ label, value }: { label: string; value: string }) {
|
|
277
|
+
return (
|
|
278
|
+
<div className="flex items-center justify-between px-2.5 py-2 text-xs">
|
|
279
|
+
<span className="text-zinc-500 dark:text-zinc-400 font-medium">{label}</span>
|
|
280
|
+
<span className="font-mono font-semibold text-amber-700 dark:text-amber-300 tabular-nums">{value}</span>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
|
22
22
|
import type { Renderer } from '@ifc-lite/renderer';
|
|
23
23
|
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
24
|
-
import { logToDesktopTerminal } from '@/services/desktop-logger';
|
|
25
24
|
import { toast } from '../ui/toast.js';
|
|
26
25
|
|
|
27
26
|
// Session-scoped flag so the linear-infrastructure hint fires at most once
|
|
@@ -87,7 +86,6 @@ const MAX_VALID_COORD = 10000;
|
|
|
87
86
|
|
|
88
87
|
function traceGeometrySync(message: string): void {
|
|
89
88
|
console.log(`[GeomSync] ${message}`);
|
|
90
|
-
void logToDesktopTerminal('info', `[GeomSync] ${message}`);
|
|
91
89
|
}
|
|
92
90
|
|
|
93
91
|
export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
|