@ifc-lite/viewer 1.7.0 → 1.9.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 (95) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
  3. package/dist/assets/browser-BXNIkE8a.js +694 -0
  4. package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
  5. package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
  6. package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
  7. package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
  8. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
  9. package/dist/assets/esbuild-COv63sf-.js +1 -0
  10. package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
  11. package/dist/assets/ffi-DlhRHxHv.js +1 -0
  12. package/dist/assets/index-6Mr3byM-.js +216 -0
  13. package/dist/assets/index-CGbokkQ9.css +1 -0
  14. package/dist/assets/index-huvR-kGC.js +98305 -0
  15. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
  16. package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
  17. package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
  18. package/dist/index.html +12 -3
  19. package/index.html +10 -1
  20. package/package.json +30 -21
  21. package/src/App.tsx +6 -1
  22. package/src/components/ui/dialog.tsx +8 -6
  23. package/src/components/viewer/CodeEditor.tsx +309 -0
  24. package/src/components/viewer/CommandPalette.tsx +597 -0
  25. package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
  26. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  27. package/src/components/viewer/ExportDialog.tsx +166 -17
  28. package/src/components/viewer/HierarchyPanel.tsx +3 -1
  29. package/src/components/viewer/LensPanel.tsx +848 -85
  30. package/src/components/viewer/MainToolbar.tsx +145 -84
  31. package/src/components/viewer/ScriptPanel.tsx +416 -0
  32. package/src/components/viewer/Section2DPanel.tsx +269 -29
  33. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  34. package/src/components/viewer/ViewerLayout.tsx +63 -11
  35. package/src/components/viewer/Viewport.tsx +58 -23
  36. package/src/components/viewer/ViewportContainer.tsx +2 -0
  37. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
  38. package/src/components/viewer/hierarchy/types.ts +1 -1
  39. package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
  40. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  41. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  42. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  43. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  44. package/src/components/viewer/useGeometryStreaming.ts +25 -5
  45. package/src/hooks/ids/idsExportService.ts +1 -1
  46. package/src/hooks/useAnnotation2D.ts +551 -0
  47. package/src/hooks/useDrawingExport.ts +83 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +114 -14
  49. package/src/hooks/useLens.ts +40 -55
  50. package/src/hooks/useLensDiscovery.ts +46 -0
  51. package/src/hooks/useModelSelection.ts +5 -22
  52. package/src/hooks/useSandbox.ts +113 -0
  53. package/src/index.css +7 -1
  54. package/src/lib/lens/adapter.ts +127 -1
  55. package/src/lib/lists/columnToAutoColor.ts +33 -0
  56. package/src/lib/recent-files.ts +122 -0
  57. package/src/lib/scripts/persistence.ts +132 -0
  58. package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
  59. package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
  60. package/src/lib/scripts/templates/envelope-check.ts +164 -0
  61. package/src/lib/scripts/templates/federation-compare.ts +189 -0
  62. package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
  63. package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
  64. package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
  65. package/src/lib/scripts/templates/reset-view.ts +6 -0
  66. package/src/lib/scripts/templates/space-validation.ts +189 -0
  67. package/src/lib/scripts/templates/tsconfig.json +13 -0
  68. package/src/lib/scripts/templates.ts +86 -0
  69. package/src/sdk/BimProvider.tsx +50 -0
  70. package/src/sdk/adapters/export-adapter.ts +283 -0
  71. package/src/sdk/adapters/lens-adapter.ts +44 -0
  72. package/src/sdk/adapters/model-adapter.ts +32 -0
  73. package/src/sdk/adapters/model-compat.ts +80 -0
  74. package/src/sdk/adapters/mutate-adapter.ts +45 -0
  75. package/src/sdk/adapters/query-adapter.ts +241 -0
  76. package/src/sdk/adapters/selection-adapter.ts +29 -0
  77. package/src/sdk/adapters/spatial-adapter.ts +37 -0
  78. package/src/sdk/adapters/types.ts +11 -0
  79. package/src/sdk/adapters/viewer-adapter.ts +103 -0
  80. package/src/sdk/adapters/visibility-adapter.ts +61 -0
  81. package/src/sdk/local-backend.ts +144 -0
  82. package/src/sdk/useBimHost.ts +69 -0
  83. package/src/store/constants.ts +10 -2
  84. package/src/store/index.ts +28 -2
  85. package/src/store/resolveEntityRef.ts +44 -0
  86. package/src/store/slices/drawing2DSlice.ts +321 -0
  87. package/src/store/slices/lensSlice.ts +46 -4
  88. package/src/store/slices/pinboardSlice.ts +171 -42
  89. package/src/store/slices/scriptSlice.ts +218 -0
  90. package/src/store/slices/uiSlice.ts +2 -0
  91. package/src/store.ts +3 -0
  92. package/tsconfig.json +5 -2
  93. package/vite.config.ts +8 -0
  94. package/dist/assets/index-dgdgiQ9p.js +0 -75456
  95. package/dist/assets/index-yTqs8kgX.css +0 -1
@@ -7,18 +7,28 @@
7
7
  *
8
8
  * Shows saved lens presets and allows activating/deactivating them.
9
9
  * Users can create, edit, and delete custom lenses with full rule editing.
10
- * When a lens is active, a color legend displays the matched rules.
10
+ * Supports both manual rule-based lenses and auto-color lenses that
11
+ * automatically color entities by distinct values of any IFC data column.
12
+ * When a lens is active, a color legend displays the matched rules/values.
11
13
  * Unmatched entities are ghosted (semi-transparent) for visual context.
14
+ *
15
+ * All dropdowns are populated dynamically from the loaded model data
16
+ * via discoveredLensData (IFC types, property sets, quantity sets,
17
+ * classification systems, materials). No hardcoded IFC class lists.
12
18
  */
13
19
 
14
- import { memo, useCallback, useEffect, useRef, useState } from 'react';
15
- import { X, EyeOff, Palette, Check, Plus, Trash2, Pencil, Save, Download, Upload } from 'lucide-react';
20
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
21
+ import { X, EyeOff, Palette, Check, Plus, Trash2, Pencil, Save, Download, Upload, Sparkles, Search, ChevronDown, ArrowUpDown } from 'lucide-react';
22
+ import { discoverDataSources } from '@ifc-lite/lens';
16
23
  import { Button } from '@/components/ui/button';
17
24
  import { cn } from '@/lib/utils';
18
25
  import { useViewerStore } from '@/store';
19
26
  import { useLens } from '@/hooks/useLens';
20
- import type { Lens, LensRule } from '@/store/slices/lensSlice';
21
- import { COMMON_IFC_CLASSES, LENS_PALETTE } from '@/store/slices/lensSlice';
27
+ import { createLensDataProvider } from '@/lib/lens';
28
+ import type { Lens, LensRule, LensCriteria, AutoColorSpec, AutoColorLegendEntry, DiscoveredLensData } from '@/store/slices/lensSlice';
29
+ import {
30
+ LENS_PALETTE, ENTITY_ATTRIBUTE_NAMES, AUTO_COLOR_SOURCES,
31
+ } from '@/store/slices/lensSlice';
22
32
 
23
33
  /** Format large counts compactly: 1234 → "1.2k" */
