@ifc-lite/viewer 1.25.2 → 1.27.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 (116) hide show
  1. package/.turbo/turbo-build.log +40 -30
  2. package/CHANGELOG.md +110 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +13 -9
  38. package/src/components/extensions/FlavorDialog.tsx +18 -2
  39. package/src/components/extensions/FlavorListView.tsx +12 -3
  40. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  41. package/src/components/mcp/data.ts +6 -0
  42. package/src/components/mcp/playground-dispatcher.ts +277 -0
  43. package/src/components/mcp/types.ts +2 -1
  44. package/src/components/ui/combo-input.tsx +163 -0
  45. package/src/components/ui/tabs.tsx +1 -1
  46. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  47. package/src/components/viewer/ClashPanel.tsx +370 -0
  48. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  49. package/src/components/viewer/CommandPalette.tsx +14 -15
  50. package/src/components/viewer/MainToolbar.tsx +155 -175
  51. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  52. package/src/components/viewer/SearchInline.tsx +62 -2
  53. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  54. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  55. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  56. package/src/components/viewer/SearchModal.tsx +19 -6
  57. package/src/components/viewer/ViewerLayout.tsx +5 -0
  58. package/src/components/viewer/Viewport.tsx +64 -9
  59. package/src/components/viewer/ViewportContainer.tsx +45 -3
  60. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  61. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  62. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  63. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  64. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  65. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  66. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  67. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  68. package/src/generated/mcp-catalog.json +4 -0
  69. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  70. package/src/hooks/ingest/streamCleanup.ts +45 -0
  71. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  72. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  73. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  74. package/src/hooks/source-key.ts +35 -0
  75. package/src/hooks/useAlignmentLines3D.ts +139 -0
  76. package/src/hooks/useClash.ts +420 -0
  77. package/src/hooks/useGridLines3D.ts +140 -0
  78. package/src/hooks/useIfcFederation.ts +16 -2
  79. package/src/hooks/useIfcLoader.ts +5 -7
  80. package/src/lib/clash/persistence.ts +308 -0
  81. package/src/lib/geo/effective-georef.test.ts +66 -0
  82. package/src/lib/length-unit-scale.ts +41 -0
  83. package/src/lib/lists/adapter.ts +136 -11
  84. package/src/lib/lists/export/csv.ts +47 -0
  85. package/src/lib/lists/export/index.ts +49 -0
  86. package/src/lib/lists/export/model.ts +111 -0
  87. package/src/lib/lists/export/pdf.ts +67 -0
  88. package/src/lib/lists/export/xlsx.ts +83 -0
  89. package/src/lib/lists/index.ts +2 -0
  90. package/src/lib/search/filter-evaluate.test.ts +81 -0
  91. package/src/lib/search/filter-evaluate.ts +59 -87
  92. package/src/lib/search/filter-match.ts +167 -0
  93. package/src/lib/search/filter-rules.test.ts +25 -0
  94. package/src/lib/search/filter-rules.ts +75 -2
  95. package/src/lib/search/filter-schema.ts +0 -0
  96. package/src/lib/slab-edit.test.ts +72 -0
  97. package/src/lib/slab-edit.ts +159 -19
  98. package/src/sdk/adapters/export-adapter.ts +3 -3
  99. package/src/sdk/adapters/query-adapter.ts +3 -3
  100. package/src/services/extensions/host.ts +13 -0
  101. package/src/store/constants.ts +33 -25
  102. package/src/store/index.ts +29 -8
  103. package/src/store/slices/clashSlice.ts +251 -0
  104. package/src/store/slices/listSlice.ts +6 -0
  105. package/src/store/slices/mutationSlice.ts +14 -6
  106. package/src/store/slices/searchSlice.ts +29 -3
  107. package/src/store/slices/visibilitySlice.test.ts +23 -5
  108. package/src/store/slices/visibilitySlice.ts +18 -8
  109. package/src/utils/nativeSpatialDataStore.ts +6 -0
  110. package/src/utils/serverDataModel.test.ts +6 -0
  111. package/src/utils/serverDataModel.ts +7 -0
  112. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  113. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  114. package/dist/assets/index-Bws3UAkj.css +0 -1
  115. package/dist/assets/raw-R2QfzPAR.js +0 -1
  116. package/dist/assets/server-client-Ctk8_Bof.js +0 -626
