@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
@@ -99,6 +99,78 @@ describe('slab-edit', () => {
99
99
  assert.deepStrictEqual(chain.footprint[2], [11, 22]);
100
100
  });
101
101
 
102
+ it('applies the IfcExtrudedAreaSolid.Position transform (offset + axis flip)', () => {
103
+ // Real authoring tools bake the slab's plan offset/rotation into the
104
+ // solid Position rather than the IfcLocalPlacement. Here the solid is
105
+ // placed at (100, 50) with RefDirection (-1,0,0) + Axis (0,0,-1) — the
106
+ // 180°-about-the-vertical flip seen in the BIMcollab fixture (#90).
107
+ const entities = makePolygonSlabFixture();
108
+ entities.push(
109
+ { expressId: 70, type: 'IFCCARTESIANPOINT', attributes: [[100, 50, 0]] },
110
+ { expressId: 71, type: 'IFCDIRECTION', attributes: [[0, 0, -1]] }, // Axis (Z)
111
+ { expressId: 72, type: 'IFCDIRECTION', attributes: [[-1, 0, 0]] }, // RefDirection (X)
112
+ { expressId: 73, type: 'IFCAXIS2PLACEMENT3D', attributes: [70, 71, 72] },
113
+ );
114
+ // Point the solid's Position slot (attr 1) at the new placement.
115
+ entities.find((e) => e.expressId === 93)!.attributes = [92, 73, null, 0.25];
116
+
117
+ const editor = new StubStoreEditor(entities) as unknown as Parameters<typeof resolveSlabEditChain>[2];
118
+ const view = new StubView() as unknown as Parameters<typeof resolveSlabEditChain>[1];
119
+ const chain = resolveSlabEditChain(dataStoreStub, view, editor, 100);
120
+ assert.ok(chain);
121
+ // X = (-1,0,0), Y = Z×X = (0,1,0) → solidXform(p) = (100 - p.x, 50 + p.y).
122
+ // Then + placement origin (10, 20).
123
+ // (0,0) → (100,50) → (110,70)
124
+ // (2,0) → (98,50) → (108,70)
125
+ // (1,2) → (99,52) → (109,72)
126
+ assert.deepStrictEqual(chain.footprint[0], [110, 70]);
127
+ assert.deepStrictEqual(chain.footprint[1], [108, 70]);
128
+ assert.deepStrictEqual(chain.footprint[2], [109, 72]);
129
+ });
130
+
131
+ it('normalizes a non-unit-length Axis/RefDirection in the solid Position', () => {
132
+ // IfcDirection.DirectionRatios are ratios, not guaranteed unit
133
+ // vectors. A valid Axis=(0,0,2) must not leak its length into the
134
+ // Y basis (Y = Z×X) or skew the Gram-Schmidt projection — otherwise
135
+ // the footprint disagrees with the (normalizing) renderer. With
136
+ // proper normalization the result matches an identity-rotation
137
+ // placement: solidXform(p) = (100 + p.x, 50 + p.y).
138
+ const entities = makePolygonSlabFixture();
139
+ entities.push(
140
+ { expressId: 70, type: 'IFCCARTESIANPOINT', attributes: [[100, 50, 0]] },
141
+ { expressId: 71, type: 'IFCDIRECTION', attributes: [[0, 0, 2]] }, // non-unit Axis (Z)
142
+ { expressId: 72, type: 'IFCDIRECTION', attributes: [[3, 0, 0]] }, // non-unit RefDirection (X)
143
+ { expressId: 73, type: 'IFCAXIS2PLACEMENT3D', attributes: [70, 71, 72] },
144
+ );
145
+ entities.find((e) => e.expressId === 93)!.attributes = [92, 73, null, 0.25];
146
+
147
+ const editor = new StubStoreEditor(entities) as unknown as Parameters<typeof resolveSlabEditChain>[2];
148
+ const view = new StubView() as unknown as Parameters<typeof resolveSlabEditChain>[1];
149
+ const chain = resolveSlabEditChain(dataStoreStub, view, editor, 100);
150
+ assert.ok(chain);
151
+ // (0,0) → (100,50) → +placement(10,20) → (110,70)
152
+ // (2,0) → (102,50) → (112,70)
153
+ // (1,2) → (101,52) → (111,72) [buggy raw-Axis would give y=74]
154
+ assert.deepStrictEqual(chain.footprint[0], [110, 70]);
155
+ assert.deepStrictEqual(chain.footprint[1], [112, 70]);
156
+ assert.deepStrictEqual(chain.footprint[2], [111, 72]);
157
+ });
158
+
159
+ it('ignores lengthUnitScale for authored (overlay) entities', () => {
160
+ // The in-store builders already emit metres, so a freshly-authored
161
+ // slab must NOT be re-scaled even on a millimetre model — otherwise
162
+ // re-splitting a just-cut half would shrink it 1000×. The stub serves
163
+ // overlay entities, so the footprint stays in its given units despite
164
+ // the 0.001 scale.
165
+ const entities = makePolygonSlabFixture();
166
+ const editor = new StubStoreEditor(entities) as unknown as Parameters<typeof resolveSlabEditChain>[2];
167
+ const view = new StubView() as unknown as Parameters<typeof resolveSlabEditChain>[1];
168
+ const chain = resolveSlabEditChain(dataStoreStub, view, editor, 100, 0.001);
169
+ assert.ok(chain);
170
+ assert.deepStrictEqual(chain.footprint[0], [10, 20]);
171
+ assert.strictEqual(chain.thickness, 0.25);
172
+ });
173
+
102
174
  it('strips the redundant closing vertex from an IfcPolyline', () => {
103
175
  const entities = makePolygonSlabFixture();
104
176
  // Append a duplicate of the first vertex to the polyline.
@@ -35,6 +35,7 @@ import type { MutablePropertyView, StoreEditor } from '@ifc-lite/mutations';
35
35
  import {
36
36
  asExpressIdRef,
37
37
  asCoordinateTriple,
38
+ asDirectionRatios,
38
39
  readAttributes,
39
40
  resolvePlacementChain,
40
41
  } from './placement-core.js';
@@ -61,6 +62,93 @@ function stepTypeToSlabLike(stepType: string): SlabLikeType | null {
61
62
  return SLAB_LIKE_STEP_TYPES[stepType.toUpperCase()] ?? null;
62
63
  }
63
64
 
65
+ /**
66
+ * A 2D rigid transform mapping a profile-coordinate point into the
67
+ * solid's local plan (XY). Built from the `IfcExtrudedAreaSolid`'s
68
+ * `Position` (an `IfcAxis2Placement3D`), it folds in the in-place
69
+ * translation + rotation that real-world authoring tools bake there.
70
+ * In-store-built slabs carry an identity Position, so the resolver
71
+ * defaults to the identity transform for them.
72
+ */
73
+ type Xform2D = (p: [number, number]) => [number, number];
74
+
75
+ const IDENTITY_XFORM2D: Xform2D = (p) => [p[0], p[1]];
76
+
77
+ function readDirection(
78
+ dataStore: IfcDataStore,
79
+ view: MutablePropertyView,
80
+ editor: StoreEditor,
81
+ id: number | null,
82
+ ): [number, number, number] | null {
83
+ if (id === null) return null;
84
+ const attrs = readAttributes(dataStore, view, editor, id);
85
+ return attrs ? asDirectionRatios(attrs[0]) : null;
86
+ }
87
+
88
+ /**
89
+ * Build the plan-space transform for an `IfcExtrudedAreaSolid.Position`.
90
+ * The profile lives in the placement's local XY plane; we map a profile
91
+ * point `(px, py)` to `origin + px·X + py·Y` and keep the XY components
92
+ * (the footprint is the plan). X comes from RefDirection (orthonormalised
93
+ * against the Axis/Z), Y = Z × X — matching the IFC placement convention,
94
+ * including axis flips (e.g. Axis `(0,0,-1)`, RefDirection `(-1,0,0)`).
95
+ * Returns identity when the placement is absent or degenerate.
96
+ */
97
+ function resolveSolidPositionXform(
98
+ dataStore: IfcDataStore,
99
+ view: MutablePropertyView,
100
+ editor: StoreEditor,
101
+ placementId: number | null,
102
+ ): Xform2D {
103
+ if (placementId === null) return IDENTITY_XFORM2D;
104
+ const attrs = readAttributes(dataStore, view, editor, placementId);
105
+ if (!attrs) return IDENTITY_XFORM2D;
106
+
107
+ // IfcAxis2Placement3D: [0] Location · [1] Axis (Z) · [2] RefDirection (X).
108
+ const locId = asExpressIdRef(attrs[0]);
109
+ let ox = 0;
110
+ let oy = 0;
111
+ if (locId !== null) {
112
+ const locAttrs = readAttributes(dataStore, view, editor, locId);
113
+ const c = locAttrs ? asCoordinateTriple(locAttrs[0]) : null;
114
+ if (c) {
115
+ ox = c[0];
116
+ oy = c[1];
117
+ }
118
+ }
119
+
120
+ // IfcDirection ratios are NOT guaranteed unit length, so normalise Z
121
+ // before using it as a basis vector — otherwise the Gram-Schmidt
122
+ // projection (which assumes |Z|=1) and Y = Z × X both pick up |Z| as a
123
+ // stray scale factor, skewing the footprint away from the rendered mesh
124
+ // for files with e.g. Axis=(0,0,2). The Rust profile extractor
125
+ // normalises the same placement.
126
+ const rawZ = readDirection(dataStore, view, editor, asExpressIdRef(attrs[1])) ?? [0, 0, 1];
127
+ const zlen = Math.hypot(rawZ[0], rawZ[1], rawZ[2]);
128
+ if (zlen < 1e-9) return IDENTITY_XFORM2D;
129
+ const z: [number, number, number] = [rawZ[0] / zlen, rawZ[1] / zlen, rawZ[2] / zlen];
130
+ const refX = readDirection(dataStore, view, editor, asExpressIdRef(attrs[2])) ?? [1, 0, 0];
131
+
132
+ // Orthonormalise X against the unit Z (Gram-Schmidt), then Y = Z × X.
133
+ const dot = refX[0] * z[0] + refX[1] * z[1] + refX[2] * z[2];
134
+ let xv: [number, number, number] = [
135
+ refX[0] - dot * z[0],
136
+ refX[1] - dot * z[1],
137
+ refX[2] - dot * z[2],
138
+ ];
139
+ const xlen = Math.hypot(xv[0], xv[1], xv[2]);
140
+ if (xlen < 1e-9) return IDENTITY_XFORM2D;
141
+ xv = [xv[0] / xlen, xv[1] / xlen, xv[2] / xlen];
142
+ // Z and X are now orthonormal, so Y = Z × X is already unit length.
143
+ const yv: [number, number, number] = [
144
+ z[1] * xv[2] - z[2] * xv[1],
145
+ z[2] * xv[0] - z[0] * xv[2],
146
+ z[0] * xv[1] - z[1] * xv[0],
147
+ ];
148
+
149
+ return (p) => [ox + p[0] * xv[0] + p[1] * yv[0], oy + p[0] * xv[1] + p[1] * yv[1]];
150
+ }
151
+
64
152
  export interface SlabEditChain {
65
153
  /** STEP type name, for the slice's dispatch. */
66
154
  elementType: SlabLikeType;
@@ -106,22 +194,23 @@ function rectangleFootprint(
106
194
  profileOrigin2D: [number, number],
107
195
  xdim: number,
108
196
  ydim: number,
197
+ solidXform: Xform2D,
109
198
  ): Point2D[] {
110
- // The profile's local frame maps directly to storey-local XY for
111
- // a slab (no in-plane rotation; the builder writes Position +
112
- // null Axis + null RefDirection so the placement is axis-aligned).
199
+ // Rectangle corners in the profile coordinate system (centred on the
200
+ // profile origin), mapped through the solid Position into plan space,
201
+ // then offset by the slab's placement origin.
113
202
  const [px, py] = placementOrigin;
114
203
  const [cx, cy] = profileOrigin2D;
115
- const xMin = px + cx - xdim / 2;
116
- const xMax = px + cx + xdim / 2;
117
- const yMin = py + cy - ydim / 2;
118
- const yMax = py + cy + ydim / 2;
119
- return [
120
- [xMin, yMin],
121
- [xMax, yMin],
122
- [xMax, yMax],
123
- [xMin, yMax],
204
+ const corners: Point2D[] = [
205
+ [cx - xdim / 2, cy - ydim / 2],
206
+ [cx + xdim / 2, cy - ydim / 2],
207
+ [cx + xdim / 2, cy + ydim / 2],
208
+ [cx - xdim / 2, cy + ydim / 2],
124
209
  ];
210
+ return corners.map((c) => {
211
+ const [wx, wy] = solidXform(c);
212
+ return [px + wx, py + wy] as Point2D;
213
+ });
125
214
  }
126
215
 
127
216
  /**
@@ -136,6 +225,7 @@ function polylineFootprint(
136
225
  polylineId: number,
137
226
  placementOrigin: [number, number, number],
138
227
  profileOrigin2D: [number, number],
228
+ solidXform: Xform2D,
139
229
  ): Point2D[] | null {
140
230
  const attrs = readAttributes(dataStore, view, editor, polylineId);
141
231
  if (!attrs) return null;
@@ -155,7 +245,10 @@ function polylineFootprint(
155
245
  // tolerantly — IFC files in the wild sometimes pad with Z=0.
156
246
  const coords = asCoordinateTriple(ptAttrs[0]);
157
247
  if (!coords) return null;
158
- out.push([px + cx + coords[0], py + cy + coords[1]]);
248
+ // Point in profile CS solid plan (Position translation + rotation)
249
+ // → slab placement origin.
250
+ const [wx, wy] = solidXform([cx + coords[0], cy + coords[1]]);
251
+ out.push([px + wx, py + wy]);
159
252
  }
160
253
  // IfcPolyline for a closed profile may or may not repeat the
161
254
  // first vertex at the end — strip if present, our clip API
@@ -170,22 +263,57 @@ function polylineFootprint(
170
263
  return out.length >= 3 ? out : null;
171
264
  }
172
265
 
266
+ /**
267
+ * Scale a chain's coordinate-bearing fields (footprint, placement
268
+ * origin, thickness) by `scale`. Identity when `scale === 1`. Used to
269
+ * lift a native-unit (e.g. millimetre) STEP read into the viewer's
270
+ * metre working space — see `resolveSlabEditChain`'s `lengthUnitScale`.
271
+ */
272
+ function scaleSlabChain(chain: SlabEditChain, scale: number): SlabEditChain {
273
+ if (scale === 1) return chain;
274
+ return {
275
+ ...chain,
276
+ placementOrigin: [
277
+ chain.placementOrigin[0] * scale,
278
+ chain.placementOrigin[1] * scale,
279
+ chain.placementOrigin[2] * scale,
280
+ ],
281
+ footprint: chain.footprint.map(([x, y]) => [x * scale, y * scale] as Point2D),
282
+ thickness: chain.thickness * scale,
283
+ };
284
+ }
285
+
173
286
  /**
174
287
  * Resolve the slab chain (placement + footprint + extrusion). Works
175
288
  * for IfcSlab / IfcRoof / IfcPlate / IfcSpace whose representation
176
289
  * matches the in-store builder shape; null otherwise.
290
+ *
291
+ * `lengthUnitScale` is the model's native-unit → metre factor (e.g.
292
+ * `0.001` for a millimetre file). Raw STEP coordinate reads are in
293
+ * native units, but the rest of the split flow — raycast cut points,
294
+ * preview meshes, selection hit-tests — lives in metres, so the
295
+ * resolved footprint/thickness are scaled to match. Authored overlay
296
+ * entities are skipped: the in-store builders already emit metres, so
297
+ * scaling them would double-apply (re-splitting a freshly-cut half).
177
298
  */
178
299
  export function resolveSlabEditChain(
179
300
  dataStore: IfcDataStore,
180
301
  view: MutablePropertyView,
181
302
  editor: StoreEditor,
182
303
  expressId: number,
304
+ lengthUnitScale = 1,
183
305
  ): SlabEditChain | null {
184
306
  const rawType = readEntityType(dataStore, view, editor, expressId);
185
307
  if (!rawType) return null;
186
308
  const elementType = stepTypeToSlabLike(rawType);
187
309
  if (!elementType) return null;
188
310
 
311
+ // Overlay (authored) entities are stored in metres by the in-store
312
+ // builders; only native STEP reads need the unit scale applied.
313
+ // `getNewEntity` returns null (not undefined) for source entities.
314
+ const isAuthored = editor.getNewEntity(expressId) != null;
315
+ const scale = isAuthored ? 1 : lengthUnitScale;
316
+
189
317
  const chain = resolvePlacementChain(dataStore, view, editor, expressId);
190
318
  if (!chain) return null;
191
319
  const placementOrigin = chain.coordinates;
@@ -212,6 +340,18 @@ export function resolveSlabEditChain(
212
340
  const thicknessRaw = solidAttrs[3];
213
341
  if (profileId === null || typeof thicknessRaw !== 'number') return null;
214
342
 
343
+ // IfcExtrudedAreaSolid.Position (attr 1) is an IfcAxis2Placement3D that
344
+ // places the profile in the solid's frame — real authoring tools bake
345
+ // the slab's plan offset + rotation here (in-store-built slabs leave it
346
+ // identity). Fold it into the footprint so the preview, cut line, and
347
+ // resulting halves land where the rendered mesh actually is.
348
+ const solidXform = resolveSolidPositionXform(
349
+ dataStore,
350
+ view,
351
+ editor,
352
+ asExpressIdRef(solidAttrs[1]),
353
+ );
354
+
215
355
  // Profile dispatch — rectangle vs polygon, both produced by
216
356
  // addSlabToStore. Source-buffer slabs with mapped representations,
217
357
  // I-shape profiles, etc. land in `null` here and the slice
@@ -244,29 +384,29 @@ export function resolveSlabEditChain(
244
384
  const xdim = profileAttrs[3];
245
385
  const ydim = profileAttrs[4];
246
386
  if (typeof xdim !== 'number' || typeof ydim !== 'number') return null;
247
- return {
387
+ return scaleSlabChain({
248
388
  elementType,
249
389
  placementOrigin,
250
- footprint: rectangleFootprint(placementOrigin, profileOrigin2D, xdim, ydim),
390
+ footprint: rectangleFootprint(placementOrigin, profileOrigin2D, xdim, ydim, solidXform),
251
391
  extrudedSolidId: solidId,
252
392
  thickness: thicknessRaw,
253
393
  profileKind: 'rectangle',
254
- };
394
+ }, scale);
255
395
  }
256
396
  if (profileType && profileType.toUpperCase() === 'IFCARBITRARYCLOSEDPROFILEDEF') {
257
397
  // OuterCurve at attr 2.
258
398
  const polylineId = asExpressIdRef(profileAttrs[2]);
259
399
  if (polylineId === null) return null;
260
- const fp = polylineFootprint(dataStore, view, editor, polylineId, placementOrigin, profileOrigin2D);
400
+ const fp = polylineFootprint(dataStore, view, editor, polylineId, placementOrigin, profileOrigin2D, solidXform);
261
401
  if (!fp) return null;
262
- return {
402
+ return scaleSlabChain({
263
403
  elementType,
264
404
  placementOrigin,
265
405
  footprint: fp,
266
406
  extrudedSolidId: solidId,
267
407
  thickness: thicknessRaw,
268
408
  profileKind: 'polygon',
269
- };
409
+ }, scale);
270
410
  }
271
411
  return null;
272
412
  }
@@ -108,7 +108,12 @@ export function resolveVisibilityFilterSets(
108
108
  * double-quotes, or newlines.
109
109
  */
110
110
  function escapeCsv(value: string, sep: string): string {
111
- if (value.includes(sep) || value.includes('"') || value.includes('\n')) {
111
+ // Neutralize spreadsheet formula injection (CWE-1236): a leading
112
+ // =, +, -, @, TAB or CR makes a cell execute as a formula in Excel/
113
+ // LibreOffice/Sheets. IFC values are attacker-controllable, so prefix
114
+ // such cells with an apostrophe.
115
+ if (/^[=+\-@\t\r]/.test(value)) value = `'${value}`;
116
+ if (value.includes(sep) || value.includes('"') || value.includes('\n') || value.includes('\r')) {
112
117
  return `"${value.replace(/"/g, '""')}"`;
113
118
  }
114
119
  return value;
@@ -153,13 +158,13 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
153
158
  if (!model?.ifcDataStore) return [];
154
159
 
155
160
  const node = new EntityNode(model.ifcDataStore, ref.expressId);
156
- return node.properties().map((pset: { name: string; globalId?: string; properties: Array<{ name: string; type: number; value: string | number | boolean | null }> }) => ({
161
+ return node.properties().map((pset) => ({
157
162
  name: pset.name,
158
163
  globalId: pset.globalId,
159
- properties: pset.properties.map((p: { name: string; type: number; value: string | number | boolean | null }) => ({
164
+ properties: pset.properties.map((p) => ({
160
165
  name: p.name,
161
166
  type: p.type,
162
- value: p.value,
167
+ value: p.value as string | number | boolean | null,
163
168
  })),
164
169
  }));
165
170
  }
@@ -136,13 +136,13 @@ export function createQueryAdapter(store: StoreApi): QueryBackendMethods {
136
136
  if (!model?.ifcDataStore) return [];
137
137
 
138
138
  const node = new EntityNode(model.ifcDataStore, ref.expressId);
139
- return node.properties().map((pset: { name: string; globalId?: string; properties: Array<{ name: string; type: number; value: string | number | boolean | null }> }) => ({
139
+ return node.properties().map((pset) => ({
140
140
  name: pset.name,
141
141
  globalId: pset.globalId,
142
- properties: pset.properties.map((p: { name: string; type: number; value: string | number | boolean | null }) => ({
142
+ properties: pset.properties.map((p) => ({
143
143
  name: p.name,
144
144
  type: p.type,
145
- value: p.value,
145
+ value: p.value as string | number | boolean | null,
146
146
  })),
147
147
  }));
148
148
  }
@@ -44,23 +44,17 @@ export function fromGlobalIdFromModels(
44
44
  models: ReverseModelMapLike,
45
45
  globalId: number,
46
46
  ): EntityRef | undefined {
47
- if (models.size <= 1) {
48
- const firstModelId = models.keys().next().value;
49
- if (firstModelId) {
50
- return {
51
- modelId: firstModelId,
52
- expressId: globalId,
53
- };
54
- }
55
- return {
56
- modelId: 'legacy',
57
- expressId: globalId,
58
- };
47
+ // No models loaded — legacy single-store fallback (expressId === globalId).
48
+ if (models.size === 0) {
49
+ return { modelId: 'legacy', expressId: globalId };
59
50
  }
60
51
 
52
+ // Resolve through every model by its offset range, regardless of count.
53
+ // For a true single model with idOffset 0 this still yields expressId === globalId.
54
+ // The `>= 0` boundary matches the canonical resolveGlobalIdFromModels (modelSlice.ts).
61
55
  for (const [modelId, model] of models.entries()) {
62
56
  const localExpressId = globalId - model.idOffset;
63
- if (localExpressId > 0 && localExpressId <= model.maxExpressId) {
57
+ if (localExpressId >= 0 && localExpressId <= model.maxExpressId) {
64
58
  return {
65
59
  modelId,
66
60
  expressId: localExpressId,
@@ -68,6 +62,14 @@ export function fromGlobalIdFromModels(
68
62
  }
69
63
  }
70
64
 
65
+ // Single-model graceful fallback: if exactly one model and the offset
66
+ // range check missed (e.g. overlay-allocated id above maxExpressId),
67
+ // still return that model with the offset-corrected id rather than undefined.
68
+ if (models.size === 1) {
69
+ const [modelId, model] = models.entries().next().value!;
70
+ return { modelId, expressId: globalId - model.idOffset };
71
+ }
72
+
71
73
  return undefined;
72
74
  }
73
75
 
@@ -34,6 +34,7 @@ import { createListSlice, type ListSlice } from './slices/listSlice.js';
34
34
  import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
35
35
  import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
36
36
  import { createClashSlice, type ClashSlice } from './slices/clashSlice.js';
37
+ import { createCompareSlice, type CompareSlice } from './slices/compareSlice.js';
37
38
  import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
38
39
  import { createChatSlice, type ChatSlice } from './slices/chatSlice.js';
39
40
  import { createCesiumSlice, type CesiumSlice } from './slices/cesiumSlice.js';
@@ -85,6 +86,7 @@ export type { PinboardSlice } from './slices/pinboardSlice.js';
85
86
 
86
87
  // Re-export Lens types
87
88
  export type { LensSlice, Lens, LensRule, LensCriteria } from './slices/lensSlice.js';
89
+ export type { CompareSlice, CompareResult } from './slices/compareSlice.js';
88
90
 
89
91
  // Re-export Script types
90
92
  export type { ScriptSlice } from './slices/scriptSlice.js';
@@ -131,6 +133,7 @@ export type ViewerState = LoadingSlice &
131
133
  PinboardSlice &
132
134
  LensSlice &
133
135
  ClashSlice &
136
+ CompareSlice &
134
137
  ScriptSlice &
135
138
  ChatSlice &
136
139
  CesiumSlice &
@@ -155,7 +158,7 @@ export type ViewerState = LoadingSlice &
155
158
  * the right panel. Routed through by the toolbar, command palette, and the
156
159
  * BCF overlay so every entry point behaves identically.
157
160
  */
158
- openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') => void;
161
+ openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions') => void;
159
162
  };
160
163
 
161
164
  /**
@@ -182,6 +185,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
182
185
  ...createPinboardSlice(...args),
183
186
  ...createLensSlice(...args),
184
187
  ...createClashSlice(...args),
188
+ ...createCompareSlice(...args),
185
189
  ...createScriptSlice(...args),
186
190
  ...createChatSlice(...args),
187
191
  ...createCesiumSlice(...args),
@@ -211,6 +215,8 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
211
215
  // Selection (multi-model)
212
216
  selectedEntity: null,
213
217
  selectedEntitiesSet: new Set(),
218
+ selectedEntities: [],
219
+ selectedModelId: null,
214
220
 
215
221
  // Visibility (legacy)
216
222
  hiddenEntities: new Set(),
@@ -235,6 +241,14 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
235
241
  pendingColorUpdates: null,
236
242
  pendingMeshColorUpdates: null,
237
243
 
244
+ // Compare (#924): drop any stale diff result — it references models by
245
+ // id and the loaded set is changing. Keep panel visibility + A/B/scope
246
+ // choices (UI prefs); the user re-runs against the new set.
247
+ compareResult: null,
248
+ compareSelectedKey: null,
249
+ compareRunning: false,
250
+ compareError: null,
251
+
238
252
  // Hover/Context
239
253
  hoverState: { entityId: null, screenX: 0, screenY: 0 },
240
254
  contextMenu: { isOpen: false, entityId: null, screenX: 0, screenY: 0 },
@@ -460,6 +474,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
460
474
  idsPanelVisible: panel === 'ids',
461
475
  lensPanelVisible: panel === 'lens',
462
476
  clashPanelVisible: panel === 'clash',
477
+ comparePanelVisible: panel === 'compare',
463
478
  extensionsPanelVisible: panel === 'extensions',
464
479
  rightPanelCollapsed: false,
465
480
  });
@@ -108,8 +108,15 @@ const STORAGE_KEY_DATA_SOURCE = 'ifc-lite:cesium-data-source';
108
108
  * Default Cesium ion token provided at build time.
109
109
  * Set via VITE_CESIUM_ION_TOKEN in .env or CI environment.
110
110
  * This means users never need to configure a token manually.
111
+ *
112
+ * NOTE: `import.meta.env` is undefined under the Vitest/Node test runner (the
113
+ * Vite define plugin doesn't run there), so this module-top-level read would
114
+ * crash with "Cannot read properties of undefined" — every viewer test imports
115
+ * the store, which imports this slice. The optional chaining on `.env` keeps the
116
+ * read safe in that environment. `import.meta.env` is typed via vite-env.d.ts so
117
+ * no `as any` cast is needed. Do NOT drop the optional chaining.
111
118
  */
112
- const DEFAULT_ION_TOKEN: string = (import.meta as any).env?.VITE_CESIUM_ION_TOKEN ?? '';
119
+ const DEFAULT_ION_TOKEN: string = import.meta.env?.VITE_CESIUM_ION_TOKEN ?? '';
113
120
 
114
121
  function loadFromStorage(key: string, fallback: string): string {
115
122
  try {
@@ -0,0 +1,96 @@
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-comparison panel state (issue #924). Holds the panel's UI state, the
7
+ * A/B model selection, the data-vs-geometry scope, and the last `@ifc-lite/diff`
8
+ * result. The orchestration (building per-entity fingerprints from each model's
9
+ * `IfcDataStore` + geometry hashes, running `diffModels`, applying the 3D
10
+ * colour/visibility overlay) lives in the `useCompare` hook — this slice is
11
+ * deliberately dumb, mirroring `clashSlice` + `useClash`.
12
+ */
13
+
14
+ import type { StateCreator } from 'zustand';
15
+ import type { DiffScope, ModelDiff } from '@ifc-lite/diff';
16
+ import type { CompareRef } from '@/lib/compare/buildFingerprints';
17
+
18
+ /** A completed comparison: the engine result plus the A/B context it ran on. */
19
+ export interface CompareResult {
20
+ /** Federation model id chosen as the base (version A). */
21
+ baseModelId: string;
22
+ /** Federation model id chosen as the head (version B). */
23
+ headModelId: string;
24
+ /** Display name of the base model. */
25
+ baseName: string;
26
+ /** Display name of the head model. */
27
+ headName: string;
28
+ /** The scope the diff was computed with. */
29
+ scope: DiffScope;
30
+ /** True when a compared model carries no geometry hashes (loaded outside the
31
+ * WASM mesh path), so geometry-scope changes can't be detected. */
32
+ geometryUnavailable: boolean;
33
+ /** The engine output — entries keyed by GlobalId, with per-entity refs. */
34
+ diff: ModelDiff<CompareRef>;
35
+ }
36
+
37
+ export interface CompareSlice {
38
+ comparePanelVisible: boolean;
39
+ /** Selected base (A) / head (B) federation model ids. */
40
+ compareBaseModelId: string | null;
41
+ compareHeadModelId: string | null;
42
+ /** What counts as a change: data, geometry, or both. */
43
+ compareScope: DiffScope;
44
+ /** Whether unchanged elements are drawn (ghosted) or hidden. */
45
+ compareShowUnchanged: boolean;
46
+ /** Last comparison result (null when idle / not yet run). */
47
+ compareResult: CompareResult | null;
48
+ compareRunning: boolean;
49
+ compareError: string | null;
50
+ /** GlobalId of the entry focused in the list (for highlight). */
51
+ compareSelectedKey: string | null;
52
+
53
+ setComparePanelVisible: (visible: boolean) => void;
54
+ toggleComparePanel: () => void;
55
+ setCompareBaseModelId: (id: string | null) => void;
56
+ setCompareHeadModelId: (id: string | null) => void;
57
+ setCompareScope: (scope: DiffScope) => void;
58
+ setCompareShowUnchanged: (show: boolean) => void;
59
+ setCompareResult: (result: CompareResult | null) => void;
60
+ setCompareRunning: (running: boolean) => void;
61
+ setCompareError: (error: string | null) => void;
62
+ setCompareSelectedKey: (key: string | null) => void;
63
+ /** Clear the run result + selection; keeps the A/B + scope choices. */
64
+ clearCompare: () => void;
65
+ }
66
+
67
+ export const createCompareSlice: StateCreator<CompareSlice, [], [], CompareSlice> = (set) => ({
68
+ comparePanelVisible: false,
69
+ compareBaseModelId: null,
70
+ compareHeadModelId: null,
71
+ compareScope: 'both',
72
+ compareShowUnchanged: false,
73
+ compareResult: null,
74
+ compareRunning: false,
75
+ compareError: null,
76
+ compareSelectedKey: null,
77
+
78
+ setComparePanelVisible: (comparePanelVisible) => set({ comparePanelVisible }),
79
+ toggleComparePanel: () => set((s) => ({ comparePanelVisible: !s.comparePanelVisible })),
80
+ setCompareBaseModelId: (compareBaseModelId) => set({ compareBaseModelId }),
81
+ setCompareHeadModelId: (compareHeadModelId) => set({ compareHeadModelId }),
82
+ setCompareScope: (compareScope) => set({ compareScope }),
83
+ setCompareShowUnchanged: (compareShowUnchanged) => set({ compareShowUnchanged }),
84
+ setCompareResult: (compareResult) => set({ compareResult }),
85
+ setCompareRunning: (compareRunning) => set({ compareRunning }),
86
+ setCompareError: (compareError) => set({ compareError }),
87
+ setCompareSelectedKey: (compareSelectedKey) => set({ compareSelectedKey }),
88
+
89
+ clearCompare: () =>
90
+ set({
91
+ compareResult: null,
92
+ compareRunning: false,
93
+ compareError: null,
94
+ compareSelectedKey: null,
95
+ }),
96
+ });
@@ -98,6 +98,11 @@ export interface LensSlice {
98
98
  lensPanelVisible: boolean;
99
99
  /** Computed: globalId → hex color for entities matched by active lens */
100
100
  lensColorMap: Map<number, string>;
101
+ /** The exact RGBA overlay the active lens last pushed to the shared color
102
+ * channel, or null when no lens is active. Lets another channel owner
103
+ * (e.g. the compare overlay) hand control back to the lens on teardown
104
+ * instead of clearing it. */
105
+ lensAppliedColors: Map<number, [number, number, number, number]> | null;
101
106
  /** Computed: globalIds to hide via lens rules */
102
107
  lensHiddenIds: Set<number>;
103
108
  /** Computed: ruleId → matched entity count for the active lens */
@@ -117,6 +122,7 @@ export interface LensSlice {
117
122
  toggleLensPanel: () => void;
118
123
  setLensPanelVisible: (visible: boolean) => void;
119
124
  setLensColorMap: (map: Map<number, string>) => void;
125
+ setLensAppliedColors: (map: Map<number, [number, number, number, number]> | null) => void;
120
126
  setLensHiddenIds: (ids: Set<number>) => void;
121
127
  setLensRuleCounts: (counts: Map<string, number>) => void;
122
128
  setLensRuleEntityIds: (ids: Map<string, number[]>) => void;
@@ -147,6 +153,7 @@ export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set,
147
153
  activeLensId: null,
148
154
  lensPanelVisible: false,
149
155
  lensColorMap: new Map(),
156
+ lensAppliedColors: null,
150
157
  lensHiddenIds: new Set(),
151
158
  lensRuleCounts: new Map(),
152
159
  lensRuleEntityIds: new Map(),
@@ -183,6 +190,7 @@ export const createLensSlice: StateCreator<LensSlice, [], [], LensSlice> = (set,
183
190
  setLensPanelVisible: (lensPanelVisible) => set({ lensPanelVisible }),
184
191
 
185
192
  setLensColorMap: (lensColorMap) => set({ lensColorMap }),
193
+ setLensAppliedColors: (lensAppliedColors) => set({ lensAppliedColors }),
186
194
  setLensHiddenIds: (lensHiddenIds) => set({ lensHiddenIds }),
187
195
  setLensRuleCounts: (lensRuleCounts) => set({ lensRuleCounts }),
188
196
  setLensRuleEntityIds: (lensRuleEntityIds) => set({ lensRuleEntityIds }),