IFClite TypeScript API
    Preparing search index...

    Interface DiffOptions

    interface DiffOptions {
        classFamilies?: readonly (readonly string[])[];
        detectSplitMerge?: boolean;
        detectSuccessors?: boolean;
        excludeTypes?: Iterable<string, any, any>;
        keyAliases?: ReadonlyMap<string, string>;
        matchUnpairedByContent?: boolean;
        maxMoveDistance?: number;
        maxSplitPieces?: number;
        moveTolerance?: number;
        reshapeTolerance?: number;
        scope?: DiffScope;
        splitPaddingMin?: number;
        splitPaddingRatio?: number;
        splitVolumeTolerance?: number;
        successorDistance?: number;
        successorOverlap?: number;
    }
    Index
    classFamilies?: readonly (readonly string[])[]

    Class families for the split/merge candidate buckets (issue #4955): rows of IFC class names an authoring tool may swap between while publishing the same material. Candidates are bucketed by family rather than by exact ifcType, so an IfcWall republished as three IfcWallStandardCases, or as IfcBuildingElementPart layers, can be claimed as a split; the claim then carries SplitMergeClaim.crossClass. Matching is case-insensitive and an unlisted class is its own family.

    Default: DEFAULT_CLASS_FAMILIES — the schema's own StandardCase / ElementedCase subtypes plus IfcBuildingElementPart under walls and the furniture classes. Pass [] to bucket by exact class as before.

    detectSplitMerge?: boolean

    Opt-in fourth stage (issue #1891): look for splits and merges among the entities still added/deleted after matchUnpairedByContent has run — one element that became several, or several that became one.

    Effective only together with matchUnpairedByContent, and only when the run has geometry to reason with (the DiffScope 'data' and mixed-capability abstentions apply unchanged — there is no non-geometric evidence channel for this). Off, and in either abstention, ModelDiff.splitMerges is undefined and nothing else changes.

    Running on the RESIDUE is load-bearing rather than incidental. Renames, moves and reshapes must be retired first, or a re-GUIDed wall plus one small genuinely-added fixture inside its box fakes a volume-conserving "split" out of two things that were never related.

    Purely additive. A claim never retires a DiffEntry and never touches ModelDiff.counts; see SplitMergeClaim for why, and SplitMergeConfidence for what each claim rests on. Default false — existing callers get byte-identical results.

    What it cannot see, stated once so it is not discovered in the field: a split across class FAMILIES (candidates are generated per family, see classFamilies: a wall becoming IfcWallStandardCase pieces or IfcBuildingElementPart layers is seen, a wall becoming a covering is not); a container polluted by two or more unrelated same-class elements; a moved split under a rotation that is not a multiple of 90°; and a real split that changed more than splitVolumeTolerance of material while carrying full volume data, which is refused rather than reported. The extent profile additionally fires on a redesign in place and on a perimeter of pieces enclosing an unfilled middle, and displaced cannot separate two congruent clusters in a repetitive building. docs/guide/model-diff.md expands each.

    detectSuccessors?: boolean

    Opt-in LAST stage (issue #4955): look for successors — one deleted entity replaced in place by one added entity whose data AND geometry both changed (a wall with a new buildup, a chair from a different family) — among what matchUnpairedByContent and detectSplitMerge left unbound. Effective only together with matchUnpairedByContent, and only with geometry (the same abstentions as split/merge; ModelDiff.successors is absent otherwise).

    Suggestions only. A claim never retires a DiffEntry, never touches counts, and never becomes an identity-map entry unless a caller passes it to identityMapFromSuccessors as ACCEPTED. Two profiles, both requiring a pairing unique in both directions with a margin: footprint (box overlap at or above successorOverlap, nothing else on either side within half of it) and position (same class family, same EntityFingerprint.container, mutual nearest within successorDistance with the runner-up at least twice as far). Default false.

    excludeTypes?: Iterable<string, any, any>

    IFC type names to leave out of the comparison entirely - a "blacklist" of classes the user does not want considered as changes (e.g. IfcOpeningElement, which is only the connective void between a wall and a removed window, not a meaningful change in its own right - issue #1470).

    An entity is dropped from the comparison if its EntityFingerprint.ifcType matches in EITHER revision, so it never appears in ModelDiff.entries, ModelDiff.byKey, or ModelDiff.counts - as if it were in neither model. Using the union of both sides means a cross-version re-class (e.g. IfcWall -> IfcWallStandardCase with IfcWall excluded) can't leak the entity back as a phantom add/delete. Matching is case-insensitive and ignores surrounding whitespace so a hand-typed ifcopeningelement still matches. Empty / whitespace-only names are ignored. Default: nothing excluded.

    keyAliases?: ReadonlyMap<string, string>

    Accepted identity claims, head key → base key, applied as key normalization before the key-based pass indexes anything (issue #1891).

    This is the consuming half of identityMapFromContentMatches: a previous comparison found that a re-GUIDed element is the same element, a human accepted that, and the claim is replayed here so the pair is matched by key rather than re-derived by content every single run. Because the rename happens before indexing, an aliased pair is classified as modified/unchanged by the ordinary key pass and never reaches matchUnpairedByContent at all — it is not a candidate, so it cannot appear in ModelDiff.contentMatches.

    The resulting DiffEntry.key is the base key; the head entity's own key stays untouched on entry.head.key, so nothing is falsified — the alias changes what the diff calls the pair, not what either file says.

    An alias is ignored (the head entity keeps its own key, exactly as if no map had been supplied) when it points at a key no base entity holds, when another head entity already holds the target key, or when two head entities claim one base key. That last case is a collision, and on a collision the alias loses and every colliding entity stays unaliased: a base entity is one entity, so a map claiming otherwise is wrong, and the map is the only thing that could have adjudicated. Refusing leaves the entities visible as add/delete — what the caller would have seen without the map — instead of silently dropping one. ModelDiff.appliedKeyAliases echoes back what actually took effect.

    Composes with excludeTypes and every scope: aliasing only decides which entities are the same entity, and both of those decide what counts as a difference between two entities already known to be the same. An alias onto an excluded base key drops the pair, which is what excludeTypes means once the two are one entity.

    Default: no aliases — byte-identical results for existing callers.

    matchUnpairedByContent?: boolean

    Opt-in second matching pass (issue #1891): GlobalIds are unreliable across a from-scratch re-export — every element gets a new GlobalId, so a pure key diff reports the whole model as deleted-and-added even when nothing substantive changed.

    When true, after the normal key-based pass, entities that came out added or deleted are bucketed by (EntityFingerprint.ifcType, EntityFingerprint.dataHash) and re-examined. Within one bucket the pass refines hierarchically — a real model is mostly repeated components, so a bucket routinely holds many same-content entities that differ only in where they sit:

    • by world geometry hash. Entities carrying a EntityFingerprint.geometryHash are sub-bucketed by it. A sub-bucket with one entity per side, or with the same count N > 1 on both sides, is an unambiguous content match: the added/deleted entries are removed from ModelDiff.entries / byKey / counts (so they no longer read as a spurious add+delete) and reported instead as a single renamed ContentMatch in ModelDiff.contentMatches. For N > 1, every bijection is observationally identical in every field the engine can see, so the match carries all N entities per side rather than a guessed pairing. Geometry is not part of the outer bucket key: an entity that genuinely moved must still meet its counterpart.
    • 1:1 leftover. One base and one head left over in a bucket pair as renamed (geometry agrees, or geometry is out of scope), moved, or reshaped — see ContentMatchKind and moveTolerance / reshapeTolerance for how an EntityFingerprint.aabb separates the last two.
    • N:M leftover. With an aabb on every candidate, leftovers are paired by iterated mutual nearest neighbour under maxMoveDistance: a base and a head pair only when each is the other's unique nearest, which abstains by construction on a symmetric layout rather than guessing. Whatever is still unpaired stays a reported group (below).

    Under DiffScope 'data' geometry is out of the comparison entirely, so no geometry sub-bucketing happens and every 1:1 match is reported as renamed. The same applies under the capability abstention described on DiffScope, which this pass shares with the key-based one: when one revision carries geometry hashes and the other carries none at all, geometry classifies nothing and every 1:1 match is renamed rather than a model-wide sweep of moved.

    Because this is the pass's only destructive path and dataHash is a 64-bit FNV-1a value rather than a cryptographic digest, a pair is retired only if it also agrees on ifcType and — when both sides carry them — on every EntityFingerprint.components sub-hash. Neither check can reject a genuine match, and neither makes the pass collision-proof; see the "Hash collisions" section of docs/guide/model-diff.md.

    • a bucket with more than one entity on either side is genuinely ambiguous: one base entity could have become several head entities ("duplicated"), several base entities could have collapsed into one head entity ("deduplicated"), or, with more than one on both sides, there is no principled way to tell which of the above happened, let alone which specific base entity corresponds to which head entity ("ambiguous"). The engine does not guess: the original added/deleted entries are left untouched in entries, and the whole bucket is additionally reported as a ContentMatch so the caller can resolve it (e.g. surface the group in a UI) rather than the engine silently picking one (see #1923, a shipped ?? candidates[0] bug of exactly that shape).

    Splits and merges — a partial geometric overlap between one entity and several others — are a separate, purely additive stage on this pass's residue: see detectSplitMerge. They are not ContentMatches and never retire anything, because one claim binding k + 1 entities on a single evidence chain must not be able to delete k + 1 real changes.

    DiffState/DiffEntry are unchanged by this option — a content match is reported only via ModelDiff.contentMatches, never by inventing a new DiffEntry.state, so existing exhaustive switches over DiffState stay exhaustive. Default false — existing callers get byte-identical results.

    maxMoveDistance?: number

    Maximum bounding-box-centre displacement (caller's units, so metres for a metre-scale model) at which the mutual-nearest-neighbour pass will pair two same-content entities. Default 10: a building-scale relocation pairs, a cross-site teleport stays ambiguous rather than being asserted to be the same element. Only used when both sides carry an EntityFingerprint.aabb.

    maxSplitPieces?: number

    Largest number of pieces one claim may bind. Default 256.

    A PERFORMANCE bail, never a semantic rule: a precast slab field really is dozens of panels, and there is no piece count at which a split stops being a split. Above the cap the candidate is dropped rather than truncated — reporting the first 256 of 400 panels would be a claim nobody made.

    Must resolve to a whole number of at least 2; anything else falls back to the default. A cap of 0 or 1 is not a tighter cap but an unachievable one — a split into fewer than two pieces is not a split — and honouring it literally would make the whole stage report nothing with no error anywhere. A fractional value at or above 2 is floored, since a cap of 2.5 pieces is a cap of 2 pieces.

    The fallback is deliberately the permissive direction, and only because of what this knob IS: a cap that resolves higher than asked can cost time on a pathological model but can never produce a wrong claim, while one that resolves to nothing produces a wrong answer. Unlike the three rates above, where a bad value is simply replaced.

    moveTolerance?: number

    Bounding-box-centre displacement (in the caller's units) below which a content-matched pair counts as not moved. Default 2e-3.

    Lifted deliberately from MOVE_EPS in the viewer's apps/viewer/src/lib/compare/describeChange.ts, which encodes issue #1197: a re-cut/re-tessellated wall that never moved reported a phantom "moved 1.09 m". Below this, ContentMatch.distance is reported as 0, exactly as describeChange clamps it. Only used when both sides carry an EntityFingerprint.aabb.

    reshapeTolerance?: number

    Per-axis bounding-box size change (caller's units) above which a content-matched pair counts as reshaped rather than moved. Default 1e-3 — the same RESHAPE_EPS the viewer's describeChange.ts uses, so the engine and the UI draw the move/reshape line in the same place. Only used when both sides carry an EntityFingerprint.aabb.

    scope?: DiffScope

    What differences count as a modification. Default 'both'.

    splitPaddingMin?: number

    Absolute floor (caller's units) of the slack allowed when asking whether a piece lies inside the container's box, and when asking whether the pieces' projections cover it. Default 0.05 (50 mm for a metre-scale model).

    Paired with splitPaddingRatio: the padding actually used is max(splitPaddingMin, splitPaddingRatio * containerBoxDiagonal). The floor is what carries a small element, where 1% of a short diagonal is less than a joint.

    The padding is always the CONTAINER's — the base element of a split, the head element of a merge. For a merge the container is the added element, and padding by each tiny deleted piece's own diagonal instead would make the test mean something different for every piece.

    splitPaddingRatio?: number

    Fraction of the container's box diagonal used as containment slack, floored by splitPaddingMin. Default 0.01 (1%).

    splitVolumeTolerance?: number

    Relative volume agreement required between the whole and the sum of the pieces, as a fraction. Default 0.03 (3%).

    Chosen from site experience, not from float error: a real construction split loses material to joints, couplings and grout, and 1% refused splits that had plainly happened. It is a GATE, not a score — a candidate whose volumes are all known and miss this band is refused outright rather than demoted, because a failed volume test is a refutation and not a reason to go looking for weaker evidence.

    The direct cost of that: a genuine split that changed more than 3% of its material, on elements that all carry volumes, is not reported.

    successorDistance?: number

    Absolute floor (caller's units) of the centre displacement the position profile accepts; the cap actually applied to a pair is max(successorDistance, 0.5 × base box diagonal). Default 0.5.

    successorOverlap?: number

    Bounding-box intersection-over-union at or above which a footprint successor claim may fire. Default 0.6: a 200 → 250 mm thickening scores 0.8 whichever face moved; a 100 mm axis shift at 200 mm scores 0.33 and is left to position. Must be in (0, 1]; anything else falls back.