/* 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/. */ import { useMemo, useState, useCallback, useEffect } from 'react'; import { Copy, Check, Focus, EyeOff, Eye, Building2, Layers, Layers2, FileText, Calculator, Tag, MousePointer2, ArrowUpDown, FileBox, PenLine, Crosshair, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { EditToolbar } from './PropertyEditor'; import { GeometryEditCard } from './GeometryEditCard'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useViewerStore } from '@/store'; import { toGlobalIdFromModels } from '@/store/globalId'; import { useIfc } from '@/hooks/useIfc'; import { configureMutationView } from '@/utils/configureMutationView'; import { IfcQuery } from '@ifc-lite/query'; import { MutablePropertyView } from '@ifc-lite/mutations'; import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractMaterialPropertiesOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, extractGroupMembersOnDemand, extractGeoreferencingOnDemand, extractLengthUnitScale, getAttributeNames, type IfcDataStore, type MaterialPsetGroup } from '@ifc-lite/parser'; import type { NewEntity } from '@ifc-lite/mutations'; import { EntityFlags, RelationshipType, isSpatialStructureTypeName, isStoreyLikeSpatialTypeName } from '@ifc-lite/data'; import type { EntityRef, FederatedModel } from '@/store/types'; import { CoordVal, CoordRow } from './properties/CoordinateDisplay'; import { PropertySetCard } from './properties/PropertySetCard'; import { QuantitySetCard } from './properties/QuantitySetCard'; import { ModelMetadataPanel } from './properties/ModelMetadataPanel'; import { ClassificationCard } from './properties/ClassificationCard'; import { MaterialCard } from './properties/MaterialCard'; import { MaterialTotalsPanel } from './properties/MaterialTotalsPanel'; import { ScheduleCard } from './properties/ScheduleCard'; import { TaskEditCard } from './properties/TaskEditCard'; import { DocumentCard } from './properties/DocumentCard'; import { RelationshipsCard } from './properties/RelationshipsCard'; import type { PropertySet, QuantitySet } from './properties/encodingUtils'; import { BsddCard } from './properties/BsddCard'; import { GeoreferencingPanel } from './properties/GeoreferencingPanel'; import { RawStepCard } from './properties/RawStepCard'; /** IFC material *definition* classes selectable from the Materials tab. */ const MATERIAL_DEF_TYPES = new Set([ 'IFCMATERIAL', 'IFCMATERIALLAYERSET', 'IFCMATERIALLAYERSETUSAGE', 'IFCMATERIALPROFILESET', 'IFCMATERIALPROFILESETUSAGE', 'IFCMATERIALCONSTITUENTSET', 'IFCMATERIALLIST', ]); type DisplayProperty = { name: string; value: unknown; isMutated: boolean; type?: number }; type DisplayPropertySet = { name: string; properties: DisplayProperty[]; isNewPset: boolean; source?: PropertySet['source']; }; /** * Synthesize an attribute list from a NewEntity record so the panel's * attributes section renders for overlay-only duplicates / scripted * adds. Positional indices are mapped to schema names; everything past * the schema's defined slots is dropped (no "Arg 9" rows in the bSDD * panel). */ function attributesFromOverlayEntity(entity: NewEntity): Array<{ name: string; value: string }> { const names = getAttributeNames(entity.type) ?? []; if (names.length === 0) return []; const out: Array<{ name: string; value: string }> = []; // Stop at the smaller of the schema and the actual attributes — IFC // entities can be partially populated (trailing optionals omitted). const len = Math.min(names.length, entity.attributes.length); for (let i = 0; i < len; i++) { const value = entity.attributes[i]; let display: string; if (value === null || value === undefined) continue; if (typeof value === 'string') { if (value === '$' || value.length === 0) continue; display = value; } else if (typeof value === 'number') { display = String(value); } else if (typeof value === 'boolean') { display = value ? 'true' : 'false'; } else { // Lists / typed values — skip the bSDD attributes panel; users // can still see them on the Raw STEP tab. continue; } out.push({ name: names[i], value: display }); } return out; } function mergePropertySetLists(base: DisplayPropertySet[], incoming: DisplayPropertySet[]): DisplayPropertySet[] { const merged = base.map(pset => ({ ...pset, properties: [...pset.properties], })); const psetMap = new Map(merged.map(pset => [pset.name, pset])); for (const incomingPset of incoming) { const existing = psetMap.get(incomingPset.name); if (!existing) { const copy = { ...incomingPset, properties: [...incomingPset.properties], }; merged.push(copy); psetMap.set(copy.name, copy); continue; } const existingPropMap = new Map(existing.properties.map(prop => [prop.name, prop])); for (const prop of incomingPset.properties) { if (!existingPropMap.has(prop.name)) { existing.properties.push(prop as DisplayProperty); } } } return merged; } export function PropertiesPanel() { const selectedEntityId = useViewerStore((s) => s.selectedEntityId); const selectedEntity = useViewerStore((s) => s.selectedEntity); const selectedEntities = useViewerStore((s) => s.selectedEntities); const selectedModelId = useViewerStore((s) => s.selectedModelId); const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks); const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility); const isEntityVisible = useViewerStore((s) => s.isEntityVisible); // Relationship navigation: select a related entity (e.g. an IfcZone) to show // its attributes, or isolate a group's members in 3D (#1075). const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity); const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds); const isolateEntities = useViewerStore((s) => s.isolateEntities); const typeVisibility = useViewerStore((s) => s.typeVisibility); const toggleTypeVisibility = useViewerStore((s) => s.toggleTypeVisibility); // Issue #540: surface a small "Layers merged" badge on walls when // the user has the merge-layers load setting active so they // understand the displayed solid is the aggregated representation. const mergeLayersActive = useViewerStore((s) => s.mergeLayers); const { query, ifcDataStore, geometryResult, models, getQueryForModel } = useIfc(); // Get model-aware query based on selectedEntity const { modelQuery, model } = useMemo(() => { // If we have a selectedEntity with modelId, use that model's query if (selectedEntity && selectedEntity.modelId !== 'legacy') { const m = models.get(selectedEntity.modelId); if (m) { return { modelQuery: m.ifcDataStore ? new IfcQuery(m.ifcDataStore) : null, model: m, }; } } // Fallback to legacy query return { modelQuery: query, model: null }; }, [selectedEntity, models, query]); // Use model-aware data store const activeDataStore = model?.ifcDataStore ?? ifcDataStore; // Subscribe to mutation views and version to trigger re-render when mutations change const mutationViews = useViewerStore((s) => s.mutationViews); const mutationVersion = useViewerStore((s) => s.mutationVersion); const getMutationView = useViewerStore((s) => s.getMutationView); const registerMutationView = useViewerStore((s) => s.registerMutationView); // Ensure mutation view exists for editing - creates it on-demand if needed useEffect(() => { if (!model || !model.ifcDataStore || !selectedEntity || selectedEntity.modelId === 'legacy') return; const modelId = selectedEntity.modelId; let mutationView = getMutationView(modelId); if (mutationView) return; // Already exists // Create new mutation view const dataStore = model.ifcDataStore; mutationView = new MutablePropertyView(dataStore.properties || null, modelId); configureMutationView(mutationView, dataStore as IfcDataStore); registerMutationView(modelId, mutationView); }, [model, selectedEntity, getMutationView, registerMutationView]); // Copy feedback state - must be before any early returns (Rules of Hooks) const [copied, setCopied] = useState(false); const [coordCopied, setCoordCopied] = useState(null); const [coordOpen, setCoordOpen] = useState(false); // Inline property editing is gated by the global edit-mode pill in // the main toolbar (see `uiSlice.editEnabled`). Reading it from the // store keeps every edit affordance — properties, attributes, // geometry manipulators, georeference placement, add-element draw // tools — behind a single switch. const editMode = useViewerStore((s) => s.editEnabled); const propertiesActiveTab = useViewerStore((s) => s.propertiesActiveTab); const setPropertiesActiveTab = useViewerStore((s) => s.setPropertiesActiveTab); const setEditEnabled = useViewerStore((s) => s.setEditEnabled); const pendingPropertyFocus = useViewerStore((s) => s.pendingPropertyFocus); const setPendingPropertyFocus = useViewerStore((s) => s.setPendingPropertyFocus); // One-shot "jump to the property I just added in bSDD" focus (issue #1107). // The bSDD card arms `pendingPropertyFocus` and the user crosses over via its // "Edit in Properties" bar. When we land on the Properties tab for the same // entity, enter edit mode, remember which row to highlight, and consume the // request so it fires exactly once. const [focusedPropKey, setFocusedPropKey] = useState(null); useEffect(() => { if (propertiesActiveTab !== 'properties') return; const focus = pendingPropertyFocus; if (!focus || !selectedEntity) return; // Match on the same raw modelId + expressId the selection carries; a stale // focus for a different entity is left untouched (never consumed here). if (focus.modelId !== selectedEntity.modelId || focus.entityId !== selectedEntity.expressId) return; setEditEnabled(true); // entityId-qualified key so it can only ever highlight the occurrence row // (not an inherited type pset that happens to share the name). setFocusedPropKey(`${focus.entityId}:${focus.psetName}:${focus.propName}`); setPendingPropertyFocus(null); }, [propertiesActiveTab, pendingPropertyFocus, selectedEntity, setEditEnabled, setPendingPropertyFocus]); // A new selection abandons the arm-then-cross-over gesture: it was tied to the // previous element, so drop any armed focus and clear the transient highlight // rather than let them resurrect on a later, unrelated entity (issue #1107). // Arm + cross-over both happen on the SAME selection (bSDD tab → Properties // tab), so this never fires mid-gesture. useEffect(() => { setFocusedPropKey(null); setPendingPropertyFocus(null); }, [selectedEntity?.modelId, selectedEntity?.expressId, setPendingPropertyFocus]); // Once the focused row is painted (after the edit-mode re-render), scroll it // into view and let the highlight fade after a beat. Match the attribute by // value rather than building a selector to stay injection-safe on odd names. useEffect(() => { if (!focusedPropKey) return; let raf2 = 0; const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => { let el: Element | null = null; document.querySelectorAll('[data-prop-key]').forEach((node) => { if (node.getAttribute('data-prop-key') === focusedPropKey) el = node; }); if (el) { const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; (el as Element).scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' }); } }); }); const fade = window.setTimeout(() => setFocusedPropKey(null), 2400); return () => { cancelAnimationFrame(raf1); if (raf2) cancelAnimationFrame(raf2); window.clearTimeout(fade); }; }, [focusedPropKey]); const copyToClipboard = useCallback((text: string) => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }, []); const copyCoords = useCallback((label: string, text: string) => { navigator.clipboard.writeText(text); setCoordCopied(label); setTimeout(() => setCoordCopied(null), 1500); }, []); // Get spatial location info // IMPORTANT: Use selectedEntity.expressId (original ID) for IfcDataStore lookups // selectedEntityId is a globalId which only works with offset=0 (first model) const spatialInfo = useMemo(() => { const originalExpressId = selectedEntity?.expressId; if (!originalExpressId || !activeDataStore?.spatialHierarchy) return null; const hierarchy = activeDataStore.spatialHierarchy; // Use O(1) lookup instead of O(n) includes() search const storeyId = hierarchy.elementToStorey.get(originalExpressId); if (!storeyId) return null; // Get height: try pre-computed, then properties/quantities, then calculate from elevations let height = hierarchy.storeyHeights?.get(storeyId); if (height === undefined && activeDataStore.properties) { for (const pset of activeDataStore.properties.getForEntity(storeyId)) { for (const prop of pset.properties) { const propName = prop.name.toLowerCase(); if (['grossheight', 'netheight', 'height'].includes(propName)) { const val = parseFloat(String(prop.value)); if (!isNaN(val) && val > 0) { height = val; break; } } } if (height !== undefined) break; } } if (height === undefined && activeDataStore.quantities) { for (const qto of activeDataStore.quantities.getForEntity(storeyId)) { for (const qty of qto.quantities) { const qtyName = qty.name.toLowerCase(); if (['grossheight', 'netheight', 'height'].includes(qtyName) && typeof qty.value === 'number' && qty.value > 0) { height = qty.value; break; } } if (height !== undefined) break; } } // Fallback: calculate from elevation difference to next storey if (height === undefined && hierarchy.storeyElevations.size > 1) { const currentElevation = hierarchy.storeyElevations.get(storeyId); if (currentElevation !== undefined) { // Find next storey with higher elevation (O(n) but only when height missing) let nextElevation: number | undefined; for (const [, elev] of hierarchy.storeyElevations) { if (elev > currentElevation && (nextElevation === undefined || elev < nextElevation)) { nextElevation = elev; } } if (nextElevation !== undefined) { height = nextElevation - currentElevation; } } } return { storeyId, storeyName: activeDataStore.entities.getName(storeyId) || `Storey #${storeyId}`, elevation: hierarchy.storeyElevations.get(storeyId), height, }; }, [selectedEntity, activeDataStore]); // Compute entity bounding box and coordinates (local scene + world) // // The full coordinate pipeline is: // 1. WASM extracts IFC positions (Z-up) and applies RTC offset (wasmRtcOffset, in Z-up) // 2. Mesh collector converts Z-up -> Y-up: newY = oldZ, newZ = -oldY // 3. CoordinateHandler may apply additional originShift (in Y-up) for large coordinates // 4. Multi-model alignment adjusts positions so all models share the first model's RTC frame // // To reverse back to world coordinates (Y-up): // world_yup = scene_local + originShift + wasmRtcOffset_converted_to_yup // // For multi-model: all models are aligned to the first model's RTC frame, // so we always use the first model's wasmRtcOffset for reconstruction. const entityCoordinates = useMemo(() => { if (!selectedEntity) return null; // Get geometry source: prefer multi-model, fallback to legacy const geoResult = model?.geometryResult ?? geometryResult; if (!geoResult?.meshes?.length) return null; // In multi-model mode, meshes use globalIds (originalExpressId + idOffset) const targetExpressId = toGlobalIdFromModels(models, selectedEntity.modelId, selectedEntity.expressId); // Compute bounding box from matching mesh positions let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; let found = false; for (const mesh of geoResult.meshes) { if (mesh.expressId !== targetExpressId) continue; found = true; const pos = mesh.positions; // Positions are in the element's local frame (world = origin + position); // fold the per-mesh origin so the reported bbox/centre is in the render // frame, matching scene.getEntityBoundingBox. No-op when origin absent. const o = mesh.origin; const ox = o ? o[0] : 0, oy = o ? o[1] : 0, oz = o ? o[2] : 0; for (let i = 0; i < pos.length; i += 3) { const x = pos[i] + ox, y = pos[i + 1] + oy, z = pos[i + 2] + oz; if (x < minX) minX = x; if (y < minY) minY = y; if (z < minZ) minZ = z; if (x > maxX) maxX = x; if (y > maxY) maxY = y; if (z > maxZ) maxZ = z; } } if (!found) return null; const coordInfo = geoResult.coordinateInfo; const shift = coordInfo?.originShift ?? { x: 0, y: 0, z: 0 }; // Get the reference WASM RTC offset for world coordinate reconstruction. // For multi-model: all models are aligned to the first model's RTC frame, // so we must use the first model's wasmRtcOffset (not the current model's). // For single/legacy: use the geometry result's own offset. let wasmRtcIfc = coordInfo?.wasmRtcOffset; if (models.size > 1) { let earliest = Infinity; for (const [, m] of models) { if (m.loadedAt < earliest) { earliest = m.loadedAt; wasmRtcIfc = m.geometryResult?.coordinateInfo?.wasmRtcOffset; } } } // Convert WASM RTC offset from IFC Z-up to viewer Y-up: // viewer X = IFC X, viewer Y = IFC Z, viewer Z = -IFC Y const wasmRtcYup = wasmRtcIfc ? { x: wasmRtcIfc.x, y: wasmRtcIfc.z, z: -wasmRtcIfc.y } : { x: 0, y: 0, z: 0 }; // Local (scene) center - what the renderer uses (Y-up, shifted) const localCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, z: (minZ + maxZ) / 2, }; // World center (Y-up) = scene_local + originShift + wasmRtcOffset_yup const worldCenterYup = { x: localCenter.x + shift.x + wasmRtcYup.x, y: localCenter.y + shift.y + wasmRtcYup.y, z: localCenter.z + shift.z + wasmRtcYup.z, }; // Convert world Y-up to IFC Z-up for display: // IFC X = viewer X, IFC Y = -viewer Z, IFC Z = viewer Y const worldCenterZup = { x: worldCenterYup.x, y: -worldCenterYup.z, z: worldCenterYup.y, }; return { local: { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ }, center: localCenter }, worldYup: { center: worldCenterYup }, worldZup: { center: worldCenterZup }, hasLargeCoordinates: (coordInfo?.hasLargeCoordinates ?? false) || !!wasmRtcIfc, }; }, [selectedEntity, model, geometryResult, models]); // Get entity node - must be computed before early return to maintain hook order // IMPORTANT: Use selectedEntity.expressId (original ID) for IfcDataStore lookups const entityNode = useMemo(() => { const originalExpressId = selectedEntity?.expressId; if (!originalExpressId || !modelQuery) return null; return modelQuery.entity(originalExpressId); }, [selectedEntity, modelQuery]); // Overlay-only entity record (duplicates, scripted adds). Carries // the type + positional attributes the StoreEditor recorded — used // as a fallback when the parsed entityNode comes up empty so the // panel doesn't render `UNKNOWN / Unknown` for fresh entities. const overlayEntity = useMemo(() => { let modelId = selectedEntity?.modelId; if (modelId === 'legacy') modelId = '__legacy__'; const expressId = selectedEntity?.expressId; if (!modelId || !expressId) return null; const view = mutationViews.get(modelId); return view?.getNewEntity(expressId) ?? null; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEntity, mutationViews, mutationVersion]); /** * Read a positional attribute from the overlay entity record as a * display string. Returns null when the entity isn't overlay-only * or the slot is empty / not stringy. */ const overlayAttr = useCallback((index: number): string | null => { if (!overlayEntity) return null; const value = overlayEntity.attributes[index]; if (typeof value === 'string' && value.length > 0 && value !== '$') return value; return null; }, [overlayEntity]); // Check if the selected entity is a type entity (IfcWallType, etc.) // Uses the entity type name to detect — type entity names end with "Type" const isTypeEntity = useMemo(() => { if (!selectedEntity) return false; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore?.entities) return false; const typeName = dataStore.entities.getTypeName(selectedEntity.expressId); return typeName.endsWith('Type'); }, [selectedEntity, model, ifcDataStore]); // Detect a material definition selected from the "Materials" hierarchy tab. // Materials aren't products, so the EntityTable's getTypeName doesn't cover // them — read the raw class from the entity index instead. const selectedMaterialId = useMemo(() => { if (!selectedEntity) return null; const dataStore = model?.ifcDataStore ?? ifcDataStore; const rawType = (dataStore as IfcDataStore | null)?.entityIndex?.byId?.get(selectedEntity.expressId)?.type; return rawType && MATERIAL_DEF_TYPES.has(rawType.toUpperCase()) ? selectedEntity.expressId : null; }, [selectedEntity, model, ifcDataStore]); // Unified property/quantity access - EntityNode handles on-demand extraction automatically // These hooks must be called before any early return to maintain hook order // Use MutablePropertyView as primary source when available (it handles base + mutations) const properties: PropertySet[] = useMemo(() => { let modelId = selectedEntity?.modelId; const expressId = selectedEntity?.expressId; // Normalize legacy model ID (selection uses 'legacy', mutation views use '__legacy__') if (modelId === 'legacy') { modelId = '__legacy__'; } // Try to get properties from mutation view first (handles both base and mutations) const mutationView = modelId ? mutationViews.get(modelId) : null; if (mutationView && expressId) { // Get merged properties from mutation view (base + mutations applied) const mergedProps = mutationView.getForEntity(expressId); // Get list of actual mutations to track which properties changed const mutations = mutationView.getMutationsForEntity(expressId); // Build a set of mutated property keys for quick lookup const mutatedKeys = new Set(); const newPsetNames = new Set(); for (const m of mutations) { if (m.psetName && m.propName) { mutatedKeys.add(`${m.psetName}:${m.propName}`); } // Track property sets that were created (not in original model) if (m.type === 'CREATE_PROPERTY_SET' && m.psetName) { newPsetNames.add(m.psetName); } // Also mark as new pset if this is a CREATE_PROPERTY for a pset that doesn't exist in base if (m.type === 'CREATE_PROPERTY' && m.psetName) { // Check if we have base properties to compare const baseProps = entityNode?.properties() ?? []; const existsInBase = baseProps.some(p => p.name === m.psetName); if (!existsInBase) { newPsetNames.add(m.psetName); } } } // If mutation view returned properties, use them if (mergedProps.length > 0) { return mergedProps.map(pset => ({ name: pset.name, properties: pset.properties.map(p => ({ name: p.name, value: p.value, isMutated: mutatedKeys.has(`${pset.name}:${p.name}`), // Carry the value type so the editor can render the right control // even when the value is null/unset (e.g. a fresh bSDD Boolean). type: p.type, })), isNewPset: newPsetNames.has(pset.name), })); } } // Fallback to entity node properties (no mutations or mutation view not available) if (!entityNode) return []; const rawProps = entityNode.properties(); let result: DisplayPropertySet[] = rawProps.map(pset => ({ name: pset.name, properties: pset.properties.map(p => ({ name: p.name, value: p.value, isMutated: false })), isNewPset: false, })); // For type entities, also extract HasPropertySets (attribute[5]) since they // aren't linked via IfcRelDefinesByProperties and thus not in onDemandPropertyMap if (isTypeEntity && expressId) { const dataStore = (activeDataStore ?? ifcDataStore) as IfcDataStore | null; if (dataStore) { const typeOwnProps = extractTypeEntityOwnProperties(dataStore, expressId); const mappedTypeOwn = typeOwnProps.map(pset => ({ name: pset.name, properties: pset.properties.map(p => ({ name: p.name, value: p.value, isMutated: false })), isNewPset: false, })); result = mergePropertySetLists(result, mappedTypeOwn); } } return result; }, [entityNode, selectedEntity, mutationViews, mutationVersion, isTypeEntity, activeDataStore, ifcDataStore]); const quantities: QuantitySet[] = useMemo(() => { let modelId = selectedEntity?.modelId; const expressId = selectedEntity?.expressId; if (modelId === 'legacy') modelId = '__legacy__'; // Try mutation view first to include added quantities from bSDD const mutationView = modelId ? mutationViews.get(modelId) : null; if (mutationView && expressId) { const merged = mutationView.getQuantitiesForEntity(expressId); if (merged.length > 0) return merged; } // Fallback to entity node quantities if (!entityNode) return []; return entityNode.quantities(); }, [entityNode, selectedEntity, mutationViews, mutationVersion]); // Build attributes array for display - must be before early return to maintain hook order // Uses schema-aware extraction to show ALL string/enum attributes for the entity type. // Merges mutated attributes (from bSDD) into the base attribute list. // Note: GlobalId is intentionally excluded since it's shown in the dedicated GUID field above const attributes = useMemo(() => { const base = entityNode ? entityNode.allAttributes() // Overlay-only entity (duplicate / scripted add) — synthesize the // attribute list from the NewEntity record using the schema's // positional names so the panel still shows Name/Description/etc. : overlayEntity ? attributesFromOverlayEntity(overlayEntity) : []; // Merge mutated attributes from bSDD let modelId = selectedEntity?.modelId; const expressId = selectedEntity?.expressId; if (modelId === 'legacy') modelId = '__legacy__'; const mutationView = modelId ? mutationViews.get(modelId) : null; if (mutationView && expressId) { const mutatedAttrs = mutationView.getAttributeMutationsForEntity(expressId); if (mutatedAttrs.length > 0) { const baseNames = new Set(base.map(a => a.name)); const merged = [...base]; for (const ma of mutatedAttrs) { if (baseNames.has(ma.name)) { // Update existing attribute value const idx = merged.findIndex(a => a.name === ma.name); if (idx >= 0) merged[idx] = { name: ma.name, value: ma.value }; } else { // Add new attribute merged.push({ name: ma.name, value: ma.value }); } } return merged; } } return base; }, [entityNode, overlayEntity, selectedEntity, mutationViews, mutationVersion]); // Resolve the entity id used for parsed-store lookups. For overlay // duplicates this is the source entity (via the view's alias) — so // materials / classifications / documents / structural rels appear // on the duplicate exactly as they do on the source. Without the // alias resolution the parsed maps would return empty for the // overlay-only id. const lookupExpressId = useMemo(() => { const expressId = selectedEntity?.expressId; if (!expressId) return null; let modelId = selectedEntity?.modelId; if (modelId === 'legacy') modelId = '__legacy__'; const view = modelId ? mutationViews.get(modelId) : null; return view?.resolveBaseEntityId(expressId) ?? expressId; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEntity, mutationViews, mutationVersion]); // Extract classifications for the selected entity from the IFC data store const classifications = useMemo(() => { if (!selectedEntity || lookupExpressId === null) return []; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore) return []; return extractClassificationsOnDemand(dataStore as IfcDataStore, lookupExpressId); }, [selectedEntity, lookupExpressId, model, ifcDataStore]); // Extract materials for the selected entity from the IFC data store const materialInfo = useMemo(() => { if (!selectedEntity || lookupExpressId === null) return null; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore) return null; return extractMaterialsOnDemand(dataStore as IfcDataStore, lookupExpressId); }, [selectedEntity, lookupExpressId, model, ifcDataStore]); // Property sets attached to the selected entity's material(s) via // IfcMaterialProperties (e.g. Pset_MaterialConcrete). These live on the // IfcMaterial — not on the object — so they never surface through the // occurrence/type pset paths; resolve them through the material association. const materialProperties: MaterialPsetGroup[] = useMemo(() => { if (!selectedEntity || lookupExpressId === null) return []; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore) return []; return extractMaterialPropertiesOnDemand(dataStore as IfcDataStore, lookupExpressId); }, [selectedEntity, lookupExpressId, model, ifcDataStore]); // Extract documents for the selected entity from the IFC data store const documents = useMemo(() => { if (!selectedEntity || lookupExpressId === null) return []; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore) return []; return extractDocumentsOnDemand(dataStore as IfcDataStore, lookupExpressId); }, [selectedEntity, lookupExpressId, model, ifcDataStore]); // Extract structural relationships (openings, fills, groups, connections) const entityRelationships = useMemo(() => { if (!selectedEntity || lookupExpressId === null) return null; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore) return null; const rels = extractRelationshipsOnDemand(dataStore as IfcDataStore, lookupExpressId); const totalCount = rels.voids.length + rels.fills.length + rels.groups.length + rels.connections.length; return totalCount > 0 ? rels : null; }, [selectedEntity, lookupExpressId, model, ifcDataStore]); // Select a related entity by express id (e.g. click an IfcZone in the // Relationships card to inspect its Name/attributes). Resolves in the same // model as the currently-selected entity. Frames geometric targets; a no-op // frame for non-geometric ones like IfcZone (#1075). const handleSelectRelatedEntity = useCallback((expressId: number) => { if (!selectedEntity) return; setSelectedEntityIds([]); setSelectedEntity({ modelId: selectedEntity.modelId, expressId }); if (cameraCallbacks.frameSelection) { window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50); } }, [selectedEntity, setSelectedEntity, setSelectedEntityIds, cameraCallbacks]); // Isolate + select all member objects of a group/zone (the IfcSpace / // IfcSpatialZone in an IfcZone — e.g. one dwelling, house number or fire // compartment). Members are hidden-by-default spatial elements, so flip their // visibility toggles on first or the isolated set would render nothing (#1075). const handleIsolateGroupMembers = useCallback((groupId: number) => { const dataStore = (model?.ifcDataStore ?? ifcDataStore) as IfcDataStore | null; if (!dataStore || !selectedEntity) return; const members = extractGroupMembersOnDemand(dataStore, groupId); if (members.length === 0) return; const globalIds = members.map((m) => toGlobalIdFromModels(models, selectedEntity.modelId, m.id)); // Only turn a hidden class toggle on when the zone actually contains members // of that class — otherwise clearing isolation later would surface unrelated // spaces/zones the user had deliberately hidden (PR #1094 review). if (!typeVisibility.spaces && members.some((m) => m.type === 'IfcSpace')) { toggleTypeVisibility('spaces'); } if (!typeVisibility.spatialZones && members.some((m) => m.type === 'IfcSpatialZone')) { toggleTypeVisibility('spatialZones'); } isolateEntities(globalIds); setSelectedEntityIds(globalIds); if (cameraCallbacks.frameSelection) { window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50); } }, [model, ifcDataStore, selectedEntity, models, typeVisibility, toggleTypeVisibility, isolateEntities, setSelectedEntityIds, cameraCallbacks]); // 4D schedule — both parsed-from-IFC and locally-generated schedules live in // the schedule slice. ScheduleCard renders nothing when no task in the // schedule lists this entity as a controlled product, so it's safe to call // unconditionally. const scheduleData = useViewerStore((s) => s.scheduleData); // Single-task selection from the Gantt triggers the Task edit card — // pull the set and its size so the Inspector can react to any change. const selectedTaskGlobalIds = useViewerStore((s) => s.selectedTaskGlobalIds); const singleSelectedTaskGlobalId = useMemo(() => { if (selectedTaskGlobalIds.size !== 1) return null; return selectedTaskGlobalIds.values().next().value ?? null; }, [selectedTaskGlobalIds]); // True when the schedule contains at least one task the user generated // locally (no expressId in the host STEP). Mixed schedules — parsed tail + // user-appended task — still surface the pending banner so the user sees // that something will be spliced on export. const scheduleIsGenerated = useMemo(() => { if (!scheduleData || scheduleData.tasks.length === 0) return false; return scheduleData.tasks.some(t => !t.expressId || t.expressId <= 0); }, [scheduleData]); const selectedEntityGlobalId = useMemo(() => { if (!selectedEntity) return null; const dataStore = model?.ifcDataStore ?? ifcDataStore; return (dataStore as IfcDataStore | null)?.entities?.getGlobalId?.(selectedEntity.expressId) ?? null; }, [selectedEntity, model, ifcDataStore]); /** True when at least one task in the current schedule controls this entity — * used to keep the Inspector's empty-state from hiding a populated card. * Federation-aware: matches globalId first (see `ScheduleCard`). */ const hasScheduleForSelection = useMemo(() => { if (!selectedEntity || !scheduleData || scheduleData.tasks.length === 0) return false; const expressId = selectedEntity.expressId; const gid = selectedEntityGlobalId; for (const task of scheduleData.tasks) { const taskHasGlobalIds = task.productGlobalIds.some(Boolean); if (gid && taskHasGlobalIds) { if (task.productGlobalIds.includes(gid)) return true; continue; } if (expressId > 0 && task.productExpressIds.includes(expressId)) return true; } return false; }, [selectedEntity, scheduleData, selectedEntityGlobalId]); // Extract georeferencing info for the model (used in coordinates section) const georef = useMemo(() => { const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore) return null; const info = extractGeoreferencingOnDemand(dataStore as IfcDataStore); return info?.hasGeoreference ? info : null; }, [model, ifcDataStore]); // Extract IFC length unit scale (e.g. 0.001 for mm, 0.3048 for ft) const lengthUnitScale = useMemo(() => { const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore?.source?.length || !dataStore?.entityIndex) return 1; return extractLengthUnitScale(dataStore.source, dataStore.entityIndex); }, [model, ifcDataStore]); // Extract type-level properties (e.g., from IfcWallType's HasPropertySets) const typeProperties = useMemo(() => { if (!selectedEntity) return null; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore) return null; const result = extractTypePropertiesOnDemand(dataStore as IfcDataStore, selectedEntity.expressId); if (!result) return null; let modelId = selectedEntity.modelId; if (modelId === 'legacy') modelId = '__legacy__'; const mutationView = modelId ? mutationViews.get(modelId) : null; const mutations = mutationView?.getMutationsForEntity(result.typeId) ?? []; const mergedTypeProps = mutationView?.getForEntity(result.typeId) ?? []; const mutatedKeys = new Set(); const newPsetNames = new Set(); for (const mutation of mutations) { if (mutation.psetName && mutation.propName) { mutatedKeys.add(`${mutation.psetName}:${mutation.propName}`); } if (mutation.type === 'CREATE_PROPERTY_SET' && mutation.psetName) { newPsetNames.add(mutation.psetName); } if (mutation.type === 'CREATE_PROPERTY' && mutation.psetName) { const existsInBase = result.properties.some(pset => pset.name === mutation.psetName); if (!existsInBase) { newPsetNames.add(mutation.psetName); } } } const sourcePsets = mergedTypeProps.length > 0 ? mergedTypeProps : result.properties.map(pset => ({ name: pset.name, globalId: pset.globalId || '', properties: pset.properties.map(p => ({ name: p.name, type: p.type, value: p.value, })), })); return { typeName: result.typeName, typeId: result.typeId, psets: sourcePsets.map(pset => ({ name: pset.name, properties: pset.properties.map(p => ({ name: p.name, value: p.value, isMutated: mutatedKeys.has(`${pset.name}:${p.name}`), })), isNewPset: newPsetNames.has(pset.name), })), }; }, [selectedEntity, model, ifcDataStore, mutationViews, mutationVersion]); // Spatial containment info for spatial containers (Project, Facility, Part, Storey, Space) const spatialContainment = useMemo(() => { if (!selectedEntity) return null; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore?.spatialHierarchy) return null; const expressId = selectedEntity.expressId; const hierarchy = dataStore.spatialHierarchy; const typeName = dataStore.entities.getTypeName(expressId); // Only show for spatial structure elements. if (!isSpatialStructureTypeName(typeName)) return null; const stats: Array<{ label: string; value: string | number }> = []; // Find the SpatialNode for this entity const findNode = (node: { expressId: number; children: { expressId: number; children: unknown[]; elements: number[]; name: string; type: number }[]; elements: number[]; name: string; type: number }, targetId: number): typeof node | null => { if (node.expressId === targetId) return node; for (const child of node.children) { const found = findNode(child as typeof node, targetId); if (found) return found; } return null; }; const spatialNode = findNode(hierarchy.project as Parameters[0], expressId); if (spatialNode) { // Direct children (spatial sub-structure) if (spatialNode.children.length > 0) { const childTypes = new Map(); for (const child of spatialNode.children) { const ct = dataStore.entities.getTypeName(child.expressId); childTypes.set(ct, (childTypes.get(ct) || 0) + 1); } for (const [ct, count] of childTypes) { stats.push({ label: ct, value: count }); } } // Direct contained elements if (spatialNode.elements.length > 0) { stats.push({ label: 'Contained Elements', value: spatialNode.elements.length }); } } // Also count from containment maps const mapSources: Array<[string, Map | undefined]> = [ ['Elements (Site)', hierarchy.bySite], ['Elements (Building-like)', hierarchy.byBuilding], ['Elements (Storey)', hierarchy.byStorey], ['Elements (Space)', hierarchy.bySpace], ]; for (const [label, map] of mapSources) { const elements = map?.get(expressId); if (elements && elements.length > 0 && !stats.some(s => s.label === 'Contained Elements')) { stats.push({ label, value: elements.length }); } } // Elevation for storeys if (isStoreyLikeSpatialTypeName(typeName)) { const elevation = hierarchy.storeyElevations.get(expressId); if (elevation !== undefined) { stats.push({ label: 'Elevation', value: `${elevation.toFixed(2)} m` }); } } return stats.length > 0 ? stats : null; }, [selectedEntity, model, ifcDataStore]); // Separate occurrence (instance) and inherited type properties. // Occurrence properties are displayed first, type properties in a separate section. // All type property sets are always shown in the inherited section so users can see // what the type defines, even when the same pset exists at occurrence level. const { occurrenceProperties, inheritedTypeProperties } = useMemo(() => { const occ: PropertySet[] = properties.map(p => ({ ...p, source: 'instance' as const })); if (!typeProperties || typeProperties.psets.length === 0) { return { occurrenceProperties: occ, inheritedTypeProperties: [] as PropertySet[] }; } const inherited: PropertySet[] = typeProperties.psets.map(typePset => ({ ...typePset, source: 'type' as const, })); return { occurrenceProperties: occ, inheritedTypeProperties: inherited }; }, [properties, typeProperties]); const typeEditImpact = useMemo(() => { if (!editMode || !selectedEntity) return null; const dataStore = model?.ifcDataStore ?? ifcDataStore; if (!dataStore?.relationships) return null; if (isTypeEntity) { const typeId = selectedEntity.expressId; const affectedOccurrenceIds = dataStore.relationships.getRelated( typeId, RelationshipType.DefinesByType, 'forward' ); return { mode: 'type' as const, typeId, typeEntityName: dataStore.entities.getTypeName(typeId), affectedCount: affectedOccurrenceIds.length, }; } if (typeProperties && inheritedTypeProperties.length > 0) { const affectedOccurrenceIds = dataStore.relationships.getRelated( typeProperties.typeId, RelationshipType.DefinesByType, 'forward' ); return { mode: 'inherited' as const, typeId: typeProperties.typeId, typeEntityName: dataStore.entities.getTypeName(typeProperties.typeId), affectedCount: affectedOccurrenceIds.length, }; } return null; }, [editMode, selectedEntity, model, ifcDataStore, isTypeEntity, typeProperties, inheritedTypeProperties]); // Combined list of all properties for bSDD deduplication and edit toolbar const mergedProperties: PropertySet[] = useMemo( () => [...occurrenceProperties, ...inheritedTypeProperties], [occurrenceProperties, inheritedTypeProperties] ); // Build a set of existing property keys ("PsetName:PropName") for bSDD deduplication const existingProps = useMemo(() => { const keys = new Set(); for (const pset of mergedProperties) { for (const prop of pset.properties) { keys.add(`${pset.name}:${prop.name}`); } } return keys; }, [mergedProperties]); // Build a set of existing quantity keys ("QsetName:QuantName") for bSDD deduplication const existingQuants = useMemo(() => { const keys = new Set(); for (const qset of quantities) { for (const q of qset.quantities) { keys.add(`${qset.name}:${q.name}`); } } return keys; }, [quantities]); // Build a set of existing attribute names for bSDD deduplication const existingAttributeNames = useMemo(() => { const names = new Set(); for (const attr of attributes) { if (attr.value) names.add(attr.name); } return names; }, [attributes]); // Overlay (authored) entities — split halves, duplicates, scripted // adds — live only in the StoreEditor overlay, NOT the parsed store. // `modelQuery.entity()` always returns a node, and its getters fall // back to the 'Unknown'/'' sentinels for ids absent from the parsed // table (entity-table.ts#getTypeName). Those non-null sentinels would // shadow the overlay record in an `entityNode ?? overlay` chain, so // when an overlay record exists it MUST take precedence. const renderedEntityType = overlayEntity?.type ?? entityNode?.type ?? 'Unknown'; const renderedEntityName = overlayAttr(2) ?? entityNode?.name ?? undefined; const renderedEntityGlobalId = overlayAttr(0) ?? entityNode?.globalId; const renderedEntityDescription = overlayAttr(3) ?? entityNode?.description ?? undefined; const renderedEntityObjectType = overlayAttr(4) ?? entityNode?.objectType ?? undefined; const renderedSpatialInfo = spatialInfo; const renderedOccurrenceProperties = occurrenceProperties; const renderedInheritedTypeProperties = inheritedTypeProperties; const renderedMergedProperties = mergedProperties; const renderedQuantities = quantities; const renderedAttributes = attributes; const renderedClassifications = classifications; const renderedMaterialInfo = materialInfo; const renderedMaterialProperties = materialProperties; const renderedDocuments = documents; const renderedEntityRelationships = entityRelationships; const renderedGeoref = georef; const renderedSpatialContainment = spatialContainment; const renderedTypeProperties = typeProperties; const renderedTypeEditImpact = typeEditImpact; const renderedIsTypeEntity = isTypeEntity; const renderedExistingProps = useMemo(() => { const keys = new Set(); for (const pset of renderedMergedProperties) { for (const prop of pset.properties) { keys.add(`${pset.name}:${prop.name}`); } } return keys; }, [renderedMergedProperties]); const renderedExistingQuants = useMemo(() => { const keys = new Set(); for (const qset of renderedQuantities) { for (const q of qset.quantities) { keys.add(`${qset.name}:${q.name}`); } } return keys; }, [renderedQuantities]); const renderedExistingAttributeNames = useMemo(() => { const names = new Set(); for (const attr of renderedAttributes) { if (attr.value) names.add(attr.name); } return names; }, [renderedAttributes]); // Model metadata display (when clicking top-level model in hierarchy) if (selectedModelId) { const selectedModel = models.get(selectedModelId); if (selectedModel) { return ; } } // Material selected from the "Materials" hierarchy tab — show the material's // own property sets plus quantities aggregated across all using elements. if (selectedMaterialId !== null && selectedEntity) { return ; } // Multi-entity selection (unified storeys) - render combined view if (selectedEntities.length > 1) { return ( ); } // Newly-created/duplicated entities live only in the mutation overlay, // so the synthesized attributes + Raw STEP tab fall back to // `overlayEntity` when `entityNode` is empty. Without including // `overlayEntity` here the panel collapses to the model-metadata // view the moment a fresh add lands. if (!selectedEntityId || !modelQuery || (!entityNode && !overlayEntity)) { // Show model metadata when a single model is loaded and nothing selected. // Handles both federated models (models.size >= 1) and legacy single-model path (models.size === 0). if (models.size === 1) { const singleModel = models.values().next().value as FederatedModel; return ; } if (ifcDataStore && models.size === 0 && geometryResult) { const legacyModel: FederatedModel = { id: '__legacy__', name: 'Model', ifcDataStore: ifcDataStore as IfcDataStore, geometryResult, visible: true, collapsed: false, schemaVersion: ((ifcDataStore as IfcDataStore).schemaVersion ?? 'IFC4') as FederatedModel['schemaVersion'], loadedAt: Date.now(), fileSize: (ifcDataStore as IfcDataStore).fileSize ?? 0, idOffset: 0, maxExpressId: (ifcDataStore as IfcDataStore).entityCount ?? 0, }; return ; } // Multi-model or no model loaded: show empty state return (

Inspector

No Selection

{models.size > 1 ? 'Select a model or element to view details' : 'Select an element to view details'}

); } // These are safe to access after the early return check (entityNode is confirmed non-null above) const entityType = renderedEntityType; const entityName = renderedEntityName; const entityGlobalId = renderedEntityGlobalId; const entityDescription = renderedEntityDescription; const entityObjectType = renderedEntityObjectType; return (
{/* Entity Header */}

