/** * Mutable property view - overlay pattern for property mutations * * This class provides a mutable view over an immutable PropertyTable. * Changes are tracked separately and applied on-the-fly during reads. * * Supports both pre-built property tables and on-demand property extraction * for optimal performance with large models. */ import type { PropertyTable, PropertySet, QuantitySet } from '@ifc-lite/data'; import { PropertyValueType, QuantityType } from '@ifc-lite/data'; import type { IfcAttributeValue, PropertyValue, EntityTypeMutation, Mutation, NewEntity, EffectiveChange } from './types.js'; import { type AttributeExtractor } from './effective-changes.js'; export type { AttributeExtractor } from './effective-changes.js'; /** * Function type for on-demand property extraction * Allows globalId to be optional to match extractPropertiesOnDemand return type */ export type PropertyExtractor = (entityId: number) => Array<{ name: string; globalId?: string; properties: Array<{ name: string; type: number; value: unknown; dataType?: string; }>; }>; /** * Function type for on-demand quantity extraction */ export type QuantityExtractor = (entityId: number) => QuantitySet[]; export declare class MutablePropertyView { private baseTable; private onDemandExtractor; private quantityExtractor; private attributeExtractor; private propertyMutations; private quantityMutations; /** * Secondary indices: entityId → mutation keys for that entity. * * `getForEntity` previously iterated the entire `propertyMutations` / * `quantityMutations` map per pset to find newly-added properties — O(M·P) * per call. These indices keep that step O(M_entity) instead. */ private propertyKeysByEntity; private quantityKeysByEntity; private attributeKeysByEntity; private deletedPsets; private deletedQsets; private newPsets; private newQsets; private attributeMutations; private positionalAttrMutations; private typeMutations; private newEntities; private tombstones; /** * Ids `createEntity` allocated and `deleteEntity` then forgot (removed from * `newEntities`, per that method's "existing entities are tombstoned; new * entities are simply forgotten" contract). Tracked separately so * `getEffectiveChanges()` / `collectEffectiveChanges` can tell "overlay-created * then forgotten" apart from "an ordinary source-buffer entity" — both are * otherwise indistinguishable, being simply absent from `newEntities`. * `restoreNewEntity` (the undo-of-delete counterpart) clears the id back out. */ private forgottenCreatedEntities; /** * Snapshot of a forgotten-created entity's overlay rows, stashed by * `deleteEntity` and restored by `restoreNewEntity`. * * `deleteEntity` on an overlay-created entity does more than drop it from * `newEntities` — it also PURGES every other overlay entry the entity left * behind (property/quantity/attribute/positional/type mutations, its * `newPsets`/`newQsets` entries, and its own `mutationHistory` records). * Without that purge, an entity that was created, edited, then deleted * before export left a dangling reference: `StepExporter` derives its * property/quantity work list from `getMutations()` (the append-only * history) and reads `getForEntity()` / `getQuantitiesForEntity()` straight * off `newPsets` / `newQsets` — neither of which the review-side * `forgottenCreatedEntities` filter in `effective-changes.ts` touches. The * review dialog looked clean while the exported file still contained an * `IFCPROPERTYSET` + `IFCRELDEFINESBYPROPERTIES` pointing at an expressId * that was never actually created (maintainer finding on #1967). * * The purged data is captured here, not discarded, because `restoreNewEntity` * (undo of the delete) must bring it all back — rows AND count AND what the * exporter would see — not just re-add the bare `NewEntity` record. */ private forgottenEntityOverlay; /** * Overlay-entity → source-entity aliases for property/quantity reads. * * When the viewer duplicates an existing entity, the new entity has * no row in the parsed property table — `getBasePropertiesForEntity` * would return `[]` and the property panel would show "No property * sets". Aliasing redirects the BASE read to the source entity so * the duplicate inherits its psets / qsets visually, while overlay * mutations (overrides, creates, deletes) stay scoped to the * overlay-entity's own id — so editing a property on the duplicate * doesn't bleed into the source. * * Aliases follow at most one hop (no chains). They never affect * STEP export — the export overlay emits the duplicate exactly as * the StoreEditor recorded it, with whatever new IfcRel*ByProperties * the caller chose to add. */ private entityAliases; private nextAllocatedId; private mutationHistory; private modelId; constructor(baseTable: PropertyTable | null, modelId: string); /** * Seed the express-ID allocator. Should be called once after parsing with * the highest existing expressId in the store; subsequent `createEntity` * calls allocate IDs strictly above this watermark. */ setExpressIdWatermark(maxExistingId: number): void; /** The next expressId that `createEntity` would allocate. */ peekNextExpressId(): number; private setPropertyMutation; private deletePropertyMutation; private setQuantityMutation; private deleteQuantityMutation; private setAttributeMutation; private deleteAttributeMutation; /** * Set an on-demand property extractor function * This is used when properties are extracted lazily from the source buffer */ setOnDemandExtractor(extractor: PropertyExtractor): void; /** * Set an on-demand quantity extractor function */ setQuantityExtractor(extractor: QuantityExtractor): void; /** * Whether this view has anything UNDER its quantity overlay. * * Properties always do — `getBasePropertiesForEntity` falls back to the * `baseTable` the constructor takes — but quantities have only * `setQuantityExtractor`, which is opt-in and defaults to `null`. A view * without one answers `getQuantitiesForEntity` from the overlay ALONE, so a * session that edits one quantity of a source quantity set sees that one * quantity and none of its siblings. * * Exposed so a consumer holding the base data can tell "this entity has no * quantities" apart from "this view cannot see them" and supply the missing * half rather than write the overlay out as if it were the whole set — which * is how a full STEP export deleted a source `IfcElementQuantity` * (github.com/LTplus-AG/ifc-lite/issues/2487). */ hasQuantityBase(): boolean; /** * Set the base entity-attribute extractor (Name, Description, ObjectType, * Tag, ...), used only to resolve `previousValue` in `getEffectiveChanges()`. * Without one, attribute `previousValue` falls back to whatever `oldValue` * the overlay entry itself carries — which undo can leave stale/absent (see * `getEffectiveChanges()` doc). */ setAttributeExtractor(extractor: AttributeExtractor): void; /** * Get base properties for an entity (before mutations) * Uses on-demand extraction if available, otherwise falls back to base table. * * Follows the entityAliases map for overlay duplicates so a fresh * duplicate inherits its source's psets without paying the cost of * eagerly cloning them into the overlay. */ private getBasePropertiesForEntity; /** * Get all property sets for an entity, with mutations applied */ getForEntity(entityId: number): PropertySet[]; /** * Get a specific property value with mutations applied */ getPropertyValue(entityId: number, psetName: string, propName: string): PropertyValue | null; /** * Set a property value * If the property set doesn't exist, creates it automatically * @param skipHistory - If true, don't add to mutation history (used for undo/redo) */ setProperty(entityId: number, psetName: string, propName: string, value: PropertyValue, valueType?: PropertyValueType, unit?: string, skipHistory?: boolean): Mutation; /** * Delete a property * @param skipHistory - If true, don't add to mutation history (used for undo/redo) */ deleteProperty(entityId: number, psetName: string, propName: string, skipHistory?: boolean): Mutation | null; /** * Create a new property set */ createPropertySet(entityId: number, psetName: string, properties: Array<{ name: string; value: PropertyValue; type?: PropertyValueType; unit?: string; }>): Mutation; /** * Delete an entire property set */ deletePropertySet(entityId: number, psetName: string): Mutation; /** * Get base quantities for an entity (before mutations) * * Follows the entityAliases map for overlay duplicates so a fresh * duplicate inherits its source's qsets. */ private getBaseQuantitiesForEntity; /** * Get all quantity sets for an entity, with mutations applied */ getQuantitiesForEntity(entityId: number): QuantitySet[]; /** * Create a new quantity set */ createQuantitySet(entityId: number, qsetName: string, quantities: Array<{ name: string; value: number; quantityType: QuantityType; unit?: string; }>): Mutation; /** * Set a single quantity value (add to existing or new quantity set) */ setQuantity(entityId: number, qsetName: string, quantName: string, value: number, qType?: QuantityType, unit?: string, skipHistory?: boolean): Mutation; /** * Delete an entire quantity set - the inverse of `createQuantitySet`, and the * exact mirror of `deletePropertySet` one level up. * * It was missing until #2508's zone write-back needed it, which is why * `deletedQsets` existed but was only ever populated by the restore path. * Without it, a writer that REPLACES an entity's quantity set can shrink it * but never empty it: re-running with no quantities to write leaves the * previous run's numbers in place, so the file states volumes beside a * property saying the volume could not be computed. */ deleteQuantitySet(entityId: number, qsetName: string): Mutation; /** * Has this entity's quantity set been DELETED this session? * * `getQuantitiesForEntity` cannot answer it: a deleted set and a set that * never existed both come back absent. The exporter needs the difference, * because it withholds a source `IfcElementQuantity` when it is writing a * REPLACEMENT for it, and a deletion has no replacement to recognise it by. * Without this, `deleteQuantitySet` masked a base set in the panel while the * exported file still carried it. */ isQuantitySetDeleted(entityId: number, qsetName: string): boolean; /** * Set an entity attribute value (Name, Description, ObjectType, Tag, etc.) */ setAttribute(entityId: number, attrName: string, value: string, oldValue?: string, skipHistory?: boolean): Mutation; /** * Set a positional STEP argument on an entity by zero-based index. * * This is the only path for editing non-IfcRoot entities (e.g. profile * dimensions on `IfcRectangleProfileDef`) where attributes have no symbolic * names. Values follow the same conventions as `NewEntity.attributes`: * numbers become `#expressId` references when paired with a reference slot, * otherwise REAL/INTEGER literals; strings become quoted STEP strings; * `null` becomes `$`. */ setPositionalAttribute(entityId: number, index: number, value: IfcAttributeValue, skipHistory?: boolean): Mutation; /** Get all positional argument overrides for an entity, keyed by index. */ getPositionalMutationsForEntity(entityId: number): Map | null; /** * Drop a single positional override. Used by undo to roll a * setPositionalAttribute back to "no override" when there was no prior * value. Mirrors `removeAttributeMutation` for symmetric naming. */ removePositionalMutation(entityId: number, index: number): void; /** * Change an entity's IFC class in place ("retype" / reassign class). * * The entity keeps its expressId, so its geometry, placement, representation * and every `IfcRel*` reference (all keyed by `#id`) carry over unchanged. * At export the exporter re-lays-out the entity's attributes BY NAME against * the target class's declared attribute list — attributes the target class * doesn't have are dropped, missing ones become `$`. This mirrors * IfcOpenShell's `ifcopenshell.util.schema.reassign_class`. * * Intended for compatible reassignments — e.g. the building-element subtypes * (`IfcBuildingElementProxy` → `IfcColumn` / `IfcBeam` / `IfcMember` / * `IfcPlate` / `IfcWall`) that share the IfcElement attribute layout. For * such retypes only the class keyword changes (and an optional PredefinedType). * * @param newType Target IFC class (canonical PascalCase, e.g. "IfcColumn"). * @param predefinedType Optional PredefinedType for the target class. Unknown * values fall back to USERDEFINED + ObjectType at export. */ setEntityType(entityId: number, newType: string, predefinedType?: string | null, oldType?: string, skipHistory?: boolean): Mutation; /** Get the retype intent for an entity, or null if it hasn't been retyped. */ getEntityTypeMutation(entityId: number): EntityTypeMutation | null; /** All retype intents, keyed by expressId. Returns a defensive copy. */ getTypeMutations(): Map; /** * Drop a retype intent, reverting the entity to its original class. Because * `setEntityType` never mutates `NewEntity.type` in place, this is a complete * revert for both source-buffer and overlay-created entities — nothing else * to roll back. */ removeTypeMutation(entityId: number): void; /** * Create a new entity in the overlay. Returns the freshly-allocated * expressId. Callers must ensure `setExpressIdWatermark` has been seeded * from the underlying store before calling this for the first time. */ createEntity(type: string, attributes: IfcAttributeValue[]): NewEntity; /** * Mark an entity for deletion. Returns false if the id is unknown to this * view, or was already tombstoned. * * An overlay-created entity is dropped from `newEntities` — so it is emitted * nowhere, which is the right answer for something created and deleted in one * session — AND tombstoned, so `isDeleted` tells the truth about it. * * It used to be only forgotten, and that made `isDeleted` lie: every guard * that asks "was this deleted" got `false` for an entity that no longer * exists, so the export still emitted the `IFCRELDEFINESBYPROPERTIES` for a * pset queued on it, dangling at a record nothing wrote (#2012). Forgetting * without tombstoning cannot be made safe one guard at a time, because the * question the guards ask has no true answer to find. * * Consumers that count entities must therefore intersect tombstones with the * source store rather than subtracting `tombstones.size` wholesale — a * created-then-deleted id is absent from BOTH the store and `getNewEntities`, * so counting it as a deletion would subtract it twice. */ deleteEntity(expressId: number): boolean; /** Returns all overlay-created entities in insertion order. */ getNewEntities(): NewEntity[]; /** Look up a single overlay-created entity. */ getNewEntity(expressId: number): NewEntity | null; isDeleted(expressId: number): boolean; /** * Reverse `deleteEntity` for an existing-entity tombstone. Returns true if * a tombstone was removed; false if the id was not tombstoned. Used by * undo of a DELETE_ENTITY mutation on a source-buffer entity. Overlay-only * entities are restored via a separate path (`restoreNewEntity`). */ restoreFromTombstone(expressId: number): boolean; /** * Alias an overlay-only entity to a source entity for property / * quantity reads. Used by the duplicate flow so a fresh duplicate * inherits its source's psets / qsets in the property panel without * eagerly cloning them. Edits on the duplicate stay scoped to the * duplicate's own id (override slots are keyed by entity id, not * by base id). * * Pass `null` as the source to clear an existing alias. */ setEntityAlias(overlayId: number, sourceId: number | null): void; /** Read the alias for a given overlay id, or null if none. */ getEntityAlias(overlayId: number): number | null; /** * Resolve to the base id used for property/quantity reads. Returns * the input id when no alias is set. Aliases follow at most one * hop — chained duplicates resolve to their immediate source, not * the original. */ resolveBaseEntityId(entityId: number): number; /** * Re-add an overlay-only entity to `newEntities`. Pairs with `deleteEntity` * to support undo of a freshly-created-and-then-deleted entity. The caller * is responsible for stashing the `NewEntity` record between delete and * restore (the slice's undo stack does this). */ restoreNewEntity(entity: NewEntity): void; /** * Move every current overlay entry for `expressId` out of the live maps * and into `forgottenEntityOverlay`, and drop this entity's own records * from `mutationHistory`. Called by `deleteEntity` when it forgets a * created entity. Only stashes a key if something was actually captured, * so `unstashEntityOverlay` on a plain (never-edited) create is a no-op. */ private stashAndPurgeEntityOverlay; /** * Reverse `stashAndPurgeEntityOverlay`: put everything `deleteEntity` * purged back into the live overlay maps. Called by `restoreNewEntity`. * A no-op if nothing was stashed for `expressId`. */ private unstashEntityOverlay; /** * Every express id this session deleted — source-buffer entities AND ones it * created and then deleted. The two are not distinguishable from this set * alone; a caller that needs to tell them apart intersects it with the store's * own index (see `deleteEntity`). */ getTombstones(): Set; /** * Get mutated attributes for an entity. * Returns only attributes that have been added/modified via mutations. */ getAttributeMutationsForEntity(entityId: number): Array<{ name: string; value: string; }>; /** * Every attribute override currently in the overlay, keyed by entity then * attribute name. * * This is the *current* overlay state, not the append-only mutation history: * an undone edit has had its overlay entry reset to the pre-edit value (or * removed outright), so it does not appear here, whereas its superseded * `UPDATE_ATTRIBUTE` record lives on in {@link getMutations} forever. Export * must read this — replaying the history resurrects undone edits (#1957). */ getAttributeMutationsByEntity(): Map>; /** * Remove a quantity mutation (used by undo for newly created quantities) */ removeQuantityMutation(entityId: number, qsetName: string, quantName?: string): void; /** * Remove an attribute mutation (used by undo for newly set attributes) */ removeAttributeMutation(entityId: number, attrName: string): void; /** * Get all mutations applied to this view */ getMutations(): Mutation[]; /** * Get mutations for a specific entity */ getMutationsForEntity(entityId: number): Mutation[]; /** * Check if an entity currently carries an overlay change. * * Reads the live overlay (same footprint as {@link hasPendingChanges}), * NOT the append-only `mutationHistory` — undo does not pop history (see * `getMutations()`), so a history-based check could report `true` for an * entity whose edit was fully undone. Called with no `entityId`, this is * exactly {@link hasPendingChanges}. * * Unlike {@link getModifiedEntityCount} (derived from * {@link getEffectiveChanges} so it can't diverge), this is a direct * per-entity map lookup kept O(1)-ish for callers that probe many entities * (e.g. a per-row "has changes" indicator) — re-deriving effective changes * per call would be O(overlay size) each time. That means it can still * report `true` for an entity whose only overlay entry is a no-op edit * (undo landed it back at the base value, so `previousValue === newValue` * — see {@link getEffectiveChanges}'s doc). Over-reporting here is the same * safe direction {@link hasPendingChanges} already documents; nothing in * this repo reads this per-entity form in production as of #1967. */ hasChanges(entityId?: number): boolean; /** * True when the overlay currently carries anything the STEP exporter would * bake (property/quantity overrides, attribute / positional / type edits, * pset/qset creates or deletes, or overlay-created/tombstoned entities). * * Unlike {@link getMutations} / {@link hasChanges}, this reflects the *current * overlay footprint* — the same set {@link clear} resets and the exporter * reads — rather than the append-only mutation history, which never shrinks. * It is deliberately a conservative over-approximation: undoing an edit resets * the overlay entry's value (or leaves a no-op DELETE marker) instead of * removing it, so a fully-reverted model can still report `true`. That is the * safe direction for gating an export bake — over-reporting only costs a * redundant (identical-output) re-bake, whereas under-reporting would silently * drop edits. */ hasPendingChanges(): boolean; /** * Get count of modified entities. * * Reads the live overlay, NOT `mutationHistory` (issue #1915): undo does * not pop history, so a history-based count could over-report — e.g. after * `setAttribute` + `removeAttributeMutation` (exactly what undoing a * freshly-created attribute mutation does), the overlay is empty again but * history still holds the one entry. This must agree with * {@link hasPendingChanges}: zero here iff that is `false`. * * Must also agree with {@link getEffectiveChanges} — an entity contributing * zero effective rows (a create -> edit -> delete `deleteEntity` forgot, or * an edit fully undone back to its base value) must not be counted here * either. `collectModifiedEntityIds` is deliberately DERIVED FROM * `getEffectiveChanges()` rather than hand-walking the overlay maps a * second time, so the two structurally cannot diverge again (issue: the * #1915 forgotten-created blind spot, and the #1967 no-op-edit blind spot * that a second hand-rolled walk reintroduced). */ getModifiedEntityCount(): number; /** Distinct entity ids with at least one row in {@link getEffectiveChanges}. */ private collectModifiedEntityIds; /** * Enumerate every change the overlay currently carries, as it stands right * now — never from `mutationHistory` (see {@link getModifiedEntityCount}). * This is what the export-review UI (issue #1915) and any snapshot test * should read: `previousValue` is derived from the base data (property * table / on-demand extractor / attribute extractor), so an undo→redo * cycle reports the true original, not a stale history entry. * * Whole-pset/qset deletes and creates are reported as a single * `pset-added` / `pset-deleted` / `qset-added` / `qset-deleted` row rather * than one row per property/quantity inside them (deletePropertySet / * createPropertySet also populate individual property/quantity mutations * internally — those are intentionally not double-reported here). * * Deterministic ordering: entityId, then kind, then name, then setName. */ getEffectiveChanges(): EffectiveChange[]; /** * Clear all mutations (reset to base state) */ clear(): void; /** * Apply a batch of mutations (e.g., from imported change set) */ applyMutations(mutations: Mutation[]): void; /** * Export mutations as JSON. Includes every record in `mutationHistory`, * including `CREATE_ENTITY` — but see `importMutations` for why replaying * that record on another view does not reconstruct the entity. */ exportMutations(): string; /** * Import mutations from JSON produced by `exportMutations`. * * **Not a full inverse of `exportMutations`.** A `CREATE_ENTITY` record * carries only the expressId in the history — not the entity's type and * attributes — so `importMutations` cannot rebuild the entity from the * record alone: it logs a `console.warn` and skips the record, and drops * every other mutation recorded against that same entity id in the same * batch too (so the round trip is lossy — entity and edits both dropped — * rather than leaving an orphaned property/attribute/quantity keyed to an * id that was never created on the receiving view). * * To carry an overlay-created entity across, call `restoreNewEntity()` * with its `NewEntity` payload (from `getNewEntity`/`getNewEntities` on * the source view) **before** calling `importMutations`. Once the id is * live in `newEntities`, its dependent mutations replay normally — only * the `console.warn` for the (now redundant) `CREATE_ENTITY` record still * fires. */ importMutations(json: string): void; } //# sourceMappingURL=mutable-property-view.d.ts.map