@ifc-lite/viewer 1.26.0 → 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 +38 -31
- package/CHANGELOG.md +29 -0
- package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
- package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
- 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-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
- package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
- package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
- package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
- package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
- package/dist/assets/index-ajK6D32J.css +1 -0
- package/dist/assets/index.es-CY202jA3.js +6866 -0
- package/dist/assets/{jpeg-BzSkwo5D.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-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
- package/dist/assets/{lzw-BBPPLW-0.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-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
- package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/{parser.worker-8md211IW.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-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
- package/dist/assets/server-client-Cjwnm7il.js +706 -0
- package/dist/assets/{webimage-YafxjjGr.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-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +10 -7
- 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/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/Viewport.tsx +15 -0
- 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/generated/mcp-catalog.json +4 -0
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +1 -26
- package/src/hooks/useGridLines3D.ts +140 -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/store/slices/listSlice.ts +6 -0
- package/src/store/slices/mutationSlice.ts +14 -6
- package/src/store/slices/searchSlice.ts +29 -3
- 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-0Q9qEa6p.js +0 -1
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +0 -1
- package/dist/assets/raw-BQrAgxwT.js +0 -1
- package/dist/assets/server-client-Bk4c1CPO.js +0 -626
|
@@ -53,6 +53,19 @@ import {
|
|
|
53
53
|
type BCFTopic,
|
|
54
54
|
} from '@ifc-lite/bcf';
|
|
55
55
|
import { parseIDS, validateIDS, type IDSDocument } from '@ifc-lite/ids';
|
|
56
|
+
import { GeometryProcessor, type MeshData } from '@ifc-lite/geometry';
|
|
57
|
+
import {
|
|
58
|
+
createClashEngine,
|
|
59
|
+
disciplineMatrixRules,
|
|
60
|
+
groupClashes,
|
|
61
|
+
type Clash,
|
|
62
|
+
type ClashMode,
|
|
63
|
+
type ClashResult,
|
|
64
|
+
type ClashRule,
|
|
65
|
+
type GroupOptions,
|
|
66
|
+
} from '@ifc-lite/clash';
|
|
67
|
+
import { elementsFromStep } from '@ifc-lite/clash/step';
|
|
68
|
+
import { createBCFFromClashResult } from '@ifc-lite/clash/bcf';
|
|
56
69
|
import { CATALOG, paramsFor } from './data';
|
|
57
70
|
import type { CatalogTool } from './types';
|
|
58
71
|
import type { ViewerController, ColorTuple } from './PlaygroundViewer';
|
|
@@ -248,6 +261,149 @@ function formatColorTuple(c: ColorTuple): string {
|
|
|
248
261
|
*/
|
|
249
262
|
const PROXIED_BSDD = new BsddNamespace({ apiBase: '/api/bsdd' });
|
|
250
263
|
|
|
264
|
+
// ── Clash detection (in-browser meshing → TS clash engine) ─────────────────
|
|
265
|
+
// Mirrors `packages/mcp/src/tools/clash.ts`: the whole model is meshed once
|
|
266
|
+
// (headless WASM pipeline, same as the inline viewer) and cached by model id,
|
|
267
|
+
// then the representation-agnostic clash engine runs against TYPE selectors.
|
|
268
|
+
|
|
269
|
+
/** Cap on clashes returned in a tool result. The dropped count is reported. */
|
|
270
|
+
const CLASH_DISPLAY_CAP = 50;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Runaway guardrail for the TS clash engine: the whole-run candidate-pair
|
|
274
|
+
* budget (broad-phase AABB-overlap survivors across every rule). The engine
|
|
275
|
+
* yields between chunks so it never hard-freezes the tab, but an agent can
|
|
276
|
+
* fire `clash_check` (all-vs-all) on an arbitrarily large uploaded model
|
|
277
|
+
* unattended — this ceiling bounds the narrow-phase work and is reported via
|
|
278
|
+
* `result.truncated` (never silent). Generous on purpose: real building models
|
|
279
|
+
* stay well under it, so totals match the viewer's (unbounded) ClashPanel; only
|
|
280
|
+
* pathological models get bounded.
|
|
281
|
+
*/
|
|
282
|
+
const CLASH_MAX_CANDIDATE_PAIRS = 5_000_000;
|
|
283
|
+
|
|
284
|
+
/** Max distinct models whose meshes we keep cached at once (LRU). The playground
|
|
285
|
+
* is effectively single-model, so a small bound is plenty and stops a long
|
|
286
|
+
* session of re-uploads from pinning every model's meshes in memory. */
|
|
287
|
+
const CLASH_MESH_CACHE_MAX = 3;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Module-level mesh cache so repeated clash calls on the same model don't
|
|
291
|
+
* re-run the (expensive) headless tessellation. Keyed by `id:fileSize`, NOT id
|
|
292
|
+
* alone: the playground reuses a filename-slug id, so an edited re-upload would
|
|
293
|
+
* otherwise hit a stale mesh — folding in the byte length forces a re-mesh when
|
|
294
|
+
* the bytes change. Bounded to CLASH_MESH_CACHE_MAX entries with LRU eviction.
|
|
295
|
+
*/
|
|
296
|
+
const clashMeshCache = new Map<string, MeshData[]>();
|
|
297
|
+
|
|
298
|
+
function meshCacheKey(m: LoadedPlaygroundModel): string {
|
|
299
|
+
return `${m.id}:${m.fileSize}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** LRU get: a hit refreshes recency so the active model survives eviction. */
|
|
303
|
+
function getCachedMeshes(key: string): MeshData[] | undefined {
|
|
304
|
+
const hit = clashMeshCache.get(key);
|
|
305
|
+
if (hit) {
|
|
306
|
+
clashMeshCache.delete(key);
|
|
307
|
+
clashMeshCache.set(key, hit);
|
|
308
|
+
}
|
|
309
|
+
return hit;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** LRU set: insert then evict the least-recently-used entries past the bound. */
|
|
313
|
+
function setCachedMeshes(key: string, meshes: MeshData[]): void {
|
|
314
|
+
clashMeshCache.set(key, meshes);
|
|
315
|
+
while (clashMeshCache.size > CLASH_MESH_CACHE_MAX) {
|
|
316
|
+
const oldest = clashMeshCache.keys().next().value;
|
|
317
|
+
if (oldest === undefined) break;
|
|
318
|
+
clashMeshCache.delete(oldest);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Mesh the whole model once (in-browser, same path as PlaygroundViewer) and
|
|
323
|
+
* cache it. Throws UNSUPPORTED_OPERATION when the model carries no drawable
|
|
324
|
+
* geometry — clash needs tessellated solids, not quantity sets. */
|
|
325
|
+
async function meshForClash(m: LoadedPlaygroundModel): Promise<MeshData[]> {
|
|
326
|
+
const key = meshCacheKey(m);
|
|
327
|
+
const cached = getCachedMeshes(key);
|
|
328
|
+
if (cached) return cached;
|
|
329
|
+
|
|
330
|
+
const processor = new GeometryProcessor({ preferNative: false });
|
|
331
|
+
await processor.init();
|
|
332
|
+
// Use our owning byte snapshot — store.source can be a detached sub-view.
|
|
333
|
+
const result = await processor.process(
|
|
334
|
+
m.bytes,
|
|
335
|
+
m.store.entityIndex.byId as unknown as Map<number, unknown>,
|
|
336
|
+
);
|
|
337
|
+
const meshes = result.meshes ?? [];
|
|
338
|
+
if (meshes.length === 0) {
|
|
339
|
+
throw new ToolExecutionError({
|
|
340
|
+
code: ToolErrorCode.UNSUPPORTED_OPERATION,
|
|
341
|
+
message: 'No mesh geometry could be produced for this model; clash detection needs tessellated solids.',
|
|
342
|
+
hint: 'Confirm the model carries explicit geometry (not schema/quantity-only data).',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
setCachedMeshes(key, meshes);
|
|
346
|
+
return meshes;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The most recent clash result, so `clash_bcf_export` can turn the last run into
|
|
351
|
+
* a rich BCF without re-clashing (mirrors the viewer, where the ClashPanel holds
|
|
352
|
+
* the result the export dialog reads). Keyed by the SAME `id:fileSize` identity
|
|
353
|
+
* as the mesh cache — keying by `m.id` alone would serve a stale result after an
|
|
354
|
+
* edited re-upload (same filename slug) even though the meshes re-compute.
|
|
355
|
+
*/
|
|
356
|
+
const lastClashResult = new Map<string, ClashResult>();
|
|
357
|
+
|
|
358
|
+
/** Run a rule set against a model's meshes, returning (and caching) the result. */
|
|
359
|
+
async function runClashRules(m: LoadedPlaygroundModel, rules: ClashRule[]): Promise<ClashResult> {
|
|
360
|
+
const meshes = await meshForClash(m);
|
|
361
|
+
const { elements, exclusions } = elementsFromStep({ store: m.store, meshes, modelId: m.id });
|
|
362
|
+
const engine = createClashEngine({ backend: 'ts' });
|
|
363
|
+
const result = await engine.run(elements, rules, { exclusions, maxCandidatePairs: CLASH_MAX_CANDIDATE_PAIRS });
|
|
364
|
+
lastClashResult.set(meshCacheKey(m), result);
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** When the candidate-pair guardrail bit, say so in the human-readable text so
|
|
369
|
+
* totals are never silently a lower bound. Empty string when the run was
|
|
370
|
+
* complete. */
|
|
371
|
+
function clashCapNote(result: ClashResult): string {
|
|
372
|
+
if (!result.truncated) return '';
|
|
373
|
+
return ` Note: the ${CLASH_MAX_CANDIDATE_PAIRS.toLocaleString()}-candidate-pair guardrail was hit`
|
|
374
|
+
+ ` (${result.truncated.droppedPairs.toLocaleString()} pairs not evaluated) — totals are a lower bound.`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Top clashes by signed distance (deepest penetration / smallest gap first),
|
|
379
|
+
* capped for display. Sort by RAW distance ascending, not |distance|: hard
|
|
380
|
+
* clashes carry a negative penetration depth, so most-negative-first surfaces
|
|
381
|
+
* the DEEPEST penetrations (the worst, most actionable rows) instead of
|
|
382
|
+
* burying them past the cap; clearance gaps are positive, so the same order
|
|
383
|
+
* surfaces the tightest gaps first.
|
|
384
|
+
*/
|
|
385
|
+
function topClashRows(clashes: Clash[], cap: number): {
|
|
386
|
+
rows: Record<string, unknown>[];
|
|
387
|
+
truncated: { shown: number; dropped: number; total: number } | null;
|
|
388
|
+
} {
|
|
389
|
+
const sorted = [...clashes].sort((x, y) => x.distance - y.distance);
|
|
390
|
+
const shown = sorted.slice(0, cap);
|
|
391
|
+
const rows = shown.map((c) => ({
|
|
392
|
+
id: c.id,
|
|
393
|
+
rule: c.rule,
|
|
394
|
+
status: c.status,
|
|
395
|
+
severity: c.severity,
|
|
396
|
+
distance: c.distance,
|
|
397
|
+
point: c.point,
|
|
398
|
+
a: { key: c.a.key, ref: c.a.ref, tag: c.a.tag, name: c.a.name },
|
|
399
|
+
b: { key: c.b.key, ref: c.b.ref, tag: c.b.tag, name: c.b.name },
|
|
400
|
+
}));
|
|
401
|
+
const truncated = sorted.length > cap
|
|
402
|
+
? { shown: shown.length, dropped: sorted.length - shown.length, total: sorted.length }
|
|
403
|
+
: null;
|
|
404
|
+
return { rows, truncated };
|
|
405
|
+
}
|
|
406
|
+
|
|
251
407
|
const IMPLS: Record<string, ToolImpl> = {
|
|
252
408
|
// ── Discovery ───────────────────────────────────────────────────────────
|
|
253
409
|
async model_info(m) {
|
|
@@ -523,6 +679,127 @@ const IMPLS: Record<string, ToolImpl> = {
|
|
|
523
679
|
return { text: area == null ? 'No Area quantity present.' : `Area = ${area.toFixed(3)} m².`, structured: { area } };
|
|
524
680
|
},
|
|
525
681
|
|
|
682
|
+
// ── Clash detection ───────────────────────────────────────────────────────
|
|
683
|
+
async clash_check(m, args) {
|
|
684
|
+
const a = (args.a as string | undefined) ?? '*';
|
|
685
|
+
const b = args.b as string | undefined;
|
|
686
|
+
const mode = (args.mode as ClashMode | undefined) ?? 'hard';
|
|
687
|
+
const tolerance = args.tolerance as number | undefined;
|
|
688
|
+
const clearance = args.clearance as number | undefined;
|
|
689
|
+
// No `b` => self-clash within A (every element vs every other in the
|
|
690
|
+
// group); with the default a="*" that is "all clashes in the model".
|
|
691
|
+
const label = b ? `${a} vs ${b}` : a === '*' ? 'all elements (self-clash)' : `${a} (self-clash)`;
|
|
692
|
+
|
|
693
|
+
const rule: ClashRule = {
|
|
694
|
+
id: 'clash_check',
|
|
695
|
+
name: label,
|
|
696
|
+
a,
|
|
697
|
+
...(b != null ? { b } : {}),
|
|
698
|
+
mode,
|
|
699
|
+
...(tolerance != null ? { tolerance } : {}),
|
|
700
|
+
...(clearance != null ? { clearance } : {}),
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const result = await runClashRules(m, [rule]);
|
|
704
|
+
const { rows, truncated } = topClashRows(result.clashes, CLASH_DISPLAY_CAP);
|
|
705
|
+
const capNote = truncated
|
|
706
|
+
? ` Showing top ${truncated.shown} by penetration depth; ${truncated.dropped} more not shown.`
|
|
707
|
+
: '';
|
|
708
|
+
return {
|
|
709
|
+
text: `Found ${result.summary.total} clash(es) for ${label} (mode=${mode}).${capNote}${clashCapNote(result)}`,
|
|
710
|
+
structured: {
|
|
711
|
+
summary: result.summary,
|
|
712
|
+
settings: { a, b: b ?? null, mode, tolerance: tolerance ?? null, clearance: clearance ?? null },
|
|
713
|
+
engineSettings: result.settings,
|
|
714
|
+
truncated: result.truncated ?? null,
|
|
715
|
+
clashes: rows,
|
|
716
|
+
clashesTruncated: truncated,
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
},
|
|
720
|
+
async clash_matrix(m, args) {
|
|
721
|
+
const mode = (args.mode as ClashMode | undefined) ?? 'hard';
|
|
722
|
+
const clearance = args.clearance as number | undefined;
|
|
723
|
+
const rules = disciplineMatrixRules(mode, clearance);
|
|
724
|
+
|
|
725
|
+
const result = await runClashRules(m, rules);
|
|
726
|
+
const { rows, truncated } = topClashRows(result.clashes, CLASH_DISPLAY_CAP);
|
|
727
|
+
const capNote = truncated
|
|
728
|
+
? ` Sampling top ${truncated.shown} by penetration depth; ${truncated.dropped} more not shown.`
|
|
729
|
+
: '';
|
|
730
|
+
return {
|
|
731
|
+
text: `Discipline matrix (mode=${mode}, ${rules.length} rules): ${result.summary.total} clash(es).${capNote}${clashCapNote(result)}`,
|
|
732
|
+
structured: {
|
|
733
|
+
mode,
|
|
734
|
+
ruleCount: rules.length,
|
|
735
|
+
byRule: result.summary.byRule,
|
|
736
|
+
bySeverity: result.summary.bySeverity,
|
|
737
|
+
byTypePair: result.summary.byTypePair,
|
|
738
|
+
summary: result.summary,
|
|
739
|
+
engineSettings: result.settings,
|
|
740
|
+
truncated: result.truncated ?? null,
|
|
741
|
+
sampleClashes: rows,
|
|
742
|
+
sampleTruncated: truncated,
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
},
|
|
746
|
+
async clash_bcf_export(m, args) {
|
|
747
|
+
const groupBy = (args.group_by as GroupOptions['by'] | undefined) ?? 'cluster';
|
|
748
|
+
const epsilon = args.cluster_epsilon as number | undefined;
|
|
749
|
+
const status = args.status as string | undefined;
|
|
750
|
+
const maxTopics = args.max_topics as number | undefined;
|
|
751
|
+
|
|
752
|
+
// Reuse the last clash run for this model; if there is none, run a default
|
|
753
|
+
// all-vs-all hard self-clash so the tool works standalone (and caches it).
|
|
754
|
+
let result = lastClashResult.get(meshCacheKey(m));
|
|
755
|
+
if (!result) {
|
|
756
|
+
result = await runClashRules(m, [{ id: 'clash_check', name: 'all elements (self-clash)', a: '*', mode: 'hard' }]);
|
|
757
|
+
}
|
|
758
|
+
if (result.summary.total === 0) {
|
|
759
|
+
return {
|
|
760
|
+
text: 'No clashes to export — the last clash run found 0. Run clash_check first (omit a and b for every element vs every other).',
|
|
761
|
+
structured: { topics: 0, clashes: 0 },
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// One BCF topic per clash group, each carrying a framed viewpoint (camera +
|
|
766
|
+
// the clashing elements as components) and severity/status/distance
|
|
767
|
+
// metadata — the same bridge the viewer's clash→BCF export uses. Snapshots
|
|
768
|
+
// are omitted: the inline ViewerController can't render frames headlessly,
|
|
769
|
+
// and BCF viewpoints are valid without an embedded image.
|
|
770
|
+
const groups = groupClashes(result, { by: groupBy, ...(epsilon != null ? { epsilon } : {}) });
|
|
771
|
+
const project = await createBCFFromClashResult(result, groups, {
|
|
772
|
+
author: 'clash@ifc-lite',
|
|
773
|
+
projectName: 'Clash report',
|
|
774
|
+
...(status ? { status } : {}),
|
|
775
|
+
...(maxTopics != null ? { maxTopics } : {}),
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const filename = coerceFilename(args.file_path as string | undefined, 'bcfzip', 'clashes');
|
|
779
|
+
const blob = await writeBCF(project);
|
|
780
|
+
const file = playgroundFiles.add({
|
|
781
|
+
filename,
|
|
782
|
+
mimeType: 'application/zip',
|
|
783
|
+
size: blob.size,
|
|
784
|
+
blob,
|
|
785
|
+
source: 'clash_bcf_export',
|
|
786
|
+
description: `${project.topics.size} clash topic(s), grouped by ${groupBy}`,
|
|
787
|
+
});
|
|
788
|
+
return {
|
|
789
|
+
text: `Bundled ${filename}: ${project.topics.size} BCF topic(s) from ${result.summary.total} clash(es), grouped by ${groupBy}`
|
|
790
|
+
+ ` — each with a framed viewpoint + clashing components + severity/distance metadata. (Snapshots omitted: the inline viewer can't capture frames headlessly.)`,
|
|
791
|
+
structured: {
|
|
792
|
+
fileId: file.id,
|
|
793
|
+
filename,
|
|
794
|
+
bytes: blob.size,
|
|
795
|
+
topics: project.topics.size,
|
|
796
|
+
groupBy,
|
|
797
|
+
clashes: result.summary.total,
|
|
798
|
+
},
|
|
799
|
+
download: { fileId: file.id, filename, mimeType: 'application/zip', size: blob.size, label: 'Get .bcfzip' },
|
|
800
|
+
};
|
|
801
|
+
},
|
|
802
|
+
|
|
526
803
|
// ── Validation ──────────────────────────────────────────────────────────
|
|
527
804
|
async model_audit(m) {
|
|
528
805
|
let issues = 0;
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
* ComboInput — a free-text input that opens a suggestion dropdown on focus
|
|
7
|
+
* and filters it as you type. Pick a suggestion or keep typing anything;
|
|
8
|
+
* the value is never restricted to the options. Used by the filter chip
|
|
9
|
+
* editors to surface real model values (materials, classifications,
|
|
10
|
+
* property values, pset/qto names) without hiding them behind a tiny chevron.
|
|
11
|
+
*
|
|
12
|
+
* The list is portaled to `document.body` and fixed-positioned under the
|
|
13
|
+
* input so it's never clipped by the modal's scroll container, and it
|
|
14
|
+
* follows the input on scroll / resize.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
18
|
+
import { createPortal } from 'react-dom';
|
|
19
|
+
import { Input } from '@/components/ui/input';
|
|
20
|
+
import { cn } from '@/lib/utils';
|
|
21
|
+
|
|
22
|
+
export interface ComboInputProps {
|
|
23
|
+
value: string;
|
|
24
|
+
onChange: (next: string) => void;
|
|
25
|
+
options: ReadonlyArray<string>;
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
/** Cap rendered suggestions (filtering still scans all options). */
|
|
29
|
+
maxRendered?: number;
|
|
30
|
+
'aria-label'?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Anchor { left: number; top: number; width: number }
|
|
34
|
+
|
|
35
|
+
export function ComboInput({
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
options,
|
|
39
|
+
placeholder,
|
|
40
|
+
className,
|
|
41
|
+
maxRendered = 50,
|
|
42
|
+
'aria-label': ariaLabel,
|
|
43
|
+
}: ComboInputProps) {
|
|
44
|
+
const [open, setOpen] = useState(false);
|
|
45
|
+
const [highlight, setHighlight] = useState(0);
|
|
46
|
+
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
|
47
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
|
|
50
|
+
const filtered = useMemo(() => {
|
|
51
|
+
const q = value.trim().toLowerCase();
|
|
52
|
+
const matches = q ? options.filter((o) => o.toLowerCase().includes(q)) : options;
|
|
53
|
+
return matches.slice(0, maxRendered);
|
|
54
|
+
}, [options, value, maxRendered]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => { setHighlight(0); }, [filtered]);
|
|
57
|
+
|
|
58
|
+
const reposition = useCallback(() => {
|
|
59
|
+
const el = inputRef.current;
|
|
60
|
+
if (!el) return;
|
|
61
|
+
const r = el.getBoundingClientRect();
|
|
62
|
+
setAnchor({ left: r.left, top: r.bottom, width: r.width });
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Track the input's position while open (capture = also catch ancestor
|
|
66
|
+
// scrolls inside the modal), and close on outside pointer-down / Escape.
|
|
67
|
+
useLayoutEffect(() => {
|
|
68
|
+
if (!open) return;
|
|
69
|
+
reposition();
|
|
70
|
+
const onScroll = () => reposition();
|
|
71
|
+
window.addEventListener('scroll', onScroll, true);
|
|
72
|
+
window.addEventListener('resize', onScroll);
|
|
73
|
+
const onDown = (e: MouseEvent) => {
|
|
74
|
+
const t = e.target as Node;
|
|
75
|
+
if (inputRef.current?.contains(t) || listRef.current?.contains(t)) return;
|
|
76
|
+
setOpen(false);
|
|
77
|
+
};
|
|
78
|
+
window.addEventListener('mousedown', onDown);
|
|
79
|
+
return () => {
|
|
80
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
81
|
+
window.removeEventListener('resize', onScroll);
|
|
82
|
+
window.removeEventListener('mousedown', onDown);
|
|
83
|
+
};
|
|
84
|
+
}, [open, reposition]);
|
|
85
|
+
|
|
86
|
+
const showList = open && filtered.length > 0 && anchor !== null;
|
|
87
|
+
|
|
88
|
+
const commit = (v: string) => {
|
|
89
|
+
onChange(v);
|
|
90
|
+
setOpen(false);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
<Input
|
|
96
|
+
ref={inputRef}
|
|
97
|
+
value={value}
|
|
98
|
+
placeholder={placeholder}
|
|
99
|
+
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
|
|
100
|
+
onFocus={() => setOpen(true)}
|
|
101
|
+
onClick={() => setOpen(true)}
|
|
102
|
+
onKeyDown={(e) => {
|
|
103
|
+
if (e.key === 'ArrowDown') {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
setOpen(true);
|
|
106
|
+
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
|
|
107
|
+
} else if (e.key === 'ArrowUp') {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
setHighlight((h) => Math.max(h - 1, 0));
|
|
110
|
+
} else if (e.key === 'Enter') {
|
|
111
|
+
if (showList && filtered[highlight] !== undefined) {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
commit(filtered[highlight]);
|
|
114
|
+
}
|
|
115
|
+
} else if (e.key === 'Escape') {
|
|
116
|
+
if (open) { e.stopPropagation(); setOpen(false); }
|
|
117
|
+
}
|
|
118
|
+
}}
|
|
119
|
+
className={className}
|
|
120
|
+
autoComplete="off"
|
|
121
|
+
role="combobox"
|
|
122
|
+
aria-expanded={showList}
|
|
123
|
+
aria-label={ariaLabel}
|
|
124
|
+
/>
|
|
125
|
+
{showList && createPortal(
|
|
126
|
+
<div
|
|
127
|
+
ref={listRef}
|
|
128
|
+
role="listbox"
|
|
129
|
+
// Portaled to <body>, which sits OUTSIDE the Radix Dialog. Radix's
|
|
130
|
+
// scroll-lock disables pointer events on everything outside the
|
|
131
|
+
// dialog, so re-enable them here or mouse clicks/scroll are dead.
|
|
132
|
+
// Stop pointerdown from bubbling to the dialog's dismissable layer
|
|
133
|
+
// so selecting a value doesn't also close the whole modal.
|
|
134
|
+
style={{ position: 'fixed', left: anchor.left, top: anchor.top + 4, minWidth: anchor.width, pointerEvents: 'auto' }}
|
|
135
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
136
|
+
className="z-[120] max-h-60 w-max max-w-[20rem] overflow-y-auto rounded-md border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-950"
|
|
137
|
+
>
|
|
138
|
+
{filtered.map((o, i) => (
|
|
139
|
+
<button
|
|
140
|
+
key={o}
|
|
141
|
+
type="button"
|
|
142
|
+
role="option"
|
|
143
|
+
aria-selected={i === highlight}
|
|
144
|
+
// mousedown (not click) so the input doesn't blur-close first.
|
|
145
|
+
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
|
|
146
|
+
onMouseEnter={() => setHighlight(i)}
|
|
147
|
+
className={cn(
|
|
148
|
+
'block w-full truncate px-2 py-1 text-left text-xs font-mono',
|
|
149
|
+
i === highlight
|
|
150
|
+
? 'bg-zinc-100 dark:bg-zinc-800'
|
|
151
|
+
: 'hover:bg-zinc-50 dark:hover:bg-zinc-900',
|
|
152
|
+
)}
|
|
153
|
+
title={o}
|
|
154
|
+
>
|
|
155
|
+
{o}
|
|
156
|
+
</button>
|
|
157
|
+
))}
|
|
158
|
+
</div>,
|
|
159
|
+
document.body,
|
|
160
|
+
)}
|
|
161
|
+
</>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -30,7 +30,7 @@ const TabsTrigger = React.forwardRef<
|
|
|
30
30
|
<TabsPrimitive.Trigger
|
|
31
31
|
ref={ref}
|
|
32
32
|
className={cn(
|
|
33
|
-
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
33
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
|
34
34
|
className
|
|
35
35
|
)}
|
|
36
36
|
{...props}
|
|
@@ -512,7 +512,7 @@ export function PropertiesPanel() {
|
|
|
512
512
|
if (!entityNode) return [];
|
|
513
513
|
|
|
514
514
|
const rawProps = entityNode.properties();
|
|
515
|
-
let result = rawProps.map(pset => ({
|
|
515
|
+
let result: DisplayPropertySet[] = rawProps.map(pset => ({
|
|
516
516
|
name: pset.name,
|
|
517
517
|
properties: pset.properties.map(p => ({ name: p.name, value: p.value, isMutated: false })),
|
|
518
518
|
isNewPset: false,
|
|
@@ -970,21 +970,28 @@ export function PropertiesPanel() {
|
|
|
970
970
|
}));
|
|
971
971
|
}, [nativeDetails]);
|
|
972
972
|
|
|
973
|
+
// Overlay (authored) entities — split halves, duplicates, scripted
|
|
974
|
+
// adds — live only in the StoreEditor overlay, NOT the parsed store.
|
|
975
|
+
// `modelQuery.entity()` always returns a node, and its getters fall
|
|
976
|
+
// back to the 'Unknown'/'' sentinels for ids absent from the parsed
|
|
977
|
+
// table (entity-table.ts#getTypeName). Those non-null sentinels would
|
|
978
|
+
// shadow the overlay record in an `entityNode ?? overlay` chain, so
|
|
979
|
+
// when an overlay record exists it MUST take precedence.
|
|
973
980
|
const renderedEntityType = isNativeLazySelection
|
|
974
981
|
? (nativeDetails?.summary.type ?? 'Loading...')
|
|
975
|
-
: (
|
|
982
|
+
: (overlayEntity?.type ?? entityNode?.type ?? 'Unknown');
|
|
976
983
|
const renderedEntityName = isNativeLazySelection
|
|
977
984
|
? (nativeDetails?.summary.name ?? `#${selectedEntity?.expressId ?? ''}`)
|
|
978
|
-
: (
|
|
985
|
+
: (overlayAttr(2) ?? entityNode?.name ?? undefined);
|
|
979
986
|
const renderedEntityGlobalId = isNativeLazySelection
|
|
980
987
|
? (nativeDetails?.summary.globalId ?? null)
|
|
981
|
-
: (
|
|
988
|
+
: (overlayAttr(0) ?? entityNode?.globalId);
|
|
982
989
|
const renderedEntityDescription = isNativeLazySelection
|
|
983
990
|
? undefined
|
|
984
|
-
: (
|
|
991
|
+
: (overlayAttr(3) ?? entityNode?.description ?? undefined);
|
|
985
992
|
const renderedEntityObjectType = isNativeLazySelection
|
|
986
993
|
? undefined
|
|
987
|
-
: (
|
|
994
|
+
: (overlayAttr(4) ?? entityNode?.objectType ?? undefined);
|
|
988
995
|
const renderedSpatialInfo = isNativeLazySelection ? nativeSpatialInfo : spatialInfo;
|
|
989
996
|
const renderedOccurrenceProperties = isNativeLazySelection ? nativeOccurrenceProperties : occurrenceProperties;
|
|
990
997
|
const renderedInheritedTypeProperties = isNativeLazySelection ? [] : inheritedTypeProperties;
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
28
|
-
import { Search, Clock, X } from 'lucide-react';
|
|
28
|
+
import { Search, Clock, X, SlidersHorizontal } from 'lucide-react';
|
|
29
29
|
import { useShallow } from 'zustand/react/shallow';
|
|
30
30
|
import { Input } from '@/components/ui/input';
|
|
31
31
|
import { useViewerStore } from '@/store';
|
|
@@ -79,6 +79,9 @@ export function SearchInline() {
|
|
|
79
79
|
exitVimCycle,
|
|
80
80
|
stepVimCycle,
|
|
81
81
|
setSearchModalOpen,
|
|
82
|
+
setSearchModalTab,
|
|
83
|
+
activeRuleCount,
|
|
84
|
+
clearFilterRules,
|
|
82
85
|
models,
|
|
83
86
|
setSelectedEntity,
|
|
84
87
|
setSelectedEntityId,
|
|
@@ -99,6 +102,9 @@ export function SearchInline() {
|
|
|
99
102
|
exitVimCycle: s.exitVimCycle,
|
|
100
103
|
stepVimCycle: s.stepVimCycle,
|
|
101
104
|
setSearchModalOpen: s.setSearchModalOpen,
|
|
105
|
+
setSearchModalTab: s.setSearchModalTab,
|
|
106
|
+
activeRuleCount: s.searchFilter.rules.length,
|
|
107
|
+
clearFilterRules: s.clearFilterRules,
|
|
102
108
|
models: s.models,
|
|
103
109
|
setSelectedEntity: s.setSelectedEntity,
|
|
104
110
|
setSelectedEntityId: s.setSelectedEntityId,
|
|
@@ -395,8 +401,10 @@ export function SearchInline() {
|
|
|
395
401
|
e.preventDefault();
|
|
396
402
|
// ⌘↵ / Ctrl+↵ opens the advanced modal instead of committing — the
|
|
397
403
|
// inline query is preserved so the modal opens already populated.
|
|
404
|
+
// Text-search entry point, so land on the Search tab.
|
|
398
405
|
if (e.metaKey || e.ctrlKey) {
|
|
399
406
|
setSearchOpen(false);
|
|
407
|
+
setSearchModalTab('search');
|
|
400
408
|
setSearchModalOpen(true);
|
|
401
409
|
return;
|
|
402
410
|
}
|
|
@@ -416,9 +424,19 @@ export function SearchInline() {
|
|
|
416
424
|
if (target) commitResult(target, idx, e.shiftKey, liveResults, live);
|
|
417
425
|
}
|
|
418
426
|
},
|
|
419
|
-
[commitResult, results, searchHighlightIndex, searchOpen, setSearchHighlightIndex, setSearchModalOpen, setSearchOpen],
|
|
427
|
+
[commitResult, results, searchHighlightIndex, searchOpen, setSearchHighlightIndex, setSearchModalOpen, setSearchModalTab, setSearchOpen],
|
|
420
428
|
);
|
|
421
429
|
|
|
430
|
+
const hasFilters = activeRuleCount > 0;
|
|
431
|
+
|
|
432
|
+
/** Open the advanced modal straight to the Filter builder — the
|
|
433
|
+
* always-visible entry point to structured filtering. */
|
|
434
|
+
const openAdvancedFilter = useCallback(() => {
|
|
435
|
+
setSearchOpen(false);
|
|
436
|
+
setSearchModalTab('filter');
|
|
437
|
+
setSearchModalOpen(true);
|
|
438
|
+
}, [setSearchOpen, setSearchModalTab, setSearchModalOpen]);
|
|
439
|
+
|
|
422
440
|
const queryTrimmedLen = searchQuery.trim().length;
|
|
423
441
|
const showPopover = searchOpen && (results.length > 0 || queryTrimmedLen > 0 || recents.length > 0);
|
|
424
442
|
const showRecents = searchOpen && queryTrimmedLen === 0 && recents.length > 0;
|
|
@@ -437,11 +455,52 @@ export function SearchInline() {
|
|
|
437
455
|
}}
|
|
438
456
|
onFocus={() => setSearchOpen(true)}
|
|
439
457
|
onKeyDown={handleInputKeyDown}
|
|
458
|
+
className={cn(hasFilters ? 'pr-[4.5rem]' : 'pr-9')}
|
|
440
459
|
aria-label="Search entities"
|
|
441
460
|
aria-autocomplete="list"
|
|
442
461
|
aria-expanded={showPopover}
|
|
443
462
|
aria-controls="search-inline-popover"
|
|
444
463
|
/>
|
|
464
|
+
{/* Advanced-filter affordance — always visible so structured
|
|
465
|
+
filtering is discoverable without the ⌘⇧F shortcut. Shows the
|
|
466
|
+
active rule count and a quick-clear when a filter is applied. */}
|
|
467
|
+
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
|
|
468
|
+
{hasFilters && (
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
aria-label="Clear filters"
|
|
472
|
+
title="Clear filters"
|
|
473
|
+
onMouseDown={(e) => {
|
|
474
|
+
e.preventDefault();
|
|
475
|
+
clearFilterRules();
|
|
476
|
+
}}
|
|
477
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800"
|
|
478
|
+
>
|
|
479
|
+
<X className="h-3.5 w-3.5" />
|
|
480
|
+
</button>
|
|
481
|
+
)}
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
aria-label={hasFilters ? `Advanced filter — ${activeRuleCount} active` : 'Advanced filter'}
|
|
485
|
+
aria-pressed={hasFilters}
|
|
486
|
+
title="Advanced filter (⌘⇧F)"
|
|
487
|
+
onMouseDown={(e) => {
|
|
488
|
+
e.preventDefault();
|
|
489
|
+
openAdvancedFilter();
|
|
490
|
+
}}
|
|
491
|
+
className={cn(
|
|
492
|
+
'flex items-center gap-1 rounded px-1.5 py-1 text-xs transition-colors',
|
|
493
|
+
hasFilters
|
|
494
|
+
? 'bg-primary/10 text-primary hover:bg-primary/15'
|
|
495
|
+
: 'text-muted-foreground hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800',
|
|
496
|
+
)}
|
|
497
|
+
>
|
|
498
|
+
<SlidersHorizontal className="h-3.5 w-3.5" />
|
|
499
|
+
{hasFilters && (
|
|
500
|
+
<span className="font-mono text-[10px] font-semibold leading-none">{activeRuleCount}</span>
|
|
501
|
+
)}
|
|
502
|
+
</button>
|
|
503
|
+
</div>
|
|
445
504
|
{/* Vim cycle hint — shows below the input whenever a cycle is active
|
|
446
505
|
and the popover is closed. Clicking it exits the cycle. */}
|
|
447
506
|
{searchVimCycle && !showPopover && (
|
|
@@ -475,6 +534,7 @@ export function SearchInline() {
|
|
|
475
534
|
onHover={(i) => setSearchHighlightIndex(i)}
|
|
476
535
|
onOpenAdvanced={() => {
|
|
477
536
|
setSearchOpen(false);
|
|
537
|
+
setSearchModalTab('search');
|
|
478
538
|
setSearchModalOpen(true);
|
|
479
539
|
}}
|
|
480
540
|
/>
|