@ifc-lite/viewer 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.turbo/turbo-build.log +45 -38
  2. package/CHANGELOG.md +93 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  8. package/dist/assets/deflate-DNGgs8Ur.js +1 -0
  9. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  10. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  11. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  12. package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
  13. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  14. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
  15. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  16. package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
  17. package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
  18. package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
  19. package/dist/assets/index-BtbXFKsX.css +1 -0
  20. package/dist/assets/index.es-CWfqZyyr.js +6866 -0
  21. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
  22. package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
  23. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  24. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  25. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
  26. package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
  27. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  28. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
  29. package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
  30. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  31. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  32. package/dist/assets/pdf-CRwaZf3s.js +135 -0
  33. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  34. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
  35. package/dist/assets/server-client-cTCJ-853.js +719 -0
  36. package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
  37. package/dist/assets/xlsx-B1YOg2QB.js +142 -0
  38. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  39. package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
  40. package/dist/index.html +10 -10
  41. package/package.json +27 -23
  42. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  43. package/src/components/mcp/data.ts +6 -0
  44. package/src/components/mcp/playground-dispatcher.ts +280 -0
  45. package/src/components/mcp/playground-files.ts +33 -1
  46. package/src/components/mcp/types.ts +2 -1
  47. package/src/components/ui/combo-input.tsx +163 -0
  48. package/src/components/ui/tabs.tsx +1 -1
  49. package/src/components/viewer/CommandPalette.tsx +6 -1
  50. package/src/components/viewer/ComparePanel.tsx +420 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  52. package/src/components/viewer/MainToolbar.tsx +19 -2
  53. package/src/components/viewer/PropertiesPanel.tsx +84 -8
  54. package/src/components/viewer/SearchInline.tsx +62 -2
  55. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  56. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  57. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  58. package/src/components/viewer/SearchModal.tsx +19 -6
  59. package/src/components/viewer/ViewerLayout.tsx +5 -0
  60. package/src/components/viewer/Viewport.tsx +18 -0
  61. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  62. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  63. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  64. package/src/components/viewer/hierarchy/types.ts +1 -0
  65. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  66. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  67. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  68. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  69. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  70. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  71. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  72. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  73. package/src/generated/mcp-catalog.json +4 -0
  74. package/src/hooks/federationLoadGate.test.ts +12 -2
  75. package/src/hooks/federationLoadGate.ts +9 -2
  76. package/src/hooks/ingest/federationAlign.ts +481 -0
  77. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  78. package/src/hooks/source-key.ts +35 -0
  79. package/src/hooks/useAlignmentLines3D.ts +1 -26
  80. package/src/hooks/useCompare.ts +0 -0
  81. package/src/hooks/useCompareOverlay.ts +119 -0
  82. package/src/hooks/useDrawingGeneration.ts +23 -1
  83. package/src/hooks/useGridLines3D.ts +140 -0
  84. package/src/hooks/useIfc.ts +1 -1
  85. package/src/hooks/useIfcCache.ts +32 -9
  86. package/src/hooks/useIfcFederation.ts +42 -810
  87. package/src/hooks/useIfcLoader.ts +361 -488
  88. package/src/hooks/useIfcServer.ts +3 -0
  89. package/src/hooks/useLens.ts +5 -1
  90. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  91. package/src/lib/compare/buildFingerprints.ts +173 -0
  92. package/src/lib/compare/describeChange.ts +0 -0
  93. package/src/lib/compare/geometricData.test.ts +54 -0
  94. package/src/lib/compare/geometricData.ts +37 -0
  95. package/src/lib/compare/overlay.test.ts +99 -0
  96. package/src/lib/compare/overlay.ts +91 -0
  97. package/src/lib/geo/cesium-placement.ts +1 -1
  98. package/src/lib/geo/reproject.ts +4 -1
  99. package/src/lib/length-unit-scale.ts +41 -0
  100. package/src/lib/lists/adapter.ts +136 -11
  101. package/src/lib/lists/export/csv.ts +47 -0
  102. package/src/lib/lists/export/index.ts +49 -0
  103. package/src/lib/lists/export/model.ts +111 -0
  104. package/src/lib/lists/export/pdf.ts +67 -0
  105. package/src/lib/lists/export/xlsx.ts +83 -0
  106. package/src/lib/lists/index.ts +2 -0
  107. package/src/lib/llm/script-edit-ops.ts +23 -0
  108. package/src/lib/llm/stream-client.ts +8 -1
  109. package/src/lib/search/filter-evaluate.test.ts +81 -0
  110. package/src/lib/search/filter-evaluate.ts +59 -87
  111. package/src/lib/search/filter-match.ts +167 -0
  112. package/src/lib/search/filter-rules.test.ts +25 -0
  113. package/src/lib/search/filter-rules.ts +75 -2
  114. package/src/lib/search/filter-schema.ts +0 -0
  115. package/src/lib/search/result-export.ts +7 -1
  116. package/src/lib/slab-edit.test.ts +72 -0
  117. package/src/lib/slab-edit.ts +159 -19
  118. package/src/sdk/adapters/export-adapter.ts +9 -4
  119. package/src/sdk/adapters/query-adapter.ts +3 -3
  120. package/src/store/globalId.ts +15 -13
  121. package/src/store/index.ts +16 -1
  122. package/src/store/slices/cesiumSlice.ts +8 -1
  123. package/src/store/slices/compareSlice.ts +96 -0
  124. package/src/store/slices/lensSlice.ts +8 -0
  125. package/src/store/slices/listSlice.ts +6 -0
  126. package/src/store/slices/mutationSlice.ts +14 -6
  127. package/src/store/slices/searchSlice.ts +29 -3
  128. package/src/utils/acquireFileBuffer.test.ts +12 -4
  129. package/src/utils/desktopModelSnapshot.ts +2 -1
  130. package/src/utils/loadingUtils.ts +32 -0
  131. package/src/utils/nativeSpatialDataStore.ts +6 -0
  132. package/src/utils/serverDataModel.test.ts +6 -0
  133. package/src/utils/serverDataModel.ts +7 -0
  134. package/src/utils/spatialHierarchy.test.ts +53 -1
  135. package/src/utils/spatialHierarchy.ts +42 -2
  136. package/src/vite-env.d.ts +2 -0
  137. package/dist/assets/deflate-Cnx0il6E.js +0 -1
  138. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  139. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  140. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  141. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  142. package/dist/assets/index-B9Ug2EqU.css +0 -1
  143. package/dist/assets/lens-PYsLu_MA.js +0 -1
  144. package/dist/assets/parser.worker-8md211IW.js +0 -182
  145. package/dist/assets/raw-BQrAgxwT.js +0 -1
  146. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
  147. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  148. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  149. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  150. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -0,0 +1,420 @@
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
+ * Model comparison panel (issue #924). Pick two loaded models as A (base) and
7
+ * B (head), choose a data/geometry/both scope, run the `@ifc-lite/diff` engine,
8
+ * and review added / modified / deleted elements — colour-coded in 3D (via
9
+ * `useCompareOverlay`) and listed here. Row click selects + frames the element.
10
+ */
11
+
12
+ import { useEffect, useMemo } from 'react';
13
+ import { GitCompareArrows, Plus, Minus, PencilLine, Loader2, Play, X, Trash2 } from 'lucide-react';
14
+ import { Button } from '@/components/ui/button';
15
+ import { ScrollArea } from '@/components/ui/scroll-area';
16
+ import { cn } from '@/lib/utils';
17
+ import { useViewerStore } from '@/store';
18
+ import { useCompare } from '@/hooks/useCompare';
19
+ import { useCompareOverlay } from '@/hooks/useCompareOverlay';
20
+ import { COMPARE_COLORS, type RGBA } from '@/lib/compare/overlay';
21
+ import type { CompareRef } from '@/lib/compare/buildFingerprints';
22
+ import { describeChange, type ChangeDetail, type FieldDelta, type GeometrySummary } from '@/lib/compare/describeChange';
23
+ import type { DiffScope, DiffState, DiffEntry } from '@ifc-lite/diff';
24
+
25
+ interface ComparePanelProps {
26
+ onClose?: () => void;
27
+ }
28
+
29
+ const SCOPES: { id: DiffScope; label: string }[] = [
30
+ { id: 'both', label: 'Both' },
31
+ { id: 'data', label: 'Data' },
32
+ { id: 'geometry', label: 'Geometry' },
33
+ ];
34
+
35
+ /** States listed in the panel (unchanged only affects 3D ghosting). */
36
+ const LISTED_STATES: { state: Exclude<DiffState, 'unchanged'>; label: string; color: RGBA; Icon: typeof Plus }[] = [
37
+ { state: 'modified', label: 'Changed', color: COMPARE_COLORS.modified, Icon: PencilLine },
38
+ { state: 'added', label: 'Added', color: COMPARE_COLORS.added, Icon: Plus },
39
+ { state: 'deleted', label: 'Deleted', color: COMPARE_COLORS.deleted, Icon: Minus },
40
+ ];
41
+
42
+ /** Cap rows rendered per group so a huge diff can't stall the DOM. */
43
+ const MAX_ROWS_PER_GROUP = 1000;
44
+
45
+ function rgbaCss([r, g, b, a]: RGBA): string {
46
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
47
+ }
48
+
49
+ interface CompareRow {
50
+ key: string;
51
+ ifcType: string;
52
+ name: string;
53
+ changeKinds: string[];
54
+ ref: CompareRef;
55
+ }
56
+
57
+ /** The side actually drawn for an entry: base for deletions, head otherwise. */
58
+ function renderRef(entry: DiffEntry<CompareRef>): CompareRef | undefined {
59
+ return (entry.state === 'deleted' ? entry.base?.ref : entry.head?.ref) ?? entry.base?.ref;
60
+ }
61
+
62
+ export function ComparePanel({ onClose }: ComparePanelProps) {
63
+ useCompareOverlay();
64
+
65
+ const models = useViewerStore((s) => s.models);
66
+ const baseModelId = useViewerStore((s) => s.compareBaseModelId);
67
+ const headModelId = useViewerStore((s) => s.compareHeadModelId);
68
+ const scope = useViewerStore((s) => s.compareScope);
69
+ const showUnchanged = useViewerStore((s) => s.compareShowUnchanged);
70
+ const selectedKey = useViewerStore((s) => s.compareSelectedKey);
71
+ const setBaseModelId = useViewerStore((s) => s.setCompareBaseModelId);
72
+ const setHeadModelId = useViewerStore((s) => s.setCompareHeadModelId);
73
+ const setScope = useViewerStore((s) => s.setCompareScope);
74
+ const setShowUnchanged = useViewerStore((s) => s.setCompareShowUnchanged);
75
+ const clearCompare = useViewerStore((s) => s.clearCompare);
76
+
77
+ const { running, result, error, runComparison } = useCompare();
78
+
79
+ const modelList = useMemo(() => Array.from(models.values()), [models]);
80
+
81
+ // Default the A/B selection to the first two loaded models, and repair the
82
+ // selection if a chosen model was removed.
83
+ useEffect(() => {
84
+ const ids = modelList.map((m) => m.id);
85
+ // A comparison computed against a model that's since been removed leaves a
86
+ // stale overlay on the survivor (the overlay hook keys off the result, not
87
+ // the model list) — drop it so the scene is restored.
88
+ const ran = useViewerStore.getState().compareResult;
89
+ if (ran && (!ids.includes(ran.baseModelId) || !ids.includes(ran.headModelId))) {
90
+ clearCompare();
91
+ }
92
+ if (ids.length === 0) return;
93
+ if (!baseModelId || !ids.includes(baseModelId)) {
94
+ setBaseModelId(ids[0]);
95
+ }
96
+ if (ids.length > 1 && (!headModelId || !ids.includes(headModelId) || headModelId === ids[0])) {
97
+ const other = ids.find((id) => id !== ids[0]);
98
+ if (other) setHeadModelId(other);
99
+ }
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ }, [modelList]);
102
+
103
+ // Resolve a display name + grouped rows from the diff result. Names live in
104
+ // the per-model store (the engine result carries only type + key), so we
105
+ // look them up here via each entry's ref.
106
+ const groups = useMemo(() => {
107
+ const empty = new Map<DiffState, { rows: CompareRow[]; truncated: number }>();
108
+ if (!result) return empty;
109
+ const out = new Map<DiffState, { rows: CompareRow[]; truncated: number }>();
110
+ for (const { state } of LISTED_STATES) out.set(state, { rows: [], truncated: 0 });
111
+
112
+ for (const entry of result.diff.entries) {
113
+ const bucket = out.get(entry.state);
114
+ if (!bucket) continue; // skip unchanged
115
+ const ref = renderRef(entry);
116
+ if (!ref) continue;
117
+ if (bucket.rows.length >= MAX_ROWS_PER_GROUP) {
118
+ bucket.truncated++;
119
+ continue;
120
+ }
121
+ const store = models.get(ref.modelId)?.ifcDataStore;
122
+ const name = store?.entities.getName(ref.localId) || '';
123
+ const ifcType = (entry.head ?? entry.base)?.ifcType ?? 'IfcProduct';
124
+ bucket.rows.push({ key: entry.key, ifcType, name, changeKinds: entry.changeKinds, ref });
125
+ }
126
+ return out;
127
+ }, [result, models]);
128
+
129
+ const counts = result?.diff.counts;
130
+ const canRun = !!baseModelId && !!headModelId && baseModelId !== headModelId && !running;
131
+
132
+ // "What changed" detail for the selected entry — computed lazily from both
133
+ // stores so a huge diff stays cheap (only the selection is described).
134
+ const detail = useMemo<ChangeDetail | null>(() => {
135
+ if (!result || !selectedKey) return null;
136
+ const entry = result.diff.byKey.get(selectedKey);
137
+ return entry ? describeChange(entry, models) : null;
138
+ }, [result, selectedKey, models]);
139
+
140
+ const selectedRow = useMemo<CompareRow | null>(() => {
141
+ if (!selectedKey) return null;
142
+ for (const bucket of groups.values()) {
143
+ const row = bucket.rows.find((r) => r.key === selectedKey);
144
+ if (row) return row;
145
+ }
146
+ return null;
147
+ }, [groups, selectedKey]);
148
+
149
+ const focusEntry = (row: CompareRow) => {
150
+ const state = useViewerStore.getState();
151
+ state.clearEntitySelection();
152
+ state.setSelectedEntityIds([row.ref.globalId]);
153
+ state.addEntitiesToSelection([{ modelId: row.ref.modelId, expressId: row.ref.localId }]);
154
+ state.setCompareSelectedKey(row.key);
155
+ requestAnimationFrame(() => state.cameraCallbacks.frameSelection?.());
156
+ };
157
+
158
+ return (
159
+ <div className="h-full flex flex-col bg-background text-foreground overflow-hidden min-w-0">
160
+ {/* Header */}
161
+ <div className="flex items-center gap-2 p-3 border-b border-border">
162
+ <GitCompareArrows className="h-4 w-4 text-primary shrink-0" />
163
+ <span className="text-sm font-semibold tracking-tight min-w-0">Compare models</span>
164
+ <div className="ml-auto flex items-center gap-1 shrink-0">
165
+ {result && (
166
+ <Button variant="ghost" size="icon" className="h-7 w-7" title="Clear results" onClick={clearCompare}>
167
+ <Trash2 className="h-4 w-4" />
168
+ </Button>
169
+ )}
170
+ {onClose && (
171
+ <Button variant="ghost" size="icon" className="h-7 w-7" title="Close" onClick={onClose}>
172
+ <X className="h-4 w-4" />
173
+ </Button>
174
+ )}
175
+ </div>
176
+ </div>
177
+
178
+ {modelList.length < 2 ? (
179
+ <div className="p-4 text-sm text-muted-foreground">
180
+ Load a second model to compare. Open two IFC files (federation), then pick
181
+ version A and version B here.
182
+ </div>
183
+ ) : (
184
+ <>
185
+ {/* Run controls */}
186
+ <div className="p-3 space-y-3 border-b border-border">
187
+ <div className="grid grid-cols-[1.25rem_1fr] items-center gap-x-2 gap-y-2 text-xs">
188
+ <span className="text-muted-foreground">A</span>
189
+ <select
190
+ value={baseModelId ?? ''}
191
+ onChange={(e) => setBaseModelId(e.target.value)}
192
+ className="w-full rounded border border-border bg-transparent px-2 py-1 text-foreground min-w-0"
193
+ >
194
+ {modelList.map((m) => (
195
+ <option key={m.id} value={m.id}>{m.name}</option>
196
+ ))}
197
+ </select>
198
+ <span className="text-muted-foreground">B</span>
199
+ <select
200
+ value={headModelId ?? ''}
201
+ onChange={(e) => setHeadModelId(e.target.value)}
202
+ className="w-full rounded border border-border bg-transparent px-2 py-1 text-foreground min-w-0"
203
+ >
204
+ {modelList.map((m) => (
205
+ <option key={m.id} value={m.id}>{m.name}</option>
206
+ ))}
207
+ </select>
208
+ </div>
209
+
210
+ {baseModelId === headModelId && (
211
+ <p className="text-xs text-[#e0af68]">Pick two different models.</p>
212
+ )}
213
+
214
+ <div className="flex flex-wrap items-center gap-2">
215
+ <div className="inline-flex rounded-md border border-border overflow-hidden text-xs shrink-0">
216
+ {SCOPES.map((s) => (
217
+ <button
218
+ key={s.id}
219
+ onClick={() => setScope(s.id)}
220
+ className={cn(
221
+ 'px-2.5 py-1 transition-colors',
222
+ scope === s.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
223
+ )}
224
+ >
225
+ {s.label}
226
+ </button>
227
+ ))}
228
+ </div>
229
+ <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
230
+ <input
231
+ type="checkbox"
232
+ checked={showUnchanged}
233
+ onChange={(e) => setShowUnchanged(e.target.checked)}
234
+ />
235
+ Show unchanged
236
+ </label>
237
+ </div>
238
+
239
+ <Button size="sm" className="w-full gap-1.5" disabled={!canRun} onClick={() => void runComparison()}>
240
+ {running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
241
+ {running ? 'Comparing…' : 'Run comparison'}
242
+ </Button>
243
+
244
+ {error && <p className="text-xs text-[#f7768e]">{error}</p>}
245
+
246
+ {result?.geometryUnavailable && scope !== 'data' && (
247
+ <p className="text-xs text-[#e0af68]">
248
+ One model has no geometry fingerprints (loaded outside the WASM
249
+ mesh path), so geometry changes can’t be detected. Data changes
250
+ are still accurate — switch to the Data scope for reliable results.
251
+ </p>
252
+ )}
253
+ </div>
254
+
255
+ {/* Counts */}
256
+ {counts && (
257
+ <div className="grid grid-cols-4 gap-1 p-3 border-b border-border text-center">
258
+ <CountBadge label="Changed" value={counts.modified} color={COMPARE_COLORS.modified} />
259
+ <CountBadge label="Added" value={counts.added} color={COMPARE_COLORS.added} />
260
+ <CountBadge label="Deleted" value={counts.deleted} color={COMPARE_COLORS.deleted} />
261
+ <CountBadge label="Unchanged" value={counts.unchanged} color={COMPARE_COLORS.unchanged} />
262
+ </div>
263
+ )}
264
+
265
+ {/* Results list */}
266
+ <ScrollArea className="flex-1 min-h-0">
267
+ {!result ? (
268
+ <div className="p-4 text-sm text-muted-foreground">
269
+ Run a comparison to see added, changed, and deleted elements.
270
+ </div>
271
+ ) : (
272
+ <div className="p-2 space-y-3">
273
+ {LISTED_STATES.map(({ state, label, color, Icon }) => {
274
+ const bucket = groups.get(state);
275
+ if (!bucket || bucket.rows.length === 0) return null;
276
+ return (
277
+ <div key={state}>
278
+ <div className="flex items-center gap-1.5 px-1 py-1 text-xs font-medium">
279
+ <Icon className="h-3.5 w-3.5" style={{ color: rgbaCss(color) }} />
280
+ <span>{label}</span>
281
+ <span className="text-muted-foreground">({bucket.rows.length + bucket.truncated})</span>
282
+ </div>
283
+ <div className="space-y-0.5">
284
+ {bucket.rows.map((row) => (
285
+ <button
286
+ key={row.key}
287
+ onClick={() => focusEntry(row)}
288
+ className={cn(
289
+ 'w-full text-left rounded px-2 py-1 flex items-center gap-2 hover:bg-muted transition-colors min-w-0',
290
+ selectedKey === row.key && 'bg-muted',
291
+ )}
292
+ >
293
+ <span
294
+ className="h-2.5 w-2.5 rounded-sm shrink-0"
295
+ style={{ backgroundColor: rgbaCss(color) }}
296
+ />
297
+ <span className="min-w-0 flex-1 truncate text-xs">
298
+ {row.name || row.ifcType}
299
+ </span>
300
+ <span className="shrink-0 text-[10px] text-muted-foreground">
301
+ {state === 'modified' && row.changeKinds.length > 0
302
+ ? row.changeKinds.join(' · ')
303
+ : row.ifcType.replace(/^Ifc/, '')}
304
+ </span>
305
+ </button>
306
+ ))}
307
+ {bucket.truncated > 0 && (
308
+ <p className="px-2 py-1 text-[10px] text-muted-foreground">
309
+ +{bucket.truncated} more not shown
310
+ </p>
311
+ )}
312
+ </div>
313
+ </div>
314
+ );
315
+ })}
316
+ {counts && counts.added + counts.modified + counts.deleted === 0 && (
317
+ <div className="p-3 text-sm text-muted-foreground">
318
+ No differences in scope “{result.scope}”. The models match.
319
+ </div>
320
+ )}
321
+ </div>
322
+ )}
323
+ </ScrollArea>
324
+
325
+ {/* What-changed detail for the selected element */}
326
+ {detail && selectedRow && <ChangeDetailView row={selectedRow} detail={detail} />}
327
+ </>
328
+ )}
329
+ </div>
330
+ );
331
+ }
332
+
333
+ /** Per-element "what changed" — geometry move/reshape + data field deltas. */
334
+ function ChangeDetailView({ row, detail }: { row: CompareRow; detail: ChangeDetail }) {
335
+ return (
336
+ <div className="border-t border-border shrink-0 max-h-[42%] overflow-auto">
337
+ <div className="px-3 pt-2.5 pb-1.5 flex items-center gap-1.5 sticky top-0 bg-background">
338
+ <PencilLine className="h-3.5 w-3.5 text-primary shrink-0" />
339
+ <span className="text-xs font-semibold truncate">{row.name || row.ifcType}</span>
340
+ <span className="ml-auto text-[10px] text-muted-foreground shrink-0">{row.ifcType.replace(/^Ifc/, '')}</span>
341
+ </div>
342
+ <div className="px-3 pb-3 space-y-2.5 text-xs">
343
+ {detail.geometry && (
344
+ <div className="space-y-1">
345
+ <div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Geometry</div>
346
+ <GeometryDetail summary={detail.geometry} />
347
+ </div>
348
+ )}
349
+ {detail.data.length > 0 ? (
350
+ <div className="space-y-1">
351
+ <div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
352
+ Data <span className="text-muted-foreground/70">({detail.data.length})</span>
353
+ </div>
354
+ <div className="space-y-1">
355
+ {detail.data.map((d, i) => <FieldDeltaRow key={i} delta={d} />)}
356
+ </div>
357
+ </div>
358
+ ) : detail.dataOnlyGeometric ? (
359
+ <div className="text-[11px] text-muted-foreground italic">
360
+ Data fingerprint differs but no field-level change could be pinpointed.
361
+ </div>
362
+ ) : !detail.geometry ? (
363
+ <div className="text-[11px] text-muted-foreground italic">No field-level detail available.</div>
364
+ ) : null}
365
+ </div>
366
+ </div>
367
+ );
368
+ }
369
+
370
+ function GeometryDetail({ summary }: { summary: GeometrySummary }) {
371
+ const moved = summary.movedDistance >= 1e-3;
372
+ const fmt = (n: number) => (Math.abs(n) < 1e-3 ? '0' : n.toFixed(n >= 1 ? 2 : 3));
373
+ const headline = summary.reshaped ? (moved ? 'Reshaped + moved' : 'Reshaped') : moved ? 'Moved' : 'Geometry changed';
374
+ return (
375
+ <div className="rounded border border-border/60 px-2 py-1.5 space-y-0.5">
376
+ <div className="font-medium">{headline}</div>
377
+ {moved && (
378
+ <div className="text-muted-foreground tabular-nums">
379
+ {fmt(summary.movedDistance)} m
380
+ <span className="text-muted-foreground/70">
381
+ {' '}(Δx {fmt(summary.delta.x)}, Δy {fmt(summary.delta.y)}, Δz {fmt(summary.delta.z)})
382
+ </span>
383
+ </div>
384
+ )}
385
+ </div>
386
+ );
387
+ }
388
+
389
+ function FieldDeltaRow({ delta }: { delta: FieldDelta }) {
390
+ const kindColor: Record<FieldDelta['kind'], string> = {
391
+ changed: 'text-[#e0af68]',
392
+ added: 'text-[#9ece6a]',
393
+ removed: 'text-[#f7768e]',
394
+ };
395
+ return (
396
+ <div className="rounded border border-border/40 px-2 py-1">
397
+ <div className="flex items-baseline gap-1.5 min-w-0">
398
+ {delta.group && <span className="text-[10px] text-muted-foreground shrink-0 truncate max-w-[40%]">{delta.group}</span>}
399
+ <span className="text-[11px] font-medium truncate">{delta.name}</span>
400
+ <span className={cn('ml-auto text-[10px] shrink-0', kindColor[delta.kind])}>{delta.kind}</span>
401
+ </div>
402
+ <div className="flex items-center gap-1.5 text-[11px] tabular-nums mt-0.5 min-w-0">
403
+ <span className="text-muted-foreground line-through truncate max-w-[45%]">{delta.before ?? '—'}</span>
404
+ <span className="text-muted-foreground/60 shrink-0">→</span>
405
+ <span className="truncate max-w-[45%]">{delta.after ?? '—'}</span>
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ function CountBadge({ label, value, color }: { label: string; value: number; color: RGBA }) {
412
+ return (
413
+ <div className="flex flex-col items-center gap-0.5">
414
+ <span className="text-sm font-semibold tabular-nums" style={{ color: rgbaCss([color[0], color[1], color[2], 1]) }}>
415
+ {value.toLocaleString()}
416
+ </span>
417
+ <span className="text-[10px] text-muted-foreground">{label}</span>
418
+ </div>
419
+ );
420
+ }
@@ -11,6 +11,7 @@ import {
11
11
  LayoutTemplate,
12
12
  FileBox,
13
13
  GripHorizontal,
14
+ Palette,
14
15
  } from 'lucide-react';
15
16
  import { Input } from '@/components/ui/input';
16
17
  import { Button } from '@/components/ui/button';
@@ -322,6 +323,34 @@ export function HierarchyPanel() {
322
323
  return;
323
324
  }
324
325
 
326
+ // Material group nodes (Materials tab) - select the material entity for the
327
+ // totals panel + isolate the elements that use it.
328
+ if (node.type === 'material-group') {
329
+ const modelId = node.modelIds[0];
330
+ const materialExpressId = node.entityExpressId;
331
+
332
+ // Clear multi-selection first (setSelectedEntityIds([]) resets selectedEntityId)
333
+ setSelectedEntityIds([]);
334
+
335
+ if (materialExpressId !== undefined) {
336
+ if (modelId && modelId !== 'legacy') {
337
+ setSelectedEntityId(toGlobalId(modelId, materialExpressId));
338
+ setSelectedEntity({ modelId, expressId: materialExpressId });
339
+ setActiveModel(modelId);
340
+ } else {
341
+ setSelectedEntityId(materialExpressId);
342
+ setSelectedEntity({ modelId: 'legacy', expressId: materialExpressId });
343
+ }
344
+ }
345
+
346
+ // Isolate the elements using this material
347
+ const elements = getNodeElements(node);
348
+ if (elements.length > 0) {
349
+ isolateEntities(elements);
350
+ }
351
+ return;
352
+ }
353
+
325
354
  // IFC type entity nodes (e.g. IfcWallType/W01) - select type entity for property panel + isolate instances
326
355
  if (node.type === 'ifc-type') {
327
356
  const modelId = node.modelIds[0];
@@ -478,14 +507,14 @@ export function HierarchyPanel() {
478
507
  ? selectedStoreys.has(node.expressIds[0])
479
508
  : node.type === 'IfcSpace' || node.type === 'element'
480
509
  ? selectedEntityId === (node.globalIds[0] ?? node.expressIds[0])
481
- : node.type === 'ifc-type'
510
+ : node.type === 'ifc-type' || node.type === 'material-group'
482
511
  ? (() => {
483
- const typeExpressId = node.entityExpressId;
484
- if (!typeExpressId) return false;
512
+ const entityExpressId = node.entityExpressId;
513
+ if (!entityExpressId) return false;
485
514
  const mId = node.modelIds[0];
486
515
  const gId = mId && mId !== 'legacy'
487
- ? toGlobalId(mId, typeExpressId)
488
- : typeExpressId;
516
+ ? toGlobalId(mId, entityExpressId)
517
+ : entityExpressId;
489
518
  return selectedEntityId === gId;
490
519
  })()
491
520
  : false;
@@ -495,7 +524,7 @@ export function HierarchyPanel() {
495
524
  if (node.type === 'element') {
496
525
  nodeHidden = hiddenEntities.has(node.globalIds[0] ?? node.expressIds[0]);
497
526
  } else if (node.type === 'IfcBuildingStorey' || node.type === 'IfcSpace' || node.type === 'unified-storey' ||
498
- node.type === 'type-group' || node.type === 'ifc-type' ||
527
+ node.type === 'type-group' || node.type === 'ifc-type' || node.type === 'material-group' ||
499
528
  (node.type === 'model-header' && node.id.startsWith('contrib-'))) {
500
529
  const elements = getNodeElements(node);
501
530
  nodeHidden = elements.length > 0 && elements.every(id => hiddenEntities.has(id));
@@ -732,6 +761,16 @@ export function HierarchyPanel() {
732
761
  <FileBox className="h-3 w-3 shrink-0 panel-compact-icon" />
733
762
  <span className="panel-compact-text">Type</span>
734
763
  </Button>
764
+ <Button
765
+ variant={groupingMode === 'material' ? 'default' : 'outline'}
766
+ size="sm"
767
+ className="h-6 text-[10px] flex-1 min-w-0 rounded-none uppercase tracking-wider"
768
+ onClick={() => setGroupingMode('material')}
769
+ title="Materials"
770
+ >
771
+ <Palette className="h-3 w-3 shrink-0 panel-compact-icon" />
772
+ <span className="panel-compact-text">Material</span>
773
+ </Button>
735
774
  </div>
736
775
  );
737
776
 
@@ -871,7 +910,7 @@ export function HierarchyPanel() {
871
910
  </div>
872
911
 
873
912
  {/* Section Header */}
874
- <SectionHeader icon={groupingMode === 'spatial' ? Building2 : groupingMode === 'type' ? Layers : FileBox} title={groupingMode === 'spatial' ? 'Hierarchy' : groupingMode === 'type' ? 'By Class' : 'By Type'} count={filteredNodes.length} />
913
+ <SectionHeader icon={groupingMode === 'spatial' ? Building2 : groupingMode === 'type' ? Layers : groupingMode === 'material' ? Palette : FileBox} title={groupingMode === 'spatial' ? 'Hierarchy' : groupingMode === 'type' ? 'By Class' : groupingMode === 'material' ? 'By Material' : 'By Type'} count={filteredNodes.length} />
875
914
 
876
915
  {/* Tree */}
877
916
  <div ref={parentRef} className="flex-1 overflow-auto scrollbar-thin bg-white dark:bg-black">
@@ -15,6 +15,7 @@ import {
15
15
  EyeOff,
16
16
  Equal,
17
17
  Crosshair,
18
+ GitCompareArrows,
18
19
  Home,
19
20
  Maximize2,
20
21
  Grid3x3,
@@ -562,6 +563,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
562
563
  const setIdsPanelVisible = useViewerStore((state) => state.setIdsPanelVisible);
563
564
  const clashPanelVisible = useViewerStore((state) => state.clashPanelVisible);
564
565
  const setClashPanelVisible = useViewerStore((state) => state.setClashPanelVisible);
566
+ const comparePanelVisible = useViewerStore((state) => state.comparePanelVisible);
567
+ const setComparePanelVisible = useViewerStore((state) => state.setComparePanelVisible);
565
568
  const listPanelVisible = useViewerStore((state) => state.listPanelVisible);
566
569
  const setListPanelVisible = useViewerStore((state) => state.setListPanelVisible);
567
570
  const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
@@ -761,7 +764,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
761
764
  setScriptPanelVisible,
762
765
  ]);
763
766
 
764
- const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'addElement' | 'extensions') => {
767
+ const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'addElement' | 'extensions') => {
765
768
  if (activeAnalysisExtension?.placement !== 'bottom') {
766
769
  closeActiveAnalysisExtension();
767
770
  }
@@ -779,6 +782,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
779
782
  const nextIdsVisible = panel === 'ids' ? !idsPanelVisible : false;
780
783
  const nextLensVisible = panel === 'lens' ? !lensPanelVisible : false;
781
784
  const nextClashVisible = panel === 'clash' ? !clashPanelVisible : false;
785
+ const nextCompareVisible = panel === 'compare' ? !comparePanelVisible : false;
782
786
  const nextExtensionsVisible = panel === 'extensions' ? !extensionsPanelVisible : false;
783
787
  const isAddElementActive = activeTool === 'addElement';
784
788
  const nextAddElementActive = panel === 'addElement' ? !isAddElementActive : false;
@@ -787,6 +791,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
787
791
  setIdsPanelVisible(nextIdsVisible);
788
792
  setLensPanelVisible(nextLensVisible);
789
793
  setClashPanelVisible(nextClashVisible);
794
+ setComparePanelVisible(nextCompareVisible);
790
795
  setExtensionsPanelVisible(nextExtensionsVisible);
791
796
 
792
797
  if (panel === 'addElement') {
@@ -795,7 +800,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
795
800
  setActiveTool('select');
796
801
  }
797
802
 
798
- if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextClashVisible || nextExtensionsVisible || nextAddElementActive) {
803
+ if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextClashVisible || nextCompareVisible || nextExtensionsVisible || nextAddElementActive) {
799
804
  setRightPanelCollapsed(false);
800
805
  }
801
806
  }, [
@@ -803,6 +808,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
803
808
  activeTool,
804
809
  bcfPanelVisible,
805
810
  clashPanelVisible,
811
+ comparePanelVisible,
806
812
  extensionsPanelVisible,
807
813
  idsPanelVisible,
808
814
  lensPanelVisible,
@@ -810,6 +816,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
810
816
  setActiveTool,
811
817
  setBcfPanelVisible,
812
818
  setClashPanelVisible,
819
+ setComparePanelVisible,
813
820
  setExtensionsPanelVisible,
814
821
  setIdsPanelVisible,
815
822
  setLensPanelVisible,
@@ -877,6 +884,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
877
884
  if (idsPanelVisible) panels.add('ids');
878
885
  if (lensPanelVisible) panels.add('lens');
879
886
  if (clashPanelVisible) panels.add('clash');
887
+ if (comparePanelVisible) panels.add('compare');
880
888
  if (extensionsPanelVisible) panels.add('extensions');
881
889
  if (activeTool === 'addElement') panels.add('addElement');
882
890
  if (analysisExtensionState.activeId) panels.add(analysisExtensionState.activeId);
@@ -886,6 +894,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
886
894
  analysisExtensionState.activeId,
887
895
  bcfPanelVisible,
888
896
  clashPanelVisible,
897
+ comparePanelVisible,
889
898
  extensionsPanelVisible,
890
899
  ganttPanelVisible,
891
900
  idsPanelVisible,
@@ -904,6 +913,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
904
913
  if (activeWorkspacePanels.has('ids')) return 'IDS Validation';
905
914
  if (activeWorkspacePanels.has('lens')) return 'Lens Rules';
906
915
  if (activeWorkspacePanels.has('clash')) return 'Clash Detection';
916
+ if (activeWorkspacePanels.has('compare')) return 'Compare Models';
907
917
  if (activeWorkspacePanels.has('extensions')) return 'Extensions';
908
918
  if (activeWorkspacePanels.has('addElement')) return 'Add Element';
909
919
  return activeAnalysisExtension?.label ?? 'Analysis';
@@ -1265,6 +1275,13 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1265
1275
  <Crosshair className="h-4 w-4 mr-2" />
1266
1276
  Clash Detection
1267
1277
  </DropdownMenuCheckboxItem>
1278
+ <DropdownMenuCheckboxItem
1279
+ checked={activeWorkspacePanels.has('compare')}
1280
+ onCheckedChange={() => handleToggleRightPanel('compare')}
1281
+ >
1282
+ <GitCompareArrows className="h-4 w-4 mr-2" />
1283
+ Compare Models
1284
+ </DropdownMenuCheckboxItem>
1268
1285
  <DropdownMenuSeparator />
1269
1286
  <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1270
1287
  Author