/* 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/. */ /** * Property Editor component for editing IFC property values inline. * Includes schema-aware property addition with IFC4 standard validation. */ import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; import { X, Plus, Trash2, PenLine, Undo, Redo, Check, BookOpen, Tag, Layers, Ruler, Replace, ArrowRight, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Badge } from '@/components/ui/badge'; import { ComboInput } from '@/components/ui/combo-input'; import { cn } from '@/lib/utils'; import { resolveReassignSchema, getReassignTargets, getPredefinedTypes, isKnownReassignTarget, isReassignableElement, COMMON_REASSIGN_TARGETS, } from '@/lib/ifc-class-reassign'; import { useViewerStore } from '@/store'; import { PropertyValueType, QuantityType } from '@ifc-lite/data'; import type { PropertyValue } from '@ifc-lite/mutations'; import { getPsetDefinitionsForType, getPropertiesForPset, CLASSIFICATION_SYSTEMS, type PsetPropertyDef, type PsetDefinition, } from '@/lib/ifc4-pset-definitions'; import { getQtoDefinitionsForType, getQuantitiesForQto, getQuantityUnit, type QtoQuantityDef, type QtoDefinition, } from '@/lib/ifc4-qto-definitions'; // ── Edit-deck button styling ──────────────────────────────────────────────── // Data-enrichment actions (Property / Quantity / Classification / Material) // live as quiet icon keys inside one segmented control — discoverable via // tooltip, compact enough to leave room for the headline action. const EDIT_TOOL_CLS = 'h-7 w-8 rounded-none border-0 bg-transparent text-zinc-500 shadow-none transition-colors hover:bg-indigo-500/10 hover:text-indigo-600 focus-visible:bg-indigo-500/10 dark:text-zinc-400 dark:hover:bg-indigo-400/15 dark:hover:text-indigo-300'; // The structural "Reassign class" action is elevated as a distinct accent // affordance — it transforms the element rather than adding data to it. const RECLASS_TOOL_CLS = 'h-7 min-w-0 gap-1.5 rounded-md px-2.5 text-[11px] font-semibold text-indigo-700 ring-1 ring-inset ring-indigo-300/70 bg-indigo-500/10 shadow-none transition-colors hover:bg-indigo-500/20 hover:text-indigo-800 dark:text-indigo-300 dark:ring-indigo-700/60 dark:bg-indigo-500/10 dark:hover:text-indigo-200'; interface PropertyEditorProps { modelId: string; entityId: number; psetName: string; propName: string; currentValue: unknown; currentType?: PropertyValueType; editScope?: PropertyEditScope; onClose?: () => void; } export interface PropertyEditScope { mode: 'type' | 'inherited'; typeEntityName: string; affectedCount: number; } /** * Inline property value editor with pen icon on the right. * Supports keyboard: Enter to save, Escape to cancel. */ export function PropertyEditor({ modelId, entityId, psetName, propName, currentValue, currentType = PropertyValueType.String, editScope, onClose, }: PropertyEditorProps) { const setProperty = useViewerStore((s) => s.setProperty); const deleteProperty = useViewerStore((s) => s.deleteProperty); const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion); const [value, setValue] = useState(formatValue(currentValue)); const [valueType, setValueType] = useState(detectValueType(currentValue, currentType)); const [isEditing, setIsEditing] = useState(false); const [showScopeConfirm, setShowScopeConfirm] = useState(false); const inputRef = useRef(null); const initialValue = formatValue(currentValue); const initialType = detectValueType(currentValue, currentType); const isUnchanged = value === initialValue && valueType === initialType; // Focus input when entering edit mode useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [isEditing]); const commitSave = useCallback(() => { const parsedValue = parseValue(value, valueType); // Normalize model ID for legacy models let normalizedModelId = modelId; if (modelId === 'legacy') { normalizedModelId = '__legacy__'; } setProperty(normalizedModelId, entityId, psetName, propName, parsedValue, valueType); bumpMutationVersion(); setShowScopeConfirm(false); setIsEditing(false); onClose?.(); }, [modelId, entityId, psetName, propName, value, valueType, setProperty, bumpMutationVersion, onClose]); const handleSave = useCallback(() => { if (editScope && !showScopeConfirm && !isUnchanged) { setShowScopeConfirm(true); return; } commitSave(); }, [editScope, showScopeConfirm, isUnchanged, commitSave]); const handleDelete = useCallback(() => { // Normalize model ID for legacy models let normalizedModelId = modelId; if (modelId === 'legacy') { normalizedModelId = '__legacy__'; } deleteProperty(normalizedModelId, entityId, psetName, propName); bumpMutationVersion(); setShowScopeConfirm(false); setIsEditing(false); onClose?.(); }, [modelId, entityId, psetName, propName, deleteProperty, bumpMutationVersion, onClose]); const handleCancel = useCallback(() => { setValue(formatValue(currentValue)); setValueType(detectValueType(currentValue, currentType)); setShowScopeConfirm(false); setIsEditing(false); onClose?.(); }, [currentValue, currentType, onClose]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } else if (e.key === 'Escape') { e.preventDefault(); if (showScopeConfirm) { setShowScopeConfirm(false); } else { handleCancel(); } } }, [handleSave, handleCancel, showScopeConfirm]); const displayValue = formatDisplayValue(currentValue); // Non-editing view: value with pen icon on right (always visible) if (!isEditing) { return (
setIsEditing(true)} title="Click to edit" > {displayValue} Edit property
); } // Editing view: inline input with type selector and action buttons return (
{/* Value input */}
{valueType === PropertyValueType.Boolean || valueType === PropertyValueType.Logical ? ( // Tri-state: a boolean property value is optional in IFC, so "Unset" // is a first-class choice — we never silently coerce to false. An // empty `value` ('') means unset (issue #1107).
{([['', 'Unset'], ['true', 'True'], ['false', 'False']] as const).map(([v, label]) => { const active = value === v; return ( ); })}
) : ( { setValue(e.target.value); if (showScopeConfirm) setShowScopeConfirm(false); }} onKeyDown={handleKeyDown} className="h-7 text-xs font-mono flex-1 bg-white dark:bg-zinc-900" placeholder="Enter value" type={valueType === PropertyValueType.Real || valueType === PropertyValueType.Integer ? 'number' : 'text'} step={valueType === PropertyValueType.Real ? 'any' : undefined} /> )} {/* Action buttons */} {editScope && !showScopeConfirm && !isUnchanged ? 'Review scope (Enter)' : 'Save (Enter)'} Cancel (Esc) Delete property
{/* Type selector - always visible */}
{[ { type: PropertyValueType.String, label: 'String' }, { type: PropertyValueType.Label, label: 'Label' }, { type: PropertyValueType.Identifier, label: 'ID' }, { type: PropertyValueType.Real, label: 'Real' }, { type: PropertyValueType.Integer, label: 'Int' }, { type: PropertyValueType.Boolean, label: 'Bool' }, ].map(({ type, label }) => ( ))}
{showScopeConfirm && editScope && (
{editScope.mode === 'type' ? `Apply this change on ${editScope.typeEntityName}?` : `Write this inherited value back to ${editScope.typeEntityName}?`}
{editScope.affectedCount} {editScope.affectedCount === 1 ? 'occurrence' : 'occurrences'} may reflect the update unless locally overridden.
)}
); } // ============================================================================ // Schema-Aware Property Dialog // ============================================================================ interface NewPropertyDialogProps { modelId: string; entityId: number; entityType: string; existingPsets: string[]; schemaVersion?: string; } /** * Schema-aware dialog for adding new properties. * Filters available property sets based on IFC entity type. * Shows property suggestions with correct types from IFC4 standard. */ export function NewPropertyDialog({ modelId, entityId, entityType, existingPsets, schemaVersion }: NewPropertyDialogProps) { const setProperty = useViewerStore((s) => s.setProperty); const createPropertySet = useViewerStore((s) => s.createPropertySet); const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion); const [open, setOpen] = useState(false); const [psetName, setPsetName] = useState(''); const [isCustomPset, setIsCustomPset] = useState(false); const [customPsetName, setCustomPsetName] = useState(''); const [propName, setPropName] = useState(''); const [customPropName, setCustomPropName] = useState(''); const [value, setValue] = useState(''); const [valueType, setValueType] = useState(PropertyValueType.String); // Get schema-valid property sets for this entity type const validPsetDefs = useMemo(() => { return getPsetDefinitionsForType(entityType, schemaVersion); }, [entityType, schemaVersion]); // Split into: already on entity vs available to add const { existingStandardPsets, availableStandardPsets } = useMemo(() => { const existing: PsetDefinition[] = []; const available: PsetDefinition[] = []; for (const def of validPsetDefs) { if (existingPsets.includes(def.name)) { existing.push(def); } else { available.push(def); } } return { existingStandardPsets: existing, availableStandardPsets: available }; }, [validPsetDefs, existingPsets]); // Get property suggestions for selected pset const propertySuggestions = useMemo((): PsetPropertyDef[] => { if (!psetName || isCustomPset) return []; return getPropertiesForPset(psetName); }, [psetName, isCustomPset]); // Determine effective property name and type const effectivePsetName = isCustomPset ? customPsetName : psetName; const effectivePropName = propName || customPropName; // Auto-update type when selecting a standard property const handlePropertySelect = useCallback((name: string) => { setPropName(name); setCustomPropName(''); // Auto-set type from schema const propDef = propertySuggestions.find(p => p.name === name); if (propDef) { setValueType(propDef.type); // Set sensible defaults for boolean properties if (propDef.type === PropertyValueType.Boolean) { setValue('false'); } } }, [propertySuggestions]); const handleSubmit = useCallback(() => { if (!effectivePsetName || !effectivePropName) return; let normalizedModelId = modelId; if (modelId === 'legacy') { normalizedModelId = '__legacy__'; } const parsedValue = parseValue(value, valueType); // Check if pset exists on entity already const psetExists = existingPsets.includes(effectivePsetName); if (!psetExists) { createPropertySet(normalizedModelId, entityId, effectivePsetName, [ { name: effectivePropName, value: parsedValue, type: valueType }, ]); } else { setProperty(normalizedModelId, entityId, effectivePsetName, effectivePropName, parsedValue, valueType); } bumpMutationVersion(); // Reset form setPsetName(''); setCustomPsetName(''); setPropName(''); setCustomPropName(''); setValue(''); setValueType(PropertyValueType.String); setIsCustomPset(false); setOpen(false); }, [modelId, entityId, effectivePsetName, effectivePropName, value, valueType, existingPsets, setProperty, createPropertySet, bumpMutationVersion]); const resetForm = useCallback(() => { setPsetName(''); setCustomPsetName(''); setPropName(''); setCustomPropName(''); setValue(''); setValueType(PropertyValueType.String); setIsCustomPset(false); }, []); return ( { setOpen(o); if (!o) resetForm(); }}> Add Property Add a property to this {entityType} element. {validPsetDefs.length > 0 && ( {schemaVersion || 'IFC4'} schema: {validPsetDefs.length} standard property set{validPsetDefs.length !== 1 ? 's' : ''} available )}
{/* Property Set Selection */}
{isCustomPset ? ( setCustomPsetName(e.target.value)} placeholder="e.g., Pset_MyCustomProperties" className="font-mono text-sm" /> ) : ( )}
{/* Property Selection */}
{propertySuggestions.length > 0 ? (
{/* Allow custom property name even for standard psets */} {!propName && ( setCustomPropName(e.target.value)} placeholder="Or type custom property name..." className="font-mono text-sm" /> )}
) : ( setCustomPropName(e.target.value)} placeholder="e.g., FireRating" className="font-mono text-sm" /> )}
{/* Type selector */}
{/* Value input */}
{valueType === PropertyValueType.Boolean ? (
setValue(checked ? 'true' : 'false')} /> {value === 'true' ? 'True' : 'False'}
) : ( setValue(e.target.value)} placeholder="Property value" type={valueType === PropertyValueType.Real || valueType === PropertyValueType.Integer ? 'number' : 'text'} className="font-mono text-sm" /> )}
); } // ============================================================================ // Classification Dialog // ============================================================================ interface AddClassificationDialogProps { modelId: string; entityId: number; entityType: string; } /** * Dialog for adding a classification reference to an entity. * Supports common classification systems (Uniclass, OmniClass, MasterFormat, etc.). * Stored as a special property set for mutation tracking. */ export function AddClassificationDialog({ modelId, entityId, entityType }: AddClassificationDialogProps) { const createPropertySet = useViewerStore((s) => s.createPropertySet); const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion); const [open, setOpen] = useState(false); const [system, setSystem] = useState(''); const [customSystem, setCustomSystem] = useState(''); const [identification, setIdentification] = useState(''); const [name, setName] = useState(''); const effectiveSystem = system === '__custom__' ? customSystem : system; const handleSubmit = useCallback(() => { if (!effectiveSystem || !identification) return; let normalizedModelId = modelId; if (modelId === 'legacy') { normalizedModelId = '__legacy__'; } // Store classification as a property set named "Classification [SystemName]" const psetName = `Classification [${effectiveSystem}]`; createPropertySet(normalizedModelId, entityId, psetName, [ { name: 'System', value: effectiveSystem, type: PropertyValueType.Label }, { name: 'Identification', value: identification, type: PropertyValueType.Identifier }, { name: 'Name', value: name || identification, type: PropertyValueType.Label }, ]); bumpMutationVersion(); // Reset form setSystem(''); setCustomSystem(''); setIdentification(''); setName(''); setOpen(false); }, [modelId, entityId, effectiveSystem, identification, name, createPropertySet, bumpMutationVersion]); return ( { setOpen(o); if (!o) { setSystem(''); setCustomSystem(''); setIdentification(''); setName(''); } }}> Add Classification Assign a classification reference to this {entityType}.
{/* Classification System */}
{system === '__custom__' && ( setCustomSystem(e.target.value)} placeholder="Classification system name" className="mt-2" /> )}
{/* Identification (code) */}
setIdentification(e.target.value)} placeholder="e.g., Ss_25_10_30 or 03 30 00" className="font-mono" />

