@ifc-lite/viewer 1.27.0 → 1.28.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 +38 -38
- package/CHANGELOG.md +64 -0
- package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-BNRDNuUJ.js} +8 -8
- package/dist/assets/{bcf-QeHK_Aud.js → bcf-DCwCuP7n.js} +56 -56
- package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/{deflate-B-d0SYQM.js → deflate-DNGgs8Ur.js} +1 -1
- package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/{exporters-B4LbZFeT.js → exporters-B9v81gi9.js} +1249 -1140
- package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
- package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-D-YCLS4g.js} +10 -10
- package/dist/assets/{ids-DjsGFN10.js → ids-CCpq-5d3.js} +952 -945
- package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-DbgS5EUA.wasm} +0 -0
- package/dist/assets/{index-COYokSKc.js → index-Bgb3_Pu_.js} +41073 -38715
- package/dist/assets/index-BtbXFKsX.css +1 -0
- package/dist/assets/{index.es-CY202jA3.js → index.es-CWfqZyyr.js} +9 -9
- package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-DGOAeUqU.js} +1 -1
- package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-XPLU2Wkq.js} +4 -4
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-DmW0_tgf.js → lerc-1PMSCHwX.js} +1 -1
- package/dist/assets/{lzw-oWetY-d6.js → lzw-C65U9lNM.js} +1 -1
- package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-XxXos6yI.js} +2 -2
- package/dist/assets/{packbits-F8Nkp4NY.js → packbits-BdMWXC3m.js} +1 -1
- package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
- package/dist/assets/{pdf-Dsh3HPZB.js → pdf-CRwaZf3s.js} +10 -10
- package/dist/assets/raw-CJgQdyuZ.js +1 -0
- package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-0sDo3g3m.js} +2960 -2552
- package/dist/assets/server-client-cTCJ-853.js +719 -0
- package/dist/assets/{webimage-BLV1dgmd.js → webimage-BtakWX7W.js} +1 -1
- package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-B1YOg2QB.js} +8 -8
- package/dist/assets/{zstd-C_1HxVrA.js → zstd-CmwsbxmM.js} +1 -1
- package/dist/index.html +9 -9
- package/package.json +24 -23
- package/src/components/mcp/playground-dispatcher.ts +3 -0
- package/src/components/mcp/playground-files.ts +33 -1
- package/src/components/viewer/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +46 -7
- package/src/components/viewer/MainToolbar.tsx +19 -2
- package/src/components/viewer/PropertiesPanel.tsx +71 -2
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +3 -0
- 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/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +481 -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 +23 -1
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +32 -9
- package/src/hooks/useIfcFederation.ts +42 -810
- package/src/hooks/useIfcLoader.ts +361 -488
- 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/store/globalId.ts +15 -13
- package/src/store/index.ts +16 -1
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/lensSlice.ts +8 -0
- package/src/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/desktopModelSnapshot.ts +2 -1
- 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/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.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/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
|
@@ -36,7 +36,7 @@ import { getNativeEntityDetails } from '@/services/desktop-native-metadata';
|
|
|
36
36
|
import { configureMutationView } from '@/utils/configureMutationView';
|
|
37
37
|
import { IfcQuery } from '@ifc-lite/query';
|
|
38
38
|
import { MutablePropertyView } from '@ifc-lite/mutations';
|
|
39
|
-
import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, extractGeoreferencingOnDemand, extractLengthUnitScale, getAttributeNames, type IfcDataStore } from '@ifc-lite/parser';
|
|
39
|
+
import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractMaterialPropertiesOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, extractGeoreferencingOnDemand, extractLengthUnitScale, getAttributeNames, type IfcDataStore, type MaterialPsetGroup } from '@ifc-lite/parser';
|
|
40
40
|
import type { NewEntity } from '@ifc-lite/mutations';
|
|
41
41
|
import { EntityFlags, RelationshipType, isSpatialStructureTypeName, isStoreyLikeSpatialTypeName } from '@ifc-lite/data';
|
|
42
42
|
import type { EntityRef, FederatedModel } from '@/store/types';
|
|
@@ -47,6 +47,7 @@ import { QuantitySetCard } from './properties/QuantitySetCard';
|
|
|
47
47
|
import { ModelMetadataPanel } from './properties/ModelMetadataPanel';
|
|
48
48
|
import { ClassificationCard } from './properties/ClassificationCard';
|
|
49
49
|
import { MaterialCard } from './properties/MaterialCard';
|
|
50
|
+
import { MaterialTotalsPanel } from './properties/MaterialTotalsPanel';
|
|
50
51
|
import { ScheduleCard } from './properties/ScheduleCard';
|
|
51
52
|
import { TaskEditCard } from './properties/TaskEditCard';
|
|
52
53
|
import { DocumentCard } from './properties/DocumentCard';
|
|
@@ -56,6 +57,17 @@ import { BsddCard } from './properties/BsddCard';
|
|
|
56
57
|
import { GeoreferencingPanel } from './properties/GeoreferencingPanel';
|
|
57
58
|
import { RawStepCard } from './properties/RawStepCard';
|
|
58
59
|
|
|
60
|
+
/** IFC material *definition* classes selectable from the Materials tab. */
|
|
61
|
+
const MATERIAL_DEF_TYPES = new Set([
|
|
62
|
+
'IFCMATERIAL',
|
|
63
|
+
'IFCMATERIALLAYERSET',
|
|
64
|
+
'IFCMATERIALLAYERSETUSAGE',
|
|
65
|
+
'IFCMATERIALPROFILESET',
|
|
66
|
+
'IFCMATERIALPROFILESETUSAGE',
|
|
67
|
+
'IFCMATERIALCONSTITUENTSET',
|
|
68
|
+
'IFCMATERIALLIST',
|
|
69
|
+
]);
|
|
70
|
+
|
|
59
71
|
type DisplayProperty = { name: string; value: unknown; isMutated: boolean };
|
|
60
72
|
type DisplayPropertySet = {
|
|
61
73
|
name: string;
|
|
@@ -450,6 +462,16 @@ export function PropertiesPanel() {
|
|
|
450
462
|
return typeName.endsWith('Type');
|
|
451
463
|
}, [selectedEntity, model, ifcDataStore]);
|
|
452
464
|
|
|
465
|
+
// Detect a material definition selected from the "Materials" hierarchy tab.
|
|
466
|
+
// Materials aren't products, so the EntityTable's getTypeName doesn't cover
|
|
467
|
+
// them — read the raw class from the entity index instead.
|
|
468
|
+
const selectedMaterialId = useMemo(() => {
|
|
469
|
+
if (!selectedEntity) return null;
|
|
470
|
+
const dataStore = model?.ifcDataStore ?? ifcDataStore;
|
|
471
|
+
const rawType = (dataStore as IfcDataStore | null)?.entityIndex?.byId?.get(selectedEntity.expressId)?.type;
|
|
472
|
+
return rawType && MATERIAL_DEF_TYPES.has(rawType.toUpperCase()) ? selectedEntity.expressId : null;
|
|
473
|
+
}, [selectedEntity, model, ifcDataStore]);
|
|
474
|
+
|
|
453
475
|
// Unified property/quantity access - EntityNode handles on-demand extraction automatically
|
|
454
476
|
// These hooks must be called before any early return to maintain hook order
|
|
455
477
|
// Use MutablePropertyView as primary source when available (it handles base + mutations)
|
|
@@ -627,6 +649,17 @@ export function PropertiesPanel() {
|
|
|
627
649
|
return extractMaterialsOnDemand(dataStore as IfcDataStore, lookupExpressId);
|
|
628
650
|
}, [selectedEntity, lookupExpressId, model, ifcDataStore]);
|
|
629
651
|
|
|
652
|
+
// Property sets attached to the selected entity's material(s) via
|
|
653
|
+
// IfcMaterialProperties (e.g. Pset_MaterialConcrete). These live on the
|
|
654
|
+
// IfcMaterial — not on the object — so they never surface through the
|
|
655
|
+
// occurrence/type pset paths; resolve them through the material association.
|
|
656
|
+
const materialProperties: MaterialPsetGroup[] = useMemo(() => {
|
|
657
|
+
if (!selectedEntity || lookupExpressId === null) return [];
|
|
658
|
+
const dataStore = model?.ifcDataStore ?? ifcDataStore;
|
|
659
|
+
if (!dataStore) return [];
|
|
660
|
+
return extractMaterialPropertiesOnDemand(dataStore as IfcDataStore, lookupExpressId);
|
|
661
|
+
}, [selectedEntity, lookupExpressId, model, ifcDataStore]);
|
|
662
|
+
|
|
630
663
|
// Extract documents for the selected entity from the IFC data store
|
|
631
664
|
const documents = useMemo(() => {
|
|
632
665
|
if (!selectedEntity || lookupExpressId === null) return [];
|
|
@@ -1002,6 +1035,7 @@ export function PropertiesPanel() {
|
|
|
1002
1035
|
const renderedAttributes = isNativeLazySelection ? [] : attributes;
|
|
1003
1036
|
const renderedClassifications = isNativeLazySelection ? [] : classifications;
|
|
1004
1037
|
const renderedMaterialInfo = isNativeLazySelection ? null : materialInfo;
|
|
1038
|
+
const renderedMaterialProperties = isNativeLazySelection ? [] : materialProperties;
|
|
1005
1039
|
const renderedDocuments = isNativeLazySelection ? [] : documents;
|
|
1006
1040
|
const renderedEntityRelationships = isNativeLazySelection ? null : entityRelationships;
|
|
1007
1041
|
const renderedGeoref = isNativeLazySelection ? null : georef;
|
|
@@ -1053,6 +1087,12 @@ export function PropertiesPanel() {
|
|
|
1053
1087
|
}
|
|
1054
1088
|
}
|
|
1055
1089
|
|
|
1090
|
+
// Material selected from the "Materials" hierarchy tab — show the material's
|
|
1091
|
+
// own property sets plus quantities aggregated across all using elements.
|
|
1092
|
+
if (selectedMaterialId !== null && selectedEntity) {
|
|
1093
|
+
return <MaterialTotalsPanel materialId={selectedMaterialId} modelId={selectedEntity.modelId} />;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1056
1096
|
// Multi-entity selection (unified storeys) - render combined view
|
|
1057
1097
|
if (selectedEntities.length > 1) {
|
|
1058
1098
|
return (
|
|
@@ -1481,6 +1521,7 @@ export function PropertiesPanel() {
|
|
|
1481
1521
|
{renderedMergedProperties.length === 0
|
|
1482
1522
|
&& renderedClassifications.length === 0
|
|
1483
1523
|
&& !renderedMaterialInfo
|
|
1524
|
+
&& renderedMaterialProperties.length === 0
|
|
1484
1525
|
&& renderedDocuments.length === 0
|
|
1485
1526
|
&& !renderedEntityRelationships
|
|
1486
1527
|
&& !hasScheduleForSelection ? (
|
|
@@ -1560,10 +1601,38 @@ export function PropertiesPanel() {
|
|
|
1560
1601
|
</>
|
|
1561
1602
|
)}
|
|
1562
1603
|
|
|
1604
|
+
{/* Material Property Sets (Pset_Material* attached to the
|
|
1605
|
+
IfcMaterial via IfcMaterialProperties). Grouped per material,
|
|
1606
|
+
mirroring the Type Properties block. */}
|
|
1607
|
+
{renderedMaterialProperties.length > 0 && (
|
|
1608
|
+
<>
|
|
1609
|
+
{(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo) && (
|
|
1610
|
+
<div className="border-t border-amber-200 dark:border-amber-800/50 pt-2 mt-2" />
|
|
1611
|
+
)}
|
|
1612
|
+
{renderedMaterialProperties.map((group) => (
|
|
1613
|
+
<div key={`matpset-${group.materialId}`} className="space-y-3">
|
|
1614
|
+
<div className="flex items-center gap-2 px-1 pb-0.5 text-[11px] text-amber-600/70 dark:text-amber-400/60 uppercase tracking-wider font-semibold">
|
|
1615
|
+
<Layers className="h-3 w-3 shrink-0" />
|
|
1616
|
+
<span className="truncate">Material Properties ({group.materialName})</span>
|
|
1617
|
+
</div>
|
|
1618
|
+
{group.psets.map((pset) => (
|
|
1619
|
+
<PropertySetCard
|
|
1620
|
+
key={`matpset-${group.materialId}-${pset.name}`}
|
|
1621
|
+
pset={{
|
|
1622
|
+
name: pset.name,
|
|
1623
|
+
properties: pset.properties.map((p) => ({ name: p.name, value: p.value, isMutated: false })),
|
|
1624
|
+
}}
|
|
1625
|
+
/>
|
|
1626
|
+
))}
|
|
1627
|
+
</div>
|
|
1628
|
+
))}
|
|
1629
|
+
</>
|
|
1630
|
+
)}
|
|
1631
|
+
|
|
1563
1632
|
{/* Documents */}
|
|
1564
1633
|
{renderedDocuments.length > 0 && (
|
|
1565
1634
|
<>
|
|
1566
|
-
{(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo) && (
|
|
1635
|
+
{(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo || renderedMaterialProperties.length > 0) && (
|
|
1567
1636
|
<div className="border-t border-zinc-200 dark:border-zinc-800 pt-2 mt-2" />
|
|
1568
1637
|
)}
|
|
1569
1638
|
{renderedDocuments.map((doc, i) => (
|
|
@@ -29,6 +29,7 @@ import { BCFPanel } from './BCFPanel';
|
|
|
29
29
|
import { IDSPanel } from './IDSPanel';
|
|
30
30
|
import { LensPanel } from './LensPanel';
|
|
31
31
|
import { ClashPanel } from './ClashPanel';
|
|
32
|
+
import { ComparePanel } from './ComparePanel';
|
|
32
33
|
import { ListPanel } from './lists/ListPanel';
|
|
33
34
|
import { ScriptPanel } from './ScriptPanel';
|
|
34
35
|
import { GanttPanel } from './schedule/GanttPanel';
|
|
@@ -135,6 +136,8 @@ export function ViewerLayout() {
|
|
|
135
136
|
const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
|
|
136
137
|
const clashPanelVisible = useViewerStore((s) => s.clashPanelVisible);
|
|
137
138
|
const setClashPanelVisible = useViewerStore((s) => s.setClashPanelVisible);
|
|
139
|
+
const comparePanelVisible = useViewerStore((s) => s.comparePanelVisible);
|
|
140
|
+
const setComparePanelVisible = useViewerStore((s) => s.setComparePanelVisible);
|
|
138
141
|
const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
|
|
139
142
|
const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
|
|
140
143
|
const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
|
|
@@ -347,6 +350,8 @@ export function ViewerLayout() {
|
|
|
347
350
|
<LensPanel onClose={() => setLensPanelVisible(false)} />
|
|
348
351
|
) : clashPanelVisible ? (
|
|
349
352
|
<ClashPanel onClose={() => setClashPanelVisible(false)} />
|
|
353
|
+
) : comparePanelVisible ? (
|
|
354
|
+
<ComparePanel onClose={() => setComparePanelVisible(false)} />
|
|
350
355
|
) : idsPanelVisible ? (
|
|
351
356
|
<IDSPanel onClose={() => setIdsPanelVisible(false)} />
|
|
352
357
|
) : bcfPanelVisible ? (
|
|
@@ -808,6 +808,9 @@ export function Viewport({
|
|
|
808
808
|
}
|
|
809
809
|
setIsInitialized(false);
|
|
810
810
|
rendererRef.current = null;
|
|
811
|
+
// Free all WebGPU resources held by this renderer instance.
|
|
812
|
+
// destroy() is idempotent, so this is safe even if init() rejected.
|
|
813
|
+
renderer.destroy();
|
|
811
814
|
// Clear BCF global refs to prevent memory leaks
|
|
812
815
|
clearGlobalRefs();
|
|
813
816
|
};
|
|
@@ -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
|
+
}
|
|
@@ -59,11 +59,21 @@ describe('federationLoadGate', () => {
|
|
|
59
59
|
const bPromise = acquireFederationLoadSlot(50).then((id) => { order.push('b'); return id; });
|
|
60
60
|
|
|
61
61
|
await new Promise((r) => setTimeout(r, 10));
|
|
62
|
+
// Releasing the blocker frees the budget for the head of the FIFO queue.
|
|
63
|
+
// A 2048 MB load costs more than the whole budget, so the first-queued load
|
|
64
|
+
// is admitted alone (single-file exception) and the 50 MB load stays queued
|
|
65
|
+
// until it releases. Awaiting them together would deadlock — and asserting
|
|
66
|
+
// that B does NOT wake alongside A is exactly what proves the gate respects
|
|
67
|
+
// the budget during the drain (the regression this guards against).
|
|
62
68
|
releaseFederationLoadSlot(blocker);
|
|
63
69
|
|
|
64
|
-
const
|
|
65
|
-
assert.
|
|
70
|
+
const a = await aPromise;
|
|
71
|
+
assert.deepStrictEqual(order, ['a']);
|
|
72
|
+
assert.strictEqual(__getFederationLoadGateStats().queuedCount, 1);
|
|
73
|
+
|
|
66
74
|
releaseFederationLoadSlot(a);
|
|
75
|
+
const b = await bPromise;
|
|
76
|
+
assert.deepStrictEqual(order, ['a', 'b']);
|
|
67
77
|
releaseFederationLoadSlot(b);
|
|
68
78
|
});
|
|
69
79
|
|