Skip to content

Commit 360cca0

Browse files
BIMvoicelouistrue
andauthored
feat(viewer): per-model load report with actionable geometry warnings (#3942)
* WIP: per-model load report (#3927) Adds a per-model load report (source/schema/load path/diagnostics/ approximation settings/affected entities) with JSON export, wired into the workspace-panel registry (ribbon + command palette). Diagnostics/format/ tessellation fields threaded onto FederatedModel from useIfcLoader.ts's finalizeModel. Pure aggregation logic in lib/loadReport.ts has unit tests (15/15 passing, 3 mutations confirmed caught). Full viewer suite not yet run to completion. * chore(changeset): add per-model load report changeset for #3927 --------- Co-authored-by: Louis Trümpler <78563314+louistrue@users.noreply.github.com>
1 parent aaa6253 commit 360cca0

11 files changed

Lines changed: 662 additions & 19 deletions

File tree

.changeset/model-load-report.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@ifc-lite/viewer': minor
3+
---
4+
5+
Add a per-model Load Report panel showing source/schema, load path, existing geometry diagnostics, and applicable approximation settings (#3927).
6+
7+
Each loaded model now has a compact report: the file's schema version, resolved load path (wasm/cache/server/point-cloud), tessellation tier and fast-mode setting, plus the load's CSG/opening diagnostics rendered as actionable text (dropped representation items, silent no-op cuts, CSG failures, oversized content-hash reference drops). A model whose diagnostics were never captured for its current load (a cache hit, the server render path, GLB, or IFCX) reads as "diagnostics unavailable", never as a false "clean" result; a model with nothing diagnostic-worthy shows a quiet "clean" line instead of a fabricated warning.
8+
9+
Diagnostic hosts that carry a captured bounding box are listed as affected entities and can be selected and framed in 3D from the panel; hosts and dropped-item categories that carry no entity identity in the diagnostics contract are summarized as counts only, never invented as a selectable entity. The report can be exported as JSON for reproduction. Reachable from the Analyze ribbon tab and the command palette ("Load Report").

apps/viewer/src/components/viewer/CommandPalette.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
FileCode2,
4343
MessageSquare,
4444
ClipboardCheck,
45+
FileWarning,
4546
FileSpreadsheet,
4647
Palette,
4748
Puzzle,
@@ -220,7 +221,7 @@ function recordUsage(id: string) {
220221
* owns the single-tenant + re-dock + detach semantics; a second activation closes
221222
* the panel back to the Information fallback. Closing any active analysis extension
222223
* first preserves the prior "panels win the slot" behavior; kept as two thin helpers so every command action keeps its call site. */
223-
function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions' | 'layers' | 'collab' | 'sources' | 'zones') {
224+
function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions' | 'layers' | 'collab' | 'sources' | 'zones' | 'loadReport') {
224225
closeActiveAnalysisExtension();
225226
useViewerStore.getState().toggleWorkspacePanel(panel);
226227
}
@@ -476,6 +477,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
476477
action: () => { activateRightPanel('sources'); } },
477478
{ id: 'panel:zones', label: 'Location Zones', keywords: 'zone section takt area construction location apportionment storey', category: 'Panels', icon: Box,
478479
action: () => { activateRightPanel('zones'); } },
480+
{ id: 'panel:loadReport', label: 'Load Report', keywords: 'geometry diagnostics warnings dropped items csg openings unsupported load report', category: 'Panels', icon: FileWarning,
481+
action: () => { activateRightPanel('loadReport'); } },
479482
...(isCollabEnabled()
480483
? [{ id: 'panel:collab', label: 'Collaboration Room', keywords: 'share invite live multiplayer presence room realtime sync', category: 'Panels' as const, icon: Users,
481484
action: () => { activateRightPanel('collab'); } }]
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* LoadReportPanel — per-model load report with actionable geometry warnings
7+
* (issue #3927).
8+
*
9+
* Presents EXISTING evidence only (`buildLoadReports` in `lib/loadReport.ts`,
10+
* the GeometryDiagnostics contract already captured on `FederatedModel`):
11+
* source/schema, load path, diagnostics, approximation settings, and
12+
* affected entities where the diagnostic contract supplies an identity. A
13+
* model whose diagnostics were never captured for its current load (a cache
14+
* hit, server render, GLB/IFCX) reads as "unavailable", never as clean; a
15+
* model with nothing diagnostic-worthy reads as a quiet "clean" line, not a
16+
* spurious warning. JSON export mirrors `idsExportService`'s pattern.
17+
*/
18+
19+
import { useCallback, useMemo } from 'react';
20+
import { FileWarning, Download, Focus, X } from 'lucide-react';
21+
import { Button } from '@/components/ui/button';
22+
import { useViewerStore } from '@/store';
23+
import { buildLoadReports, downloadLoadReportJSON, type LoadReportAffectedEntity, type LoadReportSummary } from '@/lib/loadReport';
24+
import { cn } from '@/lib/utils';
25+
26+
interface LoadReportPanelProps {
27+
onClose?: () => void;
28+
}
29+
30+
function statusLabel(report: LoadReportSummary): { text: string; className: string } {
31+
if (!report.diagnosticsAvailable) {
32+
return { text: 'Diagnostics unavailable', className: 'text-muted-foreground' };
33+
}
34+
if (report.isClean) {
35+
return { text: 'Clean', className: 'text-emerald-600 dark:text-emerald-400' };
36+
}
37+
return { text: 'Issues found', className: 'text-amber-600 dark:text-amber-400' };
38+
}
39+
40+
function AffectedEntityRow({
41+
entity,
42+
onSelect,
43+
}: {
44+
entity: LoadReportAffectedEntity;
45+
onSelect: (entity: LoadReportAffectedEntity) => void;
46+
}) {
47+
const label = `#${entity.productId} ${entity.ifcType}${entity.csgFailures} failure(s), ${entity.openings} opening(s)`;
48+
if (!entity.renderable) {
49+
// No captured bbox: nothing to frame in 3D. Show the source identity
50+
// only, per the issue's boundary — never invent a selection target.
51+
return <div className="pl-2 text-[11px] text-muted-foreground">{label}</div>;
52+
}
53+
return (
54+
<button
55+
type="button"
56+
onClick={() => onSelect(entity)}
57+
className="flex w-full items-center gap-1.5 rounded pl-2 py-0.5 text-left text-[11px] hover:bg-accent"
58+
title="Select and frame this entity"
59+
>
60+
<Focus className="h-3 w-3 shrink-0 text-muted-foreground" />
61+
<span className="truncate">{label}</span>
62+
</button>
63+
);
64+
}
65+
66+
function ModelReportCard({
67+
report,
68+
onSelectEntity,
69+
}: {
70+
report: LoadReportSummary;
71+
onSelectEntity: (modelId: string, entity: LoadReportAffectedEntity) => void;
72+
}) {
73+
const status = statusLabel(report);
74+
return (
75+
<div className="border-b p-3">
76+
<div className="flex items-center justify-between gap-2">
77+
<span className="truncate text-sm font-medium">{report.name}</span>
78+
<span className={cn('shrink-0 text-[11px] font-medium', status.className)}>{status.text}</span>
79+
</div>
80+
<div className="mt-0.5 text-[11px] text-muted-foreground">
81+
{report.schemaVersion}
82+
{report.loadFormat ? ` · ${report.loadFormat}` : ''}
83+
{report.loadPath ? ` · ${report.loadPath}` : ''}
84+
{report.tessellationTier ? ` · tier ${report.tessellationTier}` : ''}
85+
{report.skipSmallCuts ? ' · fast mode' : ''}
86+
</div>
87+
{report.actions.length > 0 && (
88+
<ul className="mt-2 list-disc space-y-1 pl-4 text-[11px]">
89+
{report.actions.map((action, i) => (
90+
// Actions are a fixed, stable-order list built from this report's
91+
// own counters — no reorderable/keyed identity beyond position.
92+
<li key={i}>{action}</li>
93+
))}
94+
</ul>
95+
)}
96+
{report.affectedEntities.length > 0 && (
97+
<div className="mt-2">
98+
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">Affected entities</div>
99+
{report.affectedEntities.map((entity) => (
100+
<AffectedEntityRow
101+
key={entity.productId}
102+
entity={entity}
103+
onSelect={(e) => onSelectEntity(report.modelId, e)}
104+
/>
105+
))}
106+
</div>
107+
)}
108+
</div>
109+
);
110+
}
111+
112+
export function LoadReportPanel({ onClose }: LoadReportPanelProps) {
113+
const models = useViewerStore((s) => s.models);
114+
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
115+
const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
116+
const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
117+
const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
118+
119+
const reports = useMemo(() => buildLoadReports(models), [models]);
120+
121+
const handleSelectEntity = useCallback(
122+
(modelId: string, entity: LoadReportAffectedEntity) => {
123+
// Two-channel selection (see AGENTS.md): the global id drives 3D
124+
// highlight/pick, the {modelId, expressId} ref drives the properties
125+
// panel — both must be set or highlighting silently breaks.
126+
setSelectedEntityIds([]);
127+
setSelectedEntityId(entity.globalId);
128+
setSelectedEntity({ modelId, expressId: entity.productId });
129+
if (cameraCallbacks.frameSelection) {
130+
window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50);
131+
}
132+
},
133+
[setSelectedEntityIds, setSelectedEntityId, setSelectedEntity, cameraCallbacks],
134+
);
135+
136+
const handleExport = useCallback(() => downloadLoadReportJSON(reports), [reports]);
137+
138+
return (
139+
<div className="flex h-full flex-col">
140+
<div className="flex items-center gap-2 border-b p-3">
141+
<FileWarning className="h-4 w-4 text-amber-600" />
142+
<span className="flex-1 text-sm font-medium">Load report</span>
143+
<Button
144+
variant="ghost"
145+
size="icon"
146+
className="h-6 w-6"
147+
onClick={handleExport}
148+
disabled={reports.length === 0}
149+
title="Export JSON"
150+
>
151+
<Download className="h-3.5 w-3.5" />
152+
</Button>
153+
{onClose && (
154+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onClose} title="Close">
155+
<X className="h-3.5 w-3.5" />
156+
</Button>
157+
)}
158+
</div>
159+
<div className="flex-1 overflow-y-auto">
160+
{reports.length === 0 ? (
161+
<div className="p-3 text-xs text-muted-foreground">No models loaded.</div>
162+
) : (
163+
reports.map((report) => (
164+
<ModelReportCard key={report.modelId} report={report} onSelectEntity={handleSelectEntity} />
165+
))
166+
)}
167+
</div>
168+
</div>
169+
);
170+
}

apps/viewer/src/components/viewer/ribbon/tabs/AnalyzeTab.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import { Issue, List, Compare, Layer, Clash, Check, Script, Schedule, Coloring } from '@/icons';
13-
import { Box as ZoneBox } from 'lucide-react';
13+
import { Box as ZoneBox, FileWarning } from 'lucide-react';
1414
import { useViewerStore } from '@/store';
1515
import { useWorkspacePanelControls } from '../../toolbar/useWorkspacePanelControls';
1616
import {
@@ -91,6 +91,14 @@ export function AnalyzeTab() {
9191
active={activeWorkspacePanels.has('zones')}
9292
onClick={() => useViewerStore.getState().toggleWorkspacePanel('zones')}
9393
/>
94+
{/* Per-model load report (#3927): actionable geometry warnings. */}
95+
<RibbonLargeButton
96+
icon={FileWarning}
97+
label="Load Report"
98+
tooltip="Per-model load report and geometry warnings"
99+
active={activeWorkspacePanels.has('loadReport')}
100+
onClick={() => useViewerStore.getState().toggleWorkspacePanel('loadReport')}
101+
/>
94102
</RibbonGroup>
95103

96104
<RibbonGroupDivider />

apps/viewer/src/components/viewer/toolbar/useWorkspacePanelControls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export function useWorkspacePanelControls() {
247247
if (layersPanelVisible) panels.add('layers');
248248
if (collabPanelVisible) panels.add('collab');
249249
if (sidebarActivePanel === 'zones') panels.add('zones');
250+
if (sidebarActivePanel === 'loadReport') panels.add('loadReport');
250251
if (analysisExtensionState.activeId) panels.add(analysisExtensionState.activeId);
251252
return panels;
252253
}, [
@@ -286,6 +287,7 @@ export function useWorkspacePanelControls() {
286287
if (activeWorkspacePanels.has('layers')) return 'Layer Stack';
287288
if (activeWorkspacePanels.has('collab')) return 'Collaboration Room';
288289
if (activeWorkspacePanels.has('zones')) return 'Location Zones';
290+
if (activeWorkspacePanels.has('loadReport')) return 'Load Report';
289291
return activeAnalysisExtension?.label ?? 'Analysis';
290292
}, [activeAnalysisExtension?.label, activeWorkspacePanels]);
291293

apps/viewer/src/hooks/useIfcLoader.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,9 @@ import { flushSync } from 'react-dom';
1515
import { useShallow } from 'zustand/react/shallow';
1616
import { getViewerStoreApi, useViewerStore, type FederatedModel } from '@/store';
1717
import { getGeomWorkerOverride, resolveLoadTessellationTier, isMeshOnlyCacheEnabled } from '../store/constants.js';
18-
import {
19-
buildModelLoadedGeometryProps,
20-
warnGeometryDiagnostics,
21-
} from './modelLoadedGeometryProps.js';
18+
import { buildModelLoadedGeometryProps, warnGeometryDiagnostics } from './modelLoadedGeometryProps.js';
2219
import { planCacheWrite, decideMeshOnlyCacheHit, decideCacheLoadOutcome } from './cacheTier.js';
20+
import { buildModelLoadReportPatch, type ModelLoadReportFields } from '../lib/loadReport';
2321
import { computeSourceFingerprint } from './sourceFingerprint.js';
2422
import { computeFullSourceHash } from '../utils/sourceContentHash.js';
2523
import { IfcParser, detectFormat, unwrapIfcZipWithResources, type IfcDataStore } from '@ifc-lite/parser';
@@ -540,7 +538,7 @@ export function useIfcLoader() {
540538
dataStore: IfcDataStore | null,
541539
geometryResult: GeometryResult | null,
542540
schemaVersion: 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5',
543-
patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number },
541+
patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number } & Pick<ModelLoadReportFields, 'loadPath' | 'tessellationTier' | 'skipSmallCuts'>, // #3927, per-call-site like buildModelLoadReportPatch's doc explains
544542
// GPU-instancing shard bytes (#1912), forwarded explicitly rather than
545543
// closed over: the WASM streaming section's `allInstancedShards` is
546544
// declared ~800 lines below this closure, so a plain closure read would
@@ -683,6 +681,7 @@ export function useIfcLoader() {
683681
pointCloudHandleId: patch?.pointCloudHandleId,
684682
preAlignment,
685683
federationAlignmentStatus,
684+
...buildModelLoadReportPatch(loadDiagnostics, format, patch),
686685
};
687686
useViewerStore.getState().addModel(federatedModel);
688687
// Spatial index AFTER id offset + alignment (final ids + world positions)
@@ -719,6 +718,7 @@ export function useIfcLoader() {
719718
cacheState: patch?.cacheState ?? 'none',
720719
loadError: patch?.loadError ?? null,
721720
pointCloudHandleId: patch?.pointCloudHandleId,
721+
...buildModelLoadReportPatch(loadDiagnostics, format, patch),
722722
});
723723
};
724724
const getSchemaVersion = (dataStore: IfcDataStore | null): 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5' => {
@@ -968,7 +968,7 @@ export function useIfcLoader() {
968968
setIfcDataStore(ingest.dataStore);
969969
}
970970
await finalizeModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
971-
pointCloudHandleId: ingest.rendererHandle.id,
971+
pointCloudHandleId: ingest.rendererHandle.id, loadPath: 'point-cloud',
972972
});
973973
setProgress({ phase: 'Complete', percent: 100 });
974974
// Snapshot: points, not meshes - the ingest GeometryResult's zero
@@ -1003,7 +1003,7 @@ export function useIfcLoader() {
10031003
setGeometryResult(result.geometryResult);
10041004
setIfcDataStore(result.dataStore);
10051005
}
1006-
await finalizeModel(result.dataStore, result.geometryResult, result.schemaVersion);
1006+
await finalizeModel(result.dataStore, result.geometryResult, result.schemaVersion, { loadPath: 'wasm' });
10071007

