@ifc-lite/diff
v0.13.0
Published
Headless model-diff engine for IFC-Lite — classifies entities as added/modified/deleted/unchanged across two revisions, with separable data vs geometry scope.
Maintainers
Readme
@ifc-lite/diff
Headless model-diff engine for IFC-Lite. Classifies entities across two revisions as added / modified / deleted / unchanged, with separable data vs geometry scope — the engine behind the viewer's "compare two versions" mode.
The public comparison API consumes adapter fingerprints without invoking a
parser or renderer. The operations run in the independent
@ifc-lite/wasm/diff Rust module. Initialize once with await initDiff() or
initDiffSync(...) before calling synchronous comparison, fingerprint, lineage,
or sidecar operations. Adapters (the CLI and viewer) extract a fingerprint per
entity and hand them over; the engine matches by key and classifies.
Initialization
initDiff and initDiffSync are the two new initialization exports. Existing
operation names and signatures remain intact and synchronous after initialization.
Calling an operation before initialization throws an error naming initDiff.
Repeated initialization is safe, and failed asynchronous initialization can be
retried. While asynchronous initialization is in progress, initDiffSync throws;
await initDiff() instead. Once initialized, either initializer is a no-op.
Initialize at your application’s async startup boundary.
initDiff() loads the independent diff WASM asset in a browser. Explicit bytes
can be supplied as await initDiff({ module_or_path: bytes }); Node callers can
read the WASM asset into a Uint8Array or Buffer. For synchronous startup use
initDiffSync({ module: bytes }). No geometry engine initialization is required.
The module is built with pnpm build:wasm; committed generated declarations
support typechecking without a Rust toolchain.
The historical TypeScript engine is a private test oracle outside src and is
excluded from published dist. It is never selected as a runtime fallback.
Installation
npm install @ifc-lite/diffUsage
import { initDiff, diffModels, type EntityFingerprint } from '@ifc-lite/diff';
await initDiff();
// Your adapter walks a store and emits one fingerprint per entity, per model.
declare function extractFingerprints(model: unknown): EntityFingerprint<number>[];
declare const baseModel: unknown;
declare const headModel: unknown;
declare const gid: string; // the GlobalId you picked in the viewer
// One fingerprint per entity, per model. `key` is the stable cross-revision
// identity (the IFC GlobalId). `dataHash` comes from buildDataFingerprint;
// `geometryHash` comes from the WASM mesh pass (MeshCollection.geometryHashValues,
// a BigUint64Array → bigint). `ref` is yours to use downstream (e.g. an express id).
const base: EntityFingerprint<number>[] = extractFingerprints(baseModel);
const head: EntityFingerprint<number>[] = extractFingerprints(headModel);
const diff = diffModels(base, head, { scope: 'both' }); // 'data' | 'geometry' | 'both'
diff.counts; // { added, modified, deleted, unchanged }
diff.byKey.get(gid); // O(1) lookup for picking — { state, changeKinds, base?, head? }Repeated comparisons and ownership
prepareDiffModel(entities) returns a public DiffModelHandle<TRef>. It takes
an immutable fingerprint snapshot and transfers it into Rust once. Reuse the
same pair with different options; no model is marshalled again during compare.
Opaque ref objects keep their identity. Source edits require a new handle.
There is no implicit cache.
import { prepareDiffModel } from '@ifc-lite/diff';
const left = prepareDiffModel(base);
try {
const right = prepareDiffModel(head);
try {
const dataDiff = left.compare(right, { scope: 'data' });
const fullDiff = left.compare(right, { scope: 'both' });
} finally { right.dispose(); }
} finally { left.dispose(); }Call dispose() in finally, or use using on platforms supporting explicit
resource management (Symbol.dispose). Release is idempotent; comparing a
released handle throws. Returned results remain valid after release and refer
to the owned snapshots. diffModels is a one-shot convenience: every call
transfers and frees both models, so use handles for repeated comparisons.
For mixed mesh capability (exactly one full input has a non-p: geometry hash),
pass stripPlacementGeometry: true. Rust ignores p: placement hashes on both
sides, allowing geometry capability abstention to work. Results expose those
hashes as undefined, exactly like stripping in JS first. Mesh hashes, bounding
boxes, volumes and refs are preserved. Keep the default false for symmetric
pairs, including two placement-only models, to retain placement move detection.
Decide whether to set it from the full inputs, before your exclusions and key
deduplication; the comparison's own capability check still ignores excluded
types. The option never
changes either handle's snapshot. A handle lazily owns a frozen stripped view:
only placement rows are shallow-copied, refs remain identical, and repeated
stripped comparisons return the same row objects. Disposal releases both views.
Scope — what counts as a change
| scope | Flags a modified when… |
| ------------ | ----------------------------------------------------------- |
| 'data' | attributes / property sets / quantity sets / IFC type differ |
| 'geometry' | the geometry fingerprint differs |
| 'both' | either (default) |
A modified entry's changeKinds ('data' / 'geometry') records why — handy
for an inspect panel even though the colour is driven by state.
Excluding classes - the blacklist
Some IFC classes are noise in a comparison: an IfcOpeningElement is only the
connective void between a wall and a window, so when the window is removed the
opening's deletion is not a meaningful change on its own (issue #1470). Pass
excludeTypes to leave those classes out of the diff entirely - matched
entities are dropped from both revisions before classification, so they never
appear in entries, byKey, or counts:
const diff = diffModels(base, head, { excludeTypes: ['IfcOpeningElement'] });
diff.excludedTypes; // ['IFCOPENINGELEMENT'] - the applied blacklist, normalizedMatching is case-insensitive and trims whitespace; empty names are ignored.
Split and merge detection
One wall becomes three; three panels become one slab. Neither is a rename, a
move or a reshape, so content matching leaves all four elements in the residue
as unrelated adds and deletes. detectSplitMerge looks for that shape on the
residue and reports what it finds on ModelDiff.splitMerges:
const diff = diffModels(base, head, {
matchUnpairedByContent: true,
detectSplitMerge: true,
});
for (const claim of diff.splitMerges ?? []) {
claim.kind; // 'split' | 'merge'
claim.confidence; // 'verified' | 'extent' | 'displaced'
claim.whole; // the single entity (base for a split, head for a merge)
claim.pieces; // the k >= 2 entities on the other side
}Candidates are bucketed by class family (classFamilies), so a wall republished
as IfcWallStandardCase pieces or IfcBuildingElementPart layers is a split like
any other, flagged crossClass: true.
Purely additive: a claim never retires a DiffEntry and never touches
counts, because one claim binding k + 1 entities on a single evidence chain
must not be able to delete k + 1 real changes. It also has no non-geometric
evidence channel, so it inherits the scope: 'data' and mixed-capability
abstentions and reports nothing under either.
confidence names the evidence rather than scoring it: verified is
containment plus complete volumes agreeing within splitVolumeTolerance (3% by
default — a real split loses material to joints); extent is containment plus
per-axis interval coverage, where a volume was missing and nothing was refuted;
displaced is a moved cluster matched by volume and congruent extents, unique
in both directions. A failed volume test is a refutation, never a reason to
fall back to the weaker tier. See
the guide
for the knobs and for what the pass deliberately cannot see.
Successor claims
A wall whose buildup changed, or a chair swapped for another family, agrees on
nothing a hash can see. detectSuccessors reports suggestions from position
alone — footprint (heavy box overlap, unique both ways) or position (same
family, same container, mutual nearest with a ×2 margin) — on
ModelDiff.successors. A claim retires nothing; identityMapFromSuccessors
mints identity only from the claims you pass it as accepted.
Identity maps — remembering an accepted match
Content-keyed matching (matchUnpairedByContent) recognises a re-GUIDed element
once and then forgets it. An identity map makes that answer durable, in the
same { base, here, reason } vocabulary a published layer carries in its
provenance manifest:
import { diffModels, identityMapFromContentMatches } from '@ifc-lite/diff';
const first = diffModels(base, head, { matchUnpairedByContent: true });
const claims = identityMapFromContentMatches(first.contentMatches);
// [{ base: 'oldGid', here: 'newGid', reason: 'content-match:renamed' }]
const aliases = new Map(claims.map((c) => [c.here, c.base] as const));
const second = diffModels(base, head, { matchUnpairedByContent: true, keyAliases: aliases });
second.appliedKeyAliases; // what actually took effectClaims come only from matches the engine committed to — a 1:1 renamed,
moved, reshaped, or respecified (same geometry and place, changed data:
the redrawn-and-renamed element). ambiguous / duplicated / deduplicated groups and
N:N renamed groups mint nothing: they are the engine saying it could not tell,
and a claim derived from an abstention is a fabrication.
keyAliases (head key → base key) is applied before the key pass indexes
anything, so an aliased pair is classified by key and never becomes a
content-match candidate. DiffEntry.key becomes the base key while the head
entity keeps its own key on entry.head.key — the alias renames the pair, not
the file. A stale or colliding alias is dropped, degrading to the un-aliased
result rather than throwing or fabricating an entry.
For plain-file workflows, createIdentityMapSidecar /
serializeIdentityMapSidecar / parseIdentityMapSidecar define a JSON sidecar
that pins the content digest of both revisions the claims were verified
against, and identityMapSidecarMismatches refuses one replayed against a
different pair. A document claiming two different base identities for one
here key is refused outright too — it is self-contradictory whatever the two
files say, and applying either claim would pick an arbitrary winner. See the
Model Diff guide.
Lineage — carrying external data across a split
lineageFromDiff turns committed matches, split/merge claims and accepted
successor claims into 1:k { base[], head[], relation, reason, shares? }
entries; rekeyByLineage answers "where does the row keyed on this old key
go" under a copy-to-all / largest-share / orphan-on-split policy.
largest-share orphans a split when shares are missing, empty, do not match
the head count, contain non-finite or negative values, or tie for the maximum.
It does not throw for invalid share evidence. Zero is valid and shares need
not sum to one. The
ifc-lite/lineage sidecar pins both model digests like the identity map does.
Building a data fingerprint
buildDataFingerprint canonicalizes (sorts) property sets, quantity sets, and
type assignments, so collection ordering never produces a spurious diff. Feed it
a plain DataFingerprintInput extracted from your store:
import { buildDataFingerprint, type DataFingerprintInput } from '@ifc-lite/diff';
// Whatever your store hands back for one entity.
declare const entity: DataFingerprintInput;
const dataHash = buildDataFingerprint({
ifcType: entity.ifcType,
name: entity.name,
description: entity.description,
objectType: entity.objectType,
predefinedType: entity.predefinedType,
propertySets: entity.propertySets,
quantitySets: entity.quantitySets,
typeAssignments: entity.typeAssignments,
});An assigned type is identified by its name and IFC class, never by its
GlobalId: IfcTypeObject is an IfcRoot, so a from-scratch re-export
re-GUIDs it and hashing that would change the fingerprint of every typed
element for no substantive reason. TypeAssignmentInput.globalId is still
accepted and still useful for display; it just does not reach any hash. See
the guide
for the discrimination that costs.
There is also an optional tag, hashed whenever you supply it. Supply it only
for an IfcTypeObject subtype, as the adapters in this repo do: a type object
has no geometry hash, so same-named types differing only in Tag are otherwise
inseparable, while an occurrence's Tag is the authoring tool's element id and
hashing it there would stop two exporters of one design from matching at all.
Why geometry hashing lives in Rust/WASM
The geometry fingerprint is computed in ifc_lite_geometry::geom_hash and
exposed over the WASM boundary (IfcAPI.setComputeGeometryHashes →
MeshCollection.geometryHashValues). It is RTC-invariant (a file's origin-shift
never registers as a change) and tolerance-quantized. This package only
consumes those hashes, keeping the comparison independent of the parser and renderer.
Docs
See the ifc-lite docs and the API Reference.
@ifc-lite/diff/internal is for in-repository consumers only and has no semver
stability guarantee. buildFingerprintBatch snapshots fresh adapter DTOs once
and computes data and component fingerprints together. It never caches inputs;
call it again after changes. Public unary fingerprint APIs remain supported.
The public DiffModelHandle and prepareDiffModel are also re-exported from
@ifc-lite/diff/internal for compatibility; the historical PreparedDiffModel
type name remains available there. Handles return owned snapshots, while
one-shot diffModels preserves original input objects unless placement stripping
requires a projected record.
License
MPL-2.0
