/* 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/. */ /** * Clash detection orchestration (Phase 1). Gathers `ClashElement`s from every * loaded model via the STEP adapter, runs the (robust, in-process) TypeScript * engine, and drives the viewer: selecting + framing a clash pair, highlighting * all, and exporting a *grouped* BCF. Coloring/identity flow through the * renderer's selection channel and the federation registry. */ import { useCallback } from 'react'; import { useViewerStore } from '@/store'; import { createClashEngine, rulesFromPresets, groupClashes, findDuplicates, type Clash, type ClashElement, type ClashElementRef, type ClashGroup, type ClashResult, type ClashRule, type ClashSeverity, type ExclusionSet, } from '@ifc-lite/clash'; import { elementsFromStep } from '@ifc-lite/clash/step'; import { createBCFFromClashResult } from '@ifc-lite/clash/bcf'; import { writeBCF } from '@ifc-lite/bcf'; import { getGlobalRenderer } from '@/hooks/useBCF'; import { posthog } from '@/lib/analytics'; interface SelectionRef { modelId: string; expressId: number; } /** * How the rest of the model is shown when a clash is focused (#1275): * - `highlight`: everything stays visible, the pair is just selected/framed; * - `isolate`: everything else is hidden; * - `ghost`: everything else fades to translucent X-Ray context. */ export type ClashFocusMode = 'highlight' | 'isolate' | 'ghost'; /** How clashes collapse into BCF topics. `storey` is omitted — Clash has no * storey, so it degrades to `rule` (see grouping.ts) and would only confuse. */ export type ClashBcfGroupBy = 'cluster' | 'rule' | 'typePair' | 'element'; /** User-controllable settings for a BCF export — "what gets created". */ export interface ClashBcfConfig { /** Grouping dimension → one BCF topic per group. */ groupBy: ClashBcfGroupBy; /** Only clashes of these severities become topics. */ severities: ClashSeverity[]; /** Render each topic's viewpoint offscreen and embed a PNG snapshot. */ includeSnapshots: boolean; /** Initial BCF topic status (Open / In Progress / ...). */ status: string; /** Safety cap on topic count; overflow is recorded in one marker topic. */ maxTopics: number; } /** Dark, neutral background for offscreen snapshot captures (Tokyo Night base). */ const SNAPSHOT_CLEAR_COLOR: [number, number, number, number] = [0.04, 0.05, 0.1, 1]; function downloadBlob(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); } /** Decode a `data:image/png;base64,...` URL into raw PNG bytes for the BCF zip. */ function dataUrlToBytes(dataUrl: string): Uint8Array | undefined { const comma = dataUrl.indexOf(','); if (comma < 0) return undefined; try { const binary = atob(dataUrl.slice(comma + 1)); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); return bytes; } catch { return undefined; } } /** Drop clashes whose severity is not selected; total is kept consistent. */ function filterResultBySeverity(result: ClashResult, severities: Set): ClashResult { const clashes = result.clashes.filter((c) => severities.has(c.severity)); return { ...result, clashes, summary: { ...result.summary, total: clashes.length } }; } export function useClash() { const result = useViewerStore((s) => s.clashResult); const groups = useViewerStore((s) => s.clashGroups); const running = useViewerStore((s) => s.clashRunning); const error = useViewerStore((s) => s.clashError); const progress = useViewerStore((s) => s.clashProgress); const mode = useViewerStore((s) => s.clashMode); const tolerance = useViewerStore((s) => s.clashTolerance); const clearance = useViewerStore((s) => s.clashClearance); const groupBy = useViewerStore((s) => s.clashGroupBy); const clusterEpsilon = useViewerStore((s) => s.clashClusterEpsilon); const reportTouch = useViewerStore((s) => s.clashReportTouch); const clashPresets = useViewerStore((s) => s.clashPresets); const selectedId = useViewerStore((s) => s.clashSelectedId); const panelVisible = useViewerStore((s) => s.clashPanelVisible); /** Number of loaded models — drives the "checking a single model" framing (#1271). */ const modelCount = useViewerStore((s) => s.models.size); const setMode = useViewerStore((s) => s.setClashMode); const setTolerance = useViewerStore((s) => s.setClashTolerance); const setClearance = useViewerStore((s) => s.setClashClearance); const setGroupBy = useViewerStore((s) => s.setClashGroupBy); const setSelectedId = useViewerStore((s) => s.setClashSelectedId); const setPanelVisible = useViewerStore((s) => s.setClashPanelVisible); const clear = useViewerStore((s) => s.clearClash); /** Build clash elements + merged exclusions from every loaded model. */ const gatherElements = useCallback((): { elements: ClashElement[]; exclusions: ExclusionSet } => { const state = useViewerStore.getState(); const elements: ClashElement[] = []; const exclusions: ExclusionSet = new Set(); const federation = { toGlobalId: (modelId: string, expressId: number) => state.toGlobalId(modelId, expressId) }; for (const [modelId, model] of state.models) { const store = model.ifcDataStore; const meshes = model.geometryResult?.meshes; if (!store || !meshes || meshes.length === 0) continue; const built = elementsFromStep({ store, meshes, modelId, federation }); elements.push(...built.elements); for (const key of built.exclusions) exclusions.add(key); } return { elements, exclusions }; }, []); const run = useCallback( async (rules: ClashRule[]): Promise => { const state = useViewerStore.getState(); state.setClashRunning(true); state.setClashError(null); // Indeterminate "preparing" state until the engine reports candidate counts. state.setClashProgress({ phase: 'broad', rule: '', done: 0, total: 0 }); try { // Let the panel paint the running state before the heavy work. await new Promise((resolve) => requestAnimationFrame(resolve)); const { elements, exclusions } = gatherElements(); if (elements.length === 0) { state.setClashError('No model geometry is loaded. Load an IFC model first.'); return; } const engine = createClashEngine({ backend: 'ts' }); const res = await engine.run(elements, rules, { exclusions, tolerance: state.clashTolerance, // The TS engine yields between chunks, so these updates actually paint. onProgress: (p) => useViewerStore.getState().setClashProgress(p), }); state.setClashResult(res); // Spatial clustering is the sensible BCF unit; the panel list groups by // its own dimension separately. Radius is the user's cluster epsilon. state.setClashGroups(groupClashes(res, { by: 'cluster', epsilon: state.clashClusterEpsilon })); state.setClashSelectedId(null); posthog.capture('clash_detection_run', { clash_count: res.clashes.length, rule_count: rules.length, mode: state.clashMode, }); } catch (err) { console.error('[clash] detection run failed', err); state.setClashError(err instanceof Error ? err.message : String(err)); posthog.captureException(err, { additional_properties: { context: 'clash_detection' } }); } finally { state.setClashRunning(false); state.setClashProgress(null); } }, [gatherElements], ); /** * Run the user's ENABLED rule set (built-in discipline rules they've kept on, * plus any custom presets). With no enabled rules, surface a clear message * instead of silently finding nothing. */ const runMatrix = useCallback((): Promise => { const enabled = clashPresets.filter((p) => p.enabled); if (enabled.length === 0) { useViewerStore.getState().setClashError('All rules are disabled — enable at least one in Clash settings (⚙).'); return Promise.resolve(); } return run(rulesFromPresets(enabled, mode, mode === 'clearance' ? clearance : undefined, reportTouch)); }, [run, mode, clearance, reportTouch, clashPresets]); /** * Detect ALL clashes in the loaded geometry — a single self-clash rule over * every element (every element vs every other), no discipline matrix or * A/B selectors needed. For a single loaded model this is "all clashes inside * the model". */ const runAll = useCallback( (): Promise => run([ { id: 'all-clashes', name: 'All elements', a: '*', mode, ...(mode === 'clearance' ? { clearance } : {}), ...(reportTouch ? { reportTouch: true } : {}), }, ]), [run, mode, clearance, reportTouch], ); const runPreset = useCallback( (presetId: string): Promise => { const preset = useViewerStore.getState().clashPresets.find((p) => p.id === presetId); if (!preset) return Promise.resolve(); return run(rulesFromPresets([preset], mode, mode === 'clearance' ? clearance : undefined, reportTouch)); }, [run, mode, clearance, reportTouch], ); /** * Scan the loaded geometry for duplicate / fully-overlapping elements (#1280). * This is an AABB-only pass (no narrow-phase triangle work), so it's fast and * doesn't go through the clash engine — but it produces the same `ClashResult` * shape, so the panel, grouping and BCF export render it unchanged. */ const runDuplicates = useCallback(async (): Promise => { const state = useViewerStore.getState(); state.setClashRunning(true); state.setClashError(null); state.setClashProgress({ phase: 'broad', rule: 'duplicates', done: 0, total: 0 }); try { // Paint the running state before the (synchronous) scan blocks the thread. await new Promise((resolve) => requestAnimationFrame(resolve)); const { elements, exclusions } = gatherElements(); if (elements.length === 0) { state.setClashError('No model geometry is loaded. Load an IFC model first.'); return; } const res = findDuplicates(elements, { exclusions }); state.setClashResult(res); state.setClashGroups(groupClashes(res, { by: 'cluster', epsilon: state.clashClusterEpsilon })); state.setClashSelectedId(null); posthog.capture('clash_duplicate_scan', { duplicate_count: res.clashes.length }); } catch (err) { console.error('[clash] duplicate scan failed', err); state.setClashError(err instanceof Error ? err.message : String(err)); posthog.captureException(err, { additional_properties: { context: 'clash_duplicates' } }); } finally { state.setClashRunning(false); state.setClashProgress(null); } }, [gatherElements]); const refOf = useCallback((ref: ClashElementRef): SelectionRef | null => { return useViewerStore.getState().fromGlobalId(ref.ref); }, []); /** * Apply a focus mode to a set of global ids in the shared visibility channels: * - `highlight`: clear isolation + ghosting (pair highlighted in full context); * - `isolate`: hide everything except the ids (#1275); * - `ghost`: keep the ids solid and fade the rest to translucent context * via the renderer's X-Ray path (#1275 "see them in context"). */ const applyFocusMode = useCallback((globalIds: number[], mode: ClashFocusMode): void => { const state = useViewerStore.getState(); if (mode === 'isolate') state.setIsolatedEntities(new Set(globalIds)); else if (mode === 'ghost') state.setGhostExceptEntities(new Set(globalIds)); else { state.clearIsolation(); state.clearGhost(); } }, []); /** * Select both elements of a clash, highlight them, frame the camera, and apply * the chosen focus `mode` (highlight / isolate / ghost) — #1275. */ const focusClash = useCallback( (clash: Clash, mode: ClashFocusMode = 'highlight'): void => { const state = useViewerStore.getState(); const a = refOf(clash.a); const b = refOf(clash.b); const refs = [a, b].filter((r): r is SelectionRef => r !== null); if (refs.length === 0) return; // The renderer highlights the GLOBAL-id set (`selectedEntityIds`) and // `frameSelection` frames it — `clash.X.ref` IS the federated global id // (see gatherElements), so drive those, not just the model-aware set. const globalIds: number[] = []; if (a) globalIds.push(clash.a.ref); if (b) globalIds.push(clash.b.ref); // Replace any existing selection so the camera frames only this clash pair. state.clearEntitySelection(); state.setSelectedEntityIds(globalIds); // highlight BOTH elements + frame target state.addEntitiesToSelection(refs); // model-aware context for the properties panel applyFocusMode(globalIds, mode); state.setClashSelectedId(clash.id); requestAnimationFrame(() => state.cameraCallbacks.frameSelection?.()); }, [refOf, applyFocusMode], ); /** * Focus a SINGLE element of a clash pair so the user can step through each side * and read it on its own (#1276), applying the chosen focus `mode`. */ const selectElement = useCallback( (el: ClashElementRef, mode: ClashFocusMode = 'highlight'): void => { const state = useViewerStore.getState(); const ref = refOf(el); if (!ref) return; state.clearEntitySelection(); state.setSelectedEntityIds([el.ref]); state.addEntitiesToSelection([ref]); applyFocusMode([el.ref], mode); requestAnimationFrame(() => state.cameraCallbacks.frameSelection?.()); }, [refOf, applyFocusMode], ); /** Highlight every element involved in any clash. */ const highlightAll = useCallback((): void => { const state = useViewerStore.getState(); const current = state.clashResult; if (!current) return; // Drive the renderer's global-id highlight set (`selectedEntityIds`); the // model-aware set is added alongside for properties / federation context. const globalIds = new Set(); const refs: SelectionRef[] = []; for (const clash of current.clashes) { for (const el of [clash.a, clash.b]) { const ref = refOf(el); if (ref) { globalIds.add(el.ref); refs.push(ref); } } } if (globalIds.size === 0) return; state.setSelectedEntityIds([...globalIds]); state.addEntitiesToSelection(refs); }, [refOf]); const clearHighlight = useCallback((): void => { const state = useViewerStore.getState(); state.clearEntitySelection(); state.clearIsolation(); // drop any clash isolation so the full model returns state.clearGhost(); // and any X-Ray ghosting setSelectedId(null); }, [setSelectedId]); /** * Preview what a given export config would produce, WITHOUT building anything: * how many clashes survive the severity filter and how many BCF topics they * collapse into under the chosen grouping (incl. the overflow marker topic). * Cheap (pure grouping) so the dialog can call it on every keystroke. */ const bcfPreview = useCallback((config: ClashBcfConfig): { clashes: number; topics: number } => { const state = useViewerStore.getState(); const current = state.clashResult; if (!current) return { clashes: 0, topics: 0 }; const filtered = filterResultBySeverity(current, new Set(config.severities)); if (filtered.clashes.length === 0) return { clashes: 0, topics: 0 }; const groups = groupClashes(filtered, { by: config.groupBy, epsilon: state.clashClusterEpsilon }); const capped = Math.min(groups.length, config.maxTopics); const overflow = groups.length > config.maxTopics ? 1 : 0; return { clashes: filtered.clashes.length, topics: capped + overflow }; }, []); /** * Export the current clash result to a BCF 2.1 archive under `config`. * * Filters by severity, groups along the chosen dimension (one topic per * group), and — when `includeSnapshots` is on and a renderer is live — * renders each topic's framing viewpoint offscreen and embeds a PNG. The * snapshot pass mirrors the IDS batch path: save viewer state, then per group * frame the bounds + isolate the members + capture, and restore at the end. * `onProgress(done, total)` ticks once per captured snapshot. */ const exportBcf = useCallback( async (config: ClashBcfConfig, onProgress?: (done: number, total: number) => void): Promise => { const state = useViewerStore.getState(); const current = state.clashResult; if (!current) return; const filtered = filterResultBySeverity(current, new Set(config.severities)); if (filtered.clashes.length === 0) return; const groups = groupClashes(filtered, { by: config.groupBy, epsilon: state.clashClusterEpsilon }); let restore: (() => void) | undefined; let snapshotProvider: ((group: ClashGroup) => Promise) | undefined; if (config.includeSnapshots) { const renderer = getGlobalRenderer(); if (renderer) { const saved = { selectedEntityId: state.selectedEntityId, selectedEntityIds: state.selectedEntityIds, isolatedEntities: state.isolatedEntities, hiddenEntities: state.hiddenEntities, }; restore = () => { useViewerStore.setState({ selectedEntityId: saved.selectedEntityId, selectedEntityIds: saved.selectedEntityIds, isolatedEntities: saved.isolatedEntities, hiddenEntities: saved.hiddenEntities, }); renderer.render({ hiddenIds: saved.hiddenEntities, isolatedIds: saved.isolatedEntities, selectedId: saved.selectedEntityId, // Repaint the full multi-selection too — the snapshot loop drove the // renderer directly without touching the store, so the store's // selectedEntityIds reference never changed and useRenderUpdates // won't re-fire. Without this the clash highlight vanishes post-export. selectedIds: saved.selectedEntityIds, }); }; const total = Math.min(groups.length, config.maxTopics); const camera = renderer.getCamera(); let done = 0; snapshotProvider = async (group: ClashGroup): Promise => { const b = group.bounds; await camera.frameBounds( { x: b.min[0], y: b.min[1], z: b.min[2] }, { x: b.max[0], y: b.max[1], z: b.max[2] }, 1, ); // Isolate just this topic's members so the snapshot is unambiguous; // no selection highlight so the captured colours read true. const isolation = new Set(); for (const m of group.members) { isolation.add(m.a.ref); isolation.add(m.b.ref); } renderer.render({ isolatedIds: isolation, selectedId: null, clearColor: SNAPSHOT_CLEAR_COLOR }); const device = renderer.getGPUDevice(); if (device) await device.queue.onSubmittedWorkDone(); // Let the compositor present the frame before reading the canvas. await new Promise((resolve) => requestAnimationFrame(() => resolve())); const dataUrl = await renderer.captureScreenshot(); done += 1; onProgress?.(done, total); return dataUrl ? dataUrlToBytes(dataUrl) : undefined; }; } } try { const project = await createBCFFromClashResult(filtered, groups, { author: 'clash@ifc-lite', projectName: 'Clash report', status: config.status, maxTopics: config.maxTopics, ...(snapshotProvider ? { snapshotProvider } : {}), }); const blob = await writeBCF(project); downloadBlob(blob, 'clashes.bcfzip'); } finally { restore?.(); } }, [], ); const clearAll = useCallback((): void => { const state = useViewerStore.getState(); state.clearEntitySelection(); state.clearIsolation(); state.clearGhost(); clear(); }, [clear]); return { // state result, groups, running, error, progress, mode, tolerance, clearance, groupBy, selectedId, panelVisible, modelCount, // Only enabled presets show as run chips; the settings dialog manages the full set. presets: clashPresets.filter((p) => p.enabled), // settings setMode, setTolerance, setClearance, setGroupBy, setPanelVisible, // actions run, runAll, runMatrix, runPreset, runDuplicates, focusClash, selectElement, highlightAll, clearHighlight, exportBcf, bcfPreview, clearAll, }; }