@@ -0,0 +1,503 @@
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
+ * Per-rule chip editors for the filter builder. Split out of
7
+ * `SearchModal.filter.builder.tsx` (which keeps the toolbar / preset /
8
+ * run-state orchestration) to stay under the module size cap. `RuleRow`
9
+ * dispatches to the right per-kind editor; the builder only imports
10
+ * `RuleRow` and `RULE_KIND_LABEL`.
11
+ */
12
+
13
+ import { useMemo } from 'react';
14
+ import { Trash2 } from 'lucide-react';
15
+ import { Button } from '@/components/ui/button';
16
+ import { Input } from '@/components/ui/input';
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuTrigger,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ } from '@/components/ui/dropdown-menu';
23
+ import {
24
+ Rule,
25
+ type FilterRule,
26
+ type SetOp,
27
+ type StringOp,
28
+ type ValueOp,
29
+ type NumericOp,
30
+ type ClassificationOp,
31
+ } from '@/lib/search/filter-rules';
32
+ import { ComboInput } from '@/components/ui/combo-input';
33
+ import { propValueKey, type FilterValueSchema } from '@/lib/search/filter-schema';
34
+
35
+ const NO_OPTIONS: readonly string[] = [];
36
+
37
+ // ── Op constants ──────────────────────────────────────────────────────
38
+
39
+ const SET_OPS: SetOp[] = ['in', 'notIn'];
40
+ const STRING_OPS: StringOp[] = ['eq', 'ne', 'contains', 'notContains', 'startsWith'];
41
+ const VALUE_OPS: ValueOp[] = [
42
+ 'eq', 'ne', 'contains', 'notContains', 'gt', 'gte', 'lt', 'lte', 'isSet', 'isNotSet',
43
+ ];
44
+ const NUMERIC_OPS: NumericOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
45
+ const CLASSIFICATION_OPS: ClassificationOp[] = [
46
+ 'contains', 'eq', 'ne', 'notContains', 'isSet', 'isNotSet',
47
+ ];
48
+
49
+ const OP_LABEL: Record<string, string> = {
50
+ in: 'is one of', notIn: 'is not one of',
51
+ eq: '=', ne: '≠',
52
+ contains: 'contains', notContains: 'does not contain',
53
+ startsWith: 'starts with',
54
+ gt: '>', gte: '≥', lt: '<', lte: '≤',
55
+ isSet: 'is set', isNotSet: 'is not set',
56
+ };
57
+
58
+ export const RULE_KIND_LABEL: Record<FilterRule['kind'], string> = {
59
+ storey: 'Storey',
60
+ ifcType: 'IFC Type',
61
+ predefinedType: 'Predefined Type',
62
+ name: 'Name',
63
+ property: 'Property',
64
+ quantity: 'Quantity',
65
+ material: 'Material',
66
+ classification: 'Classification',
67
+ elevation: 'Elevation',
68
+ };
69
+
70
+ // ── Rule row dispatcher ───────────────────────────────────────────────
71
+
72
+ export interface RuleRowProps {
73
+ rule: FilterRule;
74
+ ifcTypeOptions: string[];
75
+ storeyOptions: ReadonlyArray<readonly [string, number | null]>;
76
+ psetQto: { psets: ReadonlyArray<readonly [string, ReadonlyArray<string>]>; qtos: ReadonlyArray<readonly [string, ReadonlyArray<readonly [string, string]>]> } | null;
77
+ /** Distinct model values for value suggestions (materials, classifications, property values). */
78
+ valueSchema: FilterValueSchema | null;
79
+ onChange: (next: FilterRule) => void;
80
+ onRemove: () => void;
81
+ }
82
+
83
+ export function RuleRow({ rule, ifcTypeOptions, storeyOptions, psetQto, valueSchema, onChange, onRemove }: RuleRowProps) {
84
+ return (
85
+ <div className="flex flex-wrap items-center gap-1.5 rounded border border-zinc-200 bg-white px-2 py-1.5 dark:border-zinc-800 dark:bg-zinc-950">
86
+ <span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
87
+ {RULE_KIND_LABEL[rule.kind]}
88
+ </span>
89
+
90
+ {rule.kind === 'storey' && (
91
+ <SetRuleEditor
92
+ values={rule.values}
93
+ op={rule.op}
94
+ options={storeyOptions.map(([name, elev]) => ({
95
+ label: elev != null ? `${name} (${elev.toFixed(2)} m)` : name,
96
+ value: name,
97
+ }))}
98
+ onChange={(values, op) => onChange(Rule.storey(values, op))}
99
+ />
100
+ )}
101
+
102
+ {rule.kind === 'ifcType' && (
103
+ <SetRuleEditor
104
+ values={rule.values}
105
+ op={rule.op}
106
+ options={ifcTypeOptions.map((t) => ({ label: t, value: t }))}
107
+ onChange={(values, op) => onChange(Rule.ifcType(values, op))}
108
+ />
109
+ )}
110
+
111
+ {rule.kind === 'predefinedType' && (
112
+ <PredefinedTypeEditor
113
+ values={rule.values}
114
+ op={rule.op}
115
+ onChange={(values, op) => onChange(Rule.predefinedType(values, op))}
116
+ />
117
+ )}
118
+
119
+ {rule.kind === 'name' && (
120
+ <NameEditor
121
+ op={rule.op}
122
+ value={rule.value}
123
+ onChange={(op, value) => onChange(Rule.name(op, value))}
124
+ />
125
+ )}
126
+
127
+ {rule.kind === 'property' && (
128
+ <PropertyEditor rule={rule} psetQto={psetQto} valueSchema={valueSchema} onChange={onChange} />
129
+ )}
130
+
131
+ {rule.kind === 'quantity' && (
132
+ <QuantityEditor rule={rule} psetQto={psetQto} onChange={onChange} />
133
+ )}
134
+
135
+ {rule.kind === 'material' && (
136
+ <MaterialEditor
137
+ op={rule.op}
138
+ value={rule.value}
139
+ options={valueSchema?.materials ?? NO_OPTIONS}
140
+ onChange={(op, value) => onChange(Rule.material(op, value))}
141
+ />
142
+ )}
143
+
144
+ {rule.kind === 'classification' && (
145
+ <ClassificationEditor rule={rule} valueSchema={valueSchema} onChange={onChange} />
146
+ )}
147
+
148
+ {rule.kind === 'elevation' && (
149
+ <ElevationEditor
150
+ op={rule.op}
151
+ value={rule.value}
152
+ onChange={(op, value) => onChange(Rule.elevation(op, value))}
153
+ />
154
+ )}
155
+
156
+ <button
157
+ type="button"
158
+ onClick={onRemove}
159
+ aria-label="Remove rule"
160
+ className="ml-auto rounded p-1 text-muted-foreground hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800"
161
+ >
162
+ <Trash2 className="h-3 w-3" />
163
+ </button>
164
+ </div>
165
+ );
166
+ }
167
+
168
+ // ── Per-kind editors ──────────────────────────────────────────────────
169
+
170
+ interface SetRuleEditorProps {
171
+ values: string[];
172
+ op: SetOp;
173
+ options: Array<{ label: string; value: string }>;
174
+ onChange: (values: string[], op: SetOp) => void;
175
+ }
176
+
177
+ function SetRuleEditor({ values, op, options, onChange }: SetRuleEditorProps) {
178
+ const toggle = (v: string) => {
179
+ const next = values.includes(v) ? values.filter((x) => x !== v) : [...values, v];
180
+ onChange(next, op);
181
+ };
182
+ return (
183
+ <>
184
+ <OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
185
+ <DropdownMenu>
186
+ <DropdownMenuTrigger asChild>
187
+ <Button variant="outline" size="sm" className="h-7 gap-1 text-xs font-mono">
188
+ {values.length === 0 ? 'Pick values…' : `${values.length} selected`}
189
+ </Button>
190
+ </DropdownMenuTrigger>
191
+ <DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
192
+ {options.length === 0 && (
193
+ <DropdownMenuItem disabled className="text-muted-foreground italic">
194
+ No options available — load a model first.
195
+ </DropdownMenuItem>
196
+ )}
197
+ {options.map((o) => (
198
+ <DropdownMenuItem
199
+ key={o.value}
200
+ onSelect={(e) => {
201
+ // Keep the menu open for multi-select.
202
+ e.preventDefault();
203
+ toggle(o.value);
204
+ }}
205
+ className="font-mono"
206
+ >
207
+ <span className="mr-2 inline-block w-3 text-center">
208
+ {values.includes(o.value) ? '✓' : ''}
209
+ </span>
210
+ {o.label}
211
+ </DropdownMenuItem>
212
+ ))}
213
+ </DropdownMenuContent>
214
+ </DropdownMenu>
215
+ {values.length > 0 && (
216
+ <div className="flex flex-wrap items-center gap-1">
217
+ {values.map((v) => (
218
+ <span
219
+ key={v}
220
+ className="inline-flex items-center gap-1 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-mono dark:bg-zinc-800"
221
+ >
222
+ {v}
223
+ <button
224
+ type="button"
225
+ aria-label={`Remove ${v}`}
226
+ onClick={() => toggle(v)}
227
+ className="text-muted-foreground hover:text-foreground"
228
+ >
229
+ ×
230
+ </button>
231
+ </span>
232
+ ))}
233
+ </div>
234
+ )}
235
+ </>
236
+ );
237
+ }
238
+
239
+ function PredefinedTypeEditor({
240
+ values,
241
+ op,
242
+ onChange,
243
+ }: {
244
+ values: string[];
245
+ op: SetOp;
246
+ onChange: (values: string[], op: SetOp) => void;
247
+ }) {
248
+ // Predefined types aren't materialised in the parser today — pick
249
+ // them via free-text. The user enters comma-separated values.
250
+ const text = values.join(', ');
251
+ return (
252
+ <>
253
+ <OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
254
+ <Input
255
+ placeholder="e.g. SOLIDWALL, PARTITIONING"
256
+ value={text}
257
+ onChange={(e) =>
258
+ onChange(
259
+ e.target.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0),
260
+ op,
261
+ )
262
+ }
263
+ className="h-7 w-72 text-xs font-mono"
264
+ />
265
+ </>
266
+ );
267
+ }
268
+
269
+ function NameEditor({
270
+ op,
271
+ value,
272
+ onChange,
273
+ }: {
274
+ op: StringOp;
275
+ value: string;
276
+ onChange: (op: StringOp, value: string) => void;
277
+ }) {
278
+ return (
279
+ <>
280
+ <OpDropdown ops={STRING_OPS} value={op} onChange={(next) => onChange(next, value)} />
281
+ <Input
282
+ placeholder="text"
283
+ value={value}
284
+ onChange={(e) => onChange(op, e.target.value)}
285
+ className="h-7 w-56 text-xs font-mono"
286
+ />
287
+ </>
288
+ );
289
+ }
290
+
291
+ interface PropertyEditorProps {
292
+ rule: Extract<FilterRule, { kind: 'property' }>;
293
+ psetQto: RuleRowProps['psetQto'];
294
+ valueSchema: FilterValueSchema | null;
295
+ onChange: (next: FilterRule) => void;
296
+ }
297
+
298
+ function PropertyEditor({ rule, psetQto, valueSchema, onChange }: PropertyEditorProps) {
299
+ const psetNames = useMemo(() => (psetQto ? psetQto.psets.map(([n]) => n) : []), [psetQto]);
300
+ const propNames = useMemo(() => {
301
+ if (!psetQto) return [];
302
+ const entry = psetQto.psets.find(([n]) => n === rule.setName);
303
+ return entry ? Array.from(entry[1]) : [];
304
+ }, [psetQto, rule.setName]);
305
+ const valueOptions = useMemo(
306
+ () => valueSchema?.propertyValues.get(propValueKey(rule.setName, rule.propertyName)) ?? NO_OPTIONS,
307
+ [valueSchema, rule.setName, rule.propertyName],
308
+ );
309
+
310
+ const valueless = rule.op === 'isSet' || rule.op === 'isNotSet';
311
+
312
+ return (
313
+ <>
314
+ <ComboInput
315
+ placeholder="Pset_… (e.g. Pset_WallCommon)"
316
+ value={rule.setName}
317
+ options={psetNames}
318
+ className="h-7 w-52 text-xs font-mono"
319
+ onChange={(next) => onChange({ ...rule, setName: next, propertyName: '' })}
320
+ />
321
+ <span className="text-muted-foreground">.</span>
322
+ <ComboInput
323
+ placeholder="prop name"
324
+ value={rule.propertyName}
325
+ options={propNames}
326
+ className="h-7 w-44 text-xs font-mono"
327
+ onChange={(next) => onChange({ ...rule, propertyName: next })}
328
+ />
329
+ <OpDropdown ops={VALUE_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
330
+ {!valueless && (
331
+ <ComboInput
332
+ placeholder="value"
333
+ value={rule.value}
334
+ options={valueOptions}
335
+ className="h-7 w-44 text-xs font-mono"
336
+ onChange={(value) => onChange({ ...rule, value })}
337
+ />
338
+ )}
339
+ </>
340
+ );
341
+ }
342
+
343
+ interface QuantityEditorProps {
344
+ rule: Extract<FilterRule, { kind: 'quantity' }>;
345
+ psetQto: RuleRowProps['psetQto'];
346
+ onChange: (next: FilterRule) => void;
347
+ }
348
+
349
+ function QuantityEditor({ rule, psetQto, onChange }: QuantityEditorProps) {
350
+ const qsetNames = useMemo(() => (psetQto ? psetQto.qtos.map(([n]) => n) : []), [psetQto]);
351
+ const qtyNames = useMemo(() => {
352
+ if (!psetQto) return [];
353
+ const entry = psetQto.qtos.find(([n]) => n === rule.setName);
354
+ return entry ? entry[1].map(([n]) => n) : [];
355
+ }, [psetQto, rule.setName]);
356
+
357
+ return (
358
+ <>
359
+ <ComboInput
360
+ placeholder="Qto_… (e.g. Qto_WallBaseQuantities)"
361
+ value={rule.setName}
362
+ options={qsetNames}
363
+ className="h-7 w-56 text-xs font-mono"
364
+ onChange={(next) => onChange({ ...rule, setName: next, quantityName: '' })}
365
+ />
366
+ <span className="text-muted-foreground">.</span>
367
+ <ComboInput
368
+ placeholder="quantity name"
369
+ value={rule.quantityName}
370
+ options={qtyNames}
371
+ className="h-7 w-44 text-xs font-mono"
372
+ onChange={(next) => onChange({ ...rule, quantityName: next })}
373
+ />
374
+ <OpDropdown ops={NUMERIC_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
375
+ <Input
376
+ type="number"
377
+ placeholder="value"
378
+ value={rule.value}
379
+ onChange={(e) => onChange({ ...rule, value: Number.parseFloat(e.target.value) || 0 })}
380
+ className="h-7 w-32 text-xs font-mono"
381
+ />
382
+ </>
383
+ );
384
+ }
385
+
386
+ function MaterialEditor({
387
+ op,
388
+ value,
389
+ options,
390
+ onChange,
391
+ }: {
392
+ op: StringOp;
393
+ value: string;
394
+ options: ReadonlyArray<string>;
395
+ onChange: (op: StringOp, value: string) => void;
396
+ }) {
397
+ return (
398
+ <>
399
+ <OpDropdown ops={STRING_OPS} value={op} onChange={(next) => onChange(next, value)} />
400
+ <ComboInput
401
+ placeholder="material name (e.g. Concrete)"
402
+ value={value}
403
+ options={options}
404
+ className="h-7 w-56 text-xs font-mono"
405
+ onChange={(v) => onChange(op, v)}
406
+ />
407
+ </>
408
+ );
409
+ }
410
+
411
+ function ClassificationEditor({
412
+ rule,
413
+ valueSchema,
414
+ onChange,
415
+ }: {
416
+ rule: Extract<FilterRule, { kind: 'classification' }>;
417
+ valueSchema: FilterValueSchema | null;
418
+ onChange: (next: FilterRule) => void;
419
+ }) {
420
+ const valueless = rule.op === 'isSet' || rule.op === 'isNotSet';
421
+ return (
422
+ <>
423
+ <ComboInput
424
+ placeholder="system (optional)"
425
+ value={rule.system ?? ''}
426
+ options={valueSchema?.classificationSystems ?? NO_OPTIONS}
427
+ className="h-7 w-40 text-xs font-mono"
428
+ aria-label="Classification system — leave blank for any"
429
+ onChange={(v) => onChange(Rule.classification(v, rule.op, rule.value))}
430
+ />
431
+ <OpDropdown
432
+ ops={CLASSIFICATION_OPS}
433
+ value={rule.op}
434
+ onChange={(next) => onChange(Rule.classification(rule.system ?? '', next, rule.value))}
435
+ />
436
+ {!valueless && (
437
+ <ComboInput
438
+ placeholder="code or name"
439
+ value={rule.value}
440
+ options={valueSchema?.classifications ?? NO_OPTIONS}
441
+ className="h-7 w-44 text-xs font-mono"
442
+ onChange={(v) => onChange(Rule.classification(rule.system ?? '', rule.op, v))}
443
+ />
444
+ )}
445
+ </>
446
+ );
447
+ }
448
+
449
+ function ElevationEditor({
450
+ op,
451
+ value,
452
+ onChange,
453
+ }: {
454
+ op: NumericOp;
455
+ value: number;
456
+ onChange: (op: NumericOp, value: number) => void;
457
+ }) {
458
+ return (
459
+ <>
460
+ <OpDropdown ops={NUMERIC_OPS} value={op} onChange={(next) => onChange(next, value)} />
461
+ <Input
462
+ type="number"
463
+ step="any"
464
+ placeholder="metres"
465
+ value={value}
466
+ onChange={(e) => onChange(op, Number.parseFloat(e.target.value) || 0)}
467
+ className="h-7 w-28 text-xs font-mono"
468
+ />
469
+ <span className="text-[10px] text-muted-foreground">m (storey elevation)</span>
470
+ </>
471
+ );
472
+ }
473
+
474
+ // ── Building-block widgets ───────────────────────────────────────────
475
+
476
+ function OpDropdown<T extends string>({
477
+ ops,
478
+ value,
479
+ onChange,
480
+ }: {
481
+ ops: ReadonlyArray<T>;
482
+ value: T;
483
+ onChange: (next: T) => void;
484
+ }) {
485
+ return (
486
+ <DropdownMenu>
487
+ <DropdownMenuTrigger asChild>
488
+ <Button variant="outline" size="sm" className="h-7 min-w-[3.5rem] gap-1 text-xs font-mono">
489
+ {OP_LABEL[value] ?? value}
490
+ </Button>
491
+ </DropdownMenuTrigger>
492
+ <DropdownMenuContent>
493
+ {ops.map((op) => (
494
+ <DropdownMenuItem key={op} onSelect={() => onChange(op)} className="font-mono">
495
+ {OP_LABEL[op] ?? op}
496
+ <span className="ml-2 text-[10px] text-muted-foreground">{op}</span>
497
+ </DropdownMenuItem>
498
+ ))}
499
+ </DropdownMenuContent>
500
+ </DropdownMenu>
501
+ );
502
+ }
503
+
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
23
  import { useVirtualizer } from '@tanstack/react-virtual';
24
- import { Play, AlertCircle, Download } from 'lucide-react';
24
+ import { Play, AlertCircle, Download, ListPlus } from 'lucide-react';
25
25
  import { useShallow } from 'zustand/react/shallow';
26
26
  import { useViewerStore } from '@/store';
27
27
  import { toGlobalIdFromModels } from '@/store/globalId';
@@ -37,6 +37,7 @@ import { evaluateFilterRulesFederated } from '@/lib/search/filter-evaluate';
37
37
  import { runTier0Scan, type ScanModel } from '@/lib/search/tier0-scan';
38
38
  import { queryTier1Indexes, type Tier1Index } from '@/lib/search/tier1-index';
39
39
  import { downloadResult } from '@/lib/search/result-export';
40
+ import type { ListDefinition } from '@/lib/lists';
40
41
  import { SearchModalFilterBuilder } from './SearchModal.filter.builder';
41
42
 
42
43
  /** Rows per virtualizer page — tuned for the result table row height. */
@@ -65,6 +66,9 @@ export function SearchModalFilter() {
65
66
  setSelectedEntity,
66
67
  setSelectedEntityId,
67
68
  cameraCallbacks,
69
+ setPendingListDraft,
70
+ setListPanelVisible,
71
+ setSearchModalOpen,
68
72
  } = useViewerStore(
69
73
  useShallow((s) => ({
70
74
  searchFilter: s.searchFilter,
@@ -81,6 +85,9 @@ export function SearchModalFilter() {
81
85
  setSelectedEntity: s.setSelectedEntity,
82
86
  setSelectedEntityId: s.setSelectedEntityId,
83
87
  cameraCallbacks: s.cameraCallbacks,
88
+ setPendingListDraft: s.setPendingListDraft,
89
+ setListPanelVisible: s.setListPanelVisible,
90
+ setSearchModalOpen: s.setSearchModalOpen,
84
91
  })),
85
92
  );
86
93
 
@@ -257,6 +264,52 @@ export function SearchModalFilter() {
257
264
  downloadResult(searchFilterResult, format);
258
265
  }, [searchFilterResult]);
259
266
 
267
+ /** Freeze the current filter result into a new list — a per-model snapshot
268
+ * of the matched express IDs — and open the list builder to configure
269
+ * columns. Keyed by model so federated results don't over-select when
270
+ * local express IDs collide across files. */
271
+ const handleCreateList = useCallback(() => {
272
+ const result = searchFilterResult;
273
+ if (!result || result.rows.length === 0) return;
274
+ const idIdx = result.columns.indexOf('express_id');
275
+ if (idIdx < 0) return;
276
+ const modelIdx = result.columns.indexOf('model_id'); // only present for multi-model runs
277
+
278
+ const byModel: Record<string, number[]> = {};
279
+ const seen = new Set<string>();
280
+ for (const row of result.rows) {
281
+ const id = Number(row[idIdx]);
282
+ if (!Number.isFinite(id) || id <= 0) continue;
283
+ const modelId = modelIdx >= 0 && typeof row[modelIdx] === 'string'
284
+ ? (row[modelIdx] as string)
285
+ : (activeModelId ?? 'default');
286
+ const key = `${modelId}:${id}`;
287
+ if (seen.has(key)) continue;
288
+ seen.add(key);
289
+ (byModel[modelId] ??= []).push(id);
290
+ }
291
+ const total = Object.values(byModel).reduce((n, ids) => n + ids.length, 0);
292
+ if (total === 0) return;
293
+
294
+ const now = Date.now();
295
+ const draft: ListDefinition = {
296
+ id: crypto.randomUUID(),
297
+ name: 'Filter result',
298
+ createdAt: now,
299
+ updatedAt: now,
300
+ entityTypes: [],
301
+ expressIdsByModel: byModel,
302
+ conditions: [],
303
+ columns: [
304
+ { id: 'attr-name', source: 'attribute', propertyName: 'Name', label: 'Name' },
305
+ { id: 'attr-class', source: 'attribute', propertyName: 'Class', label: 'Class' },
306
+ ],
307
+ };
308
+ setPendingListDraft(draft);
309
+ setListPanelVisible(true);
310
+ setSearchModalOpen(false);
311
+ }, [searchFilterResult, activeModelId, setPendingListDraft, setListPanelVisible, setSearchModalOpen]);
312
+
260
313
  if (!activeStore) {
261
314
  return (
262
315
  <div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-muted-foreground">
@@ -319,6 +372,16 @@ export function SearchModalFilter() {
319
372
  )}
320
373
 
321
374
  <div className="ml-auto flex items-center gap-1.5">
375
+ <Button
376
+ variant="ghost"
377
+ size="sm"
378
+ disabled={!searchFilterResult || searchFilterResult.rows.length === 0}
379
+ onClick={handleCreateList}
380
+ className="h-7 gap-1 text-xs"
381
+ title="Freeze these results into a new list"
382
+ >
383
+ <ListPlus className="h-3 w-3" /> Create list
384
+ </Button>
322
385
  <DropdownMenu>
323
386
  <DropdownMenuTrigger asChild>
324
387
  <Button
@@ -45,19 +45,21 @@ export function SearchModal() {
45
45
  const {
46
46
  searchQuery,
47
47
  searchModalOpen,
48
+ searchModalTab,
48
49
  searchIndexes,
49
50
  models,
50
51
  setSearchModalOpen,
51
- toggleSearchModal,
52
+ setSearchModalTab,
52
53
  setSearchQuery,
53
54
  } = useViewerStore(
54
55
  useShallow((s) => ({
55
56
  searchQuery: s.searchQuery,
56
57
  searchModalOpen: s.searchModalOpen,
58
+ searchModalTab: s.searchModalTab,
57
59
  searchIndexes: s.searchIndexes,
58
60
  models: s.models,
59
61
  setSearchModalOpen: s.setSearchModalOpen,
60
- toggleSearchModal: s.toggleSearchModal,
62
+ setSearchModalTab: s.setSearchModalTab,
61
63
  setSearchQuery: s.setSearchQuery,
62
64
  })),
63
65
  );
@@ -125,19 +127,26 @@ export function SearchModal() {
125
127
  return out;
126
128
  }, [tier0Models, tier1Indexes, debouncedQuery]);
127
129
 
128
- /** Global ⌘⇧F / Ctrl+⇧F toggle — opens from anywhere, also closes when open. */
130
+ /** Global ⌘⇧F / Ctrl+⇧F toggle — opens from anywhere, also closes when open.
131
+ * This is a text-search entry point, so opening always lands on the Search
132
+ * tab (the controlled tab otherwise remembers the last-used Filter tab). */
129
133
  useEffect(() => {
130
134
  const handler = (e: globalThis.KeyboardEvent) => {
131
135
  const isAdvancedShortcut =
132
136
  (e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'f' || e.key === 'F');
133
137
  if (isAdvancedShortcut) {
134
138
  e.preventDefault();
135
- toggleSearchModal();
139
+ if (searchModalOpen) {
140
+ setSearchModalOpen(false);
141
+ } else {
142
+ setSearchModalTab('search');
143
+ setSearchModalOpen(true);
144
+ }
136
145
  }
137
146
  };
138
147
  window.addEventListener('keydown', handler);
139
148
  return () => window.removeEventListener('keydown', handler);
140
- }, [toggleSearchModal]);
149
+ }, [searchModalOpen, setSearchModalOpen, setSearchModalTab]);
141
150
 
142
151
  /**
143
152
  * Record the query in recents on the modal-close *transition* — once
@@ -189,7 +198,11 @@ export function SearchModal() {
189
198
  onEscapeKeyDown={close}
190
199
  >
191
200
  <DialogTitle className="sr-only">Advanced Search</DialogTitle>
192
- <Tabs defaultValue="search" className="flex flex-col flex-1 min-h-0">
201
+ <Tabs
202
+ value={searchModalTab}
203
+ onValueChange={(v) => setSearchModalTab(v as typeof searchModalTab)}
204
+ className="flex flex-col flex-1 min-h-0"
205
+ >
193
206
  <div className="flex items-center justify-between border-b px-4 py-3">
194
207
  <TabsList>
195
208
  <TabsTrigger value="search">
@@ -28,6 +28,7 @@ import { HoverTooltip } from './HoverTooltip';
28
28
  import { BCFPanel } from './BCFPanel';
29
29
  import { IDSPanel } from './IDSPanel';
30
30
  import { LensPanel } from './LensPanel';
31
+ import { ClashPanel } from './ClashPanel';
31
32
  import { ListPanel } from './lists/ListPanel';
32
33
  import { ScriptPanel } from './ScriptPanel';
33
34
  import { GanttPanel } from './schedule/GanttPanel';
@@ -132,6 +133,8 @@ export function ViewerLayout() {
132
133
  const setListPanelVisible = useViewerStore((s) => s.setListPanelVisible);
133
134
  const lensPanelVisible = useViewerStore((s) => s.lensPanelVisible);
134
135
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
136
+ const clashPanelVisible = useViewerStore((s) => s.clashPanelVisible);
137
+ const setClashPanelVisible = useViewerStore((s) => s.setClashPanelVisible);
135
138
  const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
136
139
  const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
137
140
  const ganttPanelVisible = useViewerStore((s) => s.ganttPanelVisible);
@@ -342,6 +345,8 @@ export function ViewerLayout() {
342
345
  <AddElementPanel onClose={() => setActiveTool('select')} />
343
346
  ) : lensPanelVisible ? (
344
347
  <LensPanel onClose={() => setLensPanelVisible(false)} />
348
+ ) : clashPanelVisible ? (
349
+ <ClashPanel onClose={() => setClashPanelVisible(false)} />
345
350
  ) : idsPanelVisible ? (
346
351
  <IDSPanel onClose={() => setIdsPanelVisible(false)} />
347
352
  ) : bcfPanelVisible ? (