@ifc-lite/viewer 1.6.0 → 1.7.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 +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,603 @@
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
+ * Lens panel — rule-based 3D filtering and coloring
7
+ *
8
+ * Shows saved lens presets and allows activating/deactivating them.
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.
11
+ * Unmatched entities are ghosted (semi-transparent) for visual context.
12
+ */
13
+
14
+ import { memo, useCallback, useEffect, useRef, useState } from 'react';
15
+ import { X, EyeOff, Palette, Check, Plus, Trash2, Pencil, Save, Download, Upload } from 'lucide-react';
16
+ import { Button } from '@/components/ui/button';
17
+ import { cn } from '@/lib/utils';
18
+ import { useViewerStore } from '@/store';
19
+ 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';
22
+
23
+ /** Format large counts compactly: 1234 → "1.2k" */
24
+ function formatCount(n: number): string {
25
+ if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
26
+ return String(n);
27
+ }
28
+
29
+ interface LensPanelProps {
30
+ onClose?: () => void;
31
+ }
32
+
33
+ // ─── Rule display (read-only, clickable for isolation) ──────────────────────
34
+
35
+ const RuleRow = memo(function RuleRow({
36
+ rule,
37
+ count,
38
+ isIsolated,
39
+ onClick,
40
+ }: {
41
+ rule: LensRule;
42
+ count: number;
43
+ isIsolated?: boolean;
44
+ onClick?: () => void;
45
+ }) {
46
+ const isEmpty = count === 0;
47
+ const isClickable = !!onClick && !isEmpty;
48
+
49
+ return (
50
+ <div
51
+ className={cn(
52
+ 'group/row relative flex items-center gap-2 pl-3 pr-3 py-1.5 text-xs',
53
+ 'border-l-2 transition-[border-color,background-color] duration-100',
54
+ !rule.enabled && 'opacity-40',
55
+ // Default: transparent left border
56
+ !isIsolated && !isEmpty && 'border-l-transparent',
57
+ // Hover: accent left border + subtle bg (only for clickable rows)
58
+ 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
+ isIsolated && 'border-l-primary bg-primary/8 dark:bg-primary/15',
61
+ // Empty: muted, non-interactive
62
+ isEmpty && 'border-l-transparent opacity-50 cursor-default',
63
+ )}
64
+ role={isClickable ? 'button' : undefined}
65
+ tabIndex={isClickable ? 0 : undefined}
66
+ onClick={(e) => { if (isClickable) { e.stopPropagation(); onClick(); } }}
67
+ 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}
69
+ >
70
+ <div
71
+ className={cn(
72
+ 'w-3 h-3 rounded-sm flex-shrink-0 ring-1 ring-black/10 dark:ring-white/20',
73
+ isEmpty && 'grayscale',
74
+ )}
75
+ style={{ backgroundColor: rule.color }}
76
+ />
77
+ <span className={cn(
78
+ 'flex-1 truncate font-medium',
79
+ isIsolated
80
+ ? 'text-zinc-900 dark:text-zinc-50'
81
+ : isEmpty
82
+ ? 'text-zinc-400 dark:text-zinc-600'
83
+ : 'text-zinc-900 dark:text-zinc-50',
84
+ )}>
85
+ {rule.name}
86
+ </span>
87
+ {isIsolated && (
88
+ <span className="text-[10px] uppercase tracking-wider font-bold text-primary">
89
+ isolated
90
+ </span>
91
+ )}
92
+ <span className={cn(
93
+ 'text-[10px] tabular-nums font-mono min-w-[2ch] text-right',
94
+ isEmpty
95
+ ? 'text-zinc-300 dark:text-zinc-700'
96
+ : 'text-zinc-400 dark:text-zinc-500',
97
+ )}>
98
+ {isEmpty ? '—' : formatCount(count)}
99
+ </span>
100
+ </div>
101
+ );
102
+ });
103
+
104
+ // ─── Rule editor (inline editing) ───────────────────────────────────────────
105
+
106
+ function RuleEditor({
107
+ rule,
108
+ onChange,
109
+ onRemove,
110
+ }: {
111
+ rule: LensRule;
112
+ onChange: (patch: Partial<LensRule>) => void;
113
+ onRemove: () => void;
114
+ }) {
115
+ 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>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // ─── Lens editor (create/edit mode) ─────────────────────────────────────────
160
+
161
+ function LensEditor({
162
+ initial,
163
+ onSave,
164
+ onCancel,
165
+ }: {
166
+ initial: Lens;
167
+ onSave: (lens: Lens) => void;
168
+ onCancel: () => void;
169
+ }) {
170
+ const [name, setName] = useState(initial.name);
171
+ const [rules, setRules] = useState<LensRule[]>(() =>
172
+ initial.rules.map(r => ({ ...r })),
173
+ );
174
+
175
+ const addRule = () => {
176
+ const colorIndex = rules.length % LENS_PALETTE.length;
177
+ setRules([...rules, {
178
+ id: `rule-${Date.now()}-${rules.length}`,
179
+ name: 'New Rule',
180
+ enabled: true,
181
+ criteria: { type: 'ifcType', ifcType: '' },
182
+ action: 'colorize',
183
+ color: LENS_PALETTE[colorIndex],
184
+ }]);
185
+ };
186
+
187
+ const updateRule = (index: number, patch: Partial<LensRule>) => {
188
+ setRules(rules.map((r, i) => i === index ? { ...r, ...patch } : r));
189
+ };
190
+
191
+ const removeRule = (index: number) => {
192
+ setRules(rules.filter((_, i) => i !== index));
193
+ };
194
+
195
+ const handleSave = () => {
196
+ const validRules = rules.filter(r => r.criteria.ifcType);
197
+ if (!name.trim() || validRules.length === 0) return;
198
+ onSave({ ...initial, name: name.trim(), rules: validRules });
199
+ };
200
+
201
+ const canSave = name.trim().length > 0 && rules.some(r => r.criteria.ifcType);
202
+
203
+ return (
204
+ <div className="border-2 border-primary bg-white dark:bg-zinc-900 rounded-sm">
205
+ {/* Name input */}
206
+ <div className="px-3 pt-3 pb-2">
207
+ <input
208
+ type="text"
209
+ value={name}
210
+ onChange={(e) => setName(e.target.value)}
211
+ placeholder="Lens name..."
212
+ 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"
213
+ autoFocus
214
+ />
215
+ </div>
216
+
217
+ {/* Rules */}
218
+ <div className="border-t border-zinc-200 dark:border-zinc-700 py-1 bg-zinc-50/50 dark:bg-zinc-800/50">
219
+ {rules.map((rule, i) => (
220
+ <RuleEditor
221
+ key={rule.id}
222
+ rule={rule}
223
+ onChange={(patch) => updateRule(i, patch)}
224
+ onRemove={() => removeRule(i)}
225
+ />
226
+ ))}
227
+
228
+ <button
229
+ onClick={addRule}
230
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary hover:text-primary/80 w-full"
231
+ >
232
+ <Plus className="h-3.5 w-3.5" />
233
+ Add Rule
234
+ </button>
235
+ </div>
236
+
237
+ {/* Actions */}
238
+ <div className="flex gap-1.5 p-2 border-t border-zinc-200 dark:border-zinc-700">
239
+ <Button
240
+ variant="default"
241
+ size="sm"
242
+ className="flex-1 h-7 text-[10px] uppercase tracking-wider rounded-sm"
243
+ onClick={handleSave}
244
+ disabled={!canSave}
245
+ >
246
+ <Save className="h-3 w-3 mr-1" />
247
+ Save
248
+ </Button>
249
+ <Button
250
+ variant="ghost"
251
+ size="sm"
252
+ className="h-7 text-[10px] uppercase tracking-wider rounded-sm"
253
+ onClick={onCancel}
254
+ >
255
+ Cancel
256
+ </Button>
257
+ </div>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ // ─── Lens card (read-only display) ──────────────────────────────────────────
263
+
264
+ function LensCard({
265
+ lens,
266
+ isActive,
267
+ onToggle,
268
+ onEdit,
269
+ onDelete,
270
+ isolatedRuleId,
271
+ onIsolateRule,
272
+ ruleCounts,
273
+ }: {
274
+ lens: Lens;
275
+ isActive: boolean;
276
+ onToggle: (id: string) => void;
277
+ onEdit?: (lens: Lens) => void;
278
+ onDelete?: (id: string) => void;
279
+ isolatedRuleId?: string | null;
280
+ onIsolateRule?: (ruleId: string) => void;
281
+ ruleCounts?: Map<string, number>;
282
+ }) {
283
+ return (
284
+ <div
285
+ className={cn(
286
+ 'border-2 transition-colors cursor-pointer group rounded-sm',
287
+ isActive
288
+ ? 'border-primary bg-white dark:bg-zinc-900'
289
+ : 'border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:border-zinc-400 dark:hover:border-zinc-500',
290
+ )}
291
+ onClick={() => onToggle(lens.id)}
292
+ >
293
+ {/* Header */}
294
+ <div className="flex items-center justify-between px-3 py-2">
295
+ <div className="flex items-center gap-2 min-w-0">
296
+ {isActive ? (
297
+ <Check className="h-3.5 w-3.5 text-primary flex-shrink-0" />
298
+ ) : (
299
+ <Palette className="h-3.5 w-3.5 text-zinc-400 dark:text-zinc-500 flex-shrink-0" />
300
+ )}
301
+ <span className="text-xs font-bold uppercase tracking-wider text-zinc-900 dark:text-zinc-100 truncate">
302
+ {lens.name}
303
+ </span>
304
+ </div>
305
+ <div className="flex items-center gap-1">
306
+ {/* Edit for all lenses, delete for custom only */}
307
+ {onEdit && (
308
+ <button
309
+ onClick={(e) => { e.stopPropagation(); onEdit(lens); }}
310
+ 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"
311
+ title="Edit lens"
312
+ >
313
+ <Pencil className="h-3 w-3" />
314
+ </button>
315
+ )}
316
+ {!lens.builtin && onDelete && (
317
+ <button
318
+ onClick={(e) => { e.stopPropagation(); onDelete(lens.id); }}
319
+ className="opacity-0 group-hover:opacity-100 text-zinc-400 hover:text-red-500 dark:text-zinc-500 dark:hover:text-red-400 p-0.5"
320
+ title="Delete lens"
321
+ >
322
+ <Trash2 className="h-3 w-3" />
323
+ </button>
324
+ )}
325
+ <span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-mono ml-1">
326
+ {lens.rules.filter(r => r.enabled).length} rules
327
+ </span>
328
+ </div>
329
+ </div>
330
+
331
+ {/* Color legend (shown when active) — click rules to isolate */}
332
+ {isActive && (
333
+ <div className="border-t border-zinc-200 dark:border-zinc-700 py-0.5 bg-zinc-50 dark:bg-zinc-800/60">
334
+ {lens.rules.map(rule => {
335
+ const count = ruleCounts?.get(rule.id) ?? 0;
336
+ return (
337
+ <RuleRow
338
+ key={rule.id}
339
+ rule={rule}
340
+ count={count}
341
+ isIsolated={isolatedRuleId === rule.id}
342
+ onClick={onIsolateRule ? () => onIsolateRule(rule.id) : undefined}
343
+ />
344
+ );
345
+ })}
346
+ </div>
347
+ )}
348
+ </div>
349
+ );
350
+ }
351
+
352
+ // ─── Main panel ─────────────────────────────────────────────────────────────
353
+
354
+ export function LensPanel({ onClose }: LensPanelProps) {
355
+ const { activeLensId, savedLenses } = useLens();
356
+ const setActiveLens = useViewerStore((s) => s.setActiveLens);
357
+ const createLens = useViewerStore((s) => s.createLens);
358
+ const updateLens = useViewerStore((s) => s.updateLens);
359
+ const deleteLens = useViewerStore((s) => s.deleteLens);
360
+ const importLenses = useViewerStore((s) => s.importLenses);
361
+ const exportLenses = useViewerStore((s) => s.exportLenses);
362
+ const hideEntities = useViewerStore((s) => s.hideEntities);
363
+ const showAll = useViewerStore((s) => s.showAll);
364
+ const isolateEntities = useViewerStore((s) => s.isolateEntities);
365
+ const clearIsolation = useViewerStore((s) => s.clearIsolation);
366
+ // For footer stats — cheap primitive subscriptions
367
+ const lensColorMapSize = useViewerStore((s) => s.lensColorMap.size);
368
+ const lensHiddenIdsSize = useViewerStore((s) => s.lensHiddenIds.size);
369
+ const lensRuleCounts = useViewerStore((s) => s.lensRuleCounts);
370
+
371
+ // Editor state: null = not editing, Lens object = editing/creating
372
+ const [editingLens, setEditingLens] = useState<Lens | null>(null);
373
+ const [isolatedRuleId, setIsolatedRuleId] = useState<string | null>(null);
374
+ const fileInputRef = useRef<HTMLInputElement>(null);
375
+
376
+ const handleToggle = useCallback((id: string) => {
377
+ setIsolatedRuleId(null);
378
+ if (activeLensId === id) {
379
+ setActiveLens(null);
380
+ showAll();
381
+ } else {
382
+ setActiveLens(id);
383
+ }
384
+ }, [activeLensId, setActiveLens, showAll]);
385
+
386
+ /** Click a rule row in the active lens to isolate matching entities */
387
+ const handleIsolateRule = useCallback((ruleId: string) => {
388
+ const lens = savedLenses.find(l => l.id === activeLensId);
389
+ if (!lens) return;
390
+
391
+ // Toggle off if clicking the already-isolated rule
392
+ if (isolatedRuleId === ruleId) {
393
+ setIsolatedRuleId(null);
394
+ clearIsolation();
395
+ return;
396
+ }
397
+
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)
402
+ const matchingIds = useViewerStore.getState().lensRuleEntityIds.get(ruleId);
403
+ if (!matchingIds || matchingIds.length === 0) return;
404
+
405
+ if (matchingIds.length > 0) {
406
+ setIsolatedRuleId(ruleId);
407
+ isolateEntities(matchingIds);
408
+ }
409
+ }, [activeLensId, savedLenses, isolatedRuleId, isolateEntities, clearIsolation]);
410
+
411
+ const handleNewLens = useCallback(() => {
412
+ setEditingLens({
413
+ id: `lens-${Date.now()}`,
414
+ name: '',
415
+ rules: [],
416
+ });
417
+ }, []);
418
+
419
+ const handleEditLens = useCallback((lens: Lens) => {
420
+ setEditingLens({ ...lens, rules: lens.rules.map(r => ({ ...r })) });
421
+ }, []);
422
+
423
+ const handleSaveLens = useCallback((lens: Lens) => {
424
+ // Check if this is an existing lens or new
425
+ const exists = savedLenses.some(l => l.id === lens.id);
426
+ if (exists) {
427
+ updateLens(lens.id, { name: lens.name, rules: lens.rules });
428
+ } else {
429
+ createLens(lens);
430
+ }
431
+ setEditingLens(null);
432
+ }, [savedLenses, createLens, updateLens]);
433
+
434
+ const handleDeleteLens = useCallback((id: string) => {
435
+ if (activeLensId === id) {
436
+ setActiveLens(null);
437
+ showAll();
438
+ }
439
+ deleteLens(id);
440
+ }, [activeLensId, setActiveLens, showAll, deleteLens]);
441
+
442
+ // Apply hidden entities when lens hidden IDs change (proper effect, not render side-effect)
443
+ useEffect(() => {
444
+ if (lensHiddenIdsSize > 0 && activeLensId) {
445
+ const ids = useViewerStore.getState().lensHiddenIds;
446
+ hideEntities(Array.from(ids));
447
+ }
448
+ }, [activeLensId, lensHiddenIdsSize, hideEntities]);
449
+
450
+ const handleExport = useCallback(() => {
451
+ const data = exportLenses();
452
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
453
+ const url = URL.createObjectURL(blob);
454
+ const a = document.createElement('a');
455
+ a.href = url;
456
+ a.download = 'lenses.json';
457
+ a.click();
458
+ URL.revokeObjectURL(url);
459
+ }, [exportLenses]);
460
+
461
+ const handleImport = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
462
+ const file = e.target.files?.[0];
463
+ if (!file) return;
464
+ const reader = new FileReader();
465
+ reader.onload = () => {
466
+ try {
467
+ const parsed = JSON.parse(reader.result as string);
468
+ const arr: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
469
+ // Validate each entry has required Lens structure
470
+ const valid = arr.filter((item): item is Lens => {
471
+ if (item === null || typeof item !== 'object') return false;
472
+ const obj = item as Record<string, unknown>;
473
+ return typeof obj.id === 'string' && obj.id.length > 0
474
+ && typeof obj.name === 'string' && obj.name.length > 0
475
+ && Array.isArray(obj.rules);
476
+ });
477
+ if (valid.length > 0) {
478
+ importLenses(valid);
479
+ }
480
+ } catch {
481
+ // invalid JSON — silently ignore
482
+ }
483
+ };
484
+ reader.readAsText(file);
485
+ // reset so same file can be re-imported
486
+ e.target.value = '';
487
+ }, [importLenses]);
488
+
489
+ return (
490
+ <div className="h-full flex flex-col bg-white dark:bg-zinc-950">
491
+ {/* Header */}
492
+ <div className="flex items-center justify-between p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900">
493
+ <div className="flex items-center gap-2">
494
+ <Palette className="h-4 w-4 text-primary" />
495
+ <h2 className="font-bold uppercase tracking-wider text-xs text-zinc-900 dark:text-zinc-100">
496
+ Lens
497
+ </h2>
498
+ </div>
499
+ <div className="flex items-center gap-1">
500
+ <Button
501
+ variant="ghost"
502
+ size="sm"
503
+ className="h-7 w-7 p-0 rounded-sm"
504
+ onClick={handleExport}
505
+ title="Export lenses as JSON"
506
+ >
507
+ <Download className="h-3.5 w-3.5" />
508
+ </Button>
509
+ <Button
510
+ variant="ghost"
511
+ size="sm"
512
+ className="h-7 w-7 p-0 rounded-sm"
513
+ onClick={() => fileInputRef.current?.click()}
514
+ title="Import lenses from JSON"
515
+ >
516
+ <Upload className="h-3.5 w-3.5" />
517
+ </Button>
518
+ <input
519
+ ref={fileInputRef}
520
+ type="file"
521
+ accept=".json"
522
+ className="hidden"
523
+ onChange={handleImport}
524
+ />
525
+ {activeLensId && (
526
+ <Button
527
+ variant="ghost"
528
+ size="sm"
529
+ className="h-7 text-[10px] uppercase tracking-wider rounded-sm"
530
+ onClick={() => { setActiveLens(null); showAll(); }}
531
+ >
532
+ <EyeOff className="h-3 w-3 mr-1" />
533
+ Clear
534
+ </Button>
535
+ )}
536
+ {onClose && (
537
+ <Button
538
+ variant="ghost"
539
+ size="sm"
540
+ className="h-7 w-7 p-0 rounded-sm"
541
+ onClick={onClose}
542
+ >
543
+ <X className="h-4 w-4" />
544
+ </Button>
545
+ )}
546
+ </div>
547
+ </div>
548
+
549
+ {/* Lens list + editor */}
550
+ <div className="flex-1 overflow-auto p-3 space-y-2">
551
+ {savedLenses.map(lens => (
552
+ editingLens?.id === lens.id ? (
553
+ <LensEditor
554
+ key={lens.id}
555
+ initial={editingLens}
556
+ onSave={handleSaveLens}
557
+ onCancel={() => setEditingLens(null)}
558
+ />
559
+ ) : (
560
+ <LensCard
561
+ key={lens.id}
562
+ lens={lens}
563
+ isActive={activeLensId === lens.id}
564
+ onToggle={handleToggle}
565
+ onEdit={handleEditLens}
566
+ onDelete={handleDeleteLens}
567
+ isolatedRuleId={activeLensId === lens.id ? isolatedRuleId : null}
568
+ onIsolateRule={activeLensId === lens.id ? handleIsolateRule : undefined}
569
+ ruleCounts={activeLensId === lens.id ? lensRuleCounts : undefined}
570
+ />
571
+ )
572
+ ))}
573
+
574
+ {/* New lens editor (when creating) */}
575
+ {editingLens && !savedLenses.some(l => l.id === editingLens.id) && (
576
+ <LensEditor
577
+ initial={editingLens}
578
+ onSave={handleSaveLens}
579
+ onCancel={() => setEditingLens(null)}
580
+ />
581
+ )}
582
+
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>
592
+ )}
593
+ </div>
594
+
595
+ {/* Status footer */}
596
+ <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 text-[10px] uppercase tracking-wide text-zinc-600 dark:text-zinc-400 text-center bg-zinc-50 dark:bg-zinc-900 font-mono">
597
+ {activeLensId
598
+ ? `Active · ${lensColorMapSize} colored · ${lensHiddenIdsSize > 0 ? `${lensHiddenIdsSize} hidden` : 'ghosted'}`
599
+ : 'Click a lens to activate'}
600
+ </div>
601
+ </div>
602
+ );
603
+ }