24
34
  function formatCount(n: number): string {
@@ -26,10 +36,123 @@ function formatCount(n: number): string {
26
36
  return String(n);
27
37
  }
28
38
 
39
+ /** Human-readable label for source / criteria types (shared) */
40
+ const TYPE_LABELS: Record<string, string> = {
41
+ ifcType: 'IFC Class',
42
+ attribute: 'Attribute',
43
+ property: 'Property',
44
+ quantity: 'Quantity',
45
+ classification: 'Classification',
46
+ material: 'Material',
47
+ };
48
+
29
49
  interface LensPanelProps {
30
50
  onClose?: () => void;
31
51
  }
32
52
 
53
+ // ─── Searchable dropdown (for large dynamic lists) ──────────────────────────
54
+
55
+ function SearchableSelect({
56
+ value,
57
+ options,
58
+ onChange,
59
+ placeholder,
60
+ className,
61
+ displayFn,
62
+ }: {
63
+ value: string;
64
+ options: readonly string[];
65
+ onChange: (value: string) => void;
66
+ placeholder?: string;
67
+ className?: string;
68
+ displayFn?: (v: string) => string;
69
+ }) {
70
+ const [open, setOpen] = useState(false);
71
+ const [filter, setFilter] = useState('');
72
+ const containerRef = useRef<HTMLDivElement>(null);
73
+ const inputRef = useRef<HTMLInputElement>(null);
74
+
75
+ const filtered = useMemo(() => {
76
+ if (!filter) return options;
77
+ const q = filter.toLowerCase();
78
+ return options.filter(o => o.toLowerCase().includes(q));
79
+ }, [options, filter]);
80
+
81
+ // Close on outside click
82
+ useEffect(() => {
83
+ if (!open) return;
84
+ const handler = (e: MouseEvent) => {
85
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
86
+ setOpen(false);
87
+ setFilter('');
88
+ }
89
+ };
90
+ document.addEventListener('mousedown', handler);
91
+ return () => document.removeEventListener('mousedown', handler);
92
+ }, [open]);
93
+
94
+ const display = displayFn ?? ((v: string) => v);
95
+
96
+ return (
97
+ <div ref={containerRef} className={cn('relative', className)}>
98
+ <button
99
+ type="button"
100
+ onClick={() => {
101
+ setOpen(!open);
102
+ if (!open) setTimeout(() => inputRef.current?.focus(), 0);
103
+ }}
104
+ className={cn(
105
+ 'w-full flex items-center justify-between gap-1 text-left',
106
+ 'text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm',
107
+ !value && 'text-zinc-400 dark:text-zinc-500',
108
+ )}
109
+ >
110
+ <span className="truncate">{value ? display(value) : (placeholder ?? 'Select...')}</span>
111
+ <ChevronDown className="h-3 w-3 flex-shrink-0 opacity-50" />
112
+ </button>
113
+ {open && (
114
+ <div className="absolute z-50 top-full left-0 right-0 mt-0.5 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 rounded-sm shadow-lg max-h-[200px] flex flex-col">
115
+ {options.length > 8 && (
116
+ <div className="flex items-center gap-1 px-1.5 py-1 border-b border-zinc-200 dark:border-zinc-700">
117
+ <Search className="h-3 w-3 text-zinc-400 flex-shrink-0" />
118
+ <input
119
+ ref={inputRef}
120
+ type="text"
121
+ value={filter}
122
+ onChange={(e) => setFilter(e.target.value)}
123
+ placeholder="Search..."
124
+ className="flex-1 text-xs bg-transparent border-0 outline-none text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400"
125
+ />
126
+ </div>
127
+ )}
128
+ <div className="overflow-y-auto flex-1">
129
+ {filtered.length === 0 && (
130
+ <div className="px-2 py-1.5 text-xs text-zinc-400">No matches</div>
131
+ )}
132
+ {filtered.map(opt => (
133
+ <button
134
+ key={opt}
135
+ type="button"
136
+ className={cn(
137
+ 'w-full text-left px-2 py-1 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-700 truncate',
138
+ opt === value && 'bg-primary/10 text-primary font-medium',
139
+ )}
140
+ onClick={() => {
141
+ onChange(opt);
142
+ setOpen(false);
143
+ setFilter('');
144
+ }}
145
+ >
146
+ {display(opt)}
147
+ </button>
148
+ ))}
149
+ </div>
150
+ </div>
151
+ )}
152
+ </div>
153
+ );
154
+ }
155
+
33
156
  // ─── Rule display (read-only, clickable for isolation) ──────────────────────
34
157
 
35
158
  const RuleRow = memo(function RuleRow({
@@ -52,20 +175,16 @@ const RuleRow = memo(function RuleRow({
52
175
  'group/row relative flex items-center gap-2 pl-3 pr-3 py-1.5 text-xs',
53
176
  'border-l-2 transition-[border-color,background-color] duration-100',
54
177
  !rule.enabled && 'opacity-40',
55
- // Default: transparent left border
56
178
  !isIsolated && !isEmpty && 'border-l-transparent',
57
- // Hover: accent left border + subtle bg (only for clickable rows)
58
179
  isClickable && 'cursor-pointer hover:border-l-primary/70 hover:bg-zinc-100/80 dark:hover:bg-zinc-700/40',
59
- // Isolated: solid accent border + bg
60
180
  isIsolated && 'border-l-primary bg-primary/8 dark:bg-primary/15',
61
- // Empty: muted, non-interactive
62
181
  isEmpty && 'border-l-transparent opacity-50 cursor-default',
63
182
  )}
64
183
  role={isClickable ? 'button' : undefined}
65
184
  tabIndex={isClickable ? 0 : undefined}
66
185
  onClick={(e) => { if (isClickable) { e.stopPropagation(); onClick(); } }}
67
186
  onKeyDown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick(); } }}
68
- title={isClickable ? 'Click to isolate / show only this class' : isEmpty ? 'No matching entities' : undefined}
187
+ title={isClickable ? 'Click to isolate / show only this group' : isEmpty ? 'No matching entities' : undefined}
69
188
  >
70
189
  <div
71
190
  className={cn(
@@ -101,57 +220,376 @@ const RuleRow = memo(function RuleRow({
101
220
  );
102
221
  });
103
222
 
104
- // ─── Rule editor (inline editing) ───────────────────────────────────────────
223
+ // ─── Auto-color legend row (read-only, clickable for isolation) ─────────────
224
+
225
+ const AutoColorRow = memo(function AutoColorRow({
226
+ entry,
227
+ isIsolated,
228
+ onClick,
229
+ }: {
230
+ entry: AutoColorLegendEntry;
231
+ isIsolated?: boolean;
232
+ onClick?: () => void;
233
+ }) {
234
+ const isEmpty = entry.count === 0;
235
+ const isClickable = !!onClick && !isEmpty;
236
+
237
+ return (
238
+ <div
239
+ className={cn(
240
+ 'group/row relative flex items-center gap-2 pl-3 pr-3 py-1.5 text-xs',
241
+ 'border-l-2 transition-[border-color,background-color] duration-100',
242
+ !isIsolated && !isEmpty && 'border-l-transparent',
243
+ isClickable && 'cursor-pointer hover:border-l-primary/70 hover:bg-zinc-100/80 dark:hover:bg-zinc-700/40',
244
+ isIsolated && 'border-l-primary bg-primary/8 dark:bg-primary/15',
245
+ isEmpty && 'border-l-transparent opacity-50 cursor-default',
246
+ )}
247
+ role={isClickable ? 'button' : undefined}
248
+ tabIndex={isClickable ? 0 : undefined}
249
+ onClick={(e) => { if (isClickable) { e.stopPropagation(); onClick(); } }}
250
+ onKeyDown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onClick(); } }}
251
+ title={isClickable ? 'Click to isolate / show only this value' : undefined}
252
+ >
253
+ <div
254
+ className="w-3 h-3 rounded-sm flex-shrink-0 ring-1 ring-black/10 dark:ring-white/20"
255
+ style={{ backgroundColor: entry.color }}
256
+ />
257
+ <span className="flex-1 truncate font-medium text-zinc-900 dark:text-zinc-50">
258
+ {entry.name}
259
+ </span>
260
+ {isIsolated && (
261
+ <span className="text-[10px] uppercase tracking-wider font-bold text-primary">
262
+ isolated
263
+ </span>
264
+ )}
265
+ <span className="text-[10px] tabular-nums font-mono min-w-[2ch] text-right text-zinc-400 dark:text-zinc-500">
266
+ {formatCount(entry.count)}
267
+ </span>
268
+ </div>
269
+ );
270
+ });
271
+
272
+ // ─── Rule editor (inline editing with criteria type selector) ────────────────
105
273
 