The classification code or reference number

{/* Name (optional) */}
setName(e.target.value)} placeholder="e.g., Cast-in-place concrete walls" />
); } // ============================================================================ // Material Dialog // ============================================================================ interface AddMaterialDialogProps { modelId: string; entityId: number; entityType: string; } /** * Dialog for assigning a material to an entity. * Stored as a special property set for mutation tracking. */ export function AddMaterialDialog({ modelId, entityId, entityType }: AddMaterialDialogProps) { const createPropertySet = useViewerStore((s) => s.createPropertySet); const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion); const [open, setOpen] = useState(false); const [materialName, setMaterialName] = useState(''); const [category, setCategory] = useState(''); const [description, setDescription] = useState(''); const handleSubmit = useCallback(() => { if (!materialName) return; let normalizedModelId = modelId; if (modelId === 'legacy') { normalizedModelId = '__legacy__'; } // Store material as a property set named "Material" const psetName = `Material [${materialName}]`; const properties: Array<{ name: string; value: string; type: PropertyValueType }> = [ { name: 'Name', value: materialName, type: PropertyValueType.Label }, ]; if (category) { properties.push({ name: 'Category', value: category, type: PropertyValueType.Label }); } if (description) { properties.push({ name: 'Description', value: description, type: PropertyValueType.Label }); } createPropertySet(normalizedModelId, entityId, psetName, properties); bumpMutationVersion(); // Reset form setMaterialName(''); setCategory(''); setDescription(''); setOpen(false); }, [modelId, entityId, materialName, category, description, createPropertySet, bumpMutationVersion]); // Common material categories (module-level constant used below) const materialCategories = MATERIAL_CATEGORIES; return ( { setOpen(o); if (!o) { setMaterialName(''); setCategory(''); setDescription(''); } }}> Add Material Assign a material to this {entityType}.
{/* Material Name */}
setMaterialName(e.target.value)} placeholder="e.g., Concrete C30/37" className="font-mono" />
{/* Category */}
{/* Description */}
setDescription(e.target.value)} placeholder="Additional details about the material" />
); } // Common material categories - static, hoisted to module scope const MATERIAL_CATEGORIES = [ 'Concrete', 'Steel', 'Wood', 'Masonry', 'Glass', 'Aluminium', 'Insulation', 'Gypsum', 'Stone', 'Ceramic', 'Plastic', 'Composite', ] as const; // ============================================================================ // Quantity Dialog // ============================================================================ interface AddQuantityDialogProps { modelId: string; entityId: number; entityType: string; existingQtos: string[]; } /** * Schema-aware dialog for adding quantities. * Filters available quantity sets based on IFC entity type. * Shows quantity suggestions with correct types from IFC4 standard. */ export function AddQuantityDialog({ modelId, entityId, entityType, existingQtos }: AddQuantityDialogProps) { const createPropertySet = useViewerStore((s) => s.createPropertySet); const setProperty = useViewerStore((s) => s.setProperty); const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion); const [open, setOpen] = useState(false); const [qtoName, setQtoName] = useState(''); const [isCustomQto, setIsCustomQto] = useState(false); const [customQtoName, setCustomQtoName] = useState(''); const [quantityName, setQuantityName] = useState(''); const [customQuantityName, setCustomQuantityName] = useState(''); const [value, setValue] = useState(''); const [quantityType, setQuantityType] = useState(QuantityType.Length); // Get schema-valid quantity sets for this entity type const validQtoDefs = useMemo(() => { return getQtoDefinitionsForType(entityType); }, [entityType]); // Split into: already on entity vs available to add const { existingStandardQtos, availableStandardQtos } = useMemo(() => { const existing: QtoDefinition[] = []; const available: QtoDefinition[] = []; for (const def of validQtoDefs) { if (existingQtos.includes(def.name)) { existing.push(def); } else { available.push(def); } } return { existingStandardQtos: existing, availableStandardQtos: available }; }, [validQtoDefs, existingQtos]); // Get quantity suggestions for selected qto set const quantitySuggestions = useMemo((): QtoQuantityDef[] => { if (!qtoName || isCustomQto) return []; return getQuantitiesForQto(qtoName); }, [qtoName, isCustomQto]); const effectiveQtoName = isCustomQto ? customQtoName : qtoName; const effectiveQuantityName = quantityName || customQuantityName; // Auto-update type when selecting a standard quantity const handleQuantitySelect = useCallback((name: string) => { setQuantityName(name); setCustomQuantityName(''); const qtyDef = quantitySuggestions.find(q => q.name === name); if (qtyDef) { setQuantityType(qtyDef.type); } }, [quantitySuggestions]); const handleSubmit = useCallback(() => { if (!effectiveQtoName || !effectiveQuantityName) return; let normalizedModelId = modelId; if (modelId === 'legacy') { normalizedModelId = '__legacy__'; } const parsedValue = parseFloat(value) || 0; // Store quantity as a property set (mutation system uses property sets) const qtoExists = existingQtos.includes(effectiveQtoName); if (!qtoExists) { createPropertySet(normalizedModelId, entityId, effectiveQtoName, [ { name: effectiveQuantityName, value: parsedValue, type: PropertyValueType.Real }, ]); } else { setProperty(normalizedModelId, entityId, effectiveQtoName, effectiveQuantityName, parsedValue, PropertyValueType.Real); } bumpMutationVersion(); // Reset form setQtoName(''); setCustomQtoName(''); setQuantityName(''); setCustomQuantityName(''); setValue(''); setQuantityType(QuantityType.Length); setIsCustomQto(false); setOpen(false); }, [modelId, entityId, effectiveQtoName, effectiveQuantityName, value, existingQtos, setProperty, createPropertySet, bumpMutationVersion]); const resetForm = useCallback(() => { setQtoName(''); setCustomQtoName(''); setQuantityName(''); setCustomQuantityName(''); setValue(''); setQuantityType(QuantityType.Length); setIsCustomQto(false); }, []); return ( { setOpen(o); if (!o) resetForm(); }}> Add Quantity Add a quantity to this {entityType} element. {validQtoDefs.length > 0 && ( IFC4 schema: {validQtoDefs.length} standard quantity set{validQtoDefs.length !== 1 ? 's' : ''} available )}
{/* Quantity Set Selection */}
{isCustomQto ? ( setCustomQtoName(e.target.value)} placeholder="e.g., Qto_MyCustomQuantities" className="font-mono text-sm" /> ) : ( )}
{/* Quantity Selection */}
{quantitySuggestions.length > 0 ? (
{!quantityName && ( setCustomQuantityName(e.target.value)} placeholder="Or type custom quantity name..." className="font-mono text-sm" /> )}
) : ( setCustomQuantityName(e.target.value)} placeholder="e.g., Length" className="font-mono text-sm" /> )}
{/* Value input */}
setValue(e.target.value)} placeholder="Numeric value" type="number" step="any" className="font-mono text-sm" />
); } // ============================================================================ // Reassign IFC Class (retype) // ============================================================================ interface ReassignClassDialogProps { modelId: string; entityId: number; /** The element's current IFC class, e.g. "IfcBuildingElementProxy". */ entityType: string; schemaVersion?: string; } /** * Reassign an entity's IFC class in place ("retype"). The expressId is * unchanged, so geometry / placement / representation and every IfcRel* * reference carry over; the new class materializes on STEP export. Mirrors * IfcOpenShell's `reassign_class`. Best for the building-element subtypes * (Proxy ↔ Column / Beam / Member / Plate / Wall) that share the IfcElement * attribute layout. */ export function ReassignClassDialog({ modelId, entityId, entityType, schemaVersion }: ReassignClassDialogProps) { const setEntityType = useViewerStore((s) => s.setEntityType); const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion); const [open, setOpen] = useState(false); const [target, setTarget] = useState(''); const [predefinedType, setPredefinedType] = useState(''); const schema = useMemo(() => resolveReassignSchema(schemaVersion), [schemaVersion]); const targets = useMemo(() => getReassignTargets(schema), [schema]); const predefinedOptions = useMemo( () => (target.trim() ? getPredefinedTypes(schema, target.trim()) : []), [schema, target], ); const quickTargets = useMemo( () => COMMON_REASSIGN_TARGETS.filter((t) => t.toUpperCase() !== entityType.toUpperCase()), [entityType], ); const trimmedTarget = target.trim(); const targetChanged = trimmedTarget.length > 0 && trimmedTarget.toUpperCase() !== entityType.toUpperCase(); const knownTarget = trimmedTarget.length > 0 && isKnownReassignTarget(schema, trimmedTarget); const validKeyword = /^[Ii][Ff][Cc][A-Za-z][A-Za-z0-9_]*$/.test(trimmedTarget); // Allow apply when the class changes, or when only setting a predefined type // on the same class (the retype API carries that too). const canApply = validKeyword && (targetChanged || (trimmedTarget.length > 0 && predefinedType.length > 0)); const reset = useCallback(() => { setTarget(''); setPredefinedType(''); }, []); // Drop a predefined type that the newly-chosen class doesn't define. useEffect(() => { if (predefinedType && !predefinedOptions.includes(predefinedType)) setPredefinedType(''); }, [predefinedOptions, predefinedType]); const handleApply = useCallback(() => { if (!canApply) return; let normalizedModelId = modelId; if (modelId === 'legacy') normalizedModelId = '__legacy__'; setEntityType(normalizedModelId, entityId, trimmedTarget, predefinedType || null); bumpMutationVersion(); reset(); setOpen(false); }, [canApply, modelId, entityId, trimmedTarget, predefinedType, setEntityType, bumpMutationVersion, reset]); return ( { setOpen(o); if (!o) reset(); }}> Reassign IFC Class Change this element's IFC class in place. It keeps its identity, geometry and relationships — only the class (and an optional predefined type) change on export.
{/* current → target */}
{entityType} {trimmedTarget || '—'}
{/* quick picks */} {quickTargets.length > 0 && (
{quickTargets.map((t) => ( ))}
)} {/* searchable full list */}
{trimmedTarget.length > 0 && !knownTarget && (

Not a standard {schema} element — it will export as a vendor/custom class.

)}
{/* predefined type */} {predefinedOptions.length > 0 && (
)}

