@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
|
@@ -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
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -23,35 +23,10 @@ import { GeometryProcessor } from '@ifc-lite/geometry';
|
|
|
23
23
|
import { useViewerStore } from '@/store';
|
|
24
24
|
import { useShallow } from 'zustand/react/shallow';
|
|
25
25
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
26
|
+
import { sourceKey } from './source-key.js';
|
|
26
27
|
|
|
27
28
|
const EMPTY_F32 = new Float32Array(0);
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
* Stable per-source cache key — FNV-1a over head/mid/tail byte windows folded
|
|
31
|
-
* with the length, so two structurally distinct sources can't alias even when
|
|
32
|
-
* they share an exact byte length (a real risk in federated views). Identical
|
|
33
|
-
* scheme to `useSymbolicAnnotations`' `sourceKey`.
|
|
34
|
-
*/
|
|
35
|
-
function sourceKey(store: IfcDataStore | null | undefined): string | null {
|
|
36
|
-
const source = store?.source;
|
|
37
|
-
if (!source || source.byteLength === 0) return null;
|
|
38
|
-
const len = source.byteLength;
|
|
39
|
-
const sampleLen = Math.min(32, len);
|
|
40
|
-
const head = source.subarray(0, sampleLen);
|
|
41
|
-
const tail = source.subarray(len - sampleLen, len);
|
|
42
|
-
const midOffset = Math.max(0, Math.floor(len / 2) - Math.floor(sampleLen / 2));
|
|
43
|
-
const mid = source.subarray(midOffset, Math.min(midOffset + sampleLen, len));
|
|
44
|
-
const hashOne = (bytes: Uint8Array): string => {
|
|
45
|
-
let h = 0x811c9dc5;
|
|
46
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
47
|
-
h ^= bytes[i];
|
|
48
|
-
h = Math.imul(h, 0x01000193);
|
|
49
|
-
}
|
|
50
|
-
return (h >>> 0).toString(16);
|
|
51
|
-
};
|
|
52
|
-
return `b${len}-${hashOne(head)}-${hashOne(mid)}-${hashOne(tail)}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
30
|
// ─── Shared parse cache ──────────────────────────────────────────────────────
|
|
56
31
|
// One WASM walk per model source; cached so re-renders (and federated views
|
|
57
32
|
// that share a source) don't re-parse.
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
* Extraction of IfcGrid / IfcGridAxis centerlines for the 3D viewport
|
|
7
|
+
* (issue #967, follow-up to #945/#966).
|
|
8
|
+
*
|
|
9
|
+
* IfcGrid carries its axes as IfcGridAxis curves (not a `Representation`), so
|
|
10
|
+
* they never produce a mesh in the streaming batch mesher. The WASM
|
|
11
|
+
* `parseGridLines` API resolves every axis through the same placement +
|
|
12
|
+
* unit-scale + RTC pipeline as the meshes and returns a flat 3D line-list in
|
|
13
|
+
* renderer Y-up world space, which we feed to `renderer.uploadGridLines3D`.
|
|
14
|
+
* This mirrors `useAlignmentLines3D`.
|
|
15
|
+
*
|
|
16
|
+
* Unlike alignment (always-on), grids are gated by the `ifcGrid` type-visibility
|
|
17
|
+
* toggle — but the parse itself is unconditional and cached; the Viewport only
|
|
18
|
+
* uploads/clears based on the toggle.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
22
|
+
import { GeometryProcessor } from '@ifc-lite/geometry';
|
|
23
|
+
import { useViewerStore } from '@/store';
|
|
24
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
25
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
26
|
+
import { sourceKey } from './source-key.js';
|
|
27
|
+
|
|
28
|
+
const EMPTY_F32 = new Float32Array(0);
|
|
29
|
+
|
|
30
|
+
// ─── Shared parse cache ──────────────────────────────────────────────────────
|
|
31
|
+
// One WASM walk per model source; cached so re-renders (and federated views
|
|
32
|
+
// that share a source) don't re-parse.
|
|
33
|
+
const PARSE_CACHE = new Map<string, Float32Array>();
|
|
34
|
+
const PARSE_INFLIGHT = new Map<string, Promise<void>>();
|
|
35
|
+
|
|
36
|
+
type CacheListener = () => void;
|
|
37
|
+
const CACHE_LISTENERS = new Set<CacheListener>();
|
|
38
|
+
function notifyCacheChange(): void {
|
|
39
|
+
for (const fn of CACHE_LISTENERS) fn();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function parseGridLinesFor(store: IfcDataStore): Promise<Float32Array> {
|
|
43
|
+
const source = store.source;
|
|
44
|
+
if (!source || source.byteLength === 0) return EMPTY_F32;
|
|
45
|
+
const processor = new GeometryProcessor();
|
|
46
|
+
try {
|
|
47
|
+
await processor.init();
|
|
48
|
+
const verts = processor.parseGridLines(source);
|
|
49
|
+
return verts && verts.length > 0 ? verts : EMPTY_F32;
|
|
50
|
+
} finally {
|
|
51
|
+
processor.dispose();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensureParseFor(stores: IfcDataStore[]): void {
|
|
56
|
+
for (const store of stores) {
|
|
57
|
+
const key = sourceKey(store);
|
|
58
|
+
if (!key) continue;
|
|
59
|
+
if (PARSE_CACHE.has(key)) continue;
|
|
60
|
+
if (PARSE_INFLIGHT.has(key)) continue;
|
|
61
|
+
|
|
62
|
+
const promise = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const verts = await parseGridLinesFor(store);
|
|
65
|
+
PARSE_CACHE.set(key, verts);
|
|
66
|
+
notifyCacheChange();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Cache empty on failure so we don't retry a doomed parse every tick.
|
|
69
|
+
// eslint-disable-next-line no-console
|
|
70
|
+
console.warn('[useGridLines3D] parse failed:', error);
|
|
71
|
+
PARSE_CACHE.set(key, EMPTY_F32);
|
|
72
|
+
notifyCacheChange();
|
|
73
|
+
} finally {
|
|
74
|
+
PARSE_INFLIGHT.delete(key);
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
PARSE_INFLIGHT.set(key, promise);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read the active store set from the viewer store. Federation-aware. */
|
|
82
|
+
function useActiveStores(): IfcDataStore[] {
|
|
83
|
+
const { models, ifcDataStore } = useViewerStore(
|
|
84
|
+
useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })),
|
|
85
|
+
);
|
|
86
|
+
return useMemo(() => {
|
|
87
|
+
const out: IfcDataStore[] = [];
|
|
88
|
+
if (models.size > 0) {
|
|
89
|
+
for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore);
|
|
90
|
+
} else if (ifcDataStore) {
|
|
91
|
+
out.push(ifcDataStore);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}, [models, ifcDataStore]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sample every loaded model's IfcGridAxis lines into a single flat
|
|
99
|
+
* `[x0,y0,z0, x1,y1,z1, …]` line-list in renderer world space (Y-up,
|
|
100
|
+
* RTC-subtracted, metres). Returns a stable empty array when no model carries a
|
|
101
|
+
* grid. Parsing is unconditional + cached; the Viewport gates rendering on the
|
|
102
|
+
* `ifcGrid` type-visibility toggle.
|
|
103
|
+
*/
|
|
104
|
+
export function useGridLines3D(): Float32Array {
|
|
105
|
+
const stores = useActiveStores();
|
|
106
|
+
const [version, setVersion] = useState(0);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
ensureParseFor(stores);
|
|
110
|
+
const listener: CacheListener = () => setVersion((v) => v + 1);
|
|
111
|
+
CACHE_LISTENERS.add(listener);
|
|
112
|
+
return () => {
|
|
113
|
+
CACHE_LISTENERS.delete(listener);
|
|
114
|
+
};
|
|
115
|
+
}, [stores]);
|
|
116
|
+
|
|
117
|
+
return useMemo(() => {
|
|
118
|
+
void version; // depend on parse-completion ticks
|
|
119
|
+
const arrays: Float32Array[] = [];
|
|
120
|
+
let total = 0;
|
|
121
|
+
for (const store of stores) {
|
|
122
|
+
const key = sourceKey(store);
|
|
123
|
+
if (!key) continue;
|
|
124
|
+
const cached = PARSE_CACHE.get(key);
|
|
125
|
+
if (cached && cached.length > 0) {
|
|
126
|
+
arrays.push(cached);
|
|
127
|
+
total += cached.length;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (total === 0) return EMPTY_F32;
|
|
131
|
+
if (arrays.length === 1) return arrays[0];
|
|
132
|
+
const merged = new Float32Array(total);
|
|
133
|
+
let offset = 0;
|
|
134
|
+
for (const a of arrays) {
|
|
135
|
+
merged.set(a, offset);
|
|
136
|
+
offset += a.length;
|
|
137
|
+
}
|
|
138
|
+
return merged;
|
|
139
|
+
}, [stores, version]);
|
|
140
|
+
}
|
|
@@ -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
|
+
/**
|
|
6
|
+
* Model length-unit → metres scale, memoised per `IfcDataStore`.
|
|
7
|
+
*
|
|
8
|
+
* The viewer's render + authoring space is **metres**: the geometry
|
|
9
|
+
* pipeline bakes the file's length-unit scale into tessellated vertices,
|
|
10
|
+
* raycast hit-points come back in metres, and `spatialHierarchy.
|
|
11
|
+
* storeyElevations` are pre-scaled. But raw coordinate reads straight off
|
|
12
|
+
* the STEP model — split footprints, placement chains — arrive in the
|
|
13
|
+
* file's **native** units (e.g. millimetres). Multiply those by this
|
|
14
|
+
* factor to bring them into the same metre space as everything else.
|
|
15
|
+
*
|
|
16
|
+
* Returns `1` when the scale can't be determined (already-metres models,
|
|
17
|
+
* or bounded-geometry mode having released the source buffer) — the
|
|
18
|
+
* safe identity that leaves native-unit reads untouched.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
|
|
22
|
+
|
|
23
|
+
const scaleCache = new WeakMap<IfcDataStore, number>();
|
|
24
|
+
|
|
25
|
+
export function getModelLengthUnitScale(dataStore: IfcDataStore | null | undefined): number {
|
|
26
|
+
if (!dataStore) return 1;
|
|
27
|
+
const cached = scaleCache.get(dataStore);
|
|
28
|
+
if (cached !== undefined) return cached;
|
|
29
|
+
|
|
30
|
+
// The columnar parser stashes the scale on the store; the wasm fast
|
|
31
|
+
// path does not, so fall back to extracting it from the source bytes.
|
|
32
|
+
let scale = typeof dataStore.lengthUnitScale === 'number' ? dataStore.lengthUnitScale : undefined;
|
|
33
|
+
if (scale === undefined || !Number.isFinite(scale) || scale <= 0) {
|
|
34
|
+
if (!dataStore.source?.length || !dataStore.entityIndex) return 1;
|
|
35
|
+
scale = extractLengthUnitScale(dataStore.source, dataStore.entityIndex);
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isFinite(scale) || scale <= 0) scale = 1;
|
|
38
|
+
|
|
39
|
+
scaleCache.set(dataStore, scale);
|
|
40
|
+
return scale;
|
|
41
|
+
}
|
package/src/lib/lists/adapter.ts
CHANGED
|
@@ -11,10 +11,31 @@
|
|
|
11
11
|
* and Tag which are not stored during the fast initial parse.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
15
|
-
import {
|
|
14
|
+
import type { IfcDataStore, MaterialInfo } from '@ifc-lite/parser';
|
|
15
|
+
import {
|
|
16
|
+
extractPropertiesOnDemand,
|
|
17
|
+
extractQuantitiesOnDemand,
|
|
18
|
+
extractEntityAttributesOnDemand,
|
|
19
|
+
extractMaterialsOnDemand,
|
|
20
|
+
extractClassificationsOnDemand,
|
|
21
|
+
} from '@ifc-lite/parser';
|
|
16
22
|
import type { PropertySet, QuantitySet } from '@ifc-lite/data';
|
|
17
|
-
import
|
|
23
|
+
import { ENTITY_ATTRIBUTES } from '@ifc-lite/lists';
|
|
24
|
+
import type { ListDataProvider, ListClassificationRef, DiscoveredColumns } from '@ifc-lite/lists';
|
|
25
|
+
|
|
26
|
+
/** Collect every material-name string an element exposes — top-level
|
|
27
|
+
* material plus layer / constituent / profile names and list members. */
|
|
28
|
+
function materialNamesOf(info: MaterialInfo | null): string[] {
|
|
29
|
+
if (!info) return [];
|
|
30
|
+
const names: string[] = [];
|
|
31
|
+
const push = (s: string | undefined) => { if (s) names.push(s); };
|
|
32
|
+
push(info.name);
|
|
33
|
+
for (const l of info.layers ?? []) { push(l.materialName); push(l.name); }
|
|
34
|
+
for (const c of info.constituents ?? []) { push(c.materialName); push(c.name); }
|
|
35
|
+
for (const p of info.profiles ?? []) { push(p.materialName); push(p.name); }
|
|
36
|
+
for (const m of info.materials ?? []) push(m.name);
|
|
37
|
+
return names;
|
|
38
|
+
}
|
|
18
39
|
|
|
19
40
|
/**
|
|
20
41
|
* Create a ListDataProvider backed by an IfcDataStore.
|
|
@@ -26,6 +47,10 @@ export function createListDataProvider(store: IfcDataStore): ListDataProvider {
|
|
|
26
47
|
// but are needed for list display. Cache avoids re-parsing per column.
|
|
27
48
|
const attrCache = new Map<number, { description: string; objectType: string; tag: string }>();
|
|
28
49
|
|
|
50
|
+
// Lazily materialised list of every non-empty express id — used for
|
|
51
|
+
// class-less list targeting. Cached because the provider outlives a run.
|
|
52
|
+
let allIdsCache: number[] | null = null;
|
|
53
|
+
|
|
29
54
|
function getOnDemandAttrs(id: number): { description: string; objectType: string; tag: string } {
|
|
30
55
|
const cached = attrCache.get(id);
|
|
31
56
|
if (cached) return cached;
|
|
@@ -42,6 +67,23 @@ export function createListDataProvider(store: IfcDataStore): ListDataProvider {
|
|
|
42
67
|
return empty;
|
|
43
68
|
}
|
|
44
69
|
|
|
70
|
+
// Complete column discovery is cached — the provider outlives a builder
|
|
71
|
+
// open, and the scan touches every entity that declares a pset/qto.
|
|
72
|
+
let columnsCache: DiscoveredColumns | null = null;
|
|
73
|
+
|
|
74
|
+
const usesOnDemandProps = !!store.onDemandPropertyMap && store.source?.length > 0;
|
|
75
|
+
const usesOnDemandQtos = !!store.onDemandQuantityMap && store.source?.length > 0;
|
|
76
|
+
|
|
77
|
+
function getPropertySetsFor(entityId: number): PropertySet[] {
|
|
78
|
+
if (usesOnDemandProps) return extractPropertiesOnDemand(store, entityId) as PropertySet[];
|
|
79
|
+
return store.properties?.getForEntity(entityId) ?? [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getQuantitySetsFor(entityId: number): QuantitySet[] {
|
|
83
|
+
if (usesOnDemandQtos) return extractQuantitiesOnDemand(store, entityId) as QuantitySet[];
|
|
84
|
+
return store.quantities?.getForEntity(entityId) ?? [];
|
|
85
|
+
}
|
|
86
|
+
|
|
45
87
|
return {
|
|
46
88
|
getEntitiesByType: (type) => store.entities.getByType(type),
|
|
47
89
|
|
|
@@ -52,18 +94,101 @@ export function createListDataProvider(store: IfcDataStore): ListDataProvider {
|
|
|
52
94
|
getEntityTag: (id) => getOnDemandAttrs(id).tag,
|
|
53
95
|
getEntityTypeName: (id) => store.entities.getTypeName(id),
|
|
54
96
|
|
|
55
|
-
getPropertySets
|
|
56
|
-
|
|
57
|
-
|
|
97
|
+
getPropertySets: getPropertySetsFor,
|
|
98
|
+
getQuantitySets: getQuantitySetsFor,
|
|
99
|
+
|
|
100
|
+
getAllEntityIds(): number[] {
|
|
101
|
+
if (allIdsCache) return allIdsCache;
|
|
102
|
+
// Restrict "all elements" to geometry-bearing (selectable) products.
|
|
103
|
+
// The raw expressId column also holds relationships, property sets,
|
|
104
|
+
// materials, classifications and other non-element records — a
|
|
105
|
+
// class-less list should not surface those as rows.
|
|
106
|
+
const ids: number[] = [];
|
|
107
|
+
const col = store.entities.expressId;
|
|
108
|
+
for (let i = 0; i < col.length; i++) {
|
|
109
|
+
const id = col[i];
|
|
110
|
+
if (id && store.entities.hasGeometry(id)) ids.push(id);
|
|
58
111
|
}
|
|
59
|
-
|
|
112
|
+
allIdsCache = ids;
|
|
113
|
+
return ids;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
getMaterialNames(entityId: number): string[] {
|
|
117
|
+
return materialNamesOf(extractMaterialsOnDemand(store, entityId));
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
getClassifications(entityId: number): ListClassificationRef[] {
|
|
121
|
+
return extractClassificationsOnDemand(store, entityId).map((c) => ({
|
|
122
|
+
system: c.system,
|
|
123
|
+
code: c.identification,
|
|
124
|
+
name: c.name,
|
|
125
|
+
}));
|
|
60
126
|
},
|
|
61
127
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
128
|
+
getStoreyName(entityId: number): string {
|
|
129
|
+
const hierarchy = store.spatialHierarchy;
|
|
130
|
+
if (!hierarchy) return '';
|
|
131
|
+
const storeyId = hierarchy.elementToStorey.get(entityId);
|
|
132
|
+
if (!storeyId) return '';
|
|
133
|
+
return store.entities.getName(storeyId) || '';
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
discoverAllColumns(): DiscoveredColumns {
|
|
137
|
+
if (columnsCache) return columnsCache;
|
|
138
|
+
|
|
139
|
+
const properties = new Map<string, Set<string>>();
|
|
140
|
+
const quantities = new Map<string, Set<string>>();
|
|
141
|
+
|
|
142
|
+
const ingestProps = (id: number) => {
|
|
143
|
+
for (const set of getPropertySetsFor(id)) {
|
|
144
|
+
if (!set.name) continue;
|
|
145
|
+
let bucket = properties.get(set.name);
|
|
146
|
+
if (!bucket) { bucket = new Set(); properties.set(set.name, bucket); }
|
|
147
|
+
for (const p of set.properties) if (p.name) bucket.add(p.name);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const ingestQtos = (id: number) => {
|
|
151
|
+
for (const set of getQuantitySetsFor(id)) {
|
|
152
|
+
if (!set.name) continue;
|
|
153
|
+
let bucket = quantities.get(set.name);
|
|
154
|
+
if (!bucket) { bucket = new Set(); quantities.set(set.name, bucket); }
|
|
155
|
+
for (const q of set.quantities) if (q.name) bucket.add(q.name);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// On-demand path: scan exactly the entities that declare a pset/qto —
|
|
160
|
+
// the minimal complete set (every distinct set/property in the model).
|
|
161
|
+
if (usesOnDemandProps && store.onDemandPropertyMap) {
|
|
162
|
+
for (const id of store.onDemandPropertyMap.keys()) ingestProps(id);
|
|
163
|
+
}
|
|
164
|
+
if (usesOnDemandQtos && store.onDemandQuantityMap) {
|
|
165
|
+
for (const id of store.onDemandQuantityMap.keys()) ingestQtos(id);
|
|
65
166
|
}
|
|
66
|
-
|
|
167
|
+
// Table path (e.g. server-loaded models): scan the entity column using
|
|
168
|
+
// the pre-built tables. Capped so it can't run away on huge models.
|
|
169
|
+
if (!usesOnDemandProps || !usesOnDemandQtos) {
|
|
170
|
+
const col = store.entities.expressId;
|
|
171
|
+
const CAP = 100_000;
|
|
172
|
+
for (let i = 0, seen = 0; i < col.length && seen < CAP; i++) {
|
|
173
|
+
const id = col[i];
|
|
174
|
+
if (!id) continue;
|
|
175
|
+
seen++;
|
|
176
|
+
if (!usesOnDemandProps) ingestProps(id);
|
|
177
|
+
if (!usesOnDemandQtos) ingestQtos(id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const toSorted = (m: Map<string, Set<string>>) => {
|
|
182
|
+
const out = new Map<string, string[]>();
|
|
183
|
+
for (const [k, s] of m) out.set(k, Array.from(s).sort());
|
|
184
|
+
return out;
|
|
185
|
+
};
|
|
186
|
+
columnsCache = {
|
|
187
|
+
attributes: [...ENTITY_ATTRIBUTES],
|
|
188
|
+
properties: toSorted(properties),
|
|
189
|
+
quantities: toSorted(quantities),
|
|
190
|
+
};
|
|
191
|
+
return columnsCache;
|
|
67
192
|
},
|
|
68
193
|
};
|
|
69
194
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { CellValue } from '@ifc-lite/lists';
|
|
6
|
+
import { displayCell, type ExportModel } from './model';
|
|
7
|
+
|
|
8
|
+
function esc(s: string, delim: string): string {
|
|
9
|
+
return /["\r\n]/.test(s) || s.includes(delim) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CSV faithful to the configured columns. When grouped, a leading "Group"
|
|
14
|
+
* column preserves the grouping as data (so it stays re-importable), rows are
|
|
15
|
+
* ordered by group, and a TOTAL row carries the grand count + sums.
|
|
16
|
+
*/
|
|
17
|
+
export function toCsv(model: ExportModel, delimiter = ','): string {
|
|
18
|
+
const grouped = model.groups !== null;
|
|
19
|
+
const header = [...(grouped ? ['Group'] : []), ...model.columns.map((c) => c.label)];
|
|
20
|
+
const lines = [header.map((h) => esc(h, delimiter)).join(delimiter)];
|
|
21
|
+
|
|
22
|
+
const line = (groupLabel: string | null, values: CellValue[]) => {
|
|
23
|
+
const cells = grouped ? [esc(groupLabel ?? '', delimiter)] : [];
|
|
24
|
+
for (let i = 0; i < model.columns.length; i++) cells.push(esc(displayCell(values[i]), delimiter));
|
|
25
|
+
return cells.join(delimiter);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (grouped && model.groups) {
|
|
29
|
+
for (const g of model.groups) for (const r of g.rows) lines.push(line(g.label, r));
|
|
30
|
+
} else {
|
|
31
|
+
for (const r of model.rows) lines.push(line(null, r));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (model.sumColumnIds.length > 0) {
|
|
35
|
+
const totalLabel = `TOTAL (${model.totals.count})`;
|
|
36
|
+
const cells = grouped ? [esc(totalLabel, delimiter)] : [];
|
|
37
|
+
for (let i = 0; i < model.columns.length; i++) {
|
|
38
|
+
const c = model.columns[i];
|
|
39
|
+
if (c.summed) cells.push(esc(displayCell(model.totals.sums[c.id]), delimiter));
|
|
40
|
+
else if (!grouped && i === 0) cells.push(esc(totalLabel, delimiter));
|
|
41
|
+
else cells.push('');
|
|
42
|
+
}
|
|
43
|
+
lines.push(cells.join(delimiter));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return lines.join('\r\n');
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
* List results export — CSV / Excel / PDF, all driven by one normalised model
|
|
7
|
+
* (columns, grouping, sums, totals). Excel and PDF writers (and their heavy
|
|
8
|
+
* libs) are lazy-loaded so they never touch the initial bundle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { toCsv } from './csv';
|
|
12
|
+
import type { ExportModel } from './model';
|
|
13
|
+
|
|
14
|
+
export type ExportFormat = 'csv' | 'xlsx' | 'pdf';
|
|
15
|
+
|
|
16
|
+
export const EXPORT_LABELS: Record<ExportFormat, string> = {
|
|
17
|
+
csv: 'CSV (.csv)',
|
|
18
|
+
xlsx: 'Excel (.xlsx)',
|
|
19
|
+
pdf: 'PDF (.pdf)',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function slug(s: string): string {
|
|
23
|
+
return (s || 'list').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'list';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function download(blob: Blob, filename: string): void {
|
|
27
|
+
const url = URL.createObjectURL(blob);
|
|
28
|
+
const a = document.createElement('a');
|
|
29
|
+
a.href = url;
|
|
30
|
+
a.download = filename;
|
|
31
|
+
a.click();
|
|
32
|
+
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function exportList(format: ExportFormat, model: ExportModel): Promise<void> {
|
|
36
|
+
const name = slug(model.title);
|
|
37
|
+
if (format === 'csv') {
|
|
38
|
+
download(new Blob([toCsv(model)], { type: 'text/csv;charset=utf-8;' }), `${name}.csv`);
|
|
39
|
+
} else if (format === 'xlsx') {
|
|
40
|
+
const { toXlsx } = await import('./xlsx');
|
|
41
|
+
download(await toXlsx(model), `${name}.xlsx`);
|
|
42
|
+
} else {
|
|
43
|
+
const { toPdf } = await import('./pdf');
|
|
44
|
+
download(await toPdf(model), `${name}.pdf`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { buildExportModel } from './model';
|
|
49
|
+
export type { ExportModel } from './model';
|