/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Material totals panel — shown when a material is selected from the * "Materials" hierarchy tab. Surfaces the material's own property sets * (IfcMaterialProperties) plus quantities aggregated across every element that * uses the material. Volumes/areas are apportioned by each element's material * share (layer thickness / constituent fraction), so a layered wall's volume is * split between its concrete and insulation rather than double-counted. */ import { useMemo } from 'react'; import { Layers, Calculator, Boxes, Info } from 'lucide-react'; import { ScrollArea } from '@/components/ui/scroll-area'; import { useIfc } from '@/hooks/useIfc'; import { buildMaterialUsageIndex, getMaterialDisplay, extractMaterialPropertiesForMaterialId, extractQuantitiesOnDemand, type IfcDataStore, } from '@ifc-lite/parser'; import { QuantityType } from '@ifc-lite/data'; import { PropertySetCard } from './PropertySetCard'; import type { PropertySet } from './encodingUtils'; interface MaterialTotals { /** Number of elements using this material (across all loaded models). */ elementCount: number; /** Elements that contributed at least one volume quantity. */ elementsWithVolume: number; volume: number; hasVolume: boolean; area: number; hasArea: boolean; weight: number; hasWeight: boolean; /** Element count per IFC class, sorted desc. */ byClass: Array<{ ifcClass: string; count: number }>; } /** Pick a quantity value by candidate names (case-insensitive), else by type. */ function pickQuantity( byName: Map, candidates: string[], ): number | undefined { for (const c of candidates) { const v = byName.get(c); if (v !== undefined) return v; } return undefined; } /** Format an aggregated quantity with magnitude-appropriate precision. */ function formatNumber(value: number): string { if (value === 0) return '0'; if (Math.abs(value) >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); if (Math.abs(value) >= 1) return value.toLocaleString(undefined, { maximumFractionDigits: 2 }); return value.toLocaleString(undefined, { maximumFractionDigits: 4 }); } export function MaterialTotalsPanel({ materialId, modelId }: { materialId: number; modelId: string }) { const { ifcDataStore, models } = useIfc(); // The store the selected material lives in, plus every loaded store (so the // totals merge same-named materials across a federation). const { selectedStore, allStores } = useMemo(() => { const stores: IfcDataStore[] = []; if (models.size > 0) { for (const [, m] of models) { if (m.ifcDataStore) stores.push(m.ifcDataStore as IfcDataStore); } } else if (ifcDataStore) { stores.push(ifcDataStore as IfcDataStore); } const sel = modelId !== 'legacy' ? (models.get(modelId)?.ifcDataStore as IfcDataStore | undefined) ?? (ifcDataStore as IfcDataStore | null) ?? undefined : (ifcDataStore as IfcDataStore | null) ?? undefined; return { selectedStore: sel, allStores: stores.length > 0 ? stores : (sel ? [sel] : []) }; }, [models, ifcDataStore, modelId]); const display = useMemo(() => { if (!selectedStore) return { name: `Material #${materialId}`, type: 'IfcMaterial' }; return getMaterialDisplay(selectedStore, materialId); }, [selectedStore, materialId]); // The material's own property sets (Pset_Material*). const psetGroups = useMemo(() => { if (!selectedStore) return []; return extractMaterialPropertiesForMaterialId(selectedStore, materialId); }, [selectedStore, materialId]); // Aggregate quantities across all elements using a material of this name. const totals = useMemo(() => { const result: MaterialTotals = { elementCount: 0, elementsWithVolume: 0, volume: 0, hasVolume: false, area: 0, hasArea: false, weight: 0, hasWeight: false, byClass: [], }; const classCounts = new Map(); const targetName = display.name; for (const store of allStores) { const usageIndex = buildMaterialUsageIndex(store); // Forward map of entity -> quantity-set ids (when on-demand parsing is // active). Used to skip the per-element extractor allocation for elements // that carry no quantities — the common case in large models, so a // material used by thousands of elements only pays the parse cost for the // subset that actually has Qto data. const qMap = store.onDemandQuantityMap; for (const usage of usageIndex.values()) { if (usage.name !== targetName) continue; for (const { entityId, weight } of usage.entries) { result.elementCount += 1; const ifcClass = store.entityIndex.byId.get(entityId)?.type || usage.ifcClass; classCounts.set(ifcClass, (classCounts.get(ifcClass) ?? 0) + 1); if (qMap && !qMap.get(entityId)?.length) continue; // no quantities — skip extraction const qsets = extractQuantitiesOnDemand(store, entityId); if (qsets.length === 0) continue; const volByName = new Map(); const areaByName = new Map(); const weightByName = new Map(); for (const qset of qsets) { for (const q of qset.quantities) { const key = q.name.toLowerCase(); if (q.type === QuantityType.Volume) volByName.set(key, q.value); else if (q.type === QuantityType.Area) areaByName.set(key, q.value); else if (q.type === QuantityType.Weight) weightByName.set(key, q.value); } } const vol = pickQuantity(volByName, ['netvolume', 'grossvolume', 'volume']) ?? (volByName.size > 0 ? [...volByName.values()][0] : undefined); if (vol !== undefined) { result.volume += vol * weight; result.hasVolume = true; result.elementsWithVolume += 1; } const area = pickQuantity(areaByName, ['netarea', 'grossarea', 'netsidearea', 'grosssidearea', 'netfloorarea', 'grossfloorarea', 'area']); if (area !== undefined) { result.area += area * weight; result.hasArea = true; } const wt = pickQuantity(weightByName, ['netweight', 'grossweight', 'weight']); if (wt !== undefined) { result.weight += wt * weight; result.hasWeight = true; } } } } result.byClass = [...classCounts.entries()] .map(([ifcClass, count]) => ({ ifcClass, count })) .sort((a, b) => b.count - a.count); return result; }, [allStores, display.name]); const psetCount = psetGroups.reduce((sum, g) => sum + g.psets.length, 0); return (
{/* Header */}

{display.name}

{display.type}

{/* Totals */}
Totals
{totals.hasVolume && ( )} {totals.hasArea && ( )} {totals.hasWeight && ( )}
{totals.elementCount > 0 && !totals.hasVolume && (
No volume quantities (Qto_*) found on these elements.
)} {totals.hasVolume && totals.elementsWithVolume < totals.elementCount && (
Volume from {totals.elementsWithVolume.toLocaleString()} of {totals.elementCount.toLocaleString()} elements with reported quantities; multi-material elements are split by layer thickness / constituent fraction.
)}
{/* Breakdown by class */} {totals.byClass.length > 0 && (
By Class
{totals.byClass.map((c) => (
{c.ifcClass} {c.count.toLocaleString()}
))}
)} {/* Material property sets */} {psetCount > 0 && (
Material Properties
{psetGroups.map((group) => group.psets.map((pset) => { const psetView: PropertySet = { name: pset.name, properties: pset.properties.map((p) => ({ name: p.name, value: p.value, isMutated: false })), }; return ; }), )}
)} {psetCount === 0 && totals.elementCount === 0 && (

No data for this material

)}
); } /** A single label/value row in the material totals card. */ function TotalRow({ label, value }: { label: string; value: string }) { return (
{label} {value}
); }