/* 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/. */ /** * Model-comparison orchestration hook (issue #924). * * Owns the "run a comparison" action for the Compare panel: it reads the two * chosen federated models from the store, builds per-entity fingerprints via * the viewer adapter, and runs the `@ifc-lite/diff` engine. Building * fingerprints (on-demand property extraction per entity) is the expensive * part, so the built sides are cached per A/B pair — toggling the * data/geometry scope re-runs only the cheap `diffModels` pass. * * Mirrors `useClash`: the slice holds dumb state, the hook does the work. */ import { useCallback, useEffect, useRef } from 'react'; import { diffModels, type EntityFingerprint } from '@ifc-lite/diff'; import { useViewerStore } from '@/store'; import { posthog } from '@/lib/analytics'; import { buildEntityFingerprints, hasGeometryHashes, type CompareRef, } from '@/lib/compare/buildFingerprints'; type Side = EntityFingerprint[]; interface BuiltPair { key: string; baseName: string; headName: string; base: Side; head: Side; } const pairKey = (a: string, b: string): string => `${a}${b}`; export function useCompare() { const baseModelId = useViewerStore((s) => s.compareBaseModelId); const headModelId = useViewerStore((s) => s.compareHeadModelId); const scope = useViewerStore((s) => s.compareScope); const running = useViewerStore((s) => s.compareRunning); const result = useViewerStore((s) => s.compareResult); const error = useViewerStore((s) => s.compareError); // Cache the built fingerprints for the current pair so a scope change is a // cheap re-diff rather than a full re-extraction. const builtRef = useRef(null); const runComparison = useCallback(async () => { const store = useViewerStore.getState(); const baseId = store.compareBaseModelId; const headId = store.compareHeadModelId; const activeScope = store.compareScope; if (!baseId || !headId) { store.setCompareError('Select a model for both A and B.'); return; } if (baseId === headId) { store.setCompareError('Pick two different models to compare.'); return; } const baseModel = store.models.get(baseId); const headModel = store.models.get(headId); if (!baseModel?.ifcDataStore || !baseModel.geometryResult) { store.setCompareError('Version A is not fully loaded yet.'); return; } if (!headModel?.ifcDataStore || !headModel.geometryResult) { store.setCompareError('Version B is not fully loaded yet.'); return; } store.setCompareError(null); store.setCompareRunning(true); // Yield a frame so the "Comparing…" state paints before the (sync, // potentially heavy) fingerprint extraction blocks the main thread. await new Promise((resolve) => setTimeout(resolve, 0)); try { const key = pairKey(baseId, headId); let built = builtRef.current; if (!built || built.key !== key) { built = { key, baseName: baseModel.name, headName: headModel.name, base: await buildEntityFingerprints({ modelId: baseId, store: baseModel.ifcDataStore, meshes: baseModel.geometryResult.meshes, instancedGeometryHashes: baseModel.geometryResult.instancedGeometryHashes, idOffset: baseModel.idOffset, }), head: await buildEntityFingerprints({ modelId: headId, store: headModel.ifcDataStore, meshes: headModel.geometryResult.meshes, instancedGeometryHashes: headModel.geometryResult.instancedGeometryHashes, idOffset: headModel.idOffset, }), }; builtRef.current = built; } const diff = diffModels(built.base, built.head, { scope: activeScope }); // Geometry hashes are produced only on the WASM mesh path; if either side // was loaded without them (e.g. a huge native desktop load), geometry/both // scopes can't see shape changes — flag it so the panel can warn. const geometryUnavailable = !hasGeometryHashes(built.base) || !hasGeometryHashes(built.head); const diffEntries = Object.keys(diff.entries).length; store.setCompareResult({ baseModelId: baseId, headModelId: headId, baseName: built.baseName, headName: built.headName, scope: activeScope, geometryUnavailable, diff, }); store.setCompareSelectedKey(null); posthog.capture('model_compare_run', { scope: activeScope, changed_entity_count: diffEntries, geometry_unavailable: geometryUnavailable, }); } catch (err) { console.error('[compare] comparison failed', err); store.setCompareError((err as Error).message ?? 'Comparison failed.'); store.setCompareResult(null); } finally { store.setCompareRunning(false); } }, []); // Scope change with an existing result for the same pair → re-diff from the // cached fingerprints (instant). No-op when nothing has been compared yet. useEffect(() => { const built = builtRef.current; if (!result || !built) return; if (built.key !== pairKey(result.baseModelId, result.headModelId)) return; if (result.scope === scope) return; const diff = diffModels(built.base, built.head, { scope }); useViewerStore.getState().setCompareResult({ ...result, scope, diff }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope]); return { baseModelId, headModelId, scope, running, result, error, runComparison }; }