@ifc-lite/viewer 1.19.0 → 1.21.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 +59 -43
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +496 -0
- package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +10 -9
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/index.html +1 -1
- package/package.json +15 -10
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +79 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +60 -15
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/generated/mcp-catalog.json +82 -0
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +12 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-BraHBeoi.js +0 -81583
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +0 -1
|
@@ -7,11 +7,23 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { StateCreator } from 'zustand';
|
|
10
|
-
import { UI_DEFAULTS } from '../constants.js';
|
|
10
|
+
import { MERGE_LAYERS_STORAGE_KEY, UI_DEFAULTS } from '../constants.js';
|
|
11
11
|
import type { ContactShadingQuality, SeparationLinesQuality } from '@ifc-lite/renderer';
|
|
12
|
+
import type { FederatedModel } from '../types.js';
|
|
13
|
+
import type { GeometryResult } from '@ifc-lite/geometry';
|
|
12
14
|
|
|
13
15
|
export type ThemeMode = 'light' | 'dark' | 'colorful';
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Cross-slice surface UISlice reaches into via the combined Zustand
|
|
19
|
+
* `get()` to decide whether toggling a load-time setting needs a
|
|
20
|
+
* reload (only meaningful while a model is in scope).
|
|
21
|
+
*/
|
|
22
|
+
export interface UICrossSliceState {
|
|
23
|
+
models: Map<string, FederatedModel>;
|
|
24
|
+
geometryResult: GeometryResult | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export interface UISlice {
|
|
16
28
|
// State
|
|
17
29
|
leftPanelCollapsed: boolean;
|
|
@@ -30,6 +42,15 @@ export interface UISlice {
|
|
|
30
42
|
separationLinesQuality: SeparationLinesQuality;
|
|
31
43
|
separationLinesIntensity: number;
|
|
32
44
|
separationLinesRadius: number;
|
|
45
|
+
/**
|
|
46
|
+
* Issue #540 — "Merge Multilayer Walls" load-time toggle. Reading
|
|
47
|
+
* this on next file load is what the WASM bridge actually uses;
|
|
48
|
+
* flipping it while a model is in scope sets
|
|
49
|
+
* `mergeLayersPendingReload` so the UI can prompt the user.
|
|
50
|
+
*/
|
|
51
|
+
mergeLayers: boolean;
|
|
52
|
+
/** True after the user flipped `mergeLayers` while a model was loaded. */
|
|
53
|
+
mergeLayersPendingReload: boolean;
|
|
33
54
|
|
|
34
55
|
// Actions
|
|
35
56
|
setLeftPanelCollapsed: (collapsed: boolean) => void;
|
|
@@ -51,6 +72,10 @@ export interface UISlice {
|
|
|
51
72
|
setSeparationLinesQuality: (quality: SeparationLinesQuality) => void;
|
|
52
73
|
setSeparationLinesIntensity: (intensity: number) => void;
|
|
53
74
|
setSeparationLinesRadius: (radius: number) => void;
|
|
75
|
+
/** Update the merge-layers toggle and persist to localStorage. */
|
|
76
|
+
setMergeLayers: (v: boolean) => void;
|
|
77
|
+
/** Acknowledge the reload banner without performing a reload. */
|
|
78
|
+
clearMergeLayersPendingReload: () => void;
|
|
54
79
|
}
|
|
55
80
|
|
|
56
81
|
/** Apply the correct CSS classes on <html> for the given theme */
|
|
@@ -60,7 +85,18 @@ function applyThemeClasses(theme: ThemeMode) {
|
|
|
60
85
|
el.classList.toggle('colorful', theme === 'colorful');
|
|
61
86
|
}
|
|
62
87
|
|
|
63
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Returns true when any geometry is loaded — federated model map has
|
|
90
|
+
* entries OR the legacy single-model `geometryResult` is non-null with
|
|
91
|
+
* at least one mesh. Centralised here so the merge-layers toggle has
|
|
92
|
+
* a single source of truth for "is a model loaded?".
|
|
93
|
+
*/
|
|
94
|
+
function hasLoadedModel(state: UICrossSliceState): boolean {
|
|
95
|
+
if (state.models.size > 0) return true;
|
|
96
|
+
return (state.geometryResult?.meshes.length ?? 0) > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const createUISlice: StateCreator<UISlice & UICrossSliceState, [], [], UISlice> = (set, get) => ({
|
|
64
100
|
// Initial state
|
|
65
101
|
leftPanelCollapsed: false,
|
|
66
102
|
rightPanelCollapsed: false,
|
|
@@ -78,6 +114,8 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
|
|
|
78
114
|
separationLinesQuality: UI_DEFAULTS.SEPARATION_LINES_QUALITY,
|
|
79
115
|
separationLinesIntensity: UI_DEFAULTS.SEPARATION_LINES_INTENSITY,
|
|
80
116
|
separationLinesRadius: UI_DEFAULTS.SEPARATION_LINES_RADIUS,
|
|
117
|
+
mergeLayers: UI_DEFAULTS.MERGE_LAYERS,
|
|
118
|
+
mergeLayersPendingReload: false,
|
|
81
119
|
|
|
82
120
|
// Actions
|
|
83
121
|
setLeftPanelCollapsed: (leftPanelCollapsed) => set({ leftPanelCollapsed }),
|
|
@@ -121,4 +159,24 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
|
|
|
121
159
|
setSeparationLinesQuality: (separationLinesQuality) => set({ separationLinesQuality }),
|
|
122
160
|
setSeparationLinesIntensity: (separationLinesIntensity) => set({ separationLinesIntensity }),
|
|
123
161
|
setSeparationLinesRadius: (separationLinesRadius) => set({ separationLinesRadius }),
|
|
162
|
+
|
|
163
|
+
setMergeLayers: (next) => {
|
|
164
|
+
const current = get();
|
|
165
|
+
if (current.mergeLayers === next) return;
|
|
166
|
+
// Persist eagerly so the next page-load picks the same value up
|
|
167
|
+
// through `getInitialMergeLayers` (constants.ts). Wrap in
|
|
168
|
+
// try/catch — Safari private mode / locked storage throws.
|
|
169
|
+
try {
|
|
170
|
+
localStorage.setItem(MERGE_LAYERS_STORAGE_KEY, String(next));
|
|
171
|
+
} catch {
|
|
172
|
+
/* storage unavailable — accept the in-memory toggle silently */
|
|
173
|
+
}
|
|
174
|
+
// Only ask the user to reload if a model is currently in scope.
|
|
175
|
+
// Toggling the setting on an empty viewer simply changes the
|
|
176
|
+
// future load behaviour with no visible effect.
|
|
177
|
+
const pending = hasLoadedModel(current);
|
|
178
|
+
set({ mergeLayers: next, mergeLayersPendingReload: pending });
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
clearMergeLayersPendingReload: () => set({ mergeLayersPendingReload: false }),
|
|
124
182
|
});
|
package/src/store/types.ts
CHANGED
|
@@ -92,6 +92,33 @@ export type SectionPlaneAxis = 'down' | 'front' | 'side';
|
|
|
92
92
|
export type { HatchPatternId as SectionCapHatchId, SectionCapStyle } from '@ifc-lite/renderer';
|
|
93
93
|
import type { SectionCapStyle } from '@ifc-lite/renderer';
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Custom (face-picked) plane override. When present, the renderer uses
|
|
97
|
+
* `normal` + `distance` directly and ignores `axis` / `position`. The
|
|
98
|
+
* cardinal `axis` / `position` / `flipped` fields are still kept in sync
|
|
99
|
+
* (nearest-cardinal for axis, percentage along it for position) so any
|
|
100
|
+
* downstream reader that pre-dates custom planes (drawings export, BCF
|
|
101
|
+
* snapshots, view controls) still gets a sensible projection rather than
|
|
102
|
+
* crashing or emitting empty data.
|
|
103
|
+
*
|
|
104
|
+
* Tangent + bitangent are derived once at pick time from `normal` via the
|
|
105
|
+
* deterministic `planeBasis` helper so the cap shader and cutter share
|
|
106
|
+
* exactly one orientation — without this the cap-hatch can rotate when
|
|
107
|
+
* the renderer re-derives the basis on every frame.
|
|
108
|
+
*/
|
|
109
|
+
export interface CustomSectionPlane {
|
|
110
|
+
/** Unit world-space normal. */
|
|
111
|
+
normal: [number, number, number];
|
|
112
|
+
/** Signed plane offset in world units: `dot(pointOnPlane, normal)`. */
|
|
113
|
+
distance: number;
|
|
114
|
+
/** World-space hit point at pick time (anchors the slider re-mapping). */
|
|
115
|
+
pickedAt: [number, number, number];
|
|
116
|
+
/** First in-plane axis, deterministic from `normal`. */
|
|
117
|
+
tangent: [number, number, number];
|
|
118
|
+
/** Second in-plane axis, deterministic from `normal`. */
|
|
119
|
+
bitangent: [number, number, number];
|
|
120
|
+
}
|
|
121
|
+
|
|
95
122
|
export interface SectionPlane {
|
|
96
123
|
axis: SectionPlaneAxis;
|
|
97
124
|
/** 0-100 percentage of model bounds */
|
|
@@ -110,6 +137,13 @@ export interface SectionPlane {
|
|
|
110
137
|
showOutlines: boolean;
|
|
111
138
|
/** User-defined colour + hatch for the cut surface. */
|
|
112
139
|
capStyle: SectionCapStyle;
|
|
140
|
+
/**
|
|
141
|
+
* Optional arbitrary-normal override populated by face-pick. When set,
|
|
142
|
+
* the renderer cuts on this plane verbatim; cardinal `axis` / `position`
|
|
143
|
+
* are kept in sync as the closest cardinal projection (see
|
|
144
|
+
* `CustomSectionPlane`).
|
|
145
|
+
*/
|
|
146
|
+
custom?: CustomSectionPlane;
|
|
113
147
|
}
|
|
114
148
|
|
|
115
149
|
// ============================================================================
|
|
@@ -120,6 +154,14 @@ export interface HoverState {
|
|
|
120
154
|
entityId: number | null;
|
|
121
155
|
screenX: number;
|
|
122
156
|
screenY: number;
|
|
157
|
+
/**
|
|
158
|
+
* World-space hit position from the GPU pick (depth readback +
|
|
159
|
+
* inverse view-projection). Unset when the picker couldn't recover
|
|
160
|
+
* one (e.g. `pointCount === 0` clear, or the pick fell on the
|
|
161
|
+
* background). Useful for point-cloud hover tooltips where the
|
|
162
|
+
* synthetic entity has no surface property to display.
|
|
163
|
+
*/
|
|
164
|
+
worldXYZ?: { x: number; y: number; z: number };
|
|
123
165
|
}
|
|
124
166
|
|
|
125
167
|
export interface ContextMenuState {
|
package/src/store.ts
CHANGED
|
@@ -51,6 +51,19 @@ export { resolveEntityRef } from './store/resolveEntityRef.js';
|
|
|
51
51
|
export { toGlobalIdFromModels, fromGlobalIdFromModels, toGlobalIdForRef } from './store/globalId.js';
|
|
52
52
|
export type { ForwardModelMapLike } from './store/globalId.js';
|
|
53
53
|
|
|
54
|
+
// Re-export custom-section-plane geometry helper (issue #243): projects
|
|
55
|
+
// `pickedAt` onto the live cut plane so visuals (cap basis origin, 3D
|
|
56
|
+
// drag gizmo) follow `distance` instead of staying anchored at the
|
|
57
|
+
// original face-pick location.
|
|
58
|
+
export { customPlaneCenter } from './store/slices/sectionSlice.js';
|
|
59
|
+
|
|
60
|
+
// Re-export last-used section mode persistence (issue #243 follow-up):
|
|
61
|
+
// `SectionPanel` reads this on mount to restore either the user's
|
|
62
|
+
// previous cardinal cut (axis + position + flipped) or to rearm pick
|
|
63
|
+
// mode for first-time users / users whose last action was a face pick.
|
|
64
|
+
export { loadLastSectionMode } from './store/slices/sectionSlice.js';
|
|
65
|
+
export type { LastSectionMode } from './store/slices/sectionSlice.js';
|
|
66
|
+
|
|
54
67
|
// Re-export Schedule (4D) types + helpers
|
|
55
68
|
export type { ScheduleSlice, ScheduleTimeRange, GanttTimeScale } from './store/slices/scheduleSlice.js';
|
|
56
69
|
export {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// NOTE: We deliberately avoid importing from `./acquireFileBuffer` directly,
|
|
12
|
+
// because that module pulls in `./ifcConfig`, which references
|
|
13
|
+
// `import.meta.env.*` (Vite-only). Instead, we shadow the threshold dependency
|
|
14
|
+
// by re-implementing the public surface against a thin re-export. The
|
|
15
|
+
// production `acquireFileBuffer()` simply calls `__acquireFileBufferWithThreshold`
|
|
16
|
+
// with `STREAM_SAB_THRESHOLD`; tests bypass `STREAM_SAB_THRESHOLD` with a
|
|
17
|
+
// kilobyte-sized injected threshold so the streaming branch is exercised
|
|
18
|
+
// without multi-hundred-MB allocations.
|
|
19
|
+
//
|
|
20
|
+
// Importing the inner function directly would still trigger the ifcConfig
|
|
21
|
+
// side-effect, so we use Node's loader hook indirection: import via a tiny
|
|
22
|
+
// module-relative path that is re-exported from acquireFileBuffer.ts itself.
|
|
23
|
+
import { __acquireFileBufferWithThreshold } from './acquireFileBuffer';
|
|
24
|
+
|
|
25
|
+
function bytes(n: number, fill: (i: number) => number = (i) => i & 0xff): Uint8Array<ArrayBuffer> {
|
|
26
|
+
// Allocate via ArrayBuffer explicitly so the resulting Uint8Array satisfies
|
|
27
|
+
// `Uint8Array<ArrayBuffer>`. Default `new Uint8Array(n)` infers
|
|
28
|
+
// `ArrayBufferLike`, which TS 5.7+'s tightened DOM lib rejects as a BlobPart.
|
|
29
|
+
const ab = new ArrayBuffer(n);
|
|
30
|
+
const u = new Uint8Array(ab);
|
|
31
|
+
for (let i = 0; i < n; i++) u[i] = fill(i);
|
|
32
|
+
return u;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function viewsEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
36
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
37
|
+
for (let i = 0; i < a.byteLength; i++) {
|
|
38
|
+
if (a[i] !== b[i]) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TEST_THRESHOLD = 4 * 1024; // 4 KB — small enough to exercise streaming cheaply.
|
|
44
|
+
|
|
45
|
+
describe('acquireFileBuffer', () => {
|
|
46
|
+
it('returns ArrayBuffer for small files (below the streaming threshold)', async () => {
|
|
47
|
+
const data = bytes(1024);
|
|
48
|
+
const file = new File([data], 'small.bin');
|
|
49
|
+
|
|
50
|
+
const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
|
|
51
|
+
|
|
52
|
+
assert.equal(acquired.isShared, false);
|
|
53
|
+
assert.equal(acquired.buffer.byteLength, data.byteLength);
|
|
54
|
+
assert.equal(acquired.view.byteLength, data.byteLength);
|
|
55
|
+
assert.ok(viewsEqual(acquired.view, data), 'bytes round-trip');
|
|
56
|
+
assert.ok(acquired.buffer instanceof ArrayBuffer, 'small files keep ArrayBuffer path');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns empty buffer for zero-size file', async () => {
|
|
60
|
+
const file = new File([], 'empty.bin');
|
|
61
|
+
|
|
62
|
+
const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
|
|
63
|
+
|
|
64
|
+
assert.equal(acquired.buffer.byteLength, 0);
|
|
65
|
+
assert.equal(acquired.view.byteLength, 0);
|
|
66
|
+
assert.equal(acquired.isShared, false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('streams large files (≥ threshold) into SharedArrayBuffer with byte-identical contents', async () => {
|
|
70
|
+
// Compose a Blob whose total size sits above the test threshold using a
|
|
71
|
+
// handful of small chunks so the read loop iterates more than once. The
|
|
72
|
+
// pattern is `(offset & 0xff)` so we can verify byte-identity without
|
|
73
|
+
// keeping a parallel copy.
|
|
74
|
+
const target = TEST_THRESHOLD + 4096;
|
|
75
|
+
const chunkSize = 1024;
|
|
76
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
77
|
+
let written = 0;
|
|
78
|
+
while (written < target) {
|
|
79
|
+
const remaining = target - written;
|
|
80
|
+
const size = Math.min(chunkSize, remaining);
|
|
81
|
+
const chunk = new Uint8Array(new ArrayBuffer(size));
|
|
82
|
+
for (let i = 0; i < size; i++) chunk[i] = (written + i) & 0xff;
|
|
83
|
+
chunks.push(chunk);
|
|
84
|
+
written += size;
|
|
85
|
+
}
|
|
86
|
+
const file = new File(chunks, 'large.bin');
|
|
87
|
+
assert.equal(file.size, target);
|
|
88
|
+
|
|
89
|
+
const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
|
|
90
|
+
|
|
91
|
+
assert.equal(acquired.buffer.byteLength, target);
|
|
92
|
+
assert.equal(acquired.view.byteLength, target);
|
|
93
|
+
|
|
94
|
+
// Spot-check at start, chunk boundaries, middle, and end. Full scan adds
|
|
95
|
+
// no coverage if any byte is correct (the streaming copy either works
|
|
96
|
+
// for all bytes or fails immediately on misalignment).
|
|
97
|
+
for (const offset of [0, 1, chunkSize - 1, chunkSize, chunkSize + 1, Math.floor(target / 2), target - 2, target - 1]) {
|
|
98
|
+
assert.equal(acquired.view[offset], offset & 0xff, `byte at offset ${offset}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// SAB iff the runtime supports it. Node 22 gives us SAB and an undefined
|
|
102
|
+
// `crossOriginIsolated`, so we expect the streaming path to engage.
|
|
103
|
+
if (typeof SharedArrayBuffer !== 'undefined') {
|
|
104
|
+
assert.equal(acquired.isShared, true);
|
|
105
|
+
assert.ok(
|
|
106
|
+
acquired.buffer instanceof SharedArrayBuffer,
|
|
107
|
+
'large files use SharedArrayBuffer when supported',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects when the underlying stream errors', async () => {
|
|
113
|
+
const fakeFile = {
|
|
114
|
+
name: 'broken.bin',
|
|
115
|
+
size: TEST_THRESHOLD + 1,
|
|
116
|
+
stream(): ReadableStream<Uint8Array> {
|
|
117
|
+
return new ReadableStream<Uint8Array>({
|
|
118
|
+
start(controller) {
|
|
119
|
+
controller.error(new Error('synthetic stream failure'));
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
arrayBuffer(): Promise<ArrayBuffer> {
|
|
124
|
+
return Promise.reject(new Error('arrayBuffer not used in this test'));
|
|
125
|
+
},
|
|
126
|
+
} as unknown as File;
|
|
127
|
+
|
|
128
|
+
await assert.rejects(
|
|
129
|
+
__acquireFileBufferWithThreshold(fakeFile, TEST_THRESHOLD),
|
|
130
|
+
/synthetic stream failure/,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('falls back to arrayBuffer() when SharedArrayBuffer is unavailable', async () => {
|
|
135
|
+
const originalSAB = (globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer;
|
|
136
|
+
try {
|
|
137
|
+
(globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer = undefined;
|
|
138
|
+
|
|
139
|
+
const data = bytes(1024);
|
|
140
|
+
const file = new File([data], 'no-sab.bin');
|
|
141
|
+
// Force the size check to think this is a large file while keeping the
|
|
142
|
+
// actual buffer small — verifies the fallback branch fires before any
|
|
143
|
+
// SAB allocation is attempted.
|
|
144
|
+
Object.defineProperty(file, 'size', { value: TEST_THRESHOLD + 1 });
|
|
145
|
+
|
|
146
|
+
const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
|
|
147
|
+
|
|
148
|
+
assert.equal(acquired.isShared, false);
|
|
149
|
+
assert.ok(acquired.buffer instanceof ArrayBuffer, 'fallback returns ArrayBuffer');
|
|
150
|
+
} finally {
|
|
151
|
+
(globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer = originalSAB;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('IFCX federation call sites do NOT use SAB streaming (memory regression guard for #647)', () => {
|
|
156
|
+
// IFCX is JSON. The federation parser path is:
|
|
157
|
+
// parseFederatedIfcx → safeUtf8Decode(new Uint8Array(buffer)) → JSON.parse
|
|
158
|
+
// safeUtf8Decode must copy SAB-backed views into a scratch buffer in
|
|
159
|
+
// Chromium/Firefox (cross-thread JS string decoding cannot read SAB
|
|
160
|
+
// directly) and retains that scratch. Net peak with SAB streaming:
|
|
161
|
+
// SAB (file.size) + scratch copy (file.size) + JSON string (~file.size)
|
|
162
|
+
// + retained scratch — strictly worse than the plain ArrayBuffer path.
|
|
163
|
+
//
|
|
164
|
+
// This is a source-level guard: it ensures the two IFCX entry points in
|
|
165
|
+
// useIfcFederation.ts (loadFederatedIfcx + addIfcxOverlays) stay on
|
|
166
|
+
// file.arrayBuffer() and don't accidentally regress back to
|
|
167
|
+
// acquireFileBuffer(). The IFC/STEP path (addModel) keeps SAB streaming.
|
|
168
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
169
|
+
const sourcePath = join(here, '..', 'hooks', 'useIfcFederation.ts');
|
|
170
|
+
const source = readFileSync(sourcePath, 'utf8');
|
|
171
|
+
|
|
172
|
+
// Find the loadFederatedIfcx and addIfcxOverlays function bodies and
|
|
173
|
+
// assert each one reads files via file.arrayBuffer(), not acquireFileBuffer.
|
|
174
|
+
const ifcxFnNames = ['loadFederatedIfcx', 'addIfcxOverlays'];
|
|
175
|
+
for (const fnName of ifcxFnNames) {
|
|
176
|
+
// Match the const declaration through the closing `}, [` of useCallback.
|
|
177
|
+
const startMarker = `const ${fnName} = useCallback`;
|
|
178
|
+
const startIdx = source.indexOf(startMarker);
|
|
179
|
+
assert.ok(startIdx >= 0, `expected ${fnName} declaration in useIfcFederation.ts`);
|
|
180
|
+
// End at the next useCallback dependency-array opener that closes this fn.
|
|
181
|
+
// We look for the first `}, [` after `startIdx`.
|
|
182
|
+
const endIdx = source.indexOf('}, [', startIdx);
|
|
183
|
+
assert.ok(endIdx > startIdx, `expected end of ${fnName} useCallback`);
|
|
184
|
+
const body = source.slice(startIdx, endIdx);
|
|
185
|
+
assert.ok(
|
|
186
|
+
body.includes('file.arrayBuffer()'),
|
|
187
|
+
`${fnName} must read files via file.arrayBuffer() (IFCX JSON path)`,
|
|
188
|
+
);
|
|
189
|
+
assert.ok(
|
|
190
|
+
!body.includes('acquireFileBuffer'),
|
|
191
|
+
`${fnName} must NOT use acquireFileBuffer() — SAB streaming worsens peak memory for IFCX/JSON (see PR #647 regression).`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Sanity check: the IFC addModel path SHOULD still use acquireFileBuffer
|
|
196
|
+
// (STEP/IFC binary path benefits from SAB streaming).
|
|
197
|
+
const addModelStart = source.indexOf('const addModel = useCallback');
|
|
198
|
+
assert.ok(addModelStart >= 0, 'expected addModel declaration');
|
|
199
|
+
const addModelEnd = source.indexOf('}, [', addModelStart);
|
|
200
|
+
const addModelBody = source.slice(addModelStart, addModelEnd);
|
|
201
|
+
assert.ok(
|
|
202
|
+
addModelBody.includes('acquireFileBuffer'),
|
|
203
|
+
'addModel (IFC/STEP path) must keep using acquireFileBuffer() for SAB streaming',
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('falls back to arrayBuffer() when crossOriginIsolated is explicitly false', async () => {
|
|
208
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crossOriginIsolated');
|
|
209
|
+
try {
|
|
210
|
+
Object.defineProperty(globalThis, 'crossOriginIsolated', {
|
|
211
|
+
configurable: true,
|
|
212
|
+
get: () => false,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const data = bytes(64);
|
|
216
|
+
const file = new File([data], 'no-coi.bin');
|
|
217
|
+
Object.defineProperty(file, 'size', { value: TEST_THRESHOLD + 1 });
|
|
218
|
+
|
|
219
|
+
const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
|
|
220
|
+
|
|
221
|
+
assert.equal(acquired.isShared, false);
|
|
222
|
+
assert.ok(acquired.buffer instanceof ArrayBuffer);
|
|
223
|
+
} finally {
|
|
224
|
+
if (originalDescriptor) {
|
|
225
|
+
Object.defineProperty(globalThis, 'crossOriginIsolated', originalDescriptor);
|
|
226
|
+
} else {
|
|
227
|
+
delete (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
* Reads a `File` into a single buffer, streaming directly into a
|
|
7
|
+
* `SharedArrayBuffer` for files above `STREAM_SAB_THRESHOLD`. Avoids the
|
|
8
|
+
* doubled peak memory of `await file.arrayBuffer()` followed by a SAB
|
|
9
|
+
* allocation+copy inside the geometry pipeline (issue #600).
|
|
10
|
+
*
|
|
11
|
+
* The returned `view` is suitable for every downstream consumer: parser,
|
|
12
|
+
* fingerprinter, format detector, geometry processor. Each downstream uses
|
|
13
|
+
* `new Uint8Array(buffer)` or works on the view directly, both of which
|
|
14
|
+
* accept SAB-backed views.
|
|
15
|
+
*
|
|
16
|
+
* Cache writes (`saveToCache`) and server uploads do their own copy via
|
|
17
|
+
* structured clone or `Blob`, so SAB ownership doesn't leak into IndexedDB
|
|
18
|
+
* or `fetch`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// `STREAM_SAB_THRESHOLD` lives in `ifcConfig`, but importing that module
|
|
22
|
+
// eagerly drags in `import.meta.env.*` (Vite-only) and breaks Node-based
|
|
23
|
+
// unit tests. We dereference it lazily inside the public `acquireFileBuffer`
|
|
24
|
+
// wrapper so the test entry point (`__acquireFileBufferWithThreshold`) can
|
|
25
|
+
// run without ever loading `ifcConfig`.
|
|
26
|
+
|
|
27
|
+
export interface AcquiredBuffer {
|
|
28
|
+
/**
|
|
29
|
+
* Underlying buffer. Either a `SharedArrayBuffer` (large files when SAB is
|
|
30
|
+
* supported) or an `ArrayBuffer` (small files, or environments without
|
|
31
|
+
* cross-origin isolation).
|
|
32
|
+
*/
|
|
33
|
+
buffer: ArrayBuffer | SharedArrayBuffer;
|
|
34
|
+
/** Zero-copy view over `buffer`. Pass this to consumers expecting bytes. */
|
|
35
|
+
view: Uint8Array;
|
|
36
|
+
/** Whether the underlying buffer is a SharedArrayBuffer. */
|
|
37
|
+
isShared: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sharedArrayBufferAvailable(): boolean {
|
|
41
|
+
if (typeof SharedArrayBuffer === 'undefined') return false;
|
|
42
|
+
// `crossOriginIsolated` is the canonical gate; some early implementations
|
|
43
|
+
// lack the global, hence the `?? true` permissiveness — if SAB *exists* in
|
|
44
|
+
// scope the environment is generally COI-enabled.
|
|
45
|
+
const coi = (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated;
|
|
46
|
+
return coi !== false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Internal entry point that accepts an injected threshold. Production code
|
|
51
|
+
* should call `acquireFileBuffer` (which uses `STREAM_SAB_THRESHOLD` from
|
|
52
|
+
* `ifcConfig`). Tests use this overload so they can exercise the streaming
|
|
53
|
+
* branch without allocating a multi-hundred-MB buffer.
|
|
54
|
+
*/
|
|
55
|
+
export async function __acquireFileBufferWithThreshold(
|
|
56
|
+
file: File,
|
|
57
|
+
threshold: number,
|
|
58
|
+
): Promise<AcquiredBuffer> {
|
|
59
|
+
const useSharedStream =
|
|
60
|
+
file.size >= threshold
|
|
61
|
+
&& sharedArrayBufferAvailable()
|
|
62
|
+
&& typeof file.stream === 'function';
|
|
63
|
+
|
|
64
|
+
if (!useSharedStream) {
|
|
65
|
+
const buffer = await file.arrayBuffer();
|
|
66
|
+
return {
|
|
67
|
+
buffer,
|
|
68
|
+
view: new Uint8Array(buffer),
|
|
69
|
+
isShared: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sab = new SharedArrayBuffer(file.size);
|
|
74
|
+
const view = new Uint8Array(sab);
|
|
75
|
+
const reader = (file.stream() as ReadableStream<Uint8Array>).getReader();
|
|
76
|
+
let offset = 0;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// Stream chunks from the File directly into the SAB. No intermediate
|
|
80
|
+
// ArrayBuffer means peak memory is ~`fileSize` instead of `2 × fileSize`
|
|
81
|
+
// at this entry point.
|
|
82
|
+
while (true) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
if (offset + value.byteLength > sab.byteLength) {
|
|
86
|
+
// Defensive: file grew while reading (rare, but possible on local
|
|
87
|
+
// disks with active writes). Truncate to the SAB size we promised.
|
|
88
|
+
view.set(value.subarray(0, sab.byteLength - offset), offset);
|
|
89
|
+
offset = sab.byteLength;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
view.set(value, offset);
|
|
93
|
+
offset += value.byteLength;
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
// releaseLock can throw if the reader is already closed/released by the
|
|
97
|
+
// platform after a stream error. The lock is gone either way, so cleanup
|
|
98
|
+
// is safe to swallow here. (CR feedback on #627.)
|
|
99
|
+
try { reader.releaseLock(); } catch { /* cleanup — safe to ignore */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate we read the expected number of bytes. A short read indicates
|
|
103
|
+
// the file shrank mid-load; surface it loudly so callers don't silently
|
|
104
|
+
// process a truncated buffer.
|
|
105
|
+
if (offset !== sab.byteLength) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`acquireFileBuffer: short read for ${file.name} (got ${offset} of ${sab.byteLength} bytes)`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
buffer: sab,
|
|
113
|
+
view,
|
|
114
|
+
isShared: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reads `file` into an in-memory buffer. Streams chunks into a pre-sized
|
|
120
|
+
* `SharedArrayBuffer` for files ≥ `STREAM_SAB_THRESHOLD` when SAB is
|
|
121
|
+
* available, otherwise falls back to `await file.arrayBuffer()`.
|
|
122
|
+
*/
|
|
123
|
+
export async function acquireFileBuffer(file: File): Promise<AcquiredBuffer> {
|
|
124
|
+
// Lazy import keeps Node-based test runs out of the Vite `import.meta.env`
|
|
125
|
+
// path that `ifcConfig` evaluates at module-load time.
|
|
126
|
+
const { STREAM_SAB_THRESHOLD } = await import('./ifcConfig.js');
|
|
127
|
+
return __acquireFileBufferWithThreshold(file, STREAM_SAB_THRESHOLD);
|
|
128
|
+
}
|
package/src/utils/ifcConfig.ts
CHANGED
|
@@ -49,6 +49,30 @@ export const CACHE_MAX_SOURCE_SIZE = 150 * 1024 * 1024;
|
|
|
49
49
|
/** Route desktop IFCs above this threshold through the bounded-memory path. */
|
|
50
50
|
export const HUGE_NATIVE_FILE_THRESHOLD = 128 * 1024 * 1024;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* File size at which the browser-File-API entry path streams directly into a
|
|
54
|
+
* `SharedArrayBuffer` instead of going through `await file.arrayBuffer()`.
|
|
55
|
+
*
|
|
56
|
+
* Below this threshold, the doubled peak (ArrayBuffer + SAB) is small enough
|
|
57
|
+
* that the simpler one-shot read is preferable. Above it, the streaming path
|
|
58
|
+
* shaves ~`fileSize` MB from peak memory and avoids hitting V8's per-buffer
|
|
59
|
+
* allocation limits on huge files. (Issue #600.)
|
|
60
|
+
*/
|
|
61
|
+
export const STREAM_SAB_THRESHOLD = 256 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* File size at which the browser parser worker defers indexing of property
|
|
65
|
+
* atoms (`IFCPROPERTYSINGLEVALUE`, `IFCPROPERTYENUMERATEDVALUE`, etc.) until
|
|
66
|
+
* a property panel actually opens.
|
|
67
|
+
*
|
|
68
|
+
* On a 14 M-entity, 986 MB file roughly 3.4 M of those entities are
|
|
69
|
+
* property atoms. Skipping them in the primary `compactByIdIndex` shaves
|
|
70
|
+
* ~4 s off the parse path; the deferred index is built on-demand in
|
|
71
|
+
* ~50 ms when the first property panel hydrates. Mirrors the desktop
|
|
72
|
+
* `hugeNativeMode` gate.
|
|
73
|
+
*/
|
|
74
|
+
export const HUGE_BROWSER_FILE_THRESHOLD = 500 * 1024 * 1024;
|
|
75
|
+
|
|
52
76
|
/** File size thresholds for various optimizations */
|
|
53
77
|
export const THRESHOLDS = {
|
|
54
78
|
/** Use streaming Parquet above this (150MB) */
|
|
@@ -166,10 +166,28 @@ export function buildIfcDataStoreFromNativeMetadata(snapshot: NativeMetadataSnap
|
|
|
166
166
|
const elements = node.elements.map((summary) => {
|
|
167
167
|
entityLookup.addSummary(summary);
|
|
168
168
|
if (nextStoreyId !== null) {
|
|
169
|
-
|
|
169
|
+
// Direct storey containment wins — only set if absent. Mirrors the
|
|
170
|
+
// descendant-walk path in `spatialHierarchy.ts` where direct
|
|
171
|
+
// IfcRelContainedInSpatialStructure entries take precedence over
|
|
172
|
+
// inherited aggregate-descendant assignments.
|
|
173
|
+
if (!elementToStorey.has(summary.expressId)) {
|
|
174
|
+
elementToStorey.set(summary.expressId, nextStoreyId);
|
|
175
|
+
}
|
|
176
|
+
// NOTE: aggregate descendants of an element (e.g. IfcBuildingElementPart
|
|
177
|
+
// children of an IfcWall) are NOT represented locally in the native
|
|
178
|
+
// metadata snapshot — `NativeMetadataSpatialNode.children` only contains
|
|
179
|
+
// spatial sub-nodes and `node.elements` is a flat list of
|
|
180
|
+
// directly-contained elements (no `children` field on
|
|
181
|
+
// `NativeMetadataEntitySummary`). They are fetched lazily through
|
|
182
|
+
// `getNativeMetadataChildren`. The aggregate-descendant-walk fix that
|
|
183
|
+
// `spatialHierarchy.ts` performs via `relationships.getRelated` cannot
|
|
184
|
+
// be replicated here without an additional native bootstrap payload
|
|
185
|
+
// change (see issue #540 follow-up).
|
|
170
186
|
}
|
|
171
187
|
if (nextSpaceId !== null) {
|
|
172
|
-
elementToSpace.
|
|
188
|
+
if (!elementToSpace.has(summary.expressId)) {
|
|
189
|
+
elementToSpace.set(summary.expressId, nextSpaceId);
|
|
190
|
+
}
|
|
173
191
|
}
|
|
174
192
|
return summary.expressId;
|
|
175
193
|
});
|