@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.
Files changed (150) hide show
  1. package/.turbo/turbo-build.log +45 -38
  2. package/CHANGELOG.md +93 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  8. package/dist/assets/deflate-DNGgs8Ur.js +1 -0
  9. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  10. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  11. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  12. package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
  13. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  14. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
  15. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  16. package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
  17. package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
  18. package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
  19. package/dist/assets/index-BtbXFKsX.css +1 -0
  20. package/dist/assets/index.es-CWfqZyyr.js +6866 -0
  21. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
  22. package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
  23. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  24. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  25. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
  26. package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
  27. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  28. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
  29. package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
  30. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  31. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  32. package/dist/assets/pdf-CRwaZf3s.js +135 -0
  33. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  34. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
  35. package/dist/assets/server-client-cTCJ-853.js +719 -0
  36. package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
  37. package/dist/assets/xlsx-B1YOg2QB.js +142 -0
  38. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  39. package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
  40. package/dist/index.html +10 -10
  41. package/package.json +27 -23
  42. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  43. package/src/components/mcp/data.ts +6 -0
  44. package/src/components/mcp/playground-dispatcher.ts +280 -0
  45. package/src/components/mcp/playground-files.ts +33 -1
  46. package/src/components/mcp/types.ts +2 -1
  47. package/src/components/ui/combo-input.tsx +163 -0
  48. package/src/components/ui/tabs.tsx +1 -1
  49. package/src/components/viewer/CommandPalette.tsx +6 -1
  50. package/src/components/viewer/ComparePanel.tsx +420 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  52. package/src/components/viewer/MainToolbar.tsx +19 -2
  53. package/src/components/viewer/PropertiesPanel.tsx +84 -8
  54. package/src/components/viewer/SearchInline.tsx +62 -2
  55. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  56. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  57. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  58. package/src/components/viewer/SearchModal.tsx +19 -6
  59. package/src/components/viewer/ViewerLayout.tsx +5 -0
  60. package/src/components/viewer/Viewport.tsx +18 -0
  61. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  62. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  63. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  64. package/src/components/viewer/hierarchy/types.ts +1 -0
  65. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  66. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  67. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  68. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  69. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  70. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  71. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  72. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  73. package/src/generated/mcp-catalog.json +4 -0
  74. package/src/hooks/federationLoadGate.test.ts +12 -2
  75. package/src/hooks/federationLoadGate.ts +9 -2
  76. package/src/hooks/ingest/federationAlign.ts +481 -0
  77. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  78. package/src/hooks/source-key.ts +35 -0
  79. package/src/hooks/useAlignmentLines3D.ts +1 -26
  80. package/src/hooks/useCompare.ts +0 -0
  81. package/src/hooks/useCompareOverlay.ts +119 -0
  82. package/src/hooks/useDrawingGeneration.ts +23 -1
  83. package/src/hooks/useGridLines3D.ts +140 -0
  84. package/src/hooks/useIfc.ts +1 -1
  85. package/src/hooks/useIfcCache.ts +32 -9
  86. package/src/hooks/useIfcFederation.ts +42 -810
  87. package/src/hooks/useIfcLoader.ts +361 -488
  88. package/src/hooks/useIfcServer.ts +3 -0
  89. package/src/hooks/useLens.ts +5 -1
  90. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  91. package/src/lib/compare/buildFingerprints.ts +173 -0
  92. package/src/lib/compare/describeChange.ts +0 -0
  93. package/src/lib/compare/geometricData.test.ts +54 -0
  94. package/src/lib/compare/geometricData.ts +37 -0
  95. package/src/lib/compare/overlay.test.ts +99 -0
  96. package/src/lib/compare/overlay.ts +91 -0
  97. package/src/lib/geo/cesium-placement.ts +1 -1
  98. package/src/lib/geo/reproject.ts +4 -1
  99. package/src/lib/length-unit-scale.ts +41 -0
  100. package/src/lib/lists/adapter.ts +136 -11
  101. package/src/lib/lists/export/csv.ts +47 -0
  102. package/src/lib/lists/export/index.ts +49 -0
  103. package/src/lib/lists/export/model.ts +111 -0
  104. package/src/lib/lists/export/pdf.ts +67 -0
  105. package/src/lib/lists/export/xlsx.ts +83 -0
  106. package/src/lib/lists/index.ts +2 -0
  107. package/src/lib/llm/script-edit-ops.ts +23 -0
  108. package/src/lib/llm/stream-client.ts +8 -1
  109. package/src/lib/search/filter-evaluate.test.ts +81 -0
  110. package/src/lib/search/filter-evaluate.ts +59 -87
  111. package/src/lib/search/filter-match.ts +167 -0
  112. package/src/lib/search/filter-rules.test.ts +25 -0
  113. package/src/lib/search/filter-rules.ts +75 -2
  114. package/src/lib/search/filter-schema.ts +0 -0
  115. package/src/lib/search/result-export.ts +7 -1
  116. package/src/lib/slab-edit.test.ts +72 -0
  117. package/src/lib/slab-edit.ts +159 -19
  118. package/src/sdk/adapters/export-adapter.ts +9 -4
  119. package/src/sdk/adapters/query-adapter.ts +3 -3
  120. package/src/store/globalId.ts +15 -13
  121. package/src/store/index.ts +16 -1
  122. package/src/store/slices/cesiumSlice.ts +8 -1
  123. package/src/store/slices/compareSlice.ts +96 -0
  124. package/src/store/slices/lensSlice.ts +8 -0
  125. package/src/store/slices/listSlice.ts +6 -0
  126. package/src/store/slices/mutationSlice.ts +14 -6
  127. package/src/store/slices/searchSlice.ts +29 -3
  128. package/src/utils/acquireFileBuffer.test.ts +12 -4
  129. package/src/utils/desktopModelSnapshot.ts +2 -1
  130. package/src/utils/loadingUtils.ts +32 -0
  131. package/src/utils/nativeSpatialDataStore.ts +6 -0
  132. package/src/utils/serverDataModel.test.ts +6 -0
  133. package/src/utils/serverDataModel.ts +7 -0
  134. package/src/utils/spatialHierarchy.test.ts +53 -1
  135. package/src/utils/spatialHierarchy.ts +42 -2
  136. package/src/vite-env.d.ts +2 -0
  137. package/dist/assets/deflate-Cnx0il6E.js +0 -1
  138. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  139. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  140. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  141. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  142. package/dist/assets/index-B9Ug2EqU.css +0 -1
  143. package/dist/assets/lens-PYsLu_MA.js +0 -1
  144. package/dist/assets/parser.worker-8md211IW.js +0 -182
  145. package/dist/assets/raw-BQrAgxwT.js +0 -1
  146. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
  147. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  148. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  149. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  150. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -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
