@ifc-lite/geometry 1.17.0 → 1.18.2
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/dist/geometry-controller.worker.d.ts +4 -0
- package/dist/geometry-controller.worker.d.ts.map +1 -0
- package/dist/geometry-controller.worker.js +349 -0
- package/dist/geometry-controller.worker.js.map +1 -0
- package/dist/geometry-parallel.d.ts +55 -13
- package/dist/geometry-parallel.d.ts.map +1 -1
- package/dist/geometry-parallel.js +507 -134
- package/dist/geometry-parallel.js.map +1 -1
- package/dist/geometry.worker.d.ts +89 -3
- package/dist/geometry.worker.d.ts.map +1 -1
- package/dist/geometry.worker.js +337 -72
- package/dist/geometry.worker.js.map +1 -1
- package/dist/ifc-lite-bridge.d.ts +25 -0
- package/dist/ifc-lite-bridge.d.ts.map +1 -1
- package/dist/ifc-lite-bridge.js +49 -0
- package/dist/ifc-lite-bridge.js.map +1 -1
- package/dist/ifc-lite-mesh-collector.d.ts +28 -1
- package/dist/ifc-lite-mesh-collector.d.ts.map +1 -1
- package/dist/ifc-lite-mesh-collector.js +48 -1
- package/dist/ifc-lite-mesh-collector.js.map +1 -1
- package/dist/index.d.ts +51 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +81 -21
- package/dist/index.js.map +1 -1
- package/dist/watchdog.d.ts +27 -0
- package/dist/watchdog.d.ts.map +1 -0
- package/dist/watchdog.js +35 -0
- package/dist/watchdog.js.map +1 -0
- package/dist/worker-count.d.ts +52 -0
- package/dist/worker-count.d.ts.map +1 -0
- package/dist/worker-count.js +89 -0
- package/dist/worker-count.js.map +1 -0
- package/package.json +5 -4
|
@@ -1,106 +1,106 @@
|
|
|
1
1
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* @param buffer Raw IFC file bytes
|
|
9
|
-
* @param coordinator CoordinateHandler used to accumulate bounds
|
|
10
|
-
*/
|
|
11
|
-
export async function* processParallel(buffer, coordinator, sharedRtcOffset) {
|
|
4
|
+
import { pickWorkerCount } from './worker-count.js';
|
|
5
|
+
export async function* processParallel(buffer, coordinator, sharedRtcOffset,
|
|
6
|
+
/** Optional pre-allocated SAB the caller already shares with another worker. */
|
|
7
|
+
existingSab, options) {
|
|
12
8
|
coordinator.reset();
|
|
13
9
|
yield { type: 'start', totalEstimate: buffer.length / 1000 };
|
|
14
10
|
yield { type: 'model-open', modelID: 0 };
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (e.data.type === 'prepass-result') {
|
|
24
|
-
w.terminate();
|
|
25
|
-
resolve(e.data.result);
|
|
26
|
-
}
|
|
27
|
-
else if (e.data.type === 'error') {
|
|
28
|
-
w.terminate();
|
|
29
|
-
reject(new Error(e.data.message));
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
w.onerror = (e) => { w.terminate(); reject(new Error(e.message)); };
|
|
33
|
-
w.postMessage({ type: 'prepass', sharedBuffer });
|
|
34
|
-
});
|
|
35
|
-
if (!prePassResult || !prePassResult.jobs || prePassResult.totalJobs === 0) {
|
|
36
|
-
const coordinateInfo = coordinator.getFinalCoordinateInfo();
|
|
37
|
-
yield { type: 'complete', totalMeshes: 0, coordinateInfo };
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
const { jobs: jobsFlat, totalJobs, unitScale, rtcOffset, needsShift, voidKeys, voidCounts, voidValues, styleIds, styleColors } = prePassResult;
|
|
41
|
-
// When a shared RTC offset is provided (2nd+ federated model), use it
|
|
42
|
-
// instead of the per-model RTC. This ensures all models share the same
|
|
43
|
-
// coordinate origin, giving pixel-perfect federation alignment.
|
|
44
|
-
const useSharedRtc = sharedRtcOffset != null;
|
|
45
|
-
const rtcX = useSharedRtc ? sharedRtcOffset.x : (rtcOffset?.[0] ?? 0);
|
|
46
|
-
const rtcY = useSharedRtc ? sharedRtcOffset.y : (rtcOffset?.[1] ?? 0);
|
|
47
|
-
const rtcZ = useSharedRtc ? sharedRtcOffset.z : (rtcOffset?.[2] ?? 0);
|
|
48
|
-
const effectiveNeedsShift = useSharedRtc ? true : needsShift;
|
|
49
|
-
yield {
|
|
50
|
-
type: 'rtcOffset',
|
|
51
|
-
rtcOffset: { x: rtcX, y: rtcY, z: rtcZ },
|
|
52
|
-
hasRtc: effectiveNeedsShift,
|
|
53
|
-
};
|
|
54
|
-
// ── PHASE 2: Dynamic worker provisioning based on device capability ──
|
|
55
|
-
const cores = typeof navigator !== 'undefined' ? (navigator.hardwareConcurrency ?? 2) : 2;
|
|
56
|
-
const deviceMemoryGB = typeof navigator !== 'undefined' ? (navigator.deviceMemory ?? 8) : 8;
|
|
57
|
-
const fileSizeGB = buffer.byteLength / (1024 * 1024 * 1024);
|
|
58
|
-
// Determine optimal workers:
|
|
59
|
-
// - Desktop (16+ cores, 16+ GB): up to 8 workers
|
|
60
|
-
// - Laptop (8 cores, 8 GB): 2-4 workers (avoid thermal throttling on fanless)
|
|
61
|
-
// - Low-end (4 cores, 4 GB): 1-2 workers
|
|
62
|
-
// - Large files need more memory per worker, so fewer workers
|
|
63
|
-
let maxWorkers;
|
|
64
|
-
if (cores >= 16 && deviceMemoryGB >= 16) {
|
|
65
|
-
maxWorkers = Math.min(8, Math.floor(cores / 2));
|
|
11
|
+
// SAB sharing — see Tier-1 / fix-RAM history. Three paths:
|
|
12
|
+
// 1. Caller-supplied SAB.
|
|
13
|
+
// 2. Input `buffer` already views a SAB.
|
|
14
|
+
// 3. Allocate fresh SAB and copy.
|
|
15
|
+
let sharedBuffer;
|
|
16
|
+
const inputBuffer = buffer.buffer;
|
|
17
|
+
if (existingSab && existingSab.byteLength === buffer.byteLength) {
|
|
18
|
+
sharedBuffer = existingSab;
|
|
66
19
|
}
|
|
67
|
-
else if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
20
|
+
else if (typeof SharedArrayBuffer !== 'undefined'
|
|
21
|
+
&& inputBuffer instanceof SharedArrayBuffer
|
|
22
|
+
&& buffer.byteOffset === 0
|
|
23
|
+
&& buffer.byteLength === inputBuffer.byteLength) {
|
|
24
|
+
sharedBuffer = inputBuffer;
|
|
71
25
|
}
|
|
72
26
|
else {
|
|
73
|
-
|
|
27
|
+
sharedBuffer = new SharedArrayBuffer(buffer.byteLength);
|
|
28
|
+
new Uint8Array(sharedBuffer).set(buffer);
|
|
74
29
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
30
|
+
// Phase 2 controller path: ONE worker that does internal rayon
|
|
31
|
+
// parallelism, instead of N independent WASM-instance workers. The
|
|
32
|
+
// controller imports the threaded bundle (`@ifc-lite/wasm-threaded`)
|
|
33
|
+
// and calls processGeometryBatchParallel for each stream-chunk.
|
|
34
|
+
//
|
|
35
|
+
// The PRE-PASS worker always stays on the legacy bundle —
|
|
36
|
+
// `geometry.worker.js` handles `prepass-streaming` messages; the
|
|
37
|
+
// controller does not. The legacy bundle is also slimmer (no
|
|
38
|
+
// wasm-bindgen-rayon snippets) so the pre-pass pays no atomics tax.
|
|
39
|
+
const useController = options?.useSingleController === true;
|
|
40
|
+
const makeGeometryWorker = () => useController
|
|
41
|
+
? new Worker(new URL('./geometry-controller.worker.js', import.meta.url), { type: 'module' })
|
|
42
|
+
: new Worker(new URL('./geometry.worker.js', import.meta.url), { type: 'module' });
|
|
43
|
+
const makePrepassWorker = () => new Worker(new URL('./geometry.worker.js', import.meta.url), { type: 'module' });
|
|
44
|
+
// Shared aggregator state used by every worker callback below.
|
|
45
|
+
const eventQueue = [];
|
|
86
46
|
let resolveWaiting = null;
|
|
47
|
+
const wake = () => {
|
|
48
|
+
if (resolveWaiting) {
|
|
49
|
+
resolveWaiting();
|
|
50
|
+
resolveWaiting = null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
// Pre-pass worker drives the entire pipeline via streaming events.
|
|
54
|
+
let prepassMeta = null;
|
|
55
|
+
let prepassJobsTotal = 0;
|
|
56
|
+
let prepassDone = false;
|
|
57
|
+
let prepassError = null;
|
|
58
|
+
// Process-worker pool — spawned UP FRONT so their WASM modules compile
|
|
59
|
+
// in parallel with the pre-pass scan. By the time `meta` arrives the
|
|
60
|
+
// workers are usually hot and the first chunk's processing time is
|
|
61
|
+
// dominated by actual geometry work, not WASM startup.
|
|
62
|
+
let workerError = null;
|
|
87
63
|
let workersCompleted = 0;
|
|
88
64
|
let totalMeshes = 0;
|
|
89
|
-
let
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
65
|
+
let endSentToWorkers = false;
|
|
66
|
+
let streamStartSentToWorkers = false;
|
|
67
|
+
/**
|
|
68
|
+
* Chunks held until BOTH `meta` (workers spawned + initialised) AND
|
|
69
|
+
* `styles` (resolved colours from the pre-pass) have arrived. Workers
|
|
70
|
+
* process every chunk with non-empty styles, giving uniform colours
|
|
71
|
+
* across the entire stream — early chunks that were previously
|
|
72
|
+
* processed with empty styles + retroactive colorUpdate didn't recolour
|
|
73
|
+
* geometry-style meshes (geometry-IDs don't match the host's
|
|
74
|
+
* mesh.expressId; only element-material colours did).
|
|
75
|
+
*/
|
|
76
|
+
const queuedChunks = [];
|
|
77
|
+
let stylesReceived = false;
|
|
78
|
+
let entityIndexReceived = false;
|
|
79
|
+
// Per-worker first-batch timestamps (filled lazily so we don't need
|
|
80
|
+
// workerCount at this point). The closure indexes by workerIndex.
|
|
81
|
+
const firstBatchByWorker = [];
|
|
82
|
+
const installWorkerHandlers = (worker, workerIndex) => {
|
|
100
83
|
worker.onmessage = (e) => {
|
|
101
84
|
const msg = e.data;
|
|
85
|
+
if (msg.type === 'ready') {
|
|
86
|
+
console.log(`[stream] worker[${workerIndex}] WASM ready @ ${elapsed()}ms`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (msg.type === 'memory') {
|
|
90
|
+
eventQueue.push({
|
|
91
|
+
type: 'workerMemory',
|
|
92
|
+
workerIndex,
|
|
93
|
+
wasmHeapBytes: msg.wasmHeapBytes,
|
|
94
|
+
meshBytes: msg.meshBytes,
|
|
95
|
+
});
|
|
96
|
+
wake();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
102
99
|
if (msg.type === 'batch') {
|
|
103
|
-
|
|
100
|
+
if (firstBatchByWorker[workerIndex] === undefined) {
|
|
101
|
+
firstBatchByWorker[workerIndex] = elapsed();
|
|
102
|
+
console.log(`[stream] worker[${workerIndex}] first batch @ ${elapsed()}ms (${msg.meshes?.length ?? 0} meshes)`);
|
|
103
|
+
}
|
|
104
104
|
const meshes = msg.meshes.map((m) => ({
|
|
105
105
|
expressId: m.expressId,
|
|
106
106
|
ifcType: m.ifcType,
|
|
@@ -110,82 +110,455 @@ export async function* processParallel(buffer, coordinator, sharedRtcOffset) {
|
|
|
110
110
|
color: m.color,
|
|
111
111
|
}));
|
|
112
112
|
if (meshes.length > 0) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
// Update totalMeshes per batch so consumers see a live
|
|
114
|
+
// running count via `totalSoFar`. The `complete` event
|
|
115
|
+
// below used to be the only updater, leaving streamed
|
|
116
|
+
// batches reporting a stale total until the worker exited.
|
|
117
|
+
totalMeshes += meshes.length;
|
|
118
|
+
coordinator.processMeshesIncremental(meshes);
|
|
119
|
+
const coordinateInfo = coordinator.getCurrentCoordinateInfo();
|
|
120
|
+
eventQueue.push({
|
|
121
|
+
type: 'batch',
|
|
122
|
+
meshes,
|
|
123
|
+
totalSoFar: totalMeshes,
|
|
124
|
+
coordinateInfo: coordinateInfo || undefined,
|
|
125
|
+
});
|
|
126
|
+
wake();
|
|
118
127
|
}
|
|
128
|
+
return;
|
|
119
129
|
}
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
if (msg.type === 'complete') {
|
|
131
|
+
// Don't add msg.totalMeshes here — batches above already
|
|
132
|
+
// updated `totalMeshes += meshes.length` per batch, so the
|
|
133
|
+
// running sum is already correct. msg.totalMeshes is the
|
|
134
|
+
// worker's per-session count; if it disagrees with the sum
|
|
135
|
+
// of batch lengths we observed, a batch was lost — log but
|
|
136
|
+
// trust our observed count to keep totalSoFar consistent
|
|
137
|
+
// with what consumers actually rendered.
|
|
122
138
|
workersCompleted++;
|
|
123
139
|
worker.terminate();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
resolveWaiting = null;
|
|
127
|
-
}
|
|
140
|
+
wake();
|
|
141
|
+
return;
|
|
128
142
|
}
|
|
129
|
-
|
|
143
|
+
if (msg.type === 'error') {
|
|
130
144
|
workerError = new Error(`Geometry worker error: ${msg.message}`);
|
|
131
145
|
workersCompleted++;
|
|
132
146
|
worker.terminate();
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
resolveWaiting = null;
|
|
136
|
-
}
|
|
147
|
+
wake();
|
|
148
|
+
return;
|
|
137
149
|
}
|
|
138
150
|
};
|
|
139
|
-
worker.onerror = (
|
|
140
|
-
workerError = new Error(`Geometry worker failed: ${
|
|
151
|
+
worker.onerror = (err) => {
|
|
152
|
+
workerError = new Error(`Geometry worker failed: ${err.message}`);
|
|
141
153
|
workersCompleted++;
|
|
142
154
|
worker.terminate();
|
|
143
|
-
|
|
144
|
-
resolveWaiting();
|
|
145
|
-
resolveWaiting = null;
|
|
146
|
-
}
|
|
155
|
+
wake();
|
|
147
156
|
};
|
|
148
|
-
|
|
157
|
+
};
|
|
158
|
+
// Pick worker count and pre-spawn them now. `pickWorkerCount` needs a
|
|
159
|
+
// totalJobs estimate; use file-size proxy. The memory-budget cap in
|
|
160
|
+
// `pickWorkerCount` keeps an over-estimate harmless.
|
|
161
|
+
const cores = typeof navigator !== 'undefined' ? (navigator.hardwareConcurrency ?? 2) : 2;
|
|
162
|
+
const deviceMemoryGB = typeof navigator !== 'undefined'
|
|
163
|
+
? (navigator.deviceMemory ?? 8) : 8;
|
|
164
|
+
const fileSizeMB = buffer.byteLength / (1024 * 1024);
|
|
165
|
+
const estimatedJobs = Math.max(1, Math.ceil(fileSizeMB * 100));
|
|
166
|
+
// Controller path: ONE worker. The worker spins up (cores - 1) rayon
|
|
167
|
+
// helpers internally; pickWorkerCount's per-worker budget no longer
|
|
168
|
+
// applies because we hold ONE WASM heap, not N. N-worker path
|
|
169
|
+
// unchanged.
|
|
170
|
+
const workerCount = useController
|
|
171
|
+
? 1
|
|
172
|
+
: pickWorkerCount({ fileSizeMB, cores, deviceMemoryGB, totalJobs: estimatedJobs });
|
|
173
|
+
const workers = [];
|
|
174
|
+
for (let i = 0; i < workerCount; i++) {
|
|
175
|
+
const worker = makeGeometryWorker();
|
|
176
|
+
workers.push(worker);
|
|
177
|
+
installWorkerHandlers(worker, i);
|
|
178
|
+
// Kick off WASM compile concurrently with the pre-pass scan. The
|
|
179
|
+
// worker's tail-promise serialiser guarantees this `init` completes
|
|
180
|
+
// before any subsequent `stream-start`/`stream-chunk` runs.
|
|
181
|
+
worker.postMessage({ type: 'init' });
|
|
182
|
+
// Issue #540: forward the user's "Merge Multilayer Walls" toggle
|
|
183
|
+
// BEFORE any stream-start so the worker's IfcAPI has the flag set
|
|
184
|
+
// before its first parse call. The tail-promise serialiser inside
|
|
185
|
+
// each worker preserves this order even though the messages are
|
|
186
|
+
// posted back-to-back. We always send the message so the controller
|
|
187
|
+
// path doesn't have to remember whether the host called it — the
|
|
188
|
+
// default `false` is a cheap no-op.
|
|
149
189
|
worker.postMessage({
|
|
150
|
-
type: '
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
190
|
+
type: 'set-merge-layers',
|
|
191
|
+
enabled: options?.mergeLayers === true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const sendStreamEnd = () => {
|
|
195
|
+
if (endSentToWorkers)
|
|
196
|
+
return;
|
|
197
|
+
endSentToWorkers = true;
|
|
198
|
+
for (const w of workers) {
|
|
199
|
+
try {
|
|
200
|
+
w.postMessage({ type: 'stream-end' });
|
|
201
|
+
}
|
|
202
|
+
catch { /* worker terminated already — safe to ignore */ }
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const sendStreamStartIfReady = () => {
|
|
206
|
+
if (streamStartSentToWorkers || !prepassMeta)
|
|
207
|
+
return;
|
|
208
|
+
streamStartSentToWorkers = true;
|
|
209
|
+
const useSharedRtc = sharedRtcOffset != null;
|
|
210
|
+
const rtcX = useSharedRtc ? sharedRtcOffset.x : prepassMeta.rtcOffset[0];
|
|
211
|
+
const rtcY = useSharedRtc ? sharedRtcOffset.y : prepassMeta.rtcOffset[1];
|
|
212
|
+
const rtcZ = useSharedRtc ? sharedRtcOffset.z : prepassMeta.rtcOffset[2];
|
|
213
|
+
const effectiveNeedsShift = useSharedRtc ? true : prepassMeta.needsShift;
|
|
214
|
+
eventQueue.push({
|
|
215
|
+
type: 'rtcOffset',
|
|
216
|
+
rtcOffset: { x: rtcX, y: rtcY, z: rtcZ },
|
|
217
|
+
hasRtc: effectiveNeedsShift,
|
|
158
218
|
});
|
|
219
|
+
wake();
|
|
220
|
+
const emptyU32 = new Uint32Array(0);
|
|
221
|
+
const emptyU8 = new Uint8Array(0);
|
|
222
|
+
for (const worker of workers) {
|
|
223
|
+
worker.postMessage({
|
|
224
|
+
type: 'stream-start',
|
|
225
|
+
sharedBuffer,
|
|
226
|
+
unitScale: prepassMeta.unitScale,
|
|
227
|
+
rtcX, rtcY, rtcZ,
|
|
228
|
+
needsShift: effectiveNeedsShift,
|
|
229
|
+
voidKeys: emptyU32,
|
|
230
|
+
voidCounts: emptyU32,
|
|
231
|
+
voidValues: emptyU32,
|
|
232
|
+
styleIds: emptyU32,
|
|
233
|
+
styleColors: emptyU8,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
// Don't drain queued chunks here — wait for the `styles` event so
|
|
237
|
+
// every chunk gets processed with resolved colours. The styles
|
|
238
|
+
// handler does the drain after posting set-styles.
|
|
239
|
+
};
|
|
240
|
+
function dispatchJobsChunkInternal(jobs) {
|
|
241
|
+
if (workers.length === 0 || jobs.length === 0)
|
|
242
|
+
return;
|
|
243
|
+
// Round-robin sending whole chunks to single workers leaves N-1
|
|
244
|
+
// workers idle whenever the chunk count is small. Instead split each
|
|
245
|
+
// Rust chunk evenly across all workers so every worker processes a
|
|
246
|
+
// slice of every chunk in parallel — full pool utilisation from the
|
|
247
|
+
// very first chunk.
|
|
248
|
+
const totalSubJobs = Math.floor(jobs.length / 3);
|
|
249
|
+
if (totalSubJobs === 0)
|
|
250
|
+
return;
|
|
251
|
+
const subPerWorker = Math.ceil(totalSubJobs / workers.length);
|
|
252
|
+
try {
|
|
253
|
+
for (let i = 0; i < workers.length; i++) {
|
|
254
|
+
const start = i * subPerWorker * 3;
|
|
255
|
+
const end = Math.min(start + subPerWorker * 3, jobs.length);
|
|
256
|
+
if (start >= end)
|
|
257
|
+
continue;
|
|
258
|
+
// `slice` allocates a new ArrayBuffer per piece so each can be in
|
|
259
|
+
// its own transfer list. Cheap relative to the WASM work that follows.
|
|
260
|
+
const sub = jobs.slice(start, end);
|
|
261
|
+
workers[i].postMessage({ type: 'stream-chunk', jobsFlat: sub }, [sub.buffer]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
workerError = new Error(`Failed to dispatch jobs chunk: ${err instanceof Error ? err.message : String(err)}`);
|
|
266
|
+
wake();
|
|
267
|
+
}
|
|
159
268
|
}
|
|
160
|
-
|
|
269
|
+
const dispatchJobsChunk = (jobs) => {
|
|
270
|
+
if (!streamStartSentToWorkers || !stylesReceived || !entityIndexReceived) {
|
|
271
|
+
// Hold until stream-start AND styles AND entity-index have all
|
|
272
|
+
// been posted to workers. Without styles the meshes would render
|
|
273
|
+
// with default per-type colours; without the pre-built entity
|
|
274
|
+
// index, the worker's first WASM call would re-scan the file
|
|
275
|
+
// (~5 s on 1 GB) to rebuild the index inside Rust.
|
|
276
|
+
queuedChunks.push(jobs);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
dispatchJobsChunkInternal(jobs);
|
|
280
|
+
};
|
|
281
|
+
/** Drain queued chunks once all gating conditions are met. */
|
|
282
|
+
const drainQueuedChunksIfReady = () => {
|
|
283
|
+
if (!streamStartSentToWorkers || !stylesReceived || !entityIndexReceived)
|
|
284
|
+
return;
|
|
285
|
+
while (queuedChunks.length > 0) {
|
|
286
|
+
dispatchJobsChunkInternal(queuedChunks.shift());
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
// Step-by-step timing so we can tell exactly where time goes.
|
|
290
|
+
const t0 = performance.now();
|
|
291
|
+
const elapsed = () => Math.round(performance.now() - t0);
|
|
292
|
+
console.log(`[stream] processParallel start, fileSizeMB=${fileSizeMB.toFixed(1)} workerCount=${workerCount}`);
|
|
293
|
+
const prepassWorker = makePrepassWorker();
|
|
294
|
+
let chunkArrivals = 0;
|
|
295
|
+
let totalDispatchedJobs = 0;
|
|
296
|
+
let firstChunkAt = -1;
|
|
297
|
+
prepassWorker.onmessage = (e) => {
|
|
298
|
+
const data = e.data;
|
|
299
|
+
if (data.type === 'prepass-progress') {
|
|
300
|
+
eventQueue.push({ type: 'progress', phase: 'prepass' });
|
|
301
|
+
wake();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (data.type === 'prepass-stream') {
|
|
305
|
+
const evt = data.event;
|
|
306
|
+
if (evt.type === 'meta') {
|
|
307
|
+
prepassMeta = {
|
|
308
|
+
unitScale: evt.unitScale,
|
|
309
|
+
rtcOffset: evt.rtcOffset,
|
|
310
|
+
needsShift: evt.needsShift,
|
|
311
|
+
buildingRotation: evt.buildingRotation ?? null,
|
|
312
|
+
};
|
|
313
|
+
console.log(`[stream] meta @ ${elapsed()}ms unitScale=${prepassMeta.unitScale} rtc=[${(prepassMeta.rtcOffset[0]).toFixed(0)},${(prepassMeta.rtcOffset[1]).toFixed(0)},${(prepassMeta.rtcOffset[2]).toFixed(0)}]`);
|
|
314
|
+
sendStreamStartIfReady();
|
|
315
|
+
wake();
|
|
316
|
+
}
|
|
317
|
+
else if (evt.type === 'jobs') {
|
|
318
|
+
const jobsArr = evt.jobs;
|
|
319
|
+
const jobCount = Math.floor(jobsArr.length / 3);
|
|
320
|
+
chunkArrivals++;
|
|
321
|
+
totalDispatchedJobs += jobCount;
|
|
322
|
+
if (firstChunkAt < 0) {
|
|
323
|
+
firstChunkAt = elapsed();
|
|
324
|
+
console.log(`[stream] first jobs chunk @ ${firstChunkAt}ms (${jobCount} jobs)`);
|
|
325
|
+
}
|
|
326
|
+
if (chunkArrivals % 10 === 1 || jobCount < 1000) {
|
|
327
|
+
console.log(`[stream] chunk #${chunkArrivals} @ ${elapsed()}ms (+${jobCount} jobs, total ${totalDispatchedJobs})`);
|
|
328
|
+
}
|
|
329
|
+
dispatchJobsChunk(jobsArr);
|
|
330
|
+
}
|
|
331
|
+
else if (evt.type === 'styles') {
|
|
332
|
+
// Streaming pre-pass resolved styles + voids after its main scan.
|
|
333
|
+
// Push them into every worker, then drain any chunks that were
|
|
334
|
+
// held waiting for styles. Workers will process every chunk with
|
|
335
|
+
// resolved colors — uniform shading across the whole stream.
|
|
336
|
+
const styleIds = evt.styleIds;
|
|
337
|
+
const styleColors = evt.styleColors;
|
|
338
|
+
const voidKeys = evt.voidKeys;
|
|
339
|
+
const voidCounts = evt.voidCounts;
|
|
340
|
+
const voidValues = evt.voidValues;
|
|
341
|
+
console.log(`[stream] styles @ ${elapsed()}ms (${styleIds.length} styled, ${voidKeys.length} void hosts), draining ${queuedChunks.length} queued chunks`);
|
|
342
|
+
for (const w of workers) {
|
|
343
|
+
// Slice each typed array per-worker so each can be in its own
|
|
344
|
+
// transfer list without conflict. The slice cost is bounded by
|
|
345
|
+
// `styleIds.length * 4` bytes — under 1 MB for ~250K styles.
|
|
346
|
+
try {
|
|
347
|
+
const sIds = styleIds.slice();
|
|
348
|
+
const sColors = styleColors.slice();
|
|
349
|
+
const vKeys = voidKeys.slice();
|
|
350
|
+
const vCounts = voidCounts.slice();
|
|
351
|
+
const vValues = voidValues.slice();
|
|
352
|
+
w.postMessage({
|
|
353
|
+
type: 'set-styles',
|
|
354
|
+
styleIds: sIds,
|
|
355
|
+
styleColors: sColors,
|
|
356
|
+
voidKeys: vKeys,
|
|
357
|
+
voidCounts: vCounts,
|
|
358
|
+
voidValues: vValues,
|
|
359
|
+
}, [sIds.buffer, sColors.buffer, vKeys.buffer, vCounts.buffer, vValues.buffer]);
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
console.warn('[stream] set-styles dispatch failed:', err);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
stylesReceived = true;
|
|
366
|
+
// Drain only when ALL gates are open (entity-index too). The
|
|
367
|
+
// worker's tail-promise serialiser ensures any set-* runs
|
|
368
|
+
// before any subsequent stream-chunk.
|
|
369
|
+
drainQueuedChunksIfReady();
|
|
370
|
+
}
|
|
371
|
+
else if (evt.type === 'entity-index') {
|
|
372
|
+
// Pre-pass exported its built entity_index. Forward to every
|
|
373
|
+
// worker so they skip the ~5 s file re-scan in Rust's lazy
|
|
374
|
+
// build path. SAB sharing for zero-copy distribution to N
|
|
375
|
+
// workers — each gets a Uint32Array view over the same buffer.
|
|
376
|
+
const ids = evt.ids;
|
|
377
|
+
const starts = evt.starts;
|
|
378
|
+
const lengths = evt.lengths;
|
|
379
|
+
console.log(`[stream] entity-index @ ${elapsed()}ms (${ids.length} entries)`);
|
|
380
|
+
if (typeof SharedArrayBuffer !== 'undefined') {
|
|
381
|
+
// Allocate one SAB triple, copy data once, share across all
|
|
382
|
+
// workers without postMessage clone cost.
|
|
383
|
+
const idsBytes = ids.byteLength;
|
|
384
|
+
const startsBytes = starts.byteLength;
|
|
385
|
+
const lengthsBytes = lengths.byteLength;
|
|
386
|
+
const sabIds = new SharedArrayBuffer(idsBytes);
|
|
387
|
+
const sabStarts = new SharedArrayBuffer(startsBytes);
|
|
388
|
+
const sabLengths = new SharedArrayBuffer(lengthsBytes);
|
|
389
|
+
new Uint32Array(sabIds).set(ids);
|
|
390
|
+
new Uint32Array(sabStarts).set(starts);
|
|
391
|
+
new Uint32Array(sabLengths).set(lengths);
|
|
392
|
+
for (const w of workers) {
|
|
393
|
+
try {
|
|
394
|
+
w.postMessage({
|
|
395
|
+
type: 'set-entity-index',
|
|
396
|
+
ids: new Uint32Array(sabIds),
|
|
397
|
+
starts: new Uint32Array(sabStarts),
|
|
398
|
+
lengths: new Uint32Array(sabLengths),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
console.warn('[stream] set-entity-index dispatch failed:', err);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Hand the same SAB triple to the parser worker (or any other
|
|
406
|
+
// listener) so it can skip its own `scanEntitiesFastBytes` call.
|
|
407
|
+
// Each consumer gets its own Uint32Array view over the shared
|
|
408
|
+
// buffers — no extra copy.
|
|
409
|
+
if (options?.onEntityIndex) {
|
|
410
|
+
try {
|
|
411
|
+
options.onEntityIndex(new Uint32Array(sabIds), new Uint32Array(sabStarts), new Uint32Array(sabLengths));
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
console.warn('[stream] onEntityIndex callback failed:', err);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
// SAB unavailable — clone per worker via structured clone.
|
|
420
|
+
for (const w of workers) {
|
|
421
|
+
try {
|
|
422
|
+
w.postMessage({
|
|
423
|
+
type: 'set-entity-index',
|
|
424
|
+
ids: ids.slice(),
|
|
425
|
+
starts: starts.slice(),
|
|
426
|
+
lengths: lengths.slice(),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
console.warn('[stream] set-entity-index dispatch failed:', err);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (options?.onEntityIndex) {
|
|
434
|
+
try {
|
|
435
|
+
options.onEntityIndex(ids.slice(), starts.slice(), lengths.slice());
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
console.warn('[stream] onEntityIndex callback failed:', err);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
entityIndexReceived = true;
|
|
443
|
+
drainQueuedChunksIfReady();
|
|
444
|
+
}
|
|
445
|
+
else if (evt.type === 'complete') {
|
|
446
|
+
prepassJobsTotal = evt.totalJobs;
|
|
447
|
+
console.log(`[stream] prepass complete @ ${elapsed()}ms totalJobs=${prepassJobsTotal} chunks=${chunkArrivals}`);
|
|
448
|
+
// Unconditionally drive the prepass-complete handler here.
|
|
449
|
+
// The outer loop's `prepassJobsTotal > 0` gate would skip
|
|
450
|
+
// zero-geometry files (no IFC geometry entities), causing
|
|
451
|
+
// the generator to wait forever. Calling here ensures
|
|
452
|
+
// prepassDone flips even when totalJobs === 0.
|
|
453
|
+
if (!prepassCompleteSeen) {
|
|
454
|
+
prepassCompleteSeen = true;
|
|
455
|
+
onPrepassComplete();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (data.type === 'error') {
|
|
461
|
+
prepassError = new Error(data.message);
|
|
462
|
+
prepassDone = true;
|
|
463
|
+
prepassWorker.terminate();
|
|
464
|
+
wake();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// The streaming variant doesn't emit `prepass-result` — the streaming
|
|
468
|
+
// worker exits naturally after the JS callback returns from
|
|
469
|
+
// `buildPrePassStreaming`. We treat unknown messages as no-ops.
|
|
470
|
+
};
|
|
471
|
+
prepassWorker.onerror = (e) => {
|
|
472
|
+
prepassError = new Error(`Pre-pass worker failed: ${e.message}`);
|
|
473
|
+
prepassDone = true;
|
|
474
|
+
prepassWorker.terminate();
|
|
475
|
+
wake();
|
|
476
|
+
};
|
|
477
|
+
// Track when the pre-pass worker finishes by listening for either a
|
|
478
|
+
// synthesized "complete" event from the Rust side OR a worker exit. The
|
|
479
|
+
// Rust side currently doesn't post anything after `complete` (it returns
|
|
480
|
+
// from JS), so we close the worker via terminate-on-complete in the host.
|
|
481
|
+
// After we see the Rust `complete` event we can sendStreamEnd.
|
|
482
|
+
const onPrepassComplete = () => {
|
|
483
|
+
prepassDone = true;
|
|
484
|
+
// Only signal stream-end to workers if they actually got
|
|
485
|
+
// stream-start (which gates on `meta`). Zero-geometry files
|
|
486
|
+
// never trigger meta → workers never start → no stream-end
|
|
487
|
+
// needed. The dedicated zero-jobs branch in the outer loop
|
|
488
|
+
// handles their teardown.
|
|
489
|
+
if (streamStartSentToWorkers) {
|
|
490
|
+
sendStreamEnd();
|
|
491
|
+
}
|
|
492
|
+
prepassWorker.terminate();
|
|
493
|
+
wake();
|
|
494
|
+
};
|
|
495
|
+
// Dispatch the streaming pre-pass.
|
|
496
|
+
// chunk_size = 50K is a deliberate compromise:
|
|
497
|
+
// • small enough that the FIRST chunk (always a tiny one — bounded by
|
|
498
|
+
// RTC_SAMPLE_THRESHOLD ≈ 50 jobs from the Rust side) reaches workers
|
|
499
|
+
// within ~1.5 s for fast TTFG;
|
|
500
|
+
// • large enough that subsequent chunks make few Rust→JS callbacks
|
|
501
|
+
// and few worker postMessages — each call into processGeometryBatch
|
|
502
|
+
// has fixed setup cost that compounds badly when invoked 30+ times.
|
|
503
|
+
// Per-chunk fan-out (see `dispatchJobsChunkInternal`) splits each chunk
|
|
504
|
+
// evenly across all workers so parallelism is preserved at every chunk.
|
|
505
|
+
prepassWorker.postMessage({ type: 'prepass-streaming', sharedBuffer, chunkSize: 50_000 });
|
|
506
|
+
// Drain the event queue until the pre-pass and all process workers complete.
|
|
507
|
+
// The pre-pass `complete` event is captured inside the message handler
|
|
508
|
+
// (we set prepassJobsTotal there) but the worker stays alive briefly
|
|
509
|
+
// while the JS callback returns. Detect end-of-stream by:
|
|
510
|
+
// a) `prepassJobsTotal > 0` (or zero-jobs file): pre-pass emitted complete
|
|
511
|
+
// b) all workers reported `complete`
|
|
512
|
+
let prepassCompleteSeen = false;
|
|
161
513
|
while (true) {
|
|
162
|
-
while (
|
|
163
|
-
|
|
164
|
-
coordinator.processMeshesIncremental(batch);
|
|
165
|
-
const coordinateInfo = coordinator.getCurrentCoordinateInfo();
|
|
166
|
-
yield {
|
|
167
|
-
type: 'batch',
|
|
168
|
-
meshes: batch,
|
|
169
|
-
totalSoFar: totalMeshes,
|
|
170
|
-
coordinateInfo: coordinateInfo || undefined,
|
|
171
|
-
};
|
|
514
|
+
while (eventQueue.length > 0) {
|
|
515
|
+
yield eventQueue.shift();
|
|
172
516
|
}
|
|
173
517
|
if (workerError) {
|
|
174
|
-
// Terminate remaining workers
|
|
175
518
|
for (const w of workers) {
|
|
176
519
|
try {
|
|
177
520
|
w.terminate();
|
|
178
521
|
}
|
|
179
522
|
catch { /* cleanup — safe to ignore */ }
|
|
180
523
|
}
|
|
524
|
+
try {
|
|
525
|
+
prepassWorker.terminate();
|
|
526
|
+
}
|
|
527
|
+
catch { /* cleanup — safe to ignore */ }
|
|
181
528
|
throw workerError;
|
|
182
529
|
}
|
|
183
|
-
if (
|
|
530
|
+
if (prepassError) {
|
|
531
|
+
for (const w of workers) {
|
|
532
|
+
try {
|
|
533
|
+
w.terminate();
|
|
534
|
+
}
|
|
535
|
+
catch { /* cleanup — safe to ignore */ }
|
|
536
|
+
}
|
|
537
|
+
throw prepassError;
|
|
538
|
+
}
|
|
539
|
+
// Edge case: pre-pass for a file with zero geometry. The Rust side
|
|
540
|
+
// emits `complete { totalJobs: 0 }`; meta never fired so workers
|
|
541
|
+
// never received stream-start. Tear them down explicitly and yield
|
|
542
|
+
// `complete`. Workers were pre-spawned with `init` so they need an
|
|
543
|
+
// explicit terminate to exit.
|
|
544
|
+
if (prepassDone && !streamStartSentToWorkers && prepassJobsTotal === 0) {
|
|
545
|
+
for (const w of workers) {
|
|
546
|
+
try {
|
|
547
|
+
w.terminate();
|
|
548
|
+
}
|
|
549
|
+
catch { /* cleanup — safe to ignore */ }
|
|
550
|
+
}
|
|
551
|
+
const coordinateInfo = coordinator.getFinalCoordinateInfo();
|
|
552
|
+
yield { type: 'complete', totalMeshes: 0, coordinateInfo };
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (prepassDone
|
|
556
|
+
&& streamStartSentToWorkers
|
|
557
|
+
&& workersCompleted >= workers.length
|
|
558
|
+
&& eventQueue.length === 0) {
|
|
184
559
|
break;
|
|
185
560
|
}
|
|
186
|
-
await new Promise((resolve) => {
|
|
187
|
-
resolveWaiting = resolve;
|
|
188
|
-
});
|
|
561
|
+
await new Promise((resolve) => { resolveWaiting = resolve; });
|
|
189
562
|
}
|
|
190
563
|
const coordinateInfo = coordinator.getFinalCoordinateInfo();
|
|
191
564
|
yield { type: 'complete', totalMeshes, coordinateInfo };
|