106
274
  function RuleEditor({
107
275
  rule,
108
276
  onChange,
109
277
  onRemove,
278
+ discovered,
279
+ onRequestDiscovery,
110
280
  }: {
111
281
  rule: LensRule;
112
282
  onChange: (patch: Partial<LensRule>) => void;
113
283
  onRemove: () => void;
284
+ discovered: DiscoveredLensData | null;
285
+ onRequestDiscovery: (categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => void;
114
286
  }) {
287
+ const criteriaType = rule.criteria.type;
288
+
289
+ // Trigger lazy discovery when user selects a criteria type that needs it
290
+ useEffect(() => {
291
+ if (!discovered) return;
292
+ if (criteriaType === 'property' && !discovered.propertySets) {
293
+ onRequestDiscovery({ properties: true });
294
+ } else if (criteriaType === 'quantity' && !discovered.quantitySets) {
295
+ onRequestDiscovery({ quantities: true });
296
+ } else if (criteriaType === 'classification' && !discovered.classificationSystems) {
297
+ onRequestDiscovery({ classifications: true });
298
+ } else if (criteriaType === 'material' && !discovered.materials) {
299
+ onRequestDiscovery({ materials: true });
300
+ }
301
+ }, [criteriaType, discovered, onRequestDiscovery]);
302
+
303
+ // Derived lists from discovered data
304
+ const ifcClasses = useMemo(() => discovered?.classes ?? [], [discovered]);
305
+ const psetNames = useMemo((): string[] => {
306
+ if (!discovered?.propertySets) return [];
307
+ return Array.from(discovered.propertySets.keys()).sort();
308
+ }, [discovered]);
309
+ const selectedPsetProps = useMemo(() => {
310
+ if (!discovered?.propertySets || !rule.criteria.propertySet) return [];
311
+ return discovered.propertySets.get(rule.criteria.propertySet) ?? [];
312
+ }, [discovered, rule.criteria.propertySet]);
313
+ const qsetNames = useMemo((): string[] => {
314
+ if (!discovered?.quantitySets) return [];
315
+ return Array.from(discovered.quantitySets.keys()).sort();
316
+ }, [discovered]);
317
+ const selectedQsetQuants = useMemo(() => {
318
+ if (!discovered?.quantitySets || !rule.criteria.quantitySet) return [];
319
+ return discovered.quantitySets.get(rule.criteria.quantitySet) ?? [];
320
+ }, [discovered, rule.criteria.quantitySet]);
321
+ const classificationSystems = useMemo(() => discovered?.classificationSystems ?? [], [discovered]);
322
+ const materialNames = useMemo(() => discovered?.materials ?? [], [discovered]);
323
+
324
+ const handleCriteriaTypeChange = (newType: LensCriteria['type']) => {
325
+ const base: LensCriteria = { type: newType };
326
+ switch (newType) {
327
+ case 'ifcType':
328
+ base.ifcType = '';
329
+ break;
330
+ case 'attribute':
331
+ base.attributeName = 'Name';
332
+ base.operator = 'contains';
333
+ base.attributeValue = '';
334
+ break;
335
+ case 'property':
336
+ base.propertySet = '';
337
+ base.propertyName = '';
338
+ base.operator = 'contains';
339
+ base.propertyValue = '';
340
+ break;
341
+ case 'quantity':
342
+ base.quantitySet = '';
343
+ base.quantityName = '';
344
+ base.operator = 'exists';
345
+ break;
346
+ case 'classification':
347
+ base.classificationSystem = '';
348
+ base.classificationCode = '';
349
+ break;
350
+ case 'material':
351
+ base.materialName = '';
352
+ break;
353
+ }
354
+ onChange({ criteria: base, name: rule.name === 'New Rule' ? TYPE_LABELS[newType] : rule.name });
355
+ };
356
+
357
+ /** Derive a human-readable name from the criteria */
358
+ const deriveRuleName = (criteria: LensCriteria): string => {
359
+ switch (criteria.type) {
360
+ case 'ifcType': return criteria.ifcType ? criteria.ifcType.replace('Ifc', '') : 'New Rule';
361
+ case 'attribute': return criteria.attributeValue || criteria.attributeName || 'Attribute';
362
+ case 'property': return criteria.propertyName || 'Property';
363
+ case 'quantity': return criteria.quantityName || 'Quantity';
364
+ case 'classification': return criteria.classificationCode || criteria.classificationSystem || 'Classification';
365
+ case 'material': return criteria.materialName || 'Material';
366
+ default: return 'Rule';
367
+ }
368
+ };
369
+
370
+ const selectClass = 'text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm';
371
+ const inputClass = 'text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm';
372
+
115
373
  return (
116
- <div className="flex items-center gap-1.5 px-2 py-1.5">
117
- <input
118
- type="color"
119
- value={rule.color}
120
- onChange={(e) => onChange({ color: e.target.value })}
121
- className="w-6 h-6 cursor-pointer border-0 p-0 bg-transparent flex-shrink-0 rounded"
122
- />
123
- <select
124
- value={rule.criteria.ifcType ?? ''}
125
- onChange={(e) => {
126
- const ifcType = e.target.value;
127
- onChange({
128
- criteria: { type: 'ifcType', ifcType },
129
- name: ifcType ? ifcType.replace('Ifc', '') : rule.name,
130
- });
131
- }}
132
- className="flex-1 min-w-0 text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm"
133
- >
134
- <option value="">Class...</option>
135
- {COMMON_IFC_CLASSES.map(t => (
136
- <option key={t} value={t}>{t.replace('Ifc', '')}</option>
137
- ))}
138
- </select>
139
- <select
140
- value={rule.action}
141
- onChange={(e) => onChange({ action: e.target.value as LensRule['action'] })}
142
- className="text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm w-[72px]"
143
- >
144
- <option value="colorize">Color</option>
145
- <option value="transparent">Transp</option>
146
- <option value="hide">Hide</option>
147
- </select>
148
- <button
149
- onClick={onRemove}
150
- className="text-zinc-400 hover:text-red-500 dark:text-zinc-500 dark:hover:text-red-400 p-0.5 flex-shrink-0"
151
- title="Remove rule"
152
- >
153
- <X className="h-3.5 w-3.5" />
154
- </button>
374
+ <div className="px-2 py-1.5 space-y-1">
375
+ <div className="flex items-center gap-1.5">
376
+ <input
377
+ type="color"
378
+ value={rule.color}
379
+ onChange={(e) => onChange({ color: e.target.value })}
380
+ className="w-6 h-6 cursor-pointer border-0 p-0 bg-transparent flex-shrink-0 rounded"
381
+ />
382
+ {/* Criteria type selector */}
383
+ <select
384
+ value={criteriaType}
385
+ onChange={(e) => handleCriteriaTypeChange(e.target.value as LensCriteria['type'])}
386
+ className={cn(selectClass, 'w-[90px]')}
387
+ >
388
+ {Object.entries(TYPE_LABELS).map(([val, label]) => (
389
+ <option key={val} value={val}>{label}</option>
390
+ ))}
391
+ </select>
392
+
393
+ {/* IFC Class: searchable dropdown from discovered classes */}
394
+ {criteriaType === 'ifcType' && (
395
+ <SearchableSelect
396
+ value={rule.criteria.ifcType ?? ''}
397
+ options={ifcClasses}
398
+ onChange={(ifcType) => {
399
+ onChange({
400
+ criteria: { ...rule.criteria, ifcType },
401
+ name: ifcType ? ifcType.replace('Ifc', '') : rule.name,
402
+ });
403
+ }}
404
+ placeholder="Class..."
405
+ className="flex-1 min-w-0"
406
+ displayFn={(v) => v.replace('Ifc', '')}
407
+ />
408
+ )}
409
+
410
+ {/* Attribute: dropdown for name, text input for value */}
411
+ {criteriaType === 'attribute' && (
412
+ <>
413
+ <select
414
+ value={rule.criteria.attributeName ?? 'Name'}
415
+ onChange={(e) => {
416
+ const updated = { ...rule.criteria, attributeName: e.target.value };
417
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
418
+ }}
419
+ className={cn(selectClass, 'w-[80px]')}
420
+ >
421
+ {ENTITY_ATTRIBUTE_NAMES.map(a => <option key={a} value={a}>{a}</option>)}
422
+ </select>
423
+ <input
424
+ type="text"
425
+ value={rule.criteria.attributeValue ?? ''}
426
+ onChange={(e) => {
427
+ const updated = { ...rule.criteria, attributeValue: e.target.value };
428
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
429
+ }}
430
+ placeholder="value..."
431
+ className={cn(inputClass, 'flex-1 min-w-0')}
432
+ />
433
+ </>
434
+ )}
435
+
436
+ {/* Property: searchable dropdowns for pset + property name */}
437
+ {criteriaType === 'property' && (
438
+ <>
439
+ <SearchableSelect
440
+ value={rule.criteria.propertySet ?? ''}
441
+ options={psetNames}
442
+ onChange={(pset) => onChange({ criteria: { ...rule.criteria, propertySet: pset, propertyName: '' } })}
443
+ placeholder="Pset..."
444
+ className="w-[100px]"
445
+ />
446
+ <SearchableSelect
447
+ value={rule.criteria.propertyName ?? ''}
448
+ options={selectedPsetProps}
449
+ onChange={(prop) => {
450
+ const updated = { ...rule.criteria, propertyName: prop };
451
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
452
+ }}
453
+ placeholder="Prop..."
454
+ className="flex-1 min-w-0"
455
+ />
456
+ </>
457
+ )}
458
+
459
+ {/* Quantity: searchable dropdowns for qset + quantity name */}
460
+ {criteriaType === 'quantity' && (
461
+ <>
462
+ <SearchableSelect
463
+ value={rule.criteria.quantitySet ?? ''}
464
+ options={qsetNames}
465
+ onChange={(qset) => onChange({ criteria: { ...rule.criteria, quantitySet: qset, quantityName: '' } })}
466
+ placeholder="Qset..."
467
+ className="w-[100px]"
468
+ />
469
+ <SearchableSelect
470
+ value={rule.criteria.quantityName ?? ''}
471
+ options={selectedQsetQuants}
472
+ onChange={(qty) => {
473
+ const updated = { ...rule.criteria, quantityName: qty };
474
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
475
+ }}
476
+ placeholder="Qty..."
477
+ className="flex-1 min-w-0"
478
+ />
479
+ </>
480
+ )}
481
+
482
+ {/* Classification: searchable dropdown for system, text input for code */}
483
+ {criteriaType === 'classification' && (
484
+ <>
485
+ <SearchableSelect
486
+ value={rule.criteria.classificationSystem ?? ''}
487
+ options={classificationSystems}
488
+ onChange={(sys) => onChange({ criteria: { ...rule.criteria, classificationSystem: sys } })}
489
+ placeholder="System..."
490
+ className="w-[100px]"
491
+ />
492
+ <input
493
+ type="text"
494
+ value={rule.criteria.classificationCode ?? ''}
495
+ onChange={(e) => {
496
+ const updated = { ...rule.criteria, classificationCode: e.target.value };
497
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
498
+ }}
499
+ placeholder="Code..."
500
+ className={cn(inputClass, 'flex-1 min-w-0')}
501
+ />
502
+ </>
503
+ )}
504
+
505
+ {/* Material: searchable dropdown from discovered materials */}
506
+ {criteriaType === 'material' && (
507
+ <SearchableSelect
508
+ value={rule.criteria.materialName ?? ''}
509
+ options={materialNames}
510
+ onChange={(mat) => {
511
+ const updated = { ...rule.criteria, materialName: mat };
512
+ onChange({ criteria: updated, name: deriveRuleName(updated) });
513
+ }}
514
+ placeholder="Material..."
515
+ className="flex-1 min-w-0"
516
+ />
517
+ )}
518
+
519
+ <button
520
+ onClick={onRemove}
521
+ className="text-zinc-400 hover:text-red-500 dark:text-zinc-500 dark:hover:text-red-400 p-0.5 flex-shrink-0"
522
+ title="Remove rule"
523
+ >
524
+ <X className="h-3.5 w-3.5" />
525
+ </button>
526
+ </div>
527
+
528
+ {/* Second row: operator + value for property/quantity/attribute */}
529
+ {(criteriaType === 'property' || criteriaType === 'quantity') && (
530
+ <div className="flex items-center gap-1.5 pl-[30px]">
531
+ <select
532
+ value={rule.criteria.operator ?? 'exists'}
533
+ onChange={(e) => onChange({ criteria: { ...rule.criteria, operator: e.target.value as LensCriteria['operator'] } })}
534
+ className={cn(selectClass, 'w-[80px]')}
535
+ >
536
+ <option value="exists">Exists</option>
537
+ <option value="equals">Equals</option>
538
+ <option value="contains">Contains</option>
539
+ </select>
540
+ {rule.criteria.operator && rule.criteria.operator !== 'exists' && (
541
+ <input
542
+ type="text"
543
+ value={
544
+ criteriaType === 'property'
545
+ ? (rule.criteria.propertyValue ?? '')
546
+ : (rule.criteria.quantityValue ?? '')
547
+ }
548
+ onChange={(e) => {
549
+ const key = criteriaType === 'property' ? 'propertyValue' : 'quantityValue';
550
+ onChange({ criteria: { ...rule.criteria, [key]: e.target.value } });
551
+ }}
552
+ placeholder="Value..."
553
+ className={cn(inputClass, 'flex-1 min-w-0')}
554
+ />
555
+ )}
556
+ <select
557
+ value={rule.action}
558
+ onChange={(e) => onChange({ action: e.target.value as LensRule['action'] })}
559
+ className={cn(selectClass, 'w-[72px]')}
560
+ >
561
+ <option value="colorize">Color</option>
562
+ <option value="transparent">Transp</option>
563
+ <option value="hide">Hide</option>
564
+ </select>
565
+ </div>
566
+ )}
567
+
568
+ {/* Action selector for simple types */}
569
+ {criteriaType !== 'property' && criteriaType !== 'quantity' && (
570
+ <div className="flex items-center gap-1.5 pl-[30px]">
571
+ {criteriaType === 'attribute' && (
572
+ <select
573
+ value={rule.criteria.operator ?? 'contains'}
574
+ onChange={(e) => onChange({ criteria: { ...rule.criteria, operator: e.target.value as LensCriteria['operator'] } })}
575
+ className={cn(selectClass, 'w-[80px]')}
576
+ >
577
+ <option value="equals">Equals</option>
578
+ <option value="contains">Contains</option>
579
+ <option value="exists">Exists</option>
580
+ </select>
581
+ )}
582
+ <select
583
+ value={rule.action}
584
+ onChange={(e) => onChange({ action: e.target.value as LensRule['action'] })}
585
+ className={cn(selectClass, 'w-[72px]')}
586
+ >
587
+ <option value="colorize">Color</option>
588
+ <option value="transparent">Transp</option>
589
+ <option value="hide">Hide</option>
590
+ </select>
591
+ </div>
592
+ )}
155
593
  </div>
156
594
  );
157
595
  }
@@ -162,10 +600,14 @@ function LensEditor({
162
600
  initial,
163
601
  onSave,
164
602
  onCancel,
603
+ discovered,
604
+ onRequestDiscovery,
165
605
  }: {
166
606
  initial: Lens;
167
607
  onSave: (lens: Lens) => void;
168
608
  onCancel: () => void;
609
+ discovered: DiscoveredLensData | null;
610
+ onRequestDiscovery: (categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => void;
169
611
  }) {
170
612
  const [name, setName] = useState(initial.name);
171
613
  const [rules, setRules] = useState<LensRule[]>(() =>
@@ -192,13 +634,27 @@ function LensEditor({
192
634
  setRules(rules.filter((_, i) => i !== index));
193
635
  };
194
636
 
637
+ /** Check if a rule has sufficient criteria to be valid */
638
+ const isRuleValid = (r: LensRule): boolean => {
639
+ const c = r.criteria;
640
+ switch (c.type) {
641
+ case 'ifcType': return !!c.ifcType;
642
+ case 'attribute': return !!c.attributeName;
643
+ case 'property': return !!c.propertySet && !!c.propertyName;
644
+ case 'quantity': return !!c.quantitySet && !!c.quantityName;
645
+ case 'classification': return !!c.classificationSystem || !!c.classificationCode;
646
+ case 'material': return !!c.materialName;
647
+ default: return false;
648
+ }
649
+ };
650
+
195
651
  const handleSave = () => {
196
- const validRules = rules.filter(r => r.criteria.ifcType);
652
+ const validRules = rules.filter(isRuleValid);
197
653
  if (!name.trim() || validRules.length === 0) return;
198
654
  onSave({ ...initial, name: name.trim(), rules: validRules });
199
655
  };
200
656
 
201
- const canSave = name.trim().length > 0 && rules.some(r => r.criteria.ifcType);
657
+ const canSave = name.trim().length > 0 && rules.some(isRuleValid);
202
658
 
203
659
  return (
204
660
  <div className="border-2 border-primary bg-white dark:bg-zinc-900 rounded-sm">
@@ -222,6 +678,8 @@ function LensEditor({
222
678
  rule={rule}
223
679
  onChange={(patch) => updateRule(i, patch)}
224
680
  onRemove={() => removeRule(i)}
681
+ discovered={discovered}
682
+ onRequestDiscovery={onRequestDiscovery}
225
683
  />
226
684
  ))}
227
685
 
@@ -259,6 +717,185 @@ function LensEditor({
259
717
  );
260
718
  }
261
719
 
720
+ // ─── Auto-color lens editor ─────────────────────────────────────────────────
721
+
722
+ function AutoColorEditor({
723
+ initial,
724
+ onSave,
725
+ onCancel,
726
+ discovered,
727
+ onRequestDiscovery,
728
+ }: {
729
+ initial: { name: string; autoColor: AutoColorSpec };
730
+ onSave: (lens: Lens) => void;
731
+ onCancel: () => void;
732
+ discovered: DiscoveredLensData | null;
733
+ onRequestDiscovery: (categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => void;
734
+ }) {
735
+ const [name, setName] = useState(initial.name);
736
+ const [source, setSource] = useState<AutoColorSpec['source']>(initial.autoColor.source);
737
+ const [psetName, setPsetName] = useState(initial.autoColor.psetName ?? '');
738
+ const [propertyName, setPropertyName] = useState(initial.autoColor.propertyName ?? '');
739
+
740
+ const needsPset = source === 'property' || source === 'quantity' || source === 'classification';
741
+ const needsPropertyName = source === 'attribute' || source === 'property' || source === 'quantity';
742
+
743
+ // Trigger lazy discovery when source changes to a category that needs it
744
+ useEffect(() => {
745
+ if (!discovered) return;
746
+ if (source === 'property' && !discovered.propertySets) {
747
+ onRequestDiscovery({ properties: true });
748
+ } else if (source === 'quantity' && !discovered.quantitySets) {
749
+ onRequestDiscovery({ quantities: true });
750
+ } else if (source === 'material' && !discovered.materials) {
751
+ onRequestDiscovery({ materials: true });
752
+ } else if (source === 'classification' && !discovered.classificationSystems) {
753
+ onRequestDiscovery({ classifications: true });
754
+ }
755
+ }, [source, discovered, onRequestDiscovery]);
756
+
757
+ // Dynamic options from discovered data
758
+ const psetOptions = useMemo(() => {
759
+ if (!discovered) return [];
760
+ if (source === 'quantity') return discovered.quantitySets ? Array.from(discovered.quantitySets.keys()).sort() : [];
761
+ if (source === 'classification') return discovered.classificationSystems ?? [];
762
+ return discovered.propertySets ? Array.from(discovered.propertySets.keys()).sort() : [];
763
+ }, [discovered, source]);
764
+
765
+ const propertyOptions = useMemo(() => {
766
+ if (!discovered) return [];
767
+ if (source === 'property') return discovered.propertySets?.get(psetName) ?? [];
768
+ if (source === 'quantity') return discovered.quantitySets?.get(psetName) ?? [];
769
+ return [];
770
+ }, [discovered, source, psetName]);
771
+
772
+ const handleSave = () => {
773
+ if (!name.trim()) return;
774
+ if (needsPset && !psetName.trim()) return;
775
+ if (needsPropertyName && !propertyName.trim()) return;
776
+
777
+ const autoColor: AutoColorSpec = { source };
778
+ if (needsPset) autoColor.psetName = psetName.trim();
779
+ if (needsPropertyName) autoColor.propertyName = propertyName.trim();
780
+
781
+ onSave({
782
+ id: `lens-auto-${Date.now()}`,
783
+ name: name.trim(),
784
+ rules: [],
785
+ autoColor,
786
+ });
787
+ };
788
+
789
+ const canSave = name.trim().length > 0
790
+ && (!needsPset || psetName.trim().length > 0)
791
+ && (!needsPropertyName || propertyName.trim().length > 0);
792
+
793
+ const selectClass = 'text-xs px-1.5 py-1 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm';
794
+
795
+ return (
796
+ <div className="border-2 border-primary bg-white dark:bg-zinc-900 rounded-sm">
797
+ <div className="px-3 pt-3 pb-2">
798
+ <input
799
+ type="text"
800
+ value={name}
801
+ onChange={(e) => setName(e.target.value)}
802
+ placeholder="Auto-color lens name..."
803
+ className="w-full px-2 py-1.5 text-xs font-bold uppercase tracking-wider bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 text-zinc-900 dark:text-zinc-100 rounded-sm placeholder:normal-case placeholder:font-normal placeholder:text-zinc-400 dark:placeholder:text-zinc-500"
804
+ autoFocus
805
+ />
806
+ </div>
807
+
808
+ <div className="border-t border-zinc-200 dark:border-zinc-700 px-3 py-2 space-y-2 bg-zinc-50/50 dark:bg-zinc-800/50">
809
+ <div className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-400">
810
+ <Sparkles className="h-3 w-3" />
811
+ <span>Auto-color by distinct values</span>
812
+ </div>
813
+
814
+ <div className="flex items-center gap-1.5">
815
+ <label className="text-[10px] uppercase tracking-wider text-zinc-500 w-[50px]">Source</label>
816
+ <select
817
+ value={source}
818
+ onChange={(e) => {
819
+ const s = e.target.value as AutoColorSpec['source'];
820
+ setSource(s);
821
+ setPsetName('');
822
+ setPropertyName('');
823
+ if (!name || name.startsWith('Color by ')) {
824
+ setName(`Color by ${TYPE_LABELS[s]}`);
825
+ }
826
+ }}
827
+ className={cn(selectClass, 'flex-1')}
828
+ >
829
+ {AUTO_COLOR_SOURCES.map(s => (
830
+ <option key={s} value={s}>{TYPE_LABELS[s]}</option>
831
+ ))}
832
+ </select>
833
+ </div>
834
+
835
+ {needsPset && (
836
+ <div className="flex items-center gap-1.5">
837
+ <label className="text-[10px] uppercase tracking-wider text-zinc-500 w-[50px]">
838
+ {source === 'property' ? 'Pset' : source === 'classification' ? 'System' : 'Qset'}
839
+ </label>
840
+ <SearchableSelect
841
+ value={psetName}
842
+ options={psetOptions}
843
+ onChange={(v) => { setPsetName(v); setPropertyName(''); }}
844
+ placeholder={source === 'property' ? 'Select property set...' : source === 'classification' ? 'Select system...' : 'Select quantity set...'}
845
+ className="flex-1"
846
+ />
847
+ </div>
848
+ )}
849
+
850
+ {needsPropertyName && (
851
+ <div className="flex items-center gap-1.5">
852
+ <label className="text-[10px] uppercase tracking-wider text-zinc-500 w-[50px]">Name</label>
853
+ {source === 'attribute' ? (
854
+ <select
855
+ value={propertyName}
856
+ onChange={(e) => setPropertyName(e.target.value)}
857
+ className={cn(selectClass, 'flex-1')}
858
+ >
859
+ <option value="">Select...</option>
860
+ {ENTITY_ATTRIBUTE_NAMES.map(a => <option key={a} value={a}>{a}</option>)}
861
+ </select>
862
+ ) : (
863
+ <SearchableSelect
864
+ value={propertyName}
865
+ options={propertyOptions}
866
+ onChange={setPropertyName}
867
+ placeholder={source === 'property' ? 'Select property...' : 'Select quantity...'}
868
+ className="flex-1"
869
+ />
870
+ )}
871
+ </div>
872
+ )}
873
+ </div>
874
+
875
+ <div className="flex gap-1.5 p-2 border-t border-zinc-200 dark:border-zinc-700">
876
+ <Button
877
+ variant="default"
878
+ size="sm"
879
+ className="flex-1 h-7 text-[10px] uppercase tracking-wider rounded-sm"
880
+ onClick={handleSave}
881
+ disabled={!canSave}
882
+ >
883
+ <Save className="h-3 w-3 mr-1" />
884
+ Save
885
+ </Button>
886
+ <Button
887
+ variant="ghost"
888
+ size="sm"
889
+ className="h-7 text-[10px] uppercase tracking-wider rounded-sm"
890
+ onClick={onCancel}
891
+ >
892
+ Cancel
893
+ </Button>
894
+ </div>
895
+ </div>
896
+ );
897
+ }
898
+
262
899
  // ─── Lens card (read-only display) ──────────────────────────────────────────
263
900
 
264
901
  function LensCard({
@@ -270,6 +907,7 @@ function LensCard({
270
907
  isolatedRuleId,
271
908
  onIsolateRule,
272
909
  ruleCounts,
910
+ autoColorLegend,
273
911
  }: {
274
912
  lens: Lens;
275
913
  isActive: boolean;
@@ -279,7 +917,28 @@ function LensCard({
279
917
  isolatedRuleId?: string | null;
280
918
  onIsolateRule?: (ruleId: string) => void;
281
919
  ruleCounts?: Map<string, number>;
920
+ autoColorLegend?: AutoColorLegendEntry[];
282
921
  }) {
922
+ const isAutoColor = !!lens.autoColor;
923
+ const enabledRuleCount = lens.rules.filter(r => r.enabled).length;
924
+ const [legendSort, setLegendSort] = useState<'count' | 'name-asc' | 'name-desc'>('count');
925
+
926
+ const legendToShow = useMemo(() => {
927
+ if (!isAutoColor || !autoColorLegend) return undefined;
928
+ if (legendSort === 'count') return autoColorLegend; // already sorted by count desc from engine
929
+ const sorted = [...autoColorLegend];
930
+ if (legendSort === 'name-asc') sorted.sort((a, b) => a.name.localeCompare(b.name));
931
+ else sorted.sort((a, b) => b.name.localeCompare(a.name));
932
+ return sorted;
933
+ }, [isAutoColor, autoColorLegend, legendSort]);
934
+
935
+ const cycleLegendSort = useCallback((e: React.MouseEvent) => {
936
+ e.stopPropagation();
937
+ setLegendSort(prev => prev === 'count' ? 'name-asc' : prev === 'name-asc' ? 'name-desc' : 'count');
938
+ }, []);
939
+
940
+ const sortLabel = legendSort === 'count' ? 'Count' : legendSort === 'name-asc' ? 'A→Z' : 'Z→A';
941
+
283
942
  return (
284
943
  <div
285
944
  className={cn(
@@ -295,6 +954,8 @@ function LensCard({
295
954
  <div className="flex items-center gap-2 min-w-0">
296
955
  {isActive ? (
297
956
  <Check className="h-3.5 w-3.5 text-primary flex-shrink-0" />
957
+ ) : isAutoColor ? (
958
+ <Sparkles className="h-3.5 w-3.5 text-zinc-400 dark:text-zinc-500 flex-shrink-0" />
298
959
  ) : (
299
960
  <Palette className="h-3.5 w-3.5 text-zinc-400 dark:text-zinc-500 flex-shrink-0" />
300
961
  )}
@@ -303,8 +964,7 @@ function LensCard({
303
964
  </span>
304
965
  </div>
305
966
  <div className="flex items-center gap-1">
306
- {/* Edit for all lenses, delete for custom only */}
307
- {onEdit && (
967
+ {onEdit && !isAutoColor && (
308
968
  <button
309
969
  onClick={(e) => { e.stopPropagation(); onEdit(lens); }}
310
970
  className="opacity-0 group-hover:opacity-100 text-zinc-400 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200 p-0.5"
@@ -323,13 +983,44 @@ function LensCard({
323
983
  </button>
324
984
  )}
325
985
  <span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-mono ml-1">
326
- {lens.rules.filter(r => r.enabled).length} rules
986
+ {isAutoColor
987
+ ? TYPE_LABELS[lens.autoColor!.source]
988
+ : `${enabledRuleCount} rules`}
327
989
  </span>
328
990
  </div>
329
991
  </div>
330
992
 
331
- {/* Color legend (shown when active) click rules to isolate */}
332
- {isActive && (
993
+ {/* Auto-color legend (shown when active + auto-color lens) */}
994
+ {isActive && legendToShow && legendToShow.length > 0 && (
995
+ <div className="border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/60">
996
+ <div className="flex items-center justify-between px-3 py-1 border-b border-zinc-200/60 dark:border-zinc-700/60">
997
+ <span className="text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-500 font-medium">
998
+ {legendToShow.length} values
999
+ </span>
1000
+ <button
1001
+ onClick={cycleLegendSort}
1002
+ className="flex items-center gap-0.5 text-[10px] uppercase tracking-wider text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300"
1003
+ title="Sort legend entries"
1004
+ >
1005
+ <ArrowUpDown className="h-2.5 w-2.5" />
1006
+ {sortLabel}
1007
+ </button>
1008
+ </div>
1009
+ <div className="max-h-[220px] overflow-y-auto py-0.5">
1010
+ {legendToShow.map(entry => (
1011
+ <AutoColorRow
1012
+ key={entry.id}
1013
+ entry={entry}
1014
+ isIsolated={isolatedRuleId === entry.id}
1015
+ onClick={onIsolateRule ? () => onIsolateRule(entry.id) : undefined}
1016
+ />
1017
+ ))}
1018
+ </div>
1019
+ </div>
1020
+ )}
1021
+
1022
+ {/* Rule-based color legend (shown when active + rule lens) */}
1023
+ {isActive && !isAutoColor && (
333
1024
  <div className="border-t border-zinc-200 dark:border-zinc-700 py-0.5 bg-zinc-50 dark:bg-zinc-800/60">
334
1025
  {lens.rules.map(rule => {
335
1026
  const count = ruleCounts?.get(rule.id) ?? 0;
@@ -367,9 +1058,60 @@ export function LensPanel({ onClose }: LensPanelProps) {
367
1058
  const lensColorMapSize = useViewerStore((s) => s.lensColorMap.size);
368
1059
  const lensHiddenIdsSize = useViewerStore((s) => s.lensHiddenIds.size);
369
1060
  const lensRuleCounts = useViewerStore((s) => s.lensRuleCounts);
1061
+ const lensAutoColorLegend = useViewerStore((s) => s.lensAutoColorLegend);
1062
+ // Discovered data from loaded models (classes = instant, rest = lazy)
1063
+ const discoveredLensData = useViewerStore((s) => s.discoveredLensData);
1064
+ const mergeDiscoveredData = useViewerStore((s) => s.mergeDiscoveredData);
1065
+
1066
+ // Track which categories are currently being discovered (prevent double-fire)
1067
+ const discoveringRef = useRef(new Set<string>());
1068
+
1069
+ // Reset discovery flags when discoveredLensData changes (e.g. new model loaded)
1070
+ useEffect(() => {
1071
+ if (!discoveredLensData) {
1072
+ discoveringRef.current.clear();
1073
+ }
1074
+ }, [discoveredLensData]);
1075
+
1076
+ /** Trigger lazy discovery for expensive data categories (psets, quantities, etc.) */
1077
+ const handleRequestDiscovery = useCallback((categories: { properties?: boolean; quantities?: boolean; classifications?: boolean; materials?: boolean }) => {
1078
+ // Skip categories already discovered or in-flight
1079
+ const toDiscover: typeof categories = {};
1080
+ const current = useViewerStore.getState().discoveredLensData;
1081
+ if (!current) return;
1082
+
1083
+ if (categories.properties && !current.propertySets && !discoveringRef.current.has('properties')) {
1084
+ toDiscover.properties = true;
1085
+ discoveringRef.current.add('properties');
1086
+ }
1087
+ if (categories.quantities && !current.quantitySets && !discoveringRef.current.has('quantities')) {
1088
+ toDiscover.quantities = true;
1089
+ discoveringRef.current.add('quantities');
1090
+ }
1091
+ if (categories.classifications && !current.classificationSystems && !discoveringRef.current.has('classifications')) {
1092
+ toDiscover.classifications = true;
1093
+ discoveringRef.current.add('classifications');
1094
+ }
1095
+ if (categories.materials && !current.materials && !discoveringRef.current.has('materials')) {
1096
+ toDiscover.materials = true;
1097
+ discoveringRef.current.add('materials');
1098
+ }
1099
+
1100
+ if (Object.keys(toDiscover).length === 0) return;
1101
+
1102
+ // Run discovery async to not block the UI
1103
+ setTimeout(() => {
1104
+ const { models, ifcDataStore } = useViewerStore.getState();
1105
+ if (models.size === 0 && !ifcDataStore) return;
1106
+ const provider = createLensDataProvider(models, ifcDataStore);
1107
+ const result = discoverDataSources(provider, toDiscover);
1108
+ mergeDiscoveredData(result);
1109
+ }, 0);
1110
+ }, [mergeDiscoveredData]);
370
1111
 
371
1112
  // Editor state: null = not editing, Lens object = editing/creating
372
1113
  const [editingLens, setEditingLens] = useState<Lens | null>(null);
1114
+ const [creatingAutoColor, setCreatingAutoColor] = useState(false);
373
1115
  const [isolatedRuleId, setIsolatedRuleId] = useState<string | null>(null);
374
1116
  const fileInputRef = useRef<HTMLInputElement>(null);
375
1117
 
@@ -383,11 +1125,8 @@ export function LensPanel({ onClose }: LensPanelProps) {
383
1125
  }
384
1126
  }, [activeLensId, setActiveLens, showAll]);
385
1127
 
386
- /** Click a rule row in the active lens to isolate matching entities */
1128
+ /** Click a rule/value row in the active lens to isolate matching entities */
387
1129
  const handleIsolateRule = useCallback((ruleId: string) => {
388
- const lens = savedLenses.find(l => l.id === activeLensId);
389
- if (!lens) return;
390
-
391
1130
  // Toggle off if clicking the already-isolated rule
392
1131
  if (isolatedRuleId === ruleId) {
393
1132
  setIsolatedRuleId(null);
@@ -395,20 +1134,16 @@ export function LensPanel({ onClose }: LensPanelProps) {
395
1134
  return;
396
1135
  }
397
1136
 
398
- const rule = lens.rules.find(r => r.id === ruleId);
399
- if (!rule || !rule.enabled) return;
400
-
401
- // Look up entities matched by this specific rule (not by color)
1137
+ // Look up entities matched by this specific rule/value
402
1138
  const matchingIds = useViewerStore.getState().lensRuleEntityIds.get(ruleId);
403
1139
  if (!matchingIds || matchingIds.length === 0) return;
404
1140
 
405
- if (matchingIds.length > 0) {
406
- setIsolatedRuleId(ruleId);
407
- isolateEntities(matchingIds);
408
- }
409
- }, [activeLensId, savedLenses, isolatedRuleId, isolateEntities, clearIsolation]);
1141
+ setIsolatedRuleId(ruleId);
1142
+ isolateEntities(matchingIds);
1143
+ }, [isolatedRuleId, isolateEntities, clearIsolation]);
410
1144
 
411
1145
  const handleNewLens = useCallback(() => {
1146
+ setCreatingAutoColor(false);
412
1147
  setEditingLens({
413
1148
  id: `lens-${Date.now()}`,
414
1149
  name: '',
@@ -416,19 +1151,24 @@ export function LensPanel({ onClose }: LensPanelProps) {
416
1151
  });
417
1152
  }, []);
418
1153
 
1154
+ const handleNewAutoColorLens = useCallback(() => {
1155
+ setEditingLens(null);
1156
+ setCreatingAutoColor(true);
1157
+ }, []);
1158
+
419
1159
  const handleEditLens = useCallback((lens: Lens) => {
420
1160
  setEditingLens({ ...lens, rules: lens.rules.map(r => ({ ...r })) });
421
1161
  }, []);
422
1162
 
423
1163
  const handleSaveLens = useCallback((lens: Lens) => {
424
- // Check if this is an existing lens or new
425
1164
  const exists = savedLenses.some(l => l.id === lens.id);
426
1165
  if (exists) {
427
- updateLens(lens.id, { name: lens.name, rules: lens.rules });
1166
+ updateLens(lens.id, { name: lens.name, rules: lens.rules, autoColor: lens.autoColor });
428
1167
  } else {
429
1168
  createLens(lens);
430
1169
  }
431
1170
  setEditingLens(null);
1171
+ setCreatingAutoColor(false);
432
1172
  }, [savedLenses, createLens, updateLens]);
433
1173
 
434
1174
  const handleDeleteLens = useCallback((id: string) => {
@@ -439,7 +1179,7 @@ export function LensPanel({ onClose }: LensPanelProps) {
439
1179
  deleteLens(id);
440
1180
  }, [activeLensId, setActiveLens, showAll, deleteLens]);
441
1181
 
442
- // Apply hidden entities when lens hidden IDs change (proper effect, not render side-effect)
1182
+ // Apply hidden entities when lens hidden IDs change
443
1183
  useEffect(() => {
444
1184
  if (lensHiddenIdsSize > 0 && activeLensId) {
445
1185
  const ids = useViewerStore.getState().lensHiddenIds;
@@ -466,7 +1206,6 @@ export function LensPanel({ onClose }: LensPanelProps) {
466
1206
  try {
467
1207
  const parsed = JSON.parse(reader.result as string);
468
1208
  const arr: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
469
- // Validate each entry has required Lens structure
470
1209
  const valid = arr.filter((item): item is Lens => {
471
1210
  if (item === null || typeof item !== 'object') return false;
472
1211
  const obj = item as Record<string, unknown>;
@@ -482,7 +1221,6 @@ export function LensPanel({ onClose }: LensPanelProps) {
482
1221
  }
483
1222
  };
484
1223
  reader.readAsText(file);
485
- // reset so same file can be re-imported
486
1224
  e.target.value = '';
487
1225
  }, [importLenses]);
488
1226
 
@@ -555,6 +1293,8 @@ export function LensPanel({ onClose }: LensPanelProps) {
555
1293
  initial={editingLens}
556
1294
  onSave={handleSaveLens}
557
1295
  onCancel={() => setEditingLens(null)}
1296
+ discovered={discoveredLensData}
1297
+ onRequestDiscovery={handleRequestDiscovery}
558
1298
  />
559
1299
  ) : (
560
1300
  <LensCard
@@ -567,28 +1307,51 @@ export function LensPanel({ onClose }: LensPanelProps) {
567
1307
  isolatedRuleId={activeLensId === lens.id ? isolatedRuleId : null}
568
1308
  onIsolateRule={activeLensId === lens.id ? handleIsolateRule : undefined}
569
1309
  ruleCounts={activeLensId === lens.id ? lensRuleCounts : undefined}
1310
+ autoColorLegend={activeLensId === lens.id ? lensAutoColorLegend : undefined}
570
1311
  />
571
1312
  )
572
1313
  ))}
573
1314
 
574
- {/* New lens editor (when creating) */}
1315
+ {/* New lens editor (when creating rule-based lens) */}
575
1316
  {editingLens && !savedLenses.some(l => l.id === editingLens.id) && (
576
1317
  <LensEditor
577
1318
  initial={editingLens}
578
1319
  onSave={handleSaveLens}
579
1320
  onCancel={() => setEditingLens(null)}
1321
+ discovered={discoveredLensData}
1322
+ onRequestDiscovery={handleRequestDiscovery}
580
1323
  />
581
1324
  )}
582
1325
 
583
- {/* New lens button */}
584
- {!editingLens && (
585
- <button
586
- onClick={handleNewLens}
587
- className="w-full border-2 border-dashed border-zinc-300 dark:border-zinc-600 hover:border-primary dark:hover:border-primary py-2.5 flex items-center justify-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-primary transition-colors rounded-sm"
588
- >
589
- <Plus className="h-3.5 w-3.5" />
590
- New Lens
591
- </button>
1326
+ {/* Auto-color editor (when creating auto-color lens) */}
1327
+ {creatingAutoColor && (
1328
+ <AutoColorEditor
1329
+ initial={{ name: 'Color by IFC Class', autoColor: { source: 'ifcType' } }}
1330
+ onSave={handleSaveLens}
1331
+ onCancel={() => setCreatingAutoColor(false)}
1332
+ discovered={discoveredLensData}
1333
+ onRequestDiscovery={handleRequestDiscovery}
1334
+ />
1335
+ )}
1336
+
1337
+ {/* New lens buttons */}
1338
+ {!editingLens && !creatingAutoColor && (
1339
+ <div className="space-y-1.5">
1340
+ <button
1341
+ onClick={handleNewLens}
1342
+ className="w-full border-2 border-dashed border-zinc-300 dark:border-zinc-600 hover:border-primary dark:hover:border-primary py-2.5 flex items-center justify-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-primary transition-colors rounded-sm"
1343
+ >
1344
+ <Plus className="h-3.5 w-3.5" />
1345
+ New Rule Lens
1346
+ </button>
1347
+ <button
1348
+ onClick={handleNewAutoColorLens}
1349
+ className="w-full border-2 border-dashed border-zinc-300 dark:border-zinc-600 hover:border-primary dark:hover:border-primary py-2.5 flex items-center justify-center gap-1.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-primary transition-colors rounded-sm"
1350
+ >
1351
+ <Sparkles className="h-3.5 w-3.5" />
1352
+ New Auto-Color Lens
1353
+ </button>
1354
+ </div>
592
1355
  )}
593
1356
  </div>
594
1357