@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.
- package/CHANGELOG.md +88 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
- package/dist/assets/browser-BXNIkE8a.js +694 -0
- package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
- package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
- package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
- package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
- package/dist/assets/esbuild-COv63sf-.js +1 -0
- package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
- package/dist/assets/ffi-DlhRHxHv.js +1 -0
- package/dist/assets/index-6Mr3byM-.js +216 -0
- package/dist/assets/index-CGbokkQ9.css +1 -0
- package/dist/assets/index-huvR-kGC.js +98305 -0
- package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
- package/dist/index.html +12 -3
- package/index.html +10 -1
- package/package.json +30 -21
- package/src/App.tsx +6 -1
- package/src/components/ui/dialog.tsx +8 -6
- package/src/components/viewer/CodeEditor.tsx +309 -0
- package/src/components/viewer/CommandPalette.tsx +597 -0
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +145 -84
- package/src/components/viewer/ScriptPanel.tsx +416 -0
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/ViewerLayout.tsx +63 -11
- package/src/components/viewer/Viewport.tsx +58 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +25 -5
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +114 -14
- package/src/hooks/useLens.ts +40 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/hooks/useSandbox.ts +113 -0
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/lib/recent-files.ts +122 -0
- package/src/lib/scripts/persistence.ts +132 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
- package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
- package/src/lib/scripts/templates/envelope-check.ts +164 -0
- package/src/lib/scripts/templates/federation-compare.ts +189 -0
- package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
- package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
- package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
- package/src/lib/scripts/templates/reset-view.ts +6 -0
- package/src/lib/scripts/templates/space-validation.ts +189 -0
- package/src/lib/scripts/templates/tsconfig.json +13 -0
- package/src/lib/scripts/templates.ts +86 -0
- package/src/sdk/BimProvider.tsx +50 -0
- package/src/sdk/adapters/export-adapter.ts +283 -0
- package/src/sdk/adapters/lens-adapter.ts +44 -0
- package/src/sdk/adapters/model-adapter.ts +32 -0
- package/src/sdk/adapters/model-compat.ts +80 -0
- package/src/sdk/adapters/mutate-adapter.ts +45 -0
- package/src/sdk/adapters/query-adapter.ts +241 -0
- package/src/sdk/adapters/selection-adapter.ts +29 -0
- package/src/sdk/adapters/spatial-adapter.ts +37 -0
- package/src/sdk/adapters/types.ts +11 -0
- package/src/sdk/adapters/viewer-adapter.ts +103 -0
- package/src/sdk/adapters/visibility-adapter.ts +61 -0
- package/src/sdk/local-backend.ts +144 -0
- package/src/sdk/useBimHost.ts +69 -0
- package/src/store/constants.ts +10 -2
- package/src/store/index.ts +28 -2
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -42
- package/src/store/slices/scriptSlice.ts +218 -0
- package/src/store/slices/uiSlice.ts +2 -0
- package/src/store.ts +3 -0
- package/tsconfig.json +5 -2
- package/vite.config.ts +8 -0
- package/dist/assets/index-dgdgiQ9p.js +0 -75456
- 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
|
-
*
|
|
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
|
|
21
|
-
import {
|
|
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
|
|
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
|
-
// ───
|
|
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="
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
onChange(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
986
|
+
{isAutoColor
|
|
987
|
+
? TYPE_LABELS[lens.autoColor!.source]
|
|
988
|
+
: `${enabledRuleCount} rules`}
|
|
327
989
|
</span>
|
|
328
990
|
</div>
|
|
329
991
|
</div>
|
|
330
992
|
|
|
331
|
-
{/*
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
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
|
-
{/*
|
|
584
|
-
{
|
|
585
|
-
<
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
|