10081008
setProgress({ phase: 'Complete', percent: 100 });
10091009
captureModelLoaded({ format: 'ifcx', file_size_mb: Math.round(fileSizeMB * 100) / 100, load_target: target.kind, load_path: 'wasm', total_elapsed_ms: Math.round(performance.now() - totalStartTime), was_hidden: wasHidden() }, snapshotFromGeometry(fileSizeMB, result.geometryResult));
@@ -1050,8 +1050,7 @@ export function useIfcLoader() {
10501050
// ids + register the model (matches the old addModel GLB path).
10511051
await finalizeModel(
10521052
target.kind === 'federated' ? result.dataStore : null,
1053-
result.geometryResult,
1054-
result.schemaVersion,
1053+
result.geometryResult, result.schemaVersion, { loadPath: 'wasm' },
10551054
);
10561055

10571056
setProgress({ phase: 'Complete', percent: 100 });
@@ -1197,8 +1196,8 @@ export function useIfcLoader() {
11971196
if (cacheOutcome === 'serve') {
11981197
const state = useViewerStore.getState();
11991198
await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore), {
1200-
loadState: 'complete',
1201-
cacheState: 'hit',
1199+
loadState: 'complete', cacheState: 'hit',
1200+
loadPath: 'cache', tessellationTier: loadTessellationTier, skipSmallCuts: skipSmallCutsAtLoad,
12021201
});
12031202
console.log(`[useIfc] TOTAL LOAD TIME (from cache): ${(performance.now() - totalStartTime).toFixed(0)}ms`);
12041203
// Geometry attribution (#2388) on a cache HIT: `loadTessellationTier`/
@@ -1270,7 +1269,7 @@ export function useIfcLoader() {
12701269
const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
12711270
if (serverSuccess) {
12721271
const state = useViewerStore.getState();
1273-
await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore));
1272+
await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore), { loadPath: 'server' });
12741273
console.log(`[useIfc] TOTAL LOAD TIME (server): ${(performance.now() - totalStartTime).toFixed(0)}ms`);
12751274
// Geometry attribution (#2388), server row: `is_resource_retry` and
12761275
// ONLY that. The retry re-enters `loadFile`, so a first attempt that
@@ -1959,7 +1958,7 @@ export function useIfcLoader() {
19591958
: {}),
19601959
};
19611960
await finalizeModel(dataStore, federatedGeometry, getSchemaVersion(dataStore), {
1962-
loadState: 'complete',
1961+
loadState: 'complete', loadPath: 'wasm', tessellationTier: loadTessellationTier, skipSmallCuts: skipSmallCutsAtLoad,
19631962
}, allInstancedShards);
19641963
return;
19651964
}
@@ -1968,7 +1967,7 @@ export function useIfcLoader() {
19681967
loadState: 'complete',
19691968
// Only show "writing" when this file will actually be cached
19701969
// under the current plan (respects the size bands + kill switch).
1971-
cacheState: cachePlan.shouldCache ? 'writing' : 'none',
1970+
cacheState: cachePlan.shouldCache ? 'writing' : 'none', loadPath: 'wasm', tessellationTier: loadTessellationTier, skipSmallCuts: skipSmallCutsAtLoad,
19721971
}, allInstancedShards);
19731972
// Build spatial index from meshes in time-sliced chunks (non-blocking).
19741973
// Previously this was synchronous inside requestIdleCallback, blocking

0 commit comments

Comments
 (0)