@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.
- package/.turbo/turbo-build.log +45 -38
- package/CHANGELOG.md +93 -0
- package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
- package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
- package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
- package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
- package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/deflate-DNGgs8Ur.js +1 -0
- package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
- package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
- package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
- package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
- package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
- package/dist/assets/index-BtbXFKsX.css +1 -0
- package/dist/assets/index.es-CWfqZyyr.js +6866 -0
- package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
- package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
- package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
- package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
- package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
- package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
- package/dist/assets/pdf-CRwaZf3s.js +135 -0
- package/dist/assets/raw-CJgQdyuZ.js +1 -0
- package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
- package/dist/assets/server-client-cTCJ-853.js +719 -0
- package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
- package/dist/assets/xlsx-B1YOg2QB.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
- package/dist/index.html +10 -10
- package/package.json +27 -23
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +280 -0
- package/src/components/mcp/playground-files.ts +33 -1
- package/src/components/mcp/types.ts +2 -1
- package/src/components/ui/combo-input.tsx +163 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +46 -7
- package/src/components/viewer/MainToolbar.tsx +19 -2
- package/src/components/viewer/PropertiesPanel.tsx +84 -8
- package/src/components/viewer/SearchInline.tsx +62 -2
- package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
- package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
- package/src/components/viewer/SearchModal.filter.tsx +64 -1
- package/src/components/viewer/SearchModal.tsx +19 -6
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +18 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
- package/src/components/viewer/lists/ListBuilder.tsx +789 -280
- package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
- package/src/components/viewer/lists/ListPanel.tsx +49 -5
- package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
- package/src/components/viewer/lists/list-table-utils.ts +123 -0
- package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +481 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +1 -26
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +23 -1
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +32 -9
- package/src/hooks/useIfcFederation.ts +42 -810
- package/src/hooks/useIfcLoader.ts +361 -488
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- package/src/lib/length-unit-scale.ts +41 -0
- package/src/lib/lists/adapter.ts +136 -11
- package/src/lib/lists/export/csv.ts +47 -0
- package/src/lib/lists/export/index.ts +49 -0
- package/src/lib/lists/export/model.ts +111 -0
- package/src/lib/lists/export/pdf.ts +67 -0
- package/src/lib/lists/export/xlsx.ts +83 -0
- package/src/lib/lists/index.ts +2 -0
- package/src/lib/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- package/src/lib/search/filter-evaluate.test.ts +81 -0
- package/src/lib/search/filter-evaluate.ts +59 -87
- package/src/lib/search/filter-match.ts +167 -0
- package/src/lib/search/filter-rules.test.ts +25 -0
- package/src/lib/search/filter-rules.ts +75 -2
- package/src/lib/search/filter-schema.ts +0 -0
- package/src/lib/search/result-export.ts +7 -1
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +9 -4
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +16 -1
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/lensSlice.ts +8 -0
- package/src/store/slices/listSlice.ts +6 -0
- package/src/store/slices/mutationSlice.ts +14 -6
- package/src/store/slices/searchSlice.ts +29 -3
- package/src/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/desktopModelSnapshot.ts +2 -1
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/dist/assets/deflate-Cnx0il6E.js +0 -1
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +0 -1
- package/dist/assets/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-8md211IW.js +0 -182
- package/dist/assets/raw-BQrAgxwT.js +0 -1
- package/dist/assets/server-client-Bk4c1CPO.js +0 -626
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- 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.
|
package/src/lib/slab-edit.ts
CHANGED
|
@@ -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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
+
return node.properties().map((pset) => ({
|
|
157
162
|
name: pset.name,
|
|
158
163
|
globalId: pset.globalId,
|
|
159
|
-
properties: pset.properties.map((p
|
|
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
|
|
139
|
+
return node.properties().map((pset) => ({
|
|
140
140
|
name: pset.name,
|
|
141
141
|
globalId: pset.globalId,
|
|
142
|
-
properties: pset.properties.map((p
|
|
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
|
}
|
package/src/store/globalId.ts
CHANGED
|
@@ -44,23 +44,17 @@ export function fromGlobalIdFromModels(
|
|
|
44
44
|
models: ReverseModelMapLike,
|
|
45
45
|
globalId: number,
|
|
46
46
|
): EntityRef | undefined {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
package/src/store/index.ts
CHANGED
|
@@ -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 =
|
|
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 }),
|