@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.
- package/.turbo/turbo-build.log +40 -30
- package/CHANGELOG.md +110 -0
- package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
- package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
- package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
- package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
- package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
- package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
- package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
- package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
- package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
- package/dist/assets/index-ajK6D32J.css +1 -0
- package/dist/assets/index.es-CY202jA3.js +6866 -0
- package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
- package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
- package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
- package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
- package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
- package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
- package/dist/assets/pdf-Dsh3HPZB.js +135 -0
- package/dist/assets/raw-D9iw0tmc.js +1 -0
- package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
- package/dist/assets/server-client-Cjwnm7il.js +706 -0
- package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
- package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +13 -9
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +277 -0
- package/src/components/mcp/types.ts +2 -1
- package/src/components/ui/combo-input.tsx +163 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +14 -15
- package/src/components/viewer/MainToolbar.tsx +155 -175
- package/src/components/viewer/PropertiesPanel.tsx +13 -6
- package/src/components/viewer/SearchInline.tsx +62 -2
- package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
- package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
- package/src/components/viewer/SearchModal.filter.tsx +64 -1
- package/src/components/viewer/SearchModal.tsx +19 -6
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +64 -9
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
- package/src/components/viewer/lists/ListBuilder.tsx +789 -280
- package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
- package/src/components/viewer/lists/ListPanel.tsx +49 -5
- package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
- package/src/components/viewer/lists/list-table-utils.ts +123 -0
- package/src/components/viewer/useGeometryStreaming.ts +21 -1
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +64 -42
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +139 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +5 -7
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/lib/length-unit-scale.ts +41 -0
- package/src/lib/lists/adapter.ts +136 -11
- package/src/lib/lists/export/csv.ts +47 -0
- package/src/lib/lists/export/index.ts +49 -0
- package/src/lib/lists/export/model.ts +111 -0
- package/src/lib/lists/export/pdf.ts +67 -0
- package/src/lib/lists/export/xlsx.ts +83 -0
- package/src/lib/lists/index.ts +2 -0
- package/src/lib/search/filter-evaluate.test.ts +81 -0
- package/src/lib/search/filter-evaluate.ts +59 -87
- package/src/lib/search/filter-match.ts +167 -0
- package/src/lib/search/filter-rules.test.ts +25 -0
- package/src/lib/search/filter-rules.ts +75 -2
- package/src/lib/search/filter-schema.ts +0 -0
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +3 -3
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +33 -25
- package/src/store/index.ts +29 -8
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/listSlice.ts +6 -0
- package/src/store/slices/mutationSlice.ts +14 -6
- package/src/store/slices/searchSlice.ts +29 -3
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +18 -8
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
- package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/raw-R2QfzPAR.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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 ? (
|