@ifc-lite/viewer 1.25.2 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/.turbo/turbo-build.log +40 -30
  2. package/CHANGELOG.md +110 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
  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/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +13 -9
  38. package/src/components/extensions/FlavorDialog.tsx +18 -2
  39. package/src/components/extensions/FlavorListView.tsx +12 -3
  40. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  41. package/src/components/mcp/data.ts +6 -0
  42. package/src/components/mcp/playground-dispatcher.ts +277 -0
  43. package/src/components/mcp/types.ts +2 -1
  44. package/src/components/ui/combo-input.tsx +163 -0
  45. package/src/components/ui/tabs.tsx +1 -1
  46. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  47. package/src/components/viewer/ClashPanel.tsx +370 -0
  48. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  49. package/src/components/viewer/CommandPalette.tsx +14 -15
  50. package/src/components/viewer/MainToolbar.tsx +155 -175
  51. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  52. package/src/components/viewer/SearchInline.tsx +62 -2
  53. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  54. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  55. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  56. package/src/components/viewer/SearchModal.tsx +19 -6
  57. package/src/components/viewer/ViewerLayout.tsx +5 -0
  58. package/src/components/viewer/Viewport.tsx +64 -9
  59. package/src/components/viewer/ViewportContainer.tsx +45 -3
  60. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  61. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  62. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  63. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  64. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  65. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  66. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  67. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  68. package/src/generated/mcp-catalog.json +4 -0
  69. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  70. package/src/hooks/ingest/streamCleanup.ts +45 -0
  71. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  72. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  73. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  74. package/src/hooks/source-key.ts +35 -0
  75. package/src/hooks/useAlignmentLines3D.ts +139 -0
  76. package/src/hooks/useClash.ts +420 -0
  77. package/src/hooks/useGridLines3D.ts +140 -0
  78. package/src/hooks/useIfcFederation.ts +16 -2
  79. package/src/hooks/useIfcLoader.ts +5 -7
  80. package/src/lib/clash/persistence.ts +308 -0
  81. package/src/lib/geo/effective-georef.test.ts +66 -0
  82. package/src/lib/length-unit-scale.ts +41 -0
  83. package/src/lib/lists/adapter.ts +136 -11
  84. package/src/lib/lists/export/csv.ts +47 -0
  85. package/src/lib/lists/export/index.ts +49 -0
  86. package/src/lib/lists/export/model.ts +111 -0
  87. package/src/lib/lists/export/pdf.ts +67 -0
  88. package/src/lib/lists/export/xlsx.ts +83 -0
  89. package/src/lib/lists/index.ts +2 -0
  90. package/src/lib/search/filter-evaluate.test.ts +81 -0
  91. package/src/lib/search/filter-evaluate.ts +59 -87
  92. package/src/lib/search/filter-match.ts +167 -0
  93. package/src/lib/search/filter-rules.test.ts +25 -0
  94. package/src/lib/search/filter-rules.ts +75 -2
  95. package/src/lib/search/filter-schema.ts +0 -0
  96. package/src/lib/slab-edit.test.ts +72 -0
  97. package/src/lib/slab-edit.ts +159 -19
  98. package/src/sdk/adapters/export-adapter.ts +3 -3
  99. package/src/sdk/adapters/query-adapter.ts +3 -3
  100. package/src/services/extensions/host.ts +13 -0
  101. package/src/store/constants.ts +33 -25
  102. package/src/store/index.ts +29 -8
  103. package/src/store/slices/clashSlice.ts +251 -0
  104. package/src/store/slices/listSlice.ts +6 -0
  105. package/src/store/slices/mutationSlice.ts +14 -6
  106. package/src/store/slices/searchSlice.ts +29 -3
  107. package/src/store/slices/visibilitySlice.test.ts +23 -5
  108. package/src/store/slices/visibilitySlice.ts +18 -8
  109. package/src/utils/nativeSpatialDataStore.ts +6 -0
  110. package/src/utils/serverDataModel.test.ts +6 -0
  111. package/src/utils/serverDataModel.ts +7 -0
  112. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  113. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  114. package/dist/assets/index-Bws3UAkj.css +0 -1
  115. package/dist/assets/raw-R2QfzPAR.js +0 -1
  116. package/dist/assets/server-client-Ctk8_Bof.js +0 -626
@@ -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
  }
@@ -153,13 +153,13 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
153
153
  if (!model?.ifcDataStore) return [];
154
154
 