+ }
@@ -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 { extractPropertiesOnDemand, extractQuantitiesOnDemand, extractEntityAttributesOnDemand } from '@ifc-lite/parser';
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 type { ListDataProvider } from '@ifc-lite/lists';
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(entityId: number): PropertySet[] {
56
- if (store.onDemandPropertyMap && store.source?.length > 0) {
57
- return extractPropertiesOnDemand(store, entityId) as PropertySet[];
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
- return store.properties?.getForEntity(entityId) ?? [];
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
- getQuantitySets(entityId: number): QuantitySet[] {
63
- if (store.onDemandQuantityMap && store.source?.length > 0) {
64
- return extractQuantitiesOnDemand(store, entityId) as QuantitySet[];
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
- return store.quantities?.getForEntity(entityId) ?? [];
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';
@@ -0,0 +1,111 @@
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
+ * Normalised export model shared by the CSV / Excel / PDF writers. Built from
7
+ * the on-screen list view so every export honours the configured columns
8
+ * (order, labels, widths), the active grouping, and the summed columns —
9
+ * grouped sections with per-group count + subtotals, plus grand totals.
10
+ */
11
+
12
+ import type { CellValue, ColumnDefinition, ListRow, ListGrouping } from '@ifc-lite/lists';
13
+
14
+ export interface ExportColumn {
15
+ id: string;
16
+ label: string;
17
+ numeric: boolean;
18
+ summed: boolean;
19
+ /** Pixel width from the table (for proportional column sizing in exports). */
20
+ width: number;
21
+ }
22
+
23
+ export interface ExportGroup {
24
+ label: string;
25
+ count: number;
26
+ sums: Record<string, number>;
27
+ rows: CellValue[][];
28
+ }
29
+
30
+ export interface ExportModel {
31
+ title: string;
32
+ generatedAt: string;
33
+ columns: ExportColumn[];
34
+ /** Grouped sections (with member rows), or null when the list isn't grouped. */
35
+ groups: ExportGroup[] | null;
36
+ /** All rows in display order (flat) — used by writers that don't section. */
37
+ rows: CellValue[][];
38
+ groupColumnId: string | null;
39
+ sumColumnIds: string[];
40
+ totals: { count: number; sums: Record<string, number> };
41
+ }
42
+
43
+ export interface BuildModelInput {
44
+ title: string;
45
+ columns: ColumnDefinition[];
46
+ /** Rows already filtered + sorted exactly as shown on screen. */
47
+ rows: ListRow[];
48
+ grouping?: ListGrouping;
49
+ numericCols: boolean[];
50
+ columnWidths: number[];
51
+ generatedAt: string;
52
+ }
53
+
54
+ /** Format a cell for text-based exports (CSV/PDF). Excel keeps raw numbers. */
55
+ export function displayCell(value: CellValue): string {
56
+ if (value === null || value === undefined) return '';
57
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
58
+ if (typeof value === 'number') {
59
+ if (Number.isInteger(value)) return value.toLocaleString();
60
+ return value.toFixed(4).replace(/\.?0+$/, '');
61
+ }
62
+ return String(value);
63
+ }
64
+
65
+ export function buildExportModel(input: BuildModelInput): ExportModel {
66
+ const { columns, rows, grouping, numericCols, columnWidths, title, generatedAt } = input;
67
+ const sumColumnIds = grouping?.sumColumnIds ?? [];
68
+ const exportCols: ExportColumn[] = columns.map((c, i) => ({
69
+ id: c.id,
70
+ label: c.label ?? c.propertyName,
71
+ numeric: !!numericCols[i],
72
+ summed: sumColumnIds.includes(c.id),
73
+ width: columnWidths[i] ?? 120,
74
+ }));
75
+
76
+ const sumIdx = sumColumnIds
77
+ .map((id) => ({ id, idx: columns.findIndex((c) => c.id === id) }))
78
+ .filter((s) => s.idx >= 0);
79
+ const zeroSums = (): Record<string, number> => Object.fromEntries(sumIdx.map((s) => [s.id, 0]));
80
+ const addSums = (acc: Record<string, number>, values: CellValue[]) => {
81
+ for (const s of sumIdx) {
82
+ const v = values[s.idx];
83
+ if (typeof v === 'number' && Number.isFinite(v)) acc[s.id] += v;
84
+ }
85
+ };
86
+
87
+ const totals = { count: rows.length, sums: zeroSums() };
88
+ const flatRows: CellValue[][] = [];
89
+ for (const r of rows) { flatRows.push(r.values); addSums(totals.sums, r.values); }
90
+
91
+ const groupColumnId = grouping?.columnId && columns.some((c) => c.id === grouping.columnId)
92
+ ? grouping.columnId : null;
93
+
94
+ let groups: ExportGroup[] | null = null;
95
+ if (groupColumnId) {
96
+ const groupIdx = columns.findIndex((c) => c.id === groupColumnId);
97
+ const byKey = new Map<string, ExportGroup>();
98
+ for (const r of rows) {
99
+ const raw = r.values[groupIdx];
100
+ const label = raw === null || raw === undefined || raw === '' ? '(none)' : displayCell(raw);
101
+ let g = byKey.get(label);
102
+ if (!g) { g = { label, count: 0, sums: zeroSums(), rows: [] }; byKey.set(label, g); }
103
+ g.count++;
104
+ g.rows.push(r.values);
105
+ addSums(g.sums, r.values);
106
+ }
107
+ groups = Array.from(byKey.values()).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
108
+ }
109
+
110
+ return { title, generatedAt, columns: exportCols, groups, rows: flatRows, groupColumnId, sumColumnIds, totals };
111
+ }
@@ -0,0 +1,67 @@
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
+ /** Quality tabular PDF report: title + meta, dark header, grouped sections
9
+ * (bold group rows carrying per-group count + subtotals), right-aligned
10
+ * numerics, a grand-total foot, and page numbers. */
11
+ export async function toPdf(model: ExportModel): Promise<Blob> {
12
+ const { jsPDF } = await import('jspdf');
13
+ const autoTable = (await import('jspdf-autotable')).default;
14
+
15
+ const landscape = model.columns.length > 5;
16
+ const doc = new jsPDF({ orientation: landscape ? 'landscape' : 'portrait', unit: 'pt', format: 'a4' });
17
+
18
+ doc.setFont('helvetica', 'bold'); doc.setFontSize(14);
19
+ doc.text(model.title || 'List', 40, 42);
20
+ doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(130);
21
+ doc.text(`${model.totals.count.toLocaleString()} elements · ${model.generatedAt}`, 40, 58);
22
+ doc.setTextColor(0);
23
+
24
+ const head = [model.columns.map((c) => c.label)];
25
+ const cell = (vals: CellValue[], i: number) => displayCell(vals[i]);
26
+ const body: Array<Array<string | { content: string; styles: Record<string, unknown> }>> = [];
27
+
28
+ if (model.groups) {
29
+ for (const g of model.groups) {
30
+ body.push(model.columns.map((c, i) => ({
31
+ content: i === 0 ? `${g.label} (${g.count})` : (c.summed ? displayCell(g.sums[c.id]) : ''),
32
+ styles: { fontStyle: 'bold', fillColor: [226, 232, 240] as unknown as number[] },
33
+ })));
34
+ for (const r of g.rows) body.push(model.columns.map((_, i) => cell(r, i)));
35
+ }
36
+ } else {
37
+ for (const r of model.rows) body.push(model.columns.map((_, i) => cell(r, i)));
38
+ }
39
+
40
+ const foot = model.sumColumnIds.length > 0
41
+ ? [model.columns.map((c, i) => (c.summed ? displayCell(model.totals.sums[c.id]) : (i === 0 ? `Total (${model.totals.count})` : '')))]
42
+ : undefined;
43
+
44
+ const columnStyles: Record<number, { halign: 'right' }> = {};
45
+ model.columns.forEach((c, i) => { if (c.numeric) columnStyles[i] = { halign: 'right' }; });
46
+
47
+ autoTable(doc, {
48
+ head,
49
+ body,
50
+ foot,
51
+ startY: 72,
52
+ margin: { left: 40, right: 40, top: 70, bottom: 40 },
53
+ styles: { fontSize: 8, cellPadding: 3, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.5 },
54
+ headStyles: { fillColor: [51, 65, 85], textColor: 255, fontStyle: 'bold' },
55
+ footStyles: { fillColor: [241, 245, 249], textColor: 20, fontStyle: 'bold' },
56
+ columnStyles,
57
+ didDrawPage: () => {
58
+ doc.setFontSize(8); doc.setTextColor(150);
59
+ const w = doc.internal.pageSize.getWidth();
60
+ const h = doc.internal.pageSize.getHeight();
61
+ doc.text(`Page ${doc.getNumberOfPages()}`, w - 60, h - 20);
62
+ doc.setTextColor(0);
63
+ },
64
+ });
65
+
66
+ return doc.output('blob');
67
+ }
@@ -0,0 +1,83 @@
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, type ExportColumn } from './model';
7
+
8
+ const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
9
+ const NUM_FMT = '#,##0.####';
10
+
11
+ /** px → Excel column-width units (≈ 7px per char). */
12
+ const excelWidth = (px: number): number => Math.max(8, Math.min(80, Math.round(px / 7)));
13
+
14
+ /** Excel keeps real numbers (so the recipient can re-aggregate); other types
15
+ * fall back to the same display string the table shows. */
16
+ function cellValue(v: CellValue, c: ExportColumn): string | number | null {
17
+ if (v === null || v === undefined) return null;
18
+ if (c.numeric && typeof v === 'number' && Number.isFinite(v)) return v;
19
+ return displayCell(v);
20
+ }
21
+
22
+ export async function toXlsx(model: ExportModel): Promise<Blob> {
23
+ const ExcelJS = (await import('exceljs')).default;
24
+ const wb = new ExcelJS.Workbook();
25
+ wb.creator = 'IFC-Lite';
26
+ const ws = wb.addWorksheet((model.title || 'List').slice(0, 31), {
27
+ views: [{ state: 'frozen', ySplit: 4 }],
28
+ });
29
+
30
+ const cols = model.columns;
31
+ const ncol = cols.length;
32
+
33
+ // Title + meta.
34
+ ws.addRow([model.title || 'List']);
35
+ ws.mergeCells(1, 1, 1, ncol);
36
+ ws.getCell(1, 1).font = { bold: true, size: 14 };
37
+ ws.addRow([`${model.totals.count.toLocaleString()} elements · ${model.generatedAt}`]);
38
+ ws.mergeCells(2, 1, 2, ncol);
39
+ ws.getCell(2, 1).font = { italic: true, size: 9, color: { argb: 'FF94A3B8' } };
40
+ ws.addRow([]);
41
+
42
+ // Header.
43
+ const header = ws.addRow(cols.map((c) => c.label));
44
+ header.font = { bold: true, color: { argb: 'FFFFFFFF' } };
45
+ header.eachCell((cell) => {
46
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF334155' } };
47
+ cell.alignment = { vertical: 'middle' };
48
+ });
49
+
50
+ // Column widths + numeric formatting/alignment.
51
+ cols.forEach((c, i) => {
52
+ const col = ws.getColumn(i + 1);
53
+ col.width = excelWidth(c.width);
54
+ if (c.numeric) { col.numFmt = NUM_FMT; col.alignment = { horizontal: 'right' }; }
55
+ });
56
+
57
+ const addDataRow = (values: CellValue[], outline?: number) => {
58
+ const r = ws.addRow(cols.map((c, i) => cellValue(values[i], c)));
59
+ if (outline) r.outlineLevel = outline;
60
+ };
61
+
62
+ if (model.groups) {
63
+ for (const g of model.groups) {
64
+ const gr = ws.addRow(cols.map((c, i) => (i === 0 ? `${g.label} (${g.count})` : (c.summed ? g.sums[c.id] : null))));
65
+ gr.font = { bold: true };
66
+ gr.eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }; });
67
+ for (const row of g.rows) addDataRow(row, 1);
68
+ }
69
+ ws.properties.outlineLevelRow = 1;
70
+ } else {
71
+ for (const row of model.rows) addDataRow(row);
72
+ }
73
+
74
+ // Grand total.
75
+ if (model.sumColumnIds.length > 0) {
76
+ const tr = ws.addRow(cols.map((c, i) => (c.summed ? model.totals.sums[c.id] : (i === 0 ? `Total (${model.totals.count})` : null))));
77
+ tr.font = { bold: true };
78
+ tr.eachCell((cell) => { cell.border = { top: { style: 'double', color: { argb: 'FF334155' } } }; });
79
+ }
80
+
81
+ const buf = await wb.xlsx.writeBuffer();
82
+ return new Blob([buf], { type: XLSX_MIME });
83
+ }
@@ -14,10 +14,12 @@ export type {
14
14
  ConditionOperator,
15
15
  DiscoveredColumns,
16
16
  EntityAttribute,
17
+ ListGrouping,
17
18
  } from '@ifc-lite/lists';