Updates immediately here, in the model tree and lists, and is written to the exported IFC. Class-based 3D colors refresh on reload.

); } /** * Inline chip surfacing a pending reassignment for the selected element. Reads * the overlay (re-rendering on `mutationVersion`) so a retype is visible in the * panel immediately, before the model is re-exported / reloaded. */ export function ReassignBadge({ modelId, entityId, entityType }: { modelId: string; entityId: number; entityType: string }) { const mutationVersion = useViewerStore((s) => s.mutationVersion); const mutationViews = useViewerStore((s) => s.mutationViews); const pending = useMemo(() => { let mid = modelId; if (mid === 'legacy') mid = '__legacy__'; const view = mutationViews.get(mid); const m = view?.getEntityTypeMutation?.(entityId) ?? null; if (!m) return null; // A no-op (same class, no predefined type) isn't worth surfacing. if (m.newType.toUpperCase() === entityType.toUpperCase() && !m.predefinedType) return null; return m; // eslint-disable-next-line react-hooks/exhaustive-deps }, [modelId, entityId, entityType, mutationViews, mutationVersion]); if (!pending) return null; return (
Reassigned {pending.newType} {pending.predefinedType && ( · {pending.predefinedType} )} on export
); } // ============================================================================ // Edit Toolbar (combines all add actions) // ============================================================================ interface EditToolbarProps { modelId: string; entityId: number; entityType: string; existingPsets: string[]; existingQtos?: string[]; schemaVersion?: string; } /** * Edit mode toolbar with dropdown for adding properties, classifications, materials, and quantities. * Schema-aware: filters available property/quantity sets based on entity type. */ export function EditToolbar({ modelId, entityId, entityType, existingPsets, existingQtos, schemaVersion }: EditToolbarProps) { // Reassign is only meaningful for occurrence building elements — not type // entities, spaces, or materials. const canReassign = isReassignableElement(resolveReassignSchema(schemaVersion), entityType); return (
{/* live-edit accent hairline */}
{/* Data-enrichment cluster — one segmented control of icon keys */}
{/* Structural transform — elevated accent action */} {canReassign && ( )}
{canReassign && }
); } // ============================================================================ // Undo/Redo // ============================================================================ interface UndoRedoButtonsProps { modelId: string; } /** * Undo/Redo buttons for property mutations */ export function UndoRedoButtons({ modelId }: UndoRedoButtonsProps) { const canUndo = useViewerStore((s) => s.canUndo); const canRedo = useViewerStore((s) => s.canRedo); const undo = useViewerStore((s) => s.undo); const redo = useViewerStore((s) => s.redo); // Normalize model ID for legacy models let normalizedModelId = modelId; if (modelId === 'legacy') { normalizedModelId = '__legacy__'; } const handleUndo = useCallback(() => { undo(normalizedModelId); }, [normalizedModelId, undo]); const handleRedo = useCallback(() => { redo(normalizedModelId); }, [normalizedModelId, redo]); return (
Undo Redo
); } // ============================================================================ // Helper Functions // ============================================================================ /** * Extract the raw value from typed IFC values. * Handles: arrays like [IFCLABEL, value], strings like "IFCLABEL,value" */ function extractRawValue(value: unknown): unknown { if (value === null || value === undefined) return value; // Handle typed value arrays [IFCTYPENAME, actualValue] if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'string' && value[0].toUpperCase().startsWith('IFC')) { return value[1]; } // Handle string format "IFCTYPENAME,actualValue" if (typeof value === 'string') { const match = value.match(/^(IFC[A-Z0-9_]+),(.*)$/i); if (match) { return match[2]; // Return just the value part } } return value; } function formatValue(value: unknown): string { const raw = extractRawValue(value); if (raw === null || raw === undefined) return ''; if (typeof raw === 'boolean') return raw ? 'true' : 'false'; if (typeof raw === 'number') return raw.toString(); if (Array.isArray(raw)) return JSON.stringify(raw); return String(raw); } function formatDisplayValue(value: unknown): string { const raw = extractRawValue(value); if (raw === null || raw === undefined) return '\u2014'; if (typeof raw === 'boolean') return raw ? 'True' : 'False'; if (typeof raw === 'number') { return Number.isInteger(raw) ? raw.toLocaleString() : raw.toLocaleString(undefined, { maximumFractionDigits: 6 }); } if (Array.isArray(raw)) return JSON.stringify(raw); // Handle boolean strings (STEP enum format) const strVal = String(raw); const upper = strVal.toUpperCase(); if (upper === '.T.') return 'True'; if (upper === '.F.') return 'False'; if (upper === '.U.') return 'Unknown'; return strVal; } function detectValueType(value: unknown, fallback: PropertyValueType): PropertyValueType { // First check if it's a typed value and extract the type if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'string') { const typeName = value[0].toUpperCase(); if (typeName === 'IFCBOOLEAN' || typeName === 'IFCLOGICAL') return PropertyValueType.Boolean; if (typeName === 'IFCREAL') return PropertyValueType.Real; if (typeName === 'IFCINTEGER') return PropertyValueType.Integer; if (typeName === 'IFCIDENTIFIER') return PropertyValueType.Identifier; if (typeName === 'IFCLABEL') return PropertyValueType.Label; if (typeName === 'IFCTEXT') return PropertyValueType.String; } // Check string format "IFCTYPE,value" if (typeof value === 'string') { const match = value.match(/^(IFC[A-Z0-9_]+),/i); if (match) { const typeName = match[1].toUpperCase(); if (typeName === 'IFCBOOLEAN' || typeName === 'IFCLOGICAL') return PropertyValueType.Boolean; if (typeName === 'IFCREAL') return PropertyValueType.Real; if (typeName === 'IFCINTEGER') return PropertyValueType.Integer; if (typeName === 'IFCIDENTIFIER') return PropertyValueType.Identifier; if (typeName === 'IFCLABEL') return PropertyValueType.Label; if (typeName === 'IFCTEXT') return PropertyValueType.String; } // Check for boolean enum values const upper = value.toUpperCase(); if (upper === '.T.' || upper === '.F.' || upper === '.U.') { return PropertyValueType.Boolean; } } // Check raw value type const raw = extractRawValue(value); if (typeof raw === 'boolean') return PropertyValueType.Boolean; if (typeof raw === 'number') { return Number.isInteger(raw) ? PropertyValueType.Integer : PropertyValueType.Real; } return fallback; } function getTypeName(type: PropertyValueType): string { switch (type) { case PropertyValueType.String: return 'String'; case PropertyValueType.Label: return 'Label'; case PropertyValueType.Identifier: return 'Identifier'; case PropertyValueType.Real: return 'Real'; case PropertyValueType.Integer: return 'Integer'; case PropertyValueType.Boolean: return 'Boolean'; case PropertyValueType.Logical: return 'Logical'; default: return 'String'; } } function parseValue(value: string, type: PropertyValueType): PropertyValue { switch (type) { case PropertyValueType.Real: return parseFloat(value) || 0; case PropertyValueType.Integer: return parseInt(value, 10) || 0; case PropertyValueType.Boolean: case PropertyValueType.Logical: // Empty = unset → null (encodes to the table's 255 sentinel, serialises // to `$`). Only an explicit choice writes a concrete boolean. if (value === '') return null; return value.toLowerCase() === 'true'; default: return value; } }