155
155
  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 }> }) => ({
156
+ return node.properties().map((pset) => ({
157
157
  name: pset.name,
158
158
  globalId: pset.globalId,
159
- properties: pset.properties.map((p: { name: string; type: number; value: string | number | boolean | null }) => ({
159
+ properties: pset.properties.map((p) => ({
160
160
  name: p.name,
161
161
  type: p.type,
162
- value: p.value,
162
+ value: p.value as string | number | boolean | null,
163
163
  })),
164
164
  }));
165
165
  }
@@ -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
  }
@@ -436,6 +436,19 @@ export class ExtensionHostService {
436
436
  } catch (err) {
437
437
  console.warn('[ext-host] lens restore on switch failed:', err);
438
438
  }
439
+ // Restore the flavor's clash config (rule-set + detection settings) from the
440
+ // opaque settings.clash blob, mirroring the lens roundtrip above. Missing /
441
+ // malformed blobs deserialize to null and are skipped (no-op).
442
+ try {
443
+ const { deserializeClashConfig } = await import('@/lib/clash/persistence');
444
+ const config = deserializeClashConfig((target.settings as Record<string, unknown> | undefined)?.clash);
445
+ if (config) {
446
+ const { useViewerStore } = await import('@/store');
447
+ useViewerStore.getState().applyClashFlavorConfig(config);
448
+ }
449
+ } catch (err) {
450
+ console.warn('[ext-host] clash restore on switch failed:', err);
451
+ }
439
452
  this.emit();
440
453
  }
441
454
 
@@ -6,6 +6,8 @@
6
6
  * Store constants - extracted magic numbers for maintainability
7
7
  */
8
8
 
9
+ import type { TypeVisibility } from './types.js';
10
+
9
11
  // ============================================================================
10
12
  // Camera Defaults
11
13
  // ============================================================================
@@ -182,37 +184,43 @@ function readPersistedBool(key: string, fallback: boolean): boolean {
182
184
  // on first load. IfcSite + IfcAnnotation + IfcGrid on — all three convey
183
185
  // design intent users expect to see by default. (Issue #862 split grid
184
186
  // into its own toggle so dense-grid models can hide grids without losing
185
- // dimensions/labels.)
186
- const SEMANTIC_DEFAULTS = {
187
+ // dimensions/labels.) Exported so the "Reset" action in the visibility
188
+ // menu can restore these without re-deriving them.
189
+ export const TYPE_VISIBILITY_SEMANTIC_DEFAULTS: TypeVisibility = {
187
190
  spaces: false,
188
191
  openings: false,
189
192
  site: true,
190
193
  ifcAnnotations: true,
191
194
  ifcGrid: true,
192
- } as const;
195
+ };
193
196
 
194
- export const TYPE_VISIBILITY_DEFAULTS = {
195
- /** IfcSpace visibility persisted across reloads. */
196
- SPACES: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.spaces, SEMANTIC_DEFAULTS.spaces),
197
- /** IfcOpeningElement visibilitypersisted across reloads. */
198
- OPENINGS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, SEMANTIC_DEFAULTS.openings),
199
- /** IfcSite visibility persisted across reloads. */
200
- SITE: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, SEMANTIC_DEFAULTS.site),
201
- /** IfcAnnotation visibility (text, dimensions, leaders) persisted. */
202
- IFC_ANNOTATIONS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcAnnotations),
203
- /**
204
- * IfcGrid visibility (axis lines + bubble tags) — persisted. Issue
205
- * #862. Migration: if the new key isn't set yet, fall back to the
206
- * legacy combined `ifcAnnotations` preference. That way a user who
207
- * previously turned the combined "Annotations & Grids" toggle off
208
- * keeps grids hidden after upgrade, instead of grids silently
209
- * reappearing (PR #868 review, chatgpt-codex P2).
210
- */
211
- IFC_GRID: readPersistedBool(
212
- TYPE_VISIBILITY_STORAGE_KEYS.ifcGrid,
213
- readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcGrid),
214
- ),
215
- } as const;
197
+ /**
198
+ * Resolve the full type-visibility preference set from localStorage.
199
+ *
200
+ * Read fresh on EVERY call not captured once at module load. The store
201
+ * applies this both at boot (slice init) and on every new-file load
202
+ * (`resetViewerState`). A module-level constant would snapshot localStorage
203
+ * at first import and then go stale after the first in-session toggle, so
204
+ * loading a second model would silently revert the user's choices (e.g.
205
+ * "Show Annotations" flipping back on). Reading live keeps every toggle
206
+ * sticky across reloads AND across model swaps within a session.
207
+ */
208
+ export function getPersistedTypeVisibility(): TypeVisibility {
209
+ return {
210
+ spaces: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.spaces, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.spaces),
211
+ openings: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.openings),
212
+ site: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.site),
213
+ ifcAnnotations: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.ifcAnnotations),
214
+ // Issue #862. Migration: if the new grid key isn't set yet, fall back to
215
+ // the legacy combined `ifcAnnotations` preference so a user who turned
216
+ // the old "Annotations & Grids" toggle off keeps grids hidden after
217
+ // upgrade instead of grids silently reappearing (PR #868 review).
218
+ ifcGrid: readPersistedBool(
219
+ TYPE_VISIBILITY_STORAGE_KEYS.ifcGrid,
220
+ readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.ifcGrid),
221
+ ),
222
+ };
223
+ }
216
224
 
