@ifc-lite/viewer 1.26.0 → 1.28.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 +45 -38
- package/CHANGELOG.md +93 -0
- package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
- package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
- 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/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/deflate-DNGgs8Ur.js +1 -0
- package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
- package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
- package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
- package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
- package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
- package/dist/assets/index-BtbXFKsX.css +1 -0
- package/dist/assets/index.es-CWfqZyyr.js +6866 -0
- package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
- package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
- package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.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-XxXos6yI.js} +2 -2
- package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
- package/dist/assets/pdf-CRwaZf3s.js +135 -0
- package/dist/assets/raw-CJgQdyuZ.js +1 -0
- package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
- package/dist/assets/server-client-cTCJ-853.js +719 -0
- package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
- package/dist/assets/xlsx-B1YOg2QB.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
- package/dist/index.html +10 -10
- package/package.json +27 -23
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +280 -0
- package/src/components/mcp/playground-files.ts +33 -1
- 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/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +46 -7
- package/src/components/viewer/MainToolbar.tsx +19 -2
- package/src/components/viewer/PropertiesPanel.tsx +84 -8
- 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 +18 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- 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/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +481 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +1 -26
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +23 -1
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +32 -9
- package/src/hooks/useIfcFederation.ts +42 -810
- package/src/hooks/useIfcLoader.ts +361 -488
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- 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/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- 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/search/result-export.ts +7 -1
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +9 -4
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +16 -1
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/lensSlice.ts +8 -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/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/desktopModelSnapshot.ts +2 -1
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/dist/assets/deflate-Cnx0il6E.js +0 -1
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- 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/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-8md211IW.js +0 -182
- package/dist/assets/raw-BQrAgxwT.js +0 -1
- package/dist/assets/server-client-Bk4c1CPO.js +0 -626
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
|
@@ -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';
|
|
@@ -175,6 +188,9 @@ async function autoStageBcfDownload(): Promise<NonNullable<ToolDispatchResult['d
|
|
|
175
188
|
description: `${project.topics.size} topic(s) · auto-updates as you edit`,
|
|
176
189
|
});
|
|
177
190
|
stagedBcfFileId = file.id;
|
|
191
|
+
// Exempt the always-current BCF bundle from store eviction so its tracked
|
|
192
|
+
// id can never be orphaned by later export/ids/model_save additions.
|
|
193
|
+
playgroundFiles.pin(file.id);
|
|
178
194
|
return {
|
|
179
195
|
fileId: file.id,
|
|
180
196
|
filename,
|
|
@@ -248,6 +264,149 @@ function formatColorTuple(c: ColorTuple): string {
|
|
|
248
264
|
*/
|
|
249
265
|
const PROXIED_BSDD = new BsddNamespace({ apiBase: '/api/bsdd' });
|
|
250
266
|
|
|
267
|
+
// ── Clash detection (in-browser meshing → TS clash engine) ─────────────────
|
|
268
|
+
// Mirrors `packages/mcp/src/tools/clash.ts`: the whole model is meshed once
|
|
269
|
+
// (headless WASM pipeline, same as the inline viewer) and cached by model id,
|
|
270
|
+
// then the representation-agnostic clash engine runs against TYPE selectors.
|
|
271
|
+
|
|
272
|
+
/** Cap on clashes returned in a tool result. The dropped count is reported. */
|
|
273
|
+
const CLASH_DISPLAY_CAP = 50;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Runaway guardrail for the TS clash engine: the whole-run candidate-pair
|
|
277
|
+
* budget (broad-phase AABB-overlap survivors across every rule). The engine
|
|
278
|
+
* yields between chunks so it never hard-freezes the tab, but an agent can
|
|
279
|
+
* fire `clash_check` (all-vs-all) on an arbitrarily large uploaded model
|
|
280
|
+
* unattended — this ceiling bounds the narrow-phase work and is reported via
|
|
281
|
+
* `result.truncated` (never silent). Generous on purpose: real building models
|
|
282
|
+
* stay well under it, so totals match the viewer's (unbounded) ClashPanel; only
|
|
283
|
+
* pathological models get bounded.
|
|
284
|
+
*/
|
|
285
|
+
const CLASH_MAX_CANDIDATE_PAIRS = 5_000_000;
|
|
286
|
+
|
|
287
|
+
/** Max distinct models whose meshes we keep cached at once (LRU). The playground
|
|
288
|
+
* is effectively single-model, so a small bound is plenty and stops a long
|
|
289
|
+
* session of re-uploads from pinning every model's meshes in memory. */
|
|
290
|
+
const CLASH_MESH_CACHE_MAX = 3;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Module-level mesh cache so repeated clash calls on the same model don't
|
|
294
|
+
* re-run the (expensive) headless tessellation. Keyed by `id:fileSize`, NOT id
|
|
295
|
+
* alone: the playground reuses a filename-slug id, so an edited re-upload would
|
|
296
|
+
* otherwise hit a stale mesh — folding in the byte length forces a re-mesh when
|
|
297
|
+
* the bytes change. Bounded to CLASH_MESH_CACHE_MAX entries with LRU eviction.
|
|
298
|
+
*/
|
|
299
|
+
const clashMeshCache = new Map<string, MeshData[]>();
|
|
300
|
+
|
|
301
|
+
function meshCacheKey(m: LoadedPlaygroundModel): string {
|
|
302
|
+
return `${m.id}:${m.fileSize}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** LRU get: a hit refreshes recency so the active model survives eviction. */
|
|
306
|
+
function getCachedMeshes(key: string): MeshData[] | undefined {
|
|
307
|
+
const hit = clashMeshCache.get(key);
|
|
308
|
+
if (hit) {
|
|
309
|
+
clashMeshCache.delete(key);
|
|
310
|
+
clashMeshCache.set(key, hit);
|
|
311
|
+
}
|
|
312
|
+
return hit;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** LRU set: insert then evict the least-recently-used entries past the bound. */
|
|
316
|
+
function setCachedMeshes(key: string, meshes: MeshData[]): void {
|
|
317
|
+
clashMeshCache.set(key, meshes);
|
|
318
|
+
while (clashMeshCache.size > CLASH_MESH_CACHE_MAX) {
|
|
319
|
+
const oldest = clashMeshCache.keys().next().value;
|
|
320
|
+
if (oldest === undefined) break;
|
|
321
|
+
clashMeshCache.delete(oldest);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Mesh the whole model once (in-browser, same path as PlaygroundViewer) and
|
|
326
|
+
* cache it. Throws UNSUPPORTED_OPERATION when the model carries no drawable
|
|
327
|
+
* geometry — clash needs tessellated solids, not quantity sets. */
|
|
328
|
+
async function meshForClash(m: LoadedPlaygroundModel): Promise<MeshData[]> {
|
|
329
|
+
const key = meshCacheKey(m);
|
|
330
|
+
const cached = getCachedMeshes(key);
|
|
331
|
+
if (cached) return cached;
|
|
332
|
+
|
|
333
|
+
const processor = new GeometryProcessor({ preferNative: false });
|
|
334
|
+
await processor.init();
|
|
335
|
+
// Use our owning byte snapshot — store.source can be a detached sub-view.
|
|
336
|
+
const result = await processor.process(
|
|
337
|
+
m.bytes,
|
|
338
|
+
m.store.entityIndex.byId as unknown as Map<number, unknown>,
|
|
339
|
+
);
|
|
340
|
+
const meshes = result.meshes ?? [];
|
|
341
|
+
if (meshes.length === 0) {
|
|
342
|
+
throw new ToolExecutionError({
|
|
343
|
+
code: ToolErrorCode.UNSUPPORTED_OPERATION,
|
|
344
|
+
message: 'No mesh geometry could be produced for this model; clash detection needs tessellated solids.',
|
|
345
|
+
hint: 'Confirm the model carries explicit geometry (not schema/quantity-only data).',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
setCachedMeshes(key, meshes);
|
|
349
|
+
return meshes;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The most recent clash result, so `clash_bcf_export` can turn the last run into
|
|
354
|
+
* a rich BCF without re-clashing (mirrors the viewer, where the ClashPanel holds
|
|
355
|
+
* the result the export dialog reads). Keyed by the SAME `id:fileSize` identity
|
|
356
|
+
* as the mesh cache — keying by `m.id` alone would serve a stale result after an
|
|
357
|
+
* edited re-upload (same filename slug) even though the meshes re-compute.
|
|
358
|
+
*/
|
|
359
|
+
const lastClashResult = new Map<string, ClashResult>();
|
|
360
|
+
|
|
361
|
+
/** Run a rule set against a model's meshes, returning (and caching) the result. */
|
|
362
|
+
async function runClashRules(m: LoadedPlaygroundModel, rules: ClashRule[]): Promise<ClashResult> {
|
|
363
|
+
const meshes = await meshForClash(m);
|
|
364
|
+
const { elements, exclusions } = elementsFromStep({ store: m.store, meshes, modelId: m.id });
|
|
365
|
+
const engine = createClashEngine({ backend: 'ts' });
|
|
366
|
+
const result = await engine.run(elements, rules, { exclusions, maxCandidatePairs: CLASH_MAX_CANDIDATE_PAIRS });
|
|
367
|
+
lastClashResult.set(meshCacheKey(m), result);
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** When the candidate-pair guardrail bit, say so in the human-readable text so
|
|
372
|
+
* totals are never silently a lower bound. Empty string when the run was
|
|
373
|
+
* complete. */
|
|
374
|
+
function clashCapNote(result: ClashResult): string {
|
|
375
|
+
if (!result.truncated) return '';
|
|
376
|
+
return ` Note: the ${CLASH_MAX_CANDIDATE_PAIRS.toLocaleString()}-candidate-pair guardrail was hit`
|
|
377
|
+
+ ` (${result.truncated.droppedPairs.toLocaleString()} pairs not evaluated) — totals are a lower bound.`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Top clashes by signed distance (deepest penetration / smallest gap first),
|
|
382
|
+
* capped for display. Sort by RAW distance ascending, not |distance|: hard
|
|
383
|
+
* clashes carry a negative penetration depth, so most-negative-first surfaces
|
|
384
|
+
* the DEEPEST penetrations (the worst, most actionable rows) instead of
|
|
385
|
+
* burying them past the cap; clearance gaps are positive, so the same order
|
|
386
|
+
* surfaces the tightest gaps first.
|
|
387
|
+
*/
|
|
388
|
+
function topClashRows(clashes: Clash[], cap: number): {
|
|
389
|
+
rows: Record<string, unknown>[];
|
|
390
|
+
truncated: { shown: number; dropped: number; total: number } | null;
|
|
391
|
+
} {
|
|
392
|
+
const sorted = [...clashes].sort((x, y) => x.distance - y.distance);
|
|
393
|
+
const shown = sorted.slice(0, cap);
|
|
394
|
+
const rows = shown.map((c) => ({
|
|
395
|
+
id: c.id,
|
|
396
|
+
rule: c.rule,
|
|
397
|
+
status: c.status,
|
|
398
|
+
severity: c.severity,
|
|
399
|
+
distance: c.distance,
|
|
400
|
+
point: c.point,
|
|
401
|
+
a: { key: c.a.key, ref: c.a.ref, tag: c.a.tag, name: c.a.name },
|
|
402
|
+
b: { key: c.b.key, ref: c.b.ref, tag: c.b.tag, name: c.b.name },
|
|
403
|
+
}));
|
|
404
|
+
const truncated = sorted.length > cap
|
|
405
|
+
? { shown: shown.length, dropped: sorted.length - shown.length, total: sorted.length }
|
|
406
|
+
: null;
|
|
407
|
+
return { rows, truncated };
|
|
408
|
+
}
|
|
409
|
+
|
|
251
410
|
const IMPLS: Record<string, ToolImpl> = {
|
|
252
411
|
// ── Discovery ───────────────────────────────────────────────────────────
|
|
253
412
|
async model_info(m) {
|
|
@@ -523,6 +682,127 @@ const IMPLS: Record<string, ToolImpl> = {
|
|
|
523
682
|
return { text: area == null ? 'No Area quantity present.' : `Area = ${area.toFixed(3)} m².`, structured: { area } };
|
|
524
683
|
},
|
|
525
684
|
|
|
685
|
+
// ── Clash detection ───────────────────────────────────────────────────────
|
|
686
|
+
async clash_check(m, args) {
|
|
687
|
+
const a = (args.a as string | undefined) ?? '*';
|
|
688
|
+
const b = args.b as string | undefined;
|
|
689
|
+
const mode = (args.mode as ClashMode | undefined) ?? 'hard';
|
|
690
|
+
const tolerance = args.tolerance as number | undefined;
|
|
691
|
+
const clearance = args.clearance as number | undefined;
|
|
692
|
+
// No `b` => self-clash within A (every element vs every other in the
|
|
693
|
+
// group); with the default a="*" that is "all clashes in the model".
|
|
694
|
+
const label = b ? `${a} vs ${b}` : a === '*' ? 'all elements (self-clash)' : `${a} (self-clash)`;
|
|
695
|
+
|
|
696
|
+
const rule: ClashRule = {
|
|
697
|
+
id: 'clash_check',
|
|
698
|
+
name: label,
|
|
699
|
+
a,
|
|
700
|
+
...(b != null ? { b } : {}),
|
|
701
|
+
mode,
|
|
702
|
+
...(tolerance != null ? { tolerance } : {}),
|
|
703
|
+
...(clearance != null ? { clearance } : {}),
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
const result = await runClashRules(m, [rule]);
|
|
707
|
+
const { rows, truncated } = topClashRows(result.clashes, CLASH_DISPLAY_CAP);
|
|
708
|
+
const capNote = truncated
|
|
709
|
+
? ` Showing top ${truncated.shown} by penetration depth; ${truncated.dropped} more not shown.`
|
|
710
|
+
: '';
|
|
711
|
+
return {
|
|
712
|
+
text: `Found ${result.summary.total} clash(es) for ${label} (mode=${mode}).${capNote}${clashCapNote(result)}`,
|
|
713
|
+
structured: {
|
|
714
|
+
summary: result.summary,
|
|
715
|
+
settings: { a, b: b ?? null, mode, tolerance: tolerance ?? null, clearance: clearance ?? null },
|
|
716
|
+
engineSettings: result.settings,
|
|
717
|
+
truncated: result.truncated ?? null,
|
|
718
|
+
clashes: rows,
|
|
719
|
+
clashesTruncated: truncated,
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
},
|
|
723
|
+
async clash_matrix(m, args) {
|
|
724
|
+
const mode = (args.mode as ClashMode | undefined) ?? 'hard';
|
|
725
|
+
const clearance = args.clearance as number | undefined;
|
|
726
|
+
const rules = disciplineMatrixRules(mode, clearance);
|
|
727
|
+
|
|
728
|
+
const result = await runClashRules(m, rules);
|
|
729
|
+
const { rows, truncated } = topClashRows(result.clashes, CLASH_DISPLAY_CAP);
|
|
730
|
+
const capNote = truncated
|
|
731
|
+
? ` Sampling top ${truncated.shown} by penetration depth; ${truncated.dropped} more not shown.`
|
|
732
|
+
: '';
|
|
733
|
+
return {
|
|
734
|
+
text: `Discipline matrix (mode=${mode}, ${rules.length} rules): ${result.summary.total} clash(es).${capNote}${clashCapNote(result)}`,
|
|
735
|
+
structured: {
|
|
736
|
+
mode,
|
|
737
|
+
ruleCount: rules.length,
|
|
738
|
+
byRule: result.summary.byRule,
|
|
739
|
+
bySeverity: result.summary.bySeverity,
|
|
740
|
+
byTypePair: result.summary.byTypePair,
|
|
741
|
+
summary: result.summary,
|
|
742
|
+
engineSettings: result.settings,
|
|
743
|
+
truncated: result.truncated ?? null,
|
|
744
|
+
sampleClashes: rows,
|
|
745
|
+
sampleTruncated: truncated,
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
},
|
|
749
|
+
async clash_bcf_export(m, args) {
|
|
750
|
+
const groupBy = (args.group_by as GroupOptions['by'] | undefined) ?? 'cluster';
|
|
751
|
+
const epsilon = args.cluster_epsilon as number | undefined;
|
|
752
|
+
const status = args.status as string | undefined;
|
|
753
|
+
const maxTopics = args.max_topics as number | undefined;
|
|
754
|
+
|
|
755
|
+
// Reuse the last clash run for this model; if there is none, run a default
|
|
756
|
+
// all-vs-all hard self-clash so the tool works standalone (and caches it).
|
|
757
|
+
let result = lastClashResult.get(meshCacheKey(m));
|
|
758
|
+
if (!result) {
|
|
759
|
+
result = await runClashRules(m, [{ id: 'clash_check', name: 'all elements (self-clash)', a: '*', mode: 'hard' }]);
|
|
760
|
+
}
|
|
761
|
+
if (result.summary.total === 0) {
|
|
762
|
+
return {
|
|
763
|
+
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).',
|
|
764
|
+
structured: { topics: 0, clashes: 0 },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// One BCF topic per clash group, each carrying a framed viewpoint (camera +
|
|
769
|
+
// the clashing elements as components) and severity/status/distance
|
|
770
|
+
// metadata — the same bridge the viewer's clash→BCF export uses. Snapshots
|
|
771
|
+
// are omitted: the inline ViewerController can't render frames headlessly,
|
|
772
|
+
// and BCF viewpoints are valid without an embedded image.
|
|
773
|
+
const groups = groupClashes(result, { by: groupBy, ...(epsilon != null ? { epsilon } : {}) });
|
|
774
|
+
const project = await createBCFFromClashResult(result, groups, {
|
|
775
|
+
author: 'clash@ifc-lite',
|
|
776
|
+
projectName: 'Clash report',
|
|
777
|
+
...(status ? { status } : {}),
|
|
778
|
+
...(maxTopics != null ? { maxTopics } : {}),
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const filename = coerceFilename(args.file_path as string | undefined, 'bcfzip', 'clashes');
|
|
782
|
+
const blob = await writeBCF(project);
|
|
783
|
+
const file = playgroundFiles.add({
|
|
784
|
+
filename,
|
|
785
|
+
mimeType: 'application/zip',
|
|
786
|
+
size: blob.size,
|
|
787
|
+
blob,
|
|
788
|
+
source: 'clash_bcf_export',
|
|
789
|
+
description: `${project.topics.size} clash topic(s), grouped by ${groupBy}`,
|
|
790
|
+
});
|
|
791
|
+
return {
|
|
792
|
+
text: `Bundled ${filename}: ${project.topics.size} BCF topic(s) from ${result.summary.total} clash(es), grouped by ${groupBy}`
|
|
793
|
+
+ ` — each with a framed viewpoint + clashing components + severity/distance metadata. (Snapshots omitted: the inline viewer can't capture frames headlessly.)`,
|
|
794
|
+
structured: {
|
|
795
|
+
fileId: file.id,
|
|
796
|
+
filename,
|
|
797
|
+
bytes: blob.size,
|
|
798
|
+
topics: project.topics.size,
|
|
799
|
+
groupBy,
|
|
800
|
+
clashes: result.summary.total,
|
|
801
|
+
},
|
|
802
|
+
download: { fileId: file.id, filename, mimeType: 'application/zip', size: blob.size, label: 'Get .bcfzip' },
|
|
803
|
+
};
|
|
804
|
+
},
|
|
805
|
+
|
|
526
806
|
// ── Validation ──────────────────────────────────────────────────────────
|
|
527
807
|
async model_audit(m) {
|
|
528
808
|
let issues = 0;
|
|
@@ -34,10 +34,17 @@ export interface PlaygroundFile {
|
|
|
34
34
|
description?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** Keep at most this many staged artifacts; oldest are evicted on add(). */
|
|
38
|
+
const MAX_FILES = 20;
|
|
39
|
+
/** Drop oldest entries once the retained Blobs exceed this cumulative size. */
|
|
40
|
+
const MAX_TOTAL_BYTES = 256 * 1024 * 1024;
|
|
41
|
+
|
|
37
42
|
class FileStore {
|
|
38
43
|
private files: PlaygroundFile[] = [];
|
|
39
44
|
private listeners = new Set<() => void>();
|
|
40
45
|
private nextId = 1;
|
|
46
|
+
/** Id of an entry exempt from eviction (e.g. the auto-staged BCF bundle). */
|
|
47
|
+
private pinnedId: string | null = null;
|
|
41
48
|
|
|
42
49
|
add(input: Omit<PlaygroundFile, 'id' | 'createdAt'>): PlaygroundFile {
|
|
43
50
|
const file: PlaygroundFile = {
|
|
@@ -45,22 +52,47 @@ class FileStore {
|
|
|
45
52
|
id: `pg-file-${this.nextId++}`,
|
|
46
53
|
createdAt: Date.now(),
|
|
47
54
|
};
|
|
48
|
-
this.files = [file, ...this.files];
|
|
55
|
+
this.files = this.evict([file, ...this.files]);
|
|
49
56
|
this.notify();
|
|
50
57
|
return file;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
/** Mark an entry as exempt from eviction; pass null to clear the pin. */
|
|
61
|
+
pin(id: string | null): void {
|
|
62
|
+
this.pinnedId = id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bound the store by count and cumulative bytes, evicting oldest-first
|
|
67
|
+
* (entries are newest-first, so trim from the tail). The pinned entry is
|
|
68
|
+
* never evicted so its tracked id can't be orphaned.
|
|
69
|
+
*/
|
|
70
|
+
private evict(files: PlaygroundFile[]): PlaygroundFile[] {
|
|
71
|
+
const kept: PlaygroundFile[] = [];
|
|
72
|
+
let bytes = 0;
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
const pinned = f.id === this.pinnedId;
|
|
75
|
+
if (!pinned && kept.length >= MAX_FILES) continue;
|
|
76
|
+
if (!pinned && bytes + f.size > MAX_TOTAL_BYTES && kept.length > 0) continue;
|
|
77
|
+
kept.push(f);
|
|
78
|
+
bytes += f.size;
|
|
79
|
+
}
|
|
80
|
+
return kept;
|
|
81
|
+
}
|
|
82
|
+
|
|
53
83
|
list(): PlaygroundFile[] {
|
|
54
84
|
return this.files;
|
|
55
85
|
}
|
|
56
86
|
|
|
57
87
|
remove(id: string): void {
|
|
58
88
|
this.files = this.files.filter((f) => f.id !== id);
|
|
89
|
+
if (this.pinnedId === id) this.pinnedId = null;
|
|
59
90
|
this.notify();
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
clear(): void {
|
|
63
94
|
this.files = [];
|
|
95
|
+
this.pinnedId = null;
|
|
64
96
|
this.notify();
|
|
65
97
|
}
|
|
66
98
|
|
|
@@ -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}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
Home,
|
|
25
25
|
Maximize2,
|
|
26
26
|
Crosshair,
|
|
27
|
+
GitCompareArrows,
|
|
27
28
|
ArrowUp,
|
|
28
29
|
ArrowDown,
|
|
29
30
|
ArrowLeft,
|
|
@@ -203,12 +204,13 @@ function downloadBlob(data: BlobPart, name: string, mime: string) {
|
|
|
203
204
|
* Closes all others first so the if-else chain in ViewerLayout renders it.
|
|
204
205
|
* If the target is already active, closes it (back to Properties). */
|
|
205
206
|
|
|
206
|
-
function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') {
|
|
207
|
+
function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions') {
|
|
207
208
|
const s = useViewerStore.getState();
|
|
208
209
|
const isActive =
|
|
209
210
|
panel === 'bcf' ? s.bcfPanelVisible :
|
|
210
211
|
panel === 'ids' ? s.idsPanelVisible :
|
|
211
212
|
panel === 'clash' ? s.clashPanelVisible :
|
|
213
|
+
panel === 'compare' ? s.comparePanelVisible :
|
|
212
214
|
panel === 'extensions' ? s.extensionsPanelVisible :
|
|
213
215
|
s.lensPanelVisible;
|
|
214
216
|
|
|
@@ -220,6 +222,7 @@ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extension
|
|
|
220
222
|
s.setIdsPanelVisible(false);
|
|
221
223
|
s.setLensPanelVisible(false);
|
|
222
224
|
s.setClashPanelVisible(false);
|
|
225
|
+
s.setComparePanelVisible(false);
|
|
223
226
|
s.setExtensionsPanelVisible(false);
|
|
224
227
|
} else {
|
|
225
228
|
// Open exclusively (closes every sibling, including clash) and un-collapse.
|
|
@@ -431,6 +434,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|
|
431
434
|
action: () => { activateRightPanel('ids'); } },
|
|
432
435
|
{ id: 'panel:clash', label: 'Clash Detection', keywords: 'collision interference clearance coordination clash matrix mep', category: 'Panels', icon: Crosshair,
|
|
433
436
|
action: () => { activateRightPanel('clash'); } },
|
|
437
|
+
{ id: 'panel:compare', label: 'Compare Models', keywords: 'diff revision version change added deleted modified geometry data', category: 'Panels', icon: GitCompareArrows,
|
|
438
|
+
action: () => { activateRightPanel('compare'); } },
|
|
434
439
|
{ id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet', category: 'Panels', icon: FileSpreadsheet,
|
|
435
440
|
action: () => { activateBottomPanel('list'); } },
|
|
436
441
|
{ id: 'panel:gantt', label: 'Construction Schedule (Gantt)', keywords: '4d timeline tasks ifctask sequence playback animation', category: 'Panels', icon: CalendarClock,
|