@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,123 @@
|
|
|
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
|
+
* Pure helpers for the Lists results table — value formatting, comparison,
|
|
7
|
+
* numeric-column detection, content-aware column widths, and the grouping /
|
|
8
|
+
* aggregation that powers the in-table (settings-free) grouped view.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CellValue, ColumnDefinition, ListRow, ListGrouping } from '@ifc-lite/lists';
|
|
12
|
+
|
|
13
|
+
export function formatCellValue(value: CellValue): string {
|
|
14
|
+
if (value === null || value === undefined) return '';
|
|
15
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
16
|
+
if (typeof value === 'number') {
|
|
17
|
+
if (Number.isInteger(value)) return value.toLocaleString();
|
|
18
|
+
return value.toFixed(4).replace(/\.?0+$/, '');
|
|
19
|
+
}
|
|
20
|
+
return String(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function compareCells(a: CellValue, b: CellValue): number {
|
|
24
|
+
if (a === null && b === null) return 0;
|
|
25
|
+
if (a === null) return -1;
|
|
26
|
+
if (b === null) return 1;
|
|
27
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
28
|
+
return String(a).localeCompare(String(b));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A column is numeric (summable) when every sampled non-empty value is a
|
|
32
|
+
* finite number and at least one such value exists. */
|
|
33
|
+
export function detectNumericColumns(columns: ColumnDefinition[], rows: ListRow[]): boolean[] {
|
|
34
|
+
const sample = rows.slice(0, 120);
|
|
35
|
+
return columns.map((_, i) => {
|
|
36
|
+
let sawNumber = false;
|
|
37
|
+
for (const r of sample) {
|
|
38
|
+
const v = r.values[i];
|
|
39
|
+
if (v === null || v === undefined || v === '') continue;
|
|
40
|
+
if (typeof v === 'number' && Number.isFinite(v)) { sawNumber = true; continue; }
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return sawNumber;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Content-aware default width: fits the header + the widest sampled value
|
|
48
|
+
* (≈7px/char), clamped to a readable range. */
|
|
49
|
+
export function autoColumnWidth(label: string, rows: ListRow[], colIdx: number): number {
|
|
50
|
+
let maxLen = label.length;
|
|
51
|
+
const sample = rows.slice(0, 200);
|
|
52
|
+
for (const r of sample) {
|
|
53
|
+
const v = r.values[colIdx];
|
|
54
|
+
if (v === null || v === undefined) continue;
|
|
55
|
+
const len = (typeof v === 'number' ? formatCellValue(v) : String(v)).length;
|
|
56
|
+
if (len > maxLen) maxLen = len;
|
|
57
|
+
}
|
|
58
|
+
return Math.max(80, Math.min(460, maxLen * 7 + 34));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DisplayItem =
|
|
62
|
+
| { kind: 'group'; key: string; label: string; count: number; sums: Record<string, number> }
|
|
63
|
+
| { kind: 'row'; row: ListRow };
|
|
64
|
+
|
|
65
|
+
export interface Totals { count: number; sums: Record<string, number> }
|
|
66
|
+
export interface GroupedView { items: DisplayItem[]; groupCount: number; totals: Totals }
|
|
67
|
+
|
|
68
|
+
function sumIndices(columns: ColumnDefinition[], sumColumnIds: string[]) {
|
|
69
|
+
return sumColumnIds
|
|
70
|
+
.map((id) => ({ id, idx: columns.findIndex((c) => c.id === id) }))
|
|
71
|
+
.filter((s) => s.idx >= 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Bucket already-filtered/sorted rows by the group-by column, accumulate
|
|
75
|
+
* per-group + grand count/sums, and flatten into a virtualizable list
|
|
76
|
+
* (group header followed by its rows when the group is expanded). */
|
|
77
|
+
export function buildGroupedView(
|
|
78
|
+
rows: ListRow[],
|
|
79
|
+
columns: ColumnDefinition[],
|
|
80
|
+
grouping: ListGrouping,
|
|
81
|
+
expanded: Set<string>,
|
|
82
|
+
): GroupedView {
|
|
83
|
+
const groupIdx = columns.findIndex((c) => c.id === grouping.columnId);
|
|
84
|
+
const sums = sumIndices(columns, grouping.sumColumnIds);
|
|
85
|
+
const zero = (): Record<string, number> => Object.fromEntries(sums.map((s) => [s.id, 0]));
|
|
86
|
+
|
|
87
|
+
const totals: Totals = { count: rows.length, sums: zero() };
|
|
88
|
+
const byKey = new Map<string, { key: string; label: string; count: number; sums: Record<string, number>; rows: ListRow[] }>();
|
|
89
|
+
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
const raw = groupIdx >= 0 ? row.values[groupIdx] : null;
|
|
92
|
+
const label = raw === null || raw === undefined || raw === '' ? '(none)' : formatCellValue(raw);
|
|
93
|
+
let g = byKey.get(label);
|
|
94
|
+
if (!g) { g = { key: label, label, count: 0, sums: zero(), rows: [] }; byKey.set(label, g); }
|
|
95
|
+
g.count++;
|
|
96
|
+
g.rows.push(row);
|
|
97
|
+
for (const s of sums) {
|
|
98
|
+
const v = row.values[s.idx];
|
|
99
|
+
if (typeof v === 'number' && Number.isFinite(v)) { g.sums[s.id] += v; totals.sums[s.id] += v; }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const groups = Array.from(byKey.values()).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
104
|
+
const items: DisplayItem[] = [];
|
|
105
|
+
for (const g of groups) {
|
|
106
|
+
items.push({ kind: 'group', key: g.key, label: g.label, count: g.count, sums: g.sums });
|
|
107
|
+
if (expanded.has(g.key)) for (const r of g.rows) items.push({ kind: 'row', row: r });
|
|
108
|
+
}
|
|
109
|
+
return { items, groupCount: groups.length, totals };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Grand totals for the flat (ungrouped) view when sum columns are active. */
|
|
113
|
+
export function flatTotals(rows: ListRow[], columns: ColumnDefinition[], sumColumnIds: string[]): Totals {
|
|
114
|
+
const sums = sumIndices(columns, sumColumnIds);
|
|
115
|
+
const acc: Record<string, number> = Object.fromEntries(sums.map((s) => [s.id, 0]));
|
|
116
|
+
for (const r of rows) {
|
|
117
|
+
for (const s of sums) {
|
|
118
|
+
const v = r.values[s.idx];
|
|
119
|
+
if (typeof v === 'number' && Number.isFinite(v)) acc[s.id] += v;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { count: rows.length, sums: acc };
|
|
123
|
+
}
|
|
@@ -46,6 +46,10 @@ export interface UseGeometryStreamingParams {
|
|
|
46
46
|
geometryContentVersion?: number;
|
|
47
47
|
coordinateInfo?: CoordinateInfo;
|
|
48
48
|
isStreaming: boolean;
|
|
49
|
+
/** Number of loaded models. When this increases (a model was added to the
|
|
50
|
+
* federation) the camera must refit to the new combined bounds — otherwise
|
|
51
|
+
* it stays framed on the first model and the newly-added one is off-screen. */
|
|
52
|
+
modelCount?: number;
|
|
49
53
|
geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
|
|
50
54
|
pendingMeshColorUpdates: Map<number, [number, number, number, number]> | null;
|
|
51
55
|
pendingColorUpdates: Map<number, [number, number, number, number]> | null;
|
|
@@ -95,6 +99,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
|
|
|
95
99
|
geometryContentVersion,
|
|
96
100
|
coordinateInfo,
|
|
97
101
|
isStreaming,
|
|
102
|
+
modelCount = 0,
|
|
98
103
|
geometryBoundsRef,
|
|
99
104
|
pendingMeshColorUpdates,
|
|
100
105
|
pendingColorUpdates,
|
|
@@ -122,6 +127,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
|
|
|
122
127
|
const lastFitPolicyKindRef = useRef<'compact' | 'linear' | null>(null);
|
|
123
128
|
const prevIsStreamingRef = useRef(isStreaming);
|
|
124
129
|
const lastContentVersionRef = useRef(geometryContentVersion ?? 0);
|
|
130
|
+
const prevModelCountRef = useRef(modelCount);
|
|
125
131
|
const queuePumpTimerRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(null);
|
|
126
132
|
|
|
127
133
|
// Only activate the timer-based queue pump when the tab is background-throttled
|
|
@@ -200,6 +206,20 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
|
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
208
|
|
|
209
|
+
// A model was added to the federation — refit the camera to the new
|
|
210
|
+
// combined bounds. Without this, `cameraFittedRef` stays true from the
|
|
211
|
+
// first model's fit, so the newly-added model renders off-screen and only
|
|
212
|
+
// its 2D grid overlay shows. Refit only on an INCREASE (a model added),
|
|
213
|
+
// and never mid-stream (the streaming first-fit + finalize refit handle
|
|
214
|
+
// the active model). The combined bounds come from the merged
|
|
215
|
+
// coordinateInfo (union of all visible models).
|
|
216
|
+
if (modelCount > prevModelCountRef.current && !isStreaming) {
|
|
217
|
+
traceGeometrySync(`model added (${prevModelCountRef.current}→${modelCount}) — refitting camera to combined bounds`);
|
|
218
|
+
cameraFittedRef.current = false;
|
|
219
|
+
finalBoundsRefittedRef.current = false;
|
|
220
|
+
}
|
|
221
|
+
prevModelCountRef.current = modelCount;
|
|
222
|
+
|
|
203
223
|
// Read AFTER the optional reset above so the classification below reflects
|
|
204
224
|
// the post-reset state (otherwise an in-place update gets misclassified as
|
|
205
225
|
// "no change" and returns early at currentLength === lastLength).
|
|
@@ -400,7 +420,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
|
|
|
400
420
|
}
|
|
401
421
|
|
|
402
422
|
renderer.requestRender();
|
|
403
|
-
}, [geometry, geometryVersion, geometryContentVersion, coordinateInfo, isInitialized, isStreaming]);
|
|
423
|
+
}, [geometry, geometryVersion, geometryContentVersion, coordinateInfo, isInitialized, isStreaming, modelCount]);
|
|
404
424
|
|
|
405
425
|
useEffect(() => {
|
|
406
426
|
return () => {
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
{ "name": "geometry_volume", "category": "Geometry", "scope": "read", "description": "Net/gross volume in m³ for a single entity or a type aggregate.", "inputSchema": { "type": "object", "required": ["global_id"] } },
|
|
26
26
|
{ "name": "geometry_area", "category": "Geometry", "scope": "read", "description": "Surface area for an entity (front/side/footprint depending on what the IFC carries).", "inputSchema": { "type": "object", "required": ["global_id"] } },
|
|
27
27
|
|
|
28
|
+
{ "name": "clash_check", "category": "Clash", "scope": "read", "description": "Find clashes in a single model — the DEFAULT for any 'find/run clashes' request. Omit BOTH a and b to check every element vs every other (all clashes in the model). Give a TYPE selector for a to self-clash within a group, or both a and b for a pairwise check (a=\"IfcDuct*\", b=\"IfcWall*\"). Meshes the model in-browser; returns a summary plus the worst clashes by penetration depth.", "inputSchema": { "type": "object", "properties": { "a": { "type": "string", "description": "Type selector for set A. Defaults to \"*\" (all elements). e.g. \"IfcDuct*|IfcPipe*\", \"!IfcSpace\"." }, "b": { "type": "string", "description": "Type selector for set B. OMIT for a self-clash within A (every element vs every other in the group)." }, "mode": { "type": "string", "enum": ["hard", "clearance"] }, "tolerance": { "type": "number", "description": "Touching band (m). Defaults to the engine tolerance." }, "clearance": { "type": "number", "description": "Required gap (m) for mode=\"clearance\"." }, "model_id": { "type": "string", "description": "Optional; defaults to the only loaded model." } } } },
|
|
29
|
+
{ "name": "clash_matrix", "category": "Clash", "scope": "read", "description": "Run the standard discipline clash matrix (MEP×STR, HVAC×ARCH, ...) — INTER-discipline pairs ONLY. Returns NOTHING on a single-discipline or architectural model (no cross-discipline pairs exist to test). For a general 'find all clashes' request use clash_check instead; use this only when the user explicitly asks for the discipline matrix. Returns per-rule and per-severity breakdowns plus a sample of the worst clashes.", "inputSchema": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["hard", "clearance"] }, "clearance": { "type": "number", "description": "Required gap (m) applied to every matrix rule when mode=\"clearance\". Without it a clearance matrix reports nothing." }, "model_id": { "type": "string", "description": "Optional; defaults to the only loaded model." } } } },
|
|
30
|
+
{ "name": "clash_bcf_export", "category": "Clash", "scope": "export", "description": "Turn the last clash run (clash_check / clash_matrix) into a rich .bcfzip: one BCF topic per clash group, each with a framed 3D viewpoint, the clashing elements as components, and severity/status/distance metadata. Runs a default all-vs-all clash_check first if you haven't clashed yet. Use THIS for 'create/export BCF from clashes' — not bcf_topic_create. (Snapshots are omitted: the inline viewer can't render frames headlessly; BCF viewpoints are valid without an image.)", "inputSchema": { "type": "object", "properties": { "group_by": { "type": "string", "enum": ["cluster", "rule", "typePair", "element"], "description": "How clashes collapse into topics. Default \"cluster\" (spatially-near clashes merge into one topic)." }, "cluster_epsilon": { "type": "number", "description": "Cluster radius (m) for group_by=\"cluster\". Default 1.5." }, "status": { "type": "string", "description": "BCF topic status, e.g. \"Open\"." }, "max_topics": { "type": "integer", "description": "Cap the number of topics. Default 1000." }, "file_path": { "type": "string", "description": "Output .bcfzip name." } } } },
|
|
31
|
+
|
|
28
32
|
{ "name": "model_audit", "category": "Validation", "scope": "read", "description": "Out-of-the-box health score + a list of issues (missing GlobalIds, broken refs, orphan entities).", "inputSchema": { "type": "object" } },
|
|
29
33
|
{ "name": "ids_validate", "category": "Validation", "scope": "read", "description": "Run a buildingSMART IDS spec against the loaded model. Per-spec pass/fail with offending entities.", "inputSchema": { "type": "object", "required": ["ids_path"], "properties": { "ids_path": { "type": "string" } } } },
|
|
30
34
|
{ "name": "ids_explain", "category": "Validation", "scope": "read", "description": "Parse + summarize an IDS file in plain language — what each spec asks for, in what order.", "inputSchema": { "type": "object", "required": ["ids_path"] } },
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
|
|
8
|
+
import { boundedIteratorReturn } from './streamCleanup.js';
|
|
9
|
+
|
|
10
|
+
describe('boundedIteratorReturn', () => {
|
|
11
|
+
it('resolves promptly even when return() never settles (the stalled-worker case)', async () => {
|
|
12
|
+
// Mirrors a geometry generator parked on an unresolved await: its return()
|
|
13
|
+
// can never settle, so an unbounded await would re-wedge the caller.
|
|
14
|
+
const iterator = { return: () => new Promise<never>(() => { /* never settles */ }) };
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
await boundedIteratorReturn(iterator, 50);
|
|
17
|
+
const elapsed = Date.now() - start;
|
|
18
|
+
assert.ok(elapsed < 1000, `expected bounded (<1000ms), took ${elapsed}ms`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('awaits a fast return() to completion (lets the generator finally run)', async () => {
|
|
22
|
+
let returned = false;
|
|
23
|
+
const iterator = {
|
|
24
|
+
return: async () => {
|
|
25
|
+
returned = true;
|
|
26
|
+
return { done: true, value: undefined };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
await boundedIteratorReturn(iterator, 1000);
|
|
30
|
+
assert.strictEqual(returned, true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('swallows a rejecting return() without throwing', async () => {
|
|
34
|
+
const iterator = { return: () => Promise.reject(new Error('teardown blew up')) };
|
|
35
|
+
await assert.doesNotReject(() => boundedIteratorReturn(iterator, 1000));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('is a no-op when the iterator has no return()', async () => {
|
|
39
|
+
await assert.doesNotReject(() => boundedIteratorReturn({}, 1000));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/** How long to wait for an abandoned geometry iterator to shut down before
|
|
6
|
+
* giving up on it. Generous enough for a healthy generator to run its
|
|
7
|
+
* `finally` (freeing WASM handles, terminating workers), short enough that a
|
|
8
|
+
* wedged one never holds the caller hostage. */
|
|
9
|
+
export const GEOMETRY_ITERATOR_CLEANUP_MS = 2000;
|
|
10
|
+
|
|
11
|
+
interface ClosableAsyncIterator {
|
|
12
|
+
return?: (value?: unknown) => Promise<unknown> | unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Abandon an async iterator without letting its shutdown wedge the caller.
|
|
17
|
+
*
|
|
18
|
+
* `AsyncIterator.return()` cannot interrupt a generator parked on an unresolved
|
|
19
|
+
* `await` — e.g. the geometry drain loop suspended waiting on a worker that
|
|
20
|
+
* failed to instantiate ("Worker from an empty source") and therefore never
|
|
21
|
+
* resolves the promise. Awaiting `return()` unbounded would re-block on the
|
|
22
|
+
* exact stall the stream watchdog just escaped, swallowing the timeout error so
|
|
23
|
+
* the load hangs in cleanup instead of surfacing a recoverable failure. Racing
|
|
24
|
+
* it against a deadline guarantees the caller always proceeds; a healthy
|
|
25
|
+
* generator still resolves well within the deadline so its `finally` runs.
|
|
26
|
+
*/
|
|
27
|
+
export async function boundedIteratorReturn(
|
|
28
|
+
iterator: ClosableAsyncIterator,
|
|
29
|
+
cleanupMs: number = GEOMETRY_ITERATOR_CLEANUP_MS,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
if (typeof iterator.return !== 'function') return;
|
|
32
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
33
|
+
try {
|
|
34
|
+
await Promise.race([
|
|
35
|
+
Promise.resolve(iterator.return(undefined)).catch(() => {
|
|
36
|
+
/* cleanup — safe to ignore */
|
|
37
|
+
}),
|
|
38
|
+
new Promise<void>((resolve) => {
|
|
39
|
+
timer = setTimeout(resolve, cleanupMs);
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
} finally {
|
|
43
|
+
if (timer !== null) clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -9,6 +9,7 @@ import { loadGLBToMeshData } from '@ifc-lite/cache';
|
|
|
9
9
|
import type { SchemaVersion } from '../../store/types.js';
|
|
10
10
|
import { calculateMeshBounds, calculateStoreyHeights, createCoordinateInfo, normalizeColor } from '../../utils/localParsingUtils.js';
|
|
11
11
|
import { resolveDataStoreOrAbort } from './resolveDataStoreOrAbort.js';
|
|
12
|
+
import { watchedGeometryStream } from './watchedGeometryStream.js';
|
|
12
13
|
|
|
13
14
|
type RgbaColor = [number, number, number, number];
|
|
14
15
|
|
|
@@ -264,7 +265,7 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
|
|
|
264
265
|
});
|
|
265
266
|
|
|
266
267
|
const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(options.buffer);
|
|
267
|
-
|
|
268
|
+
const geometryStream = geometryProcessor.processAdaptive(geometryView, {
|
|
268
269
|
sizeThreshold: 2 * 1024 * 1024,
|
|
269
270
|
batchSize: options.getDynamicBatchSize(options.fileSizeMB),
|
|
270
271
|
sharedRtcOffset: options.sharedRtcOffset,
|
|
@@ -272,48 +273,69 @@ export async function parseStepBufferViewerModel(options: StepBufferIngestOption
|
|
|
272
273
|
onEntityIndex: (ids, starts, lengths) => {
|
|
273
274
|
workerParser?.setEntityIndex(ids, starts, lengths);
|
|
274
275
|
},
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
276
|
+
});
|
|
277
|
+
let lastTotalMeshes = 0;
|
|
278
|
+
// The federated/added-model path was missing the size-aware stream watchdog
|
|
279
|
+
// the single-model loader has, so a geometry worker that failed to spawn would
|
|
280
|
+
// hang the load forever on "Processing geometry (N meshes)" instead of
|
|
281
|
+
// surfacing a recoverable error. watchedGeometryStream re-yields each event
|
|
282
|
+
// under that watchdog and bounds iterator teardown on every exit path.
|
|
283
|
+
try {
|
|
284
|
+
for await (const event of watchedGeometryStream(geometryStream, {
|
|
285
|
+
fileName: options.fileName,
|
|
286
|
+
fileSizeMB: options.fileSizeMB,
|
|
287
|
+
shouldAbort: options.shouldAbort,
|
|
288
|
+
getBatchCount: () => batchIndex,
|
|
289
|
+
getLastTotalMeshes: () => lastTotalMeshes,
|
|
290
|
+
})) {
|
|
291
|
+
switch (event.type) {
|
|
292
|
+
case 'start':
|
|
293
|
+
estimatedTotal = event.totalEstimate;
|
|
294
|
+
break;
|
|
295
|
+
case 'colorUpdate':
|
|
296
|
+
for (const [expressId, color] of event.updates) {
|
|
297
|
+
cumulativeColorUpdates.set(expressId, color);
|
|
298
|
+
}
|
|
299
|
+
options.onColorUpdate?.(event.updates);
|
|
300
|
+
break;
|
|
301
|
+
case 'rtcOffset':
|
|
302
|
+
if (event.hasRtc) {
|
|
303
|
+
capturedRtcOffset = event.rtcOffset;
|
|
304
|
+
options.onRtcOffset?.({ rtcOffset: event.rtcOffset });
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
case 'batch':
|
|
308
|
+
batchIndex += 1;
|
|
309
|
+
for (let i = 0; i < event.meshes.length; i++) {
|
|
310
|
+
allMeshes.push(event.meshes[i]);
|
|
311
|
+
}
|
|
312
|
+
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
313
|
+
lastTotalMeshes = event.totalSoFar;
|
|
314
|
+
options.onBatch?.({
|
|
315
|
+
batchIndex,
|
|
316
|
+
estimatedTotal,
|
|
317
|
+
totalSoFar: event.totalSoFar,
|
|
318
|
+
meshes: event.meshes,
|
|
319
|
+
coordinateInfo: event.coordinateInfo ?? null,
|
|
320
|
+
});
|
|
321
|
+
options.onProgress?.({
|
|
322
|
+
phase: `Processing geometry (${event.totalSoFar} meshes)`,
|
|
323
|
+
percent: 10 + Math.min(80, (allMeshes.length / 1000) * 0.8),
|
|
324
|
+
});
|
|
325
|
+
break;
|
|
326
|
+
case 'complete':
|
|
327
|
+
finalCoordinateInfo = event.coordinateInfo ?? null;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
316
330
|
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
// Watchdog stall (or other stream error): the parser worker may be
|
|
333
|
+
// blocked in `waitForEntityIndex`, which only the geometry pre-pass would
|
|
334
|
+
// unblock. Terminate it here so it doesn't leak — the normal path below
|
|
335
|
+
// still awaits it via resolveDataStoreOrAbort. watchedGeometryStream's
|
|
336
|
+
// finally has already bounded teardown of the geometry iterator itself.
|
|
337
|
+
workerParser?.terminate();
|
|
338
|
+
throw err;
|
|
317
339
|
}
|
|
318
340
|
|
|
319
341
|
// If the load was cancelled, don't await dataStorePromise: a worker parse
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
|
|
8
|
+
import { watchedGeometryStream } from './watchedGeometryStream.js';
|
|
9
|
+
|
|
10
|
+
/** Build a controllable async source that records when return() is called. */
|
|
11
|
+
function makeSource<T>(values: T[]): { source: AsyncIterable<T>; returned: () => boolean } {
|
|
12
|
+
let didReturn = false;
|
|
13
|
+
const source: AsyncIterable<T> = {
|
|
14
|
+
[Symbol.asyncIterator]() {
|
|
15
|
+
let i = 0;
|
|
16
|
+
return {
|
|
17
|
+
next: async () => (i < values.length
|
|
18
|
+
? { done: false, value: values[i++] }
|
|
19
|
+
: { done: true, value: undefined as unknown as T }),
|
|
20
|
+
return: async () => {
|
|
21
|
+
didReturn = true;
|
|
22
|
+
return { done: true, value: undefined as unknown as T };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
return { source, returned: () => didReturn };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const baseOpts = {
|
|
31
|
+
fileName: 'test.ifc',
|
|
32
|
+
fileSizeMB: 1,
|
|
33
|
+
getBatchCount: () => 0,
|
|
34
|
+
getLastTotalMeshes: () => 0,
|
|
35
|
+
cleanupMs: 50,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('watchedGeometryStream', () => {
|
|
39
|
+
it('re-yields every event in order then completes', async () => {
|
|
40
|
+
const { source } = makeSource([1, 2, 3]);
|
|
41
|
+
const seen: number[] = [];
|
|
42
|
+
for await (const v of watchedGeometryStream(source, baseOpts)) seen.push(v);
|
|
43
|
+
assert.deepStrictEqual(seen, [1, 2, 3]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('stops early when shouldAbort() turns true', async () => {
|
|
47
|
+
const { source } = makeSource([1, 2, 3, 4]);
|
|
48
|
+
const seen: number[] = [];
|
|
49
|
+
let calls = 0;
|
|
50
|
+
for await (const v of watchedGeometryStream(source, {
|
|
51
|
+
...baseOpts,
|
|
52
|
+
// Abort after the second event has been consumed.
|
|
53
|
+
shouldAbort: () => (++calls > 2),
|
|
54
|
+
})) {
|
|
55
|
+
seen.push(v);
|
|
56
|
+
}
|
|
57
|
+
assert.deepStrictEqual(seen, [1, 2]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('tears down the underlying iterator on normal completion', async () => {
|
|
61
|
+
const { source, returned } = makeSource([1]);
|
|
62
|
+
let count = 0;
|
|
63
|
+
for await (const v of watchedGeometryStream(source, baseOpts)) count += v;
|
|
64
|
+
assert.strictEqual(count, 1);
|
|
65
|
+
assert.strictEqual(returned(), true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('tears down the underlying iterator when the consumer breaks early', async () => {
|
|
69
|
+
const { source, returned } = makeSource([1, 2, 3]);
|
|
70
|
+
const seen: number[] = [];
|
|
71
|
+
for await (const v of watchedGeometryStream(source, baseOpts)) {
|
|
72
|
+
seen.push(v);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
assert.deepStrictEqual(seen, [1]);
|
|
76
|
+
assert.strictEqual(returned(), true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
import { getGeometryStreamWatchdogMs } from '@ifc-lite/geometry';
|
|
6
|
+
import { boundedIteratorReturn } from './streamCleanup.js';
|
|
7
|
+
|
|
8
|
+
export interface WatchedGeometryStreamOptions {
|
|
9
|
+
/** File name, for the stall error message. */
|
|
10
|
+
fileName: string;
|
|
11
|
+
/** File size in MB, feeds the size-aware watchdog deadline. */
|
|
12
|
+
fileSizeMB: number;
|
|
13
|
+
/** Abort the stream cooperatively (e.g. user cancelled the load). */
|
|
14
|
+
shouldAbort?: () => boolean;
|
|
15
|
+
/** Current batch index — feeds the size-aware watchdog deadline. */
|
|
16
|
+
getBatchCount: () => number;
|
|
17
|
+
/** Meshes rendered so far, for the stall error message. */
|
|
18
|
+
getLastTotalMeshes: () => number;
|
|
19
|
+
/** Override the abandon-cleanup deadline (mostly for tests). */
|
|
20
|
+
cleanupMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Drive a geometry stream under a size-aware watchdog, re-yielding every event.
|
|
25
|
+
*
|
|
26
|
+
* The parallel pipeline only ends once EVERY spawned geometry worker reports
|
|
27
|
+
* `complete`; if the browser fails to instantiate a worker (the "Attempting to
|
|
28
|
+
* create a Worker from an empty source" warning) that worker never reports
|
|
29
|
+
* `ready`/`complete` and never fires `onerror`, so the underlying generator can
|
|
30
|
+
* wedge forever, stranding the load on "Processing geometry (N meshes)". Racing
|
|
31
|
+
* each `next()` against a deadline converts that silent wedge into a thrown,
|
|
32
|
+
* recoverable error. On ANY exit — normal completion, abort, consumer `break`,
|
|
33
|
+
* or a watchdog throw — the `finally` bounds the underlying iterator's shutdown
|
|
34
|
+
* so cleanup (the generator's own `finally`: freeing WASM handles, tearing down
|
|
35
|
+
* workers) runs without re-blocking on the very stall the watchdog just escaped.
|
|
36
|
+
*
|
|
37
|
+
* Generic over the event type so the consumer keeps full type-narrowing in its
|
|
38
|
+
* own `switch`.
|
|
39
|
+
*/
|
|
40
|
+
export async function* watchedGeometryStream<T>(
|
|
41
|
+
source: AsyncIterable<T>,
|
|
42
|
+
options: WatchedGeometryStreamOptions,
|
|
43
|
+
): AsyncGenerator<T> {
|
|
44
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
45
|
+
try {
|
|
46
|
+
while (true) {
|
|
47
|
+
const watchdogMs = getGeometryStreamWatchdogMs({
|
|
48
|
+
desktopStableWasm: false,
|
|
49
|
+
batchCount: options.getBatchCount(),
|
|
50
|
+
fileSizeMB: options.fileSizeMB,
|
|
51
|
+
});
|
|
52
|
+
let watchdogId: ReturnType<typeof setTimeout> | null = null;
|
|
53
|
+
let result: IteratorResult<T>;
|
|
54
|
+
try {
|
|
55
|
+
result = await Promise.race([
|
|
56
|
+
iterator.next(),
|
|
57
|
+
new Promise<never>((_, reject) => {
|
|
58
|
+
watchdogId = setTimeout(() => {
|
|
59
|
+
reject(new Error(
|
|
60
|
+
`Geometry stream stalled after ${watchdogMs}ms while loading ${options.fileName}. `
|
|
61
|
+
+ `Last rendered meshes: ${options.getLastTotalMeshes()}. A geometry worker likely failed to start.`,
|
|
62
|
+
));
|
|
63
|
+
}, watchdogMs);
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
} finally {
|
|
67
|
+
if (watchdogId !== null) clearTimeout(watchdogId);
|
|
68
|
+
}
|
|
69
|
+
if (result.done) return;
|
|
70
|
+
if (options.shouldAbort?.()) return;
|
|
71
|
+
yield result.value;
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
await boundedIteratorReturn(iterator, options.cleanupMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
6
|
+
|
|
7
|
+
// Per-source-object memo so the full-content hash runs only once per loaded
|
|
8
|
+
// buffer (the same Uint8Array instance is reused across re-renders).
|
|
9
|
+
const SOURCE_KEY_CACHE = new WeakMap<Uint8Array, string>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable per-source cache key — a **full-content** FNV-1a hash (not sampled
|
|
13
|
+
* byte windows), so two distinct IFC binaries can't collide and reuse the wrong
|
|
14
|
+
* cache entry (which would render another model's overlay). The O(n) hash is
|
|
15
|
+
* memoised per source object via a WeakMap, so it runs once per loaded buffer.
|
|
16
|
+
*
|
|
17
|
+
* Shared by the per-source overlay hooks (alignment + grid lines) so they stay
|
|
18
|
+
* in lockstep — see #967 review (CodeRabbit aliasing finding applied to both).
|
|
19
|
+
*/
|
|
20
|
+
export function sourceKey(store: IfcDataStore | null | undefined): string | null {
|
|
21
|
+
const source = store?.source;
|
|
22
|
+
if (!source || source.byteLength === 0) return null;
|
|
23
|
+
|
|
24
|
+
const cached = SOURCE_KEY_CACHE.get(source);
|
|
25
|
+
if (cached) return cached;
|
|
26
|
+
|
|
27
|
+
let h = 0x811c9dc5;
|
|
28
|
+
for (let i = 0; i < source.length; i++) {
|
|
29
|
+
h ^= source[i];
|
|
30
|
+
h = Math.imul(h, 0x01000193);
|
|
31
|
+
}
|
|
32
|
+
const key = `b${source.byteLength}-${(h >>> 0).toString(16)}`;
|
|
33
|
+
SOURCE_KEY_CACHE.set(source, key);
|
|
34
|
+
return key;
|
|
35
|
+
}
|