217
225
  // ============================================================================
218
226
  // Data Defaults
@@ -33,6 +33,7 @@ import { createExtensionsSlice, type ExtensionsSlice } from './slices/extensions
33
33
  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
+ import { createClashSlice, type ClashSlice } from './slices/clashSlice.js';
36
37
  import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
37
38
  import { createChatSlice, type ChatSlice } from './slices/chatSlice.js';
38
39
  import { createCesiumSlice, type CesiumSlice } from './slices/cesiumSlice.js';
@@ -49,7 +50,7 @@ import { createPointCloudSlice, type PointCloudSlice, POINT_CLOUD_DEFAULTS } fro
49
50
  import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
50
51
 
51
52
  // Import constants for reset function
52
- import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, TYPE_VISIBILITY_DEFAULTS } from './constants.js';
53
+ import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, getPersistedTypeVisibility } from './constants.js';
53
54
 
54
55
  // Re-export types for consumers
55
56
  export type * from './types.js';
@@ -129,6 +130,7 @@ export type ViewerState = LoadingSlice &
129
130
  ListSlice &
130
131
  PinboardSlice &
131
132
  LensSlice &
133
+ ClashSlice &
132
134
  ScriptSlice &
133
135
  ChatSlice &
134
136
  CesiumSlice &
@@ -144,6 +146,16 @@ export type ViewerState = LoadingSlice &
144
146
  PointCloudSlice &
145
147
  ExtensionsSlice & {
146
148
  resetViewerState: () => void;
149
+ /**
150
+ * Open one right-side analysis panel and close the others, so the chosen
151
+ * panel is always the topmost/active one. The right panel renders a single
152
+ * mutually-exclusive chain (lens → clash → ids → bcf → extensions), so
153
+ * leaving a sibling flag set would keep the higher-precedence panel on top
154
+ * (the cause of "I have to close clash before I see BCF"). Also un-collapses
155
+ * the right panel. Routed through by the toolbar, command palette, and the
156
+ * BCF overlay so every entry point behaves identically.
157
+ */
158
+ openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') => void;
147
159
  };
148
160
 
149
161
  /**
@@ -169,6 +181,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
169
181
  ...createListSlice(...args),
170
182
  ...createPinboardSlice(...args),
171
183
  ...createLensSlice(...args),
184
+ ...createClashSlice(...args),
172
185
  ...createScriptSlice(...args),
173
186
  ...createChatSlice(...args),
174
187
  ...createCesiumSlice(...args),
@@ -203,13 +216,9 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
203
216
  hiddenEntities: new Set(),
204
217
  isolatedEntities: null,
205
218
  classFilter: null,
206
- typeVisibility: {
207
- spaces: TYPE_VISIBILITY_DEFAULTS.SPACES,
208
- openings: TYPE_VISIBILITY_DEFAULTS.OPENINGS,
209
- site: TYPE_VISIBILITY_DEFAULTS.SITE,
210
- ifcAnnotations: TYPE_VISIBILITY_DEFAULTS.IFC_ANNOTATIONS,
211
- ifcGrid: TYPE_VISIBILITY_DEFAULTS.IFC_GRID,
212
- },
219
+ // Re-read persisted toggles on every file load so a new model never
220
+ // reverts the user's visibility choices (e.g. "Show Annotations").
221
+ typeVisibility: getPersistedTypeVisibility(),
213
222
 
214
223
  // Visibility (multi-model)
215
224
  hiddenEntitiesByModel: new Map(),
@@ -443,6 +452,18 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
443
452
  pointCloudFixedColor: [...POINT_CLOUD_DEFAULTS.pointCloudFixedColor] as [number, number, number, number],
444
453
  });
445
454
  },
455
+
456
+ openWorkspacePanel: (panel) => {
457
+ const [set] = args;
458
+ set({
459
+ bcfPanelVisible: panel === 'bcf',
460
+ idsPanelVisible: panel === 'ids',
461
+ lensPanelVisible: panel === 'lens',
462
+ clashPanelVisible: panel === 'clash',
463
+ extensionsPanelVisible: panel === 'extensions',
464
+ rightPanelCollapsed: false,
465
+ });
466
+ },
446
467
  }));
447
468
 
448
469
  const STORE_SINGLETON_KEY = '__ifc_lite_viewer_store__';