18
19
  export {
19
20
  ENTITY_ATTRIBUTES,
20
21
  executeList,
22
+ summariseListRows,
21
23
  listResultToCSV,
22
24
  discoverColumns,
23
25
  LIST_PRESETS,
@@ -456,6 +456,29 @@ export function applyScriptEditOperations(params: {
456
456
  return { ok: true, content, selection, revision, appliedOpIds, changes, status: 'ok' };
457
457
  }
458
458
 
459
+ if (operations.length > 1 && operations.some((op) => op.type === 'replaceAll')) {
460
+ const diagnostic = createPatchDiagnostic(
461
+ 'patch_semantic_error',
462
+ 'A replaceAll edit must be the only operation in its batch; it cannot be combined with positional ops.',
463
+ 'error',
464
+ {
465
+ failureKind: 'mixed_repair_scopes',
466
+ fixHint:
467
+ 'Emit replaceAll on its own, or use only positional ops (insert/replaceRange/append) in one batch.',
468
+ },
469
+ );
470
+ return {
471
+ ok: false,
472
+ content: params.content,
473
+ selection: params.selection,
474
+ revision,
475
+ appliedOpIds: [],
476
+ status: 'semantic_error',
477
+ error: diagnostic.message,
478
+ diagnostic,
479
+ };
480
+ }
481
+
459
482
  if (params.intent === 'repair') {
460
483
  const metadataError = validateRepairBatchMetadata(operations);
461
484
  if (metadataError) {
@@ -139,7 +139,14 @@ export async function readSseStream(
139
139
  if (!line.startsWith('data: ')) continue;
140
140
  const data = line.slice(6);
141
141
  if (data === '[DONE]') continue;
142
- try { onEvent(data); } catch { /* skip malformed */ }
142
+ try {
143
+ onEvent(data);
144
+ } catch (err) {
145
+ // Malformed JSON payloads are expected and skipped, but a genuine
146
+ // callback failure (onChunk/onUsageInfo/logCacheHit/fullText) would
147
+ // otherwise be silently dropped — surface it for diagnosability.
148
+ console.debug('[sse] skipped event', err);
149
+ }
143
150
  }
144
151
  }
145
152
  };