{entityName || `${entityType}`}

{/* Issue #540: indicate that the wall solid the user is looking at represents aggregated multilayer parts. We over-trigger on any IfcWall* class instead of probing the aggregation graph — the chip is cheap and informative, and walls that aren't actually layered simply confirm the user's selection is the parent. */} {mergeLayersActive && entityType?.toLowerCase().startsWith('ifcwall') && ( Layers merged Multilayer wall parts have been merged into the parent solid. )}

{entityType}

{/* Show associated type entity for occurrences */} {!renderedIsTypeEntity && renderedTypeProperties && (

{activeDataStore?.entities.getTypeName(renderedTypeProperties.typeId) || 'Type'}: {renderedTypeProperties.typeName}

)}
Zoom to {selectedEntityId && isEntityVisible(selectedEntityId) ? 'Hide' : 'Show'}
{/* GlobalId */} {entityGlobalId && (
{entityGlobalId}
)} {/* Spatial Location */} {renderedSpatialInfo && (
{renderedSpatialInfo.storeyName}
{renderedSpatialInfo.elevation !== undefined && ( {renderedSpatialInfo.elevation >= 0 ? '+' : ''}{renderedSpatialInfo.elevation.toFixed(2)}m

Elevation: {renderedSpatialInfo.elevation >= 0 ? '+' : ''}{renderedSpatialInfo.elevation.toFixed(2)}m from ground

)} {renderedSpatialInfo.height !== undefined && ( {renderedSpatialInfo.height.toFixed(2)}m {renderedSpatialInfo.height.toFixed(1)}m

Height: {renderedSpatialInfo.height.toFixed(2)}m to next storey

)}
)} {/* World coordinates + Georeferencing — single consolidated section */} {(entityCoordinates || renderedGeoref || editMode) && ( World {!coordOpen && ( <> {entityCoordinates && ( {' '} {' '} )} {renderedGeoref?.projectedCRS?.name && ( {renderedGeoref.projectedCRS.name} )} details )} {entityCoordinates && (
Size {(entityCoordinates.local.max.x - entityCoordinates.local.min.x).toFixed(2)} x {(entityCoordinates.local.max.y - entityCoordinates.local.min.y).toFixed(2)} x {(entityCoordinates.local.max.z - entityCoordinates.local.min.z).toFixed(2)}
)}
)} {/* Model Source (when multiple models loaded) - below storey, less prominent */} {models.size > 1 && model && (
{model.name}
)}
{/* IFC Attributes */} {renderedAttributes.length > 0 && ( Attributes {editMode && } {renderedAttributes.length}
{renderedAttributes.map((attr) => (
{attr.name} {editMode && selectedEntity ? ( ) : (
{String(attr.value)}
)}
))}
)} {/* Spatial Containment - for spatial containers (Project, Site, Building, Storey) */} {renderedSpatialContainment && ( Structure {renderedSpatialContainment.length}
{renderedSpatialContainment.map((item) => (
{item.label} {item.value}
))}
)} {/* Tabs */} setPropertiesActiveTab(v as 'properties' | 'quantities' | 'bsdd' | 'raw-step')} className="flex-1 flex flex-col overflow-hidden" > Properties Quantities bSDD {/* Bracket glyphs read as "code" without an icon dependency, stay readable at 9px, and free up width for the three primary tabs to keep their text visible at the default panel size. */} </> Raw STEP {/* Task edit card — renders when exactly one Gantt task is selected. Shown above any entity properties because the user's attention shifted to editing the task, not the 3D element. Other tabs (quantities / relationships / bSDD) still show entity content regardless. */} {singleSelectedTaskGlobalId && (
)} {/* Edit toolbar - only shown when edit mode is active */} {editMode && selectedEntity && ( <> p.name)} existingQtos={renderedQuantities.map(q => q.name)} schemaVersion={activeDataStore?.schemaVersion} /> )} {renderedMergedProperties.length === 0 && renderedClassifications.length === 0 && !renderedMaterialInfo && renderedMaterialProperties.length === 0 && renderedDocuments.length === 0 && !renderedEntityRelationships && !hasScheduleForSelection ? (

No property sets

) : (
{/* Occurrence/Type Properties (based on whether entity itself is a type) */} {renderedOccurrenceProperties.length > 0 && ( <> {(renderedIsTypeEntity || (renderedTypeProperties && renderedTypeProperties.psets.length > 0)) && (
{renderedIsTypeEntity ? ( <> Type Properties: ) : 'Occurrence Properties:'}
)} {renderedOccurrenceProperties.map((pset: PropertySet) => ( ))} )} {/* Inherited Type Properties */} {renderedInheritedTypeProperties.length > 0 && renderedTypeProperties && ( <> {renderedOccurrenceProperties.length > 0 && (
)}
Type Properties ({renderedTypeProperties.typeName})
{renderedInheritedTypeProperties.map((pset: PropertySet) => ( ))} )} {/* Classifications */} {renderedClassifications.length > 0 && ( <> {renderedMergedProperties.length > 0 && (
)} {renderedClassifications.map((classification, i) => ( ))} )} {/* Materials */} {renderedMaterialInfo && ( <> {(renderedMergedProperties.length > 0 || renderedClassifications.length > 0) && (
)} )} {/* Material Property Sets (Pset_Material* attached to the IfcMaterial via IfcMaterialProperties). Grouped per material, mirroring the Type Properties block. */} {renderedMaterialProperties.length > 0 && ( <> {(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo) && (
)} {renderedMaterialProperties.map((group) => (
Material Properties ({group.materialName})
{group.psets.map((pset) => ( ({ name: p.name, value: p.value, isMutated: false })), }} /> ))}
))} )} {/* Documents */} {renderedDocuments.length > 0 && ( <> {(renderedMergedProperties.length > 0 || renderedClassifications.length > 0 || renderedMaterialInfo || renderedMaterialProperties.length > 0) && (
)} {renderedDocuments.map((doc, i) => ( ))} )} {/* Relationships */} {renderedEntityRelationships && ( <>
)} {/* 4D / Construction schedule — controlling tasks for this entity. Gated on `hasScheduleForSelection` so the separator above doesn't render on its own when ScheduleCard would return null. */} {selectedEntity && scheduleData && hasScheduleForSelection && ( <>
)}
)} {renderedQuantities.length === 0 ? (

No quantities

) : (
{renderedQuantities.map((qset: QuantitySet) => ( ))}
)}
{selectedEntity && ( p.name)} existingProps={renderedExistingProps} existingQsets={renderedQuantities.map(q => q.name)} existingQuants={renderedExistingQuants} existingAttributes={renderedExistingAttributeNames} /> )} {selectedEntity ? ( ) : (

Select an entity to inspect raw STEP arguments

)}
); } /** Inline attribute editor — pen icon to enter edit mode, input + save/cancel */ function AttributeEditorField({ modelId, entityId, attrName, currentValue, }: { modelId: string; entityId: number; attrName: string; currentValue: string; }) { const setAttribute = useViewerStore((s) => s.setAttribute); const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion); const [editing, setEditing] = useState(false); const [value, setValue] = useState(currentValue); const inputRef = useCallback((node: HTMLInputElement | null) => { if (node) { node.focus(); node.select(); } }, []); const save = useCallback(() => { let normalizedModelId = modelId; if (modelId === 'legacy') normalizedModelId = '__legacy__'; setAttribute(normalizedModelId, entityId, attrName, value, currentValue || undefined); bumpMutationVersion(); setEditing(false); }, [modelId, entityId, attrName, value, currentValue, setAttribute, bumpMutationVersion]); const cancel = useCallback(() => { setValue(currentValue); setEditing(false); }, [currentValue]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); save(); } else if (e.key === 'Escape') { e.preventDefault(); cancel(); } }, [save, cancel]); if (editing) { return (
setValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={save} className="flex-1 min-w-0 h-6 px-1.5 text-sm font-mono bg-white dark:bg-zinc-900 border border-purple-300 dark:border-purple-700 outline-none focus:ring-1 focus:ring-purple-400" />
); } return (
setEditing(true)} > {currentValue || empty} Edit attribute
); } /** Multi-entity panel for unified storeys - shows data from multiple entities stacked */ function MultiEntityPanel({ entities, models, ifcDataStore, }: { entities: EntityRef[]; models: Map; ifcDataStore: IfcDataStore | null; }) { return (
{/* Header */}

Unified Storey

{entities.length} models
{/* Scrollable content with each entity's data */}
{entities.map((entityRef, index) => ( ))}
); } /** Renders data for a single entity (used in multi-entity panel) */ function EntityDataSection({ entityRef, models, ifcDataStore, showModelName, }: { entityRef: EntityRef; models: Map; ifcDataStore: IfcDataStore | null; showModelName: boolean; }) { // Get the appropriate data store and query const { dataStore, model } = useMemo(() => { if (entityRef.modelId !== 'legacy') { const m = models.get(entityRef.modelId); if (m) { return { dataStore: m.ifcDataStore, model: m }; } } return { dataStore: ifcDataStore, model: null }; }, [entityRef.modelId, models, ifcDataStore]); const query = useMemo(() => { return dataStore ? new IfcQuery(dataStore) : null; }, [dataStore]); const entityNode = useMemo(() => { if (!query) return null; return query.entity(entityRef.expressId); }, [query, entityRef.expressId]); // Overlay-aware display class: a pending retype (setEntityType) reassigns the // class in place, so the header reflects the new class immediately — before // the model is re-exported / reloaded. Re-evaluates on `mutationVersion`. const headerMutationViews = useViewerStore((s) => s.mutationViews); const headerMutationVersion = useViewerStore((s) => s.mutationVersion); const displayType = useMemo(() => { let mid = entityRef.modelId; if (mid === 'legacy') mid = '__legacy__'; const pending = headerMutationViews.get(mid)?.getEntityTypeMutation?.(entityRef.expressId)?.newType; return pending ?? entityNode?.type ?? ''; // eslint-disable-next-line react-hooks/exhaustive-deps }, [headerMutationViews, headerMutationVersion, entityRef.modelId, entityRef.expressId, entityNode]); // Get properties and quantities const properties: PropertySet[] = useMemo(() => { if (!entityNode) return []; const rawProps = entityNode.properties(); return rawProps.map(pset => ({ name: pset.name, properties: pset.properties.map(p => ({ name: p.name, value: p.value })), })); }, [entityNode]); const quantities: QuantitySet[] = useMemo(() => { if (!entityNode) return []; return entityNode.quantities(); }, [entityNode]); // Get attributes - uses schema-aware extraction to show ALL string/enum attributes // Note: GlobalId is intentionally excluded since it's shown in the dedicated GUID field above const attributes = useMemo(() => { if (!entityNode) return []; return entityNode.allAttributes(); }, [entityNode]); // Get elevation info const elevationInfo = useMemo(() => { if (!dataStore?.spatialHierarchy) return null; const elevation = dataStore.spatialHierarchy.storeyElevations.get(entityRef.expressId); return elevation !== undefined ? elevation : null; }, [dataStore, entityRef.expressId]); if (!entityNode) { return (
Unable to load entity data
); } return (
{/* Entity Header with model name */}
{showModelName && model && (
{model.name}
)}

{entityNode.name || `${displayType} #${entityRef.expressId}`}

{displayType}

{elevationInfo !== null && ( {elevationInfo >= 0 ? '+' : ''}{elevationInfo.toFixed(2)}m )}
{/* Attributes */} {attributes.length > 0 && ( Attributes {attributes.length}
{attributes.map((attr) => (
{attr.name}
{attr.value}
))}
)} {/* Properties */} {properties.length > 0 && ( Properties {properties.length} sets
{properties.map((pset) => ( ))}
)} {/* Quantities */} {quantities.length > 0 && ( Quantities {quantities.length} sets
{quantities.map((qset) => ( ))}
)}
); }