/* 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/. */ /** * "What changed" detail for a single compare entry (issue #924). * * The diff engine classifies (added / modified / deleted) and says *which * signals* changed (`changeKinds`), but not *what*. This computes the * human-readable per-field delta lazily on selection — it re-extracts both * revisions of the entity from their `IfcDataStore`s and walks attributes + * property sets, plus a geometry summary (centroid move / bbox reshape) from * the tessellated meshes. Kept out of the engine result so a 100k-entity diff * stays cheap; only the selected element is described. */ import type { DiffEntry } from '@ifc-lite/diff'; import { RelationshipType } from '@ifc-lite/data'; import { extractAllEntityAttributes, extractPropertiesOnDemand, extractQuantitiesOnDemand, type IfcDataStore, } from '@ifc-lite/parser'; import type { MeshData } from '@ifc-lite/geometry'; import type { FederatedModel } from '../../store/types.js'; import type { CompareRef } from './buildFingerprints.js'; import { isGeometricDataName } from './geometricData.js'; /** One changed/added/removed field between the A and B revisions. */ export interface FieldDelta { /** `attribute` = root attribute (Name/Description/…); `property` = Pset member; * `quantity` = quantity-set member (Volume/Area/Length/…). */ category: 'attribute' | 'property' | 'quantity'; /** Property-/quantity-set name (undefined for root attributes). */ group?: string; name: string; /** Display value in A (undefined = absent in A → added in B). */ before?: string; /** Display value in B (undefined = absent in B → removed). */ after?: string; kind: 'changed' | 'added' | 'removed'; } export interface GeometrySummary { /** Bounding-box-centre displacement A→B, in model units (metres in the * renderer frame). 0 when below {@link MOVE_EPS} (tessellation/float noise). */ movedDistance: number; delta: { x: number; y: number; z: number }; /** True when the bounding-box size changed beyond tolerance (not a pure move). */ reshaped: boolean; /** Per-axis bounding-box size change A→B in the display frame (x, y=plan, z=up). */ sizeDelta: { x: number; y: number; z: number }; } /** A centre shift below this (renderer metres) is tessellation / float noise, * not a move — keeps a re-cut or re-tessellated element that stayed put from * reading as "moved" (#1197). */ const MOVE_EPS = 2e-3; /** Per-axis bounding-box size change above this counts as a reshape. */ const RESHAPE_EPS = 1e-3; export interface ChangeDetail { /** Non-geometric attribute/property changes (the "Data" story). */ data: FieldDelta[]; /** Geometry move/reshape summary, or null when geometry didn't change. */ geometry: GeometrySummary | null; /** True when `data` is empty but the data hash still flagged a change — * i.e. the only data difference was geometric (placement/quantity) and is * intentionally not shown as data. */ dataOnlyGeometric: boolean; } function displayValue(value: unknown): string { if (value == null) return '∅'; if (typeof value === 'number') { // Trim float noise so "3.5000001" vs "3.5" doesn't read as different. return Number.isInteger(value) ? String(value) : value.toFixed(4).replace(/\.?0+$/, ''); } if (typeof value === 'boolean') return value ? 'true' : 'false'; return String(value); } /** Are two extracted values equal for display purposes (stable, type-loose). */ function sameValue(a: unknown, b: unknown): boolean { return displayValue(a) === displayValue(b); } interface ExtractedField { group: string; name: string; value: unknown } interface ExtractedData { attributes: Map; /** `${psetName}${propName}` → value */ properties: Map; /** `${qtoName} ${qtyName}` -> value */ quantities: Map; } function extractData(store: IfcDataStore, localId: number): ExtractedData { const attributes = new Map(); attributes.set('Name', store.entities.getName(localId) || undefined); attributes.set('Description', store.entities.getDescription(localId) || undefined); attributes.set('ObjectType', store.entities.getObjectType(localId) || undefined); for (const attr of extractAllEntityAttributes(store, localId)) { // Skip GlobalId (identity, never a "change") + any geometric/placement attr. if (attr.name === 'GlobalId') continue; if (isGeometricDataName(attr.name)) continue; if (attr.value == null || attr.value === '') continue; attributes.set(attr.name, attr.value); } const properties = new Map(); for (const set of extractPropertiesOnDemand(store, localId)) { for (const prop of set.properties) { if (isGeometricDataName(prop.name) || isGeometricDataName(set.name)) continue; properties.set(`${set.name}${prop.name}`, { group: set.name, name: prop.name, value: prop.value, }); } } // Quantities mirror the data fingerprint (buildFingerprints.ts) so an added / // removed / edited quantity surfaces here as a concrete field delta (#1198). const quantities = new Map(); for (const set of extractQuantitiesOnDemand(store, localId)) { if (isGeometricDataName(set.name)) continue; for (const qty of set.quantities) { if (isGeometricDataName(qty.name)) continue; quantities.set(`${set.name} ${qty.name}`, { group: set.name, name: qty.name, value: qty.value, }); } } return { attributes, properties, quantities }; } function diffData(a: ExtractedData, b: ExtractedData): FieldDelta[] { const out: FieldDelta[] = []; // Root attributes const attrNames = new Set([...a.attributes.keys(), ...b.attributes.keys()]); for (const name of attrNames) { const av = a.attributes.get(name); const bv = b.attributes.get(name); if (av == null && bv == null) continue; if (av != null && bv != null) { if (!sameValue(av, bv)) out.push({ category: 'attribute', name, before: displayValue(av), after: displayValue(bv), kind: 'changed' }); } else if (bv != null) { out.push({ category: 'attribute', name, after: displayValue(bv), kind: 'added' }); } else { out.push({ category: 'attribute', name, before: displayValue(av), kind: 'removed' }); } } // Grouped property-set + quantity-set members (same added/removed/changed // logic; quantities are surfaced for #1198). diffFieldMap(a.properties, b.properties, 'property', out); diffFieldMap(a.quantities, b.quantities, 'quantity', out); // Attributes first, then properties, then quantities; alphabetical within. const rank: Record = { attribute: 0, property: 1, quantity: 2 }; return out.sort((x, y) => rank[x.category] - rank[y.category] || (x.group ?? '').localeCompare(y.group ?? '') || x.name.localeCompare(y.name), ); } /** Emit added/removed/changed deltas for one keyed field map (properties or * quantities) into `out`. */ function diffFieldMap( a: Map, b: Map, category: 'property' | 'quantity', out: FieldDelta[], ): void { const keys = new Set([...a.keys(), ...b.keys()]); for (const key of keys) { const af = a.get(key); const bf = b.get(key); const group = (af ?? bf)!.group; const name = (af ?? bf)!.name; if (af && bf) { if (!sameValue(af.value, bf.value)) out.push({ category, group, name, before: displayValue(af.value), after: displayValue(bf.value), kind: 'changed' }); } else if (bf) { out.push({ category, group, name, after: displayValue(bf.value), kind: 'added' }); } else if (af) { out.push({ category, group, name, before: displayValue(af.value), kind: 'removed' }); } } } /** The assigned type's stable label (DefinesByType), or '' when untyped. * Mirrors the typeAssignments that go into the data fingerprint. */ function typeAssignmentLabel(store: IfcDataStore, localId: number): string { const ids = store.relationships.getRelated(localId, RelationshipType.DefinesByType, 'inverse'); return ids .map((typeId) => store.entities.getName(typeId) || store.entities.getGlobalId(typeId) || '') .filter(Boolean) .sort() .join(', '); } /** Axis-aligned bounding box in the renderer world frame. */ export interface Aabb { min: readonly [number, number, number]; max: readonly [number, number, number] } /** Axis-aligned bounding box of an entity's meshes (renderer world frame). */ function meshBounds(meshes: readonly MeshData[], globalId: number): Aabb | null { let any = false; let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; for (const mesh of meshes) { if (mesh.expressId !== globalId) continue; const p = mesh.positions; for (let i = 0; i < p.length; i += 3) { const x = p[i], y = p[i + 1], z = p[i + 2]; any = true; 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 (!any) return null; return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] }; } function geometrySummary( a: FederatedModel | undefined, aGlobalId: number, b: FederatedModel | undefined, bGlobalId: number, ): GeometrySummary | null { const ba = a?.geometryResult ? meshBounds(a.geometryResult.meshes, aGlobalId) : null; const bb = b?.geometryResult ? meshBounds(b.geometryResult.meshes, bGlobalId) : null; return summarizeGeometryChange(ba, bb); } /** * Classify an A→B bounding-box change as a move and/or reshape. Pure (takes * pre-computed AABBs) so a bulk report can pre-index every element's bounds in * one pass instead of re-scanning the mesh array per element (#1202). */ export function summarizeGeometryChange(ba: Aabb | null, bb: Aabb | null): GeometrySummary | null { const zero = { x: 0, y: 0, z: 0 }; if (!ba || !bb) return { movedDistance: 0, delta: zero, reshaped: true, sizeDelta: zero }; // Position is the AABB *centre*, not a vertex-weighted centroid: a void re-cut // or a different triangulation redistributes mesh vertices and drags a weighted // centroid metres away while the element occupies the exact same space — that // was the phantom "moved 1.09 m" on a wall that never moved (#1197). The box // centre only shifts when the element's spatial extent actually translates. const dx = (bb.min[0] + bb.max[0]) / 2 - (ba.min[0] + ba.max[0]) / 2; const dy = (bb.min[1] + bb.max[1]) / 2 - (ba.min[1] + ba.max[1]) / 2; const dz = (bb.min[2] + bb.max[2]) / 2 - (ba.min[2] + ba.max[2]) / 2; const rawMoved = Math.hypot(dx, dy, dz); const movedDistance = rawMoved < MOVE_EPS ? 0 : rawMoved; // Renderer is Y-up; report deltas in IFC-ish terms (x, y=plan, z=up) by mapping // renderer (x, y_up, z) → display (x, z, y_up) so "z" reads as height. const delta = movedDistance === 0 ? zero : { x: dx, y: dz, z: dy }; const sdx = (bb.max[0] - bb.min[0]) - (ba.max[0] - ba.min[0]); const sdy = (bb.max[1] - bb.min[1]) - (ba.max[1] - ba.min[1]); const sdz = (bb.max[2] - bb.min[2]) - (ba.max[2] - ba.min[2]); const reshaped = Math.abs(sdx) > RESHAPE_EPS || Math.abs(sdy) > RESHAPE_EPS || Math.abs(sdz) > RESHAPE_EPS; const sizeDelta = reshaped ? { x: sdx, y: sdz, z: sdy } : zero; return { movedDistance, delta, reshaped, sizeDelta }; } /** * Describe what changed for one `modified` compare entry. Returns null for * non-modified entries (added/deleted/unchanged have no A↔B field delta). */ export function describeChange( entry: DiffEntry, models: ReadonlyMap, ): ChangeDetail | null { if (entry.state !== 'modified' || !entry.base || !entry.head) return null; const aRef = entry.base.ref; const bRef = entry.head.ref; const aStore = models.get(aRef.modelId)?.ifcDataStore; const bStore = models.get(bRef.modelId)?.ifcDataStore; // Only describe data when the data signal is in scope — `changeKinds` is // already scope-filtered by the engine, so a Geometry-scope diff shows the // move only (no data), and vice versa. This keeps the detail panel as cleanly // separated as the list badges. const data: FieldDelta[] = []; if (entry.changeKinds.includes('data')) { // IFC type change (the engine counts a type flip as a data change). if (entry.base.ifcType !== entry.head.ifcType) { data.push({ category: 'attribute', name: 'Type', before: entry.base.ifcType, after: entry.head.ifcType, kind: 'changed' }); } if (aStore && bStore) { data.push(...diffData(extractData(aStore, aRef.localId), extractData(bStore, bRef.localId))); // Type assignment (DefinesByType) — in the data fingerprint, so surface it. const aType = typeAssignmentLabel(aStore, aRef.localId); const bType = typeAssignmentLabel(bStore, bRef.localId); if (aType !== bType && (aType || bType)) { data.push({ category: 'attribute', name: 'Type assignment', before: aType || undefined, after: bType || undefined, kind: aType && bType ? 'changed' : bType ? 'added' : 'removed', }); } } } const geometry = entry.changeKinds.includes('geometry') ? geometrySummary(models.get(aRef.modelId), aRef.globalId, models.get(bRef.modelId), bRef.globalId) : null; const dataOnlyGeometric = entry.changeKinds.includes('data') && data.length === 0; return { data, geometry, dataOnlyGeometric }; }