@ifc-lite/geometry 1.16.6 → 1.18.1

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.
@@ -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
- * Run the full pre-pass in a dedicated worker, then fan geometry jobs
6
- * out to N workers and yield batches as they complete.
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
- // Copy file bytes into SharedArrayBuffer for zero-copy sharing with workers
16
- const sharedBuffer = new SharedArrayBuffer(buffer.byteLength);
17
- new Uint8Array(sharedBuffer).set(buffer);
18
- // ── PHASE 1: Full pre-pass in worker ──
19
- const makeWorker = () => new Worker(new URL('./geometry.worker.ts', import.meta.url), { type: 'module' });
20
- const prePassResult = await new Promise((resolve, reject) => {
21
- const w = makeWorker();
22
- w.onmessage = (e) => {
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 (cores >= 8 && deviceMemoryGB >= 8) {
68
- // MacBook Air M-series: 8 cores but fanless → throttles with too many workers
69
- // Use 3 workers: enough parallelism without severe throttling
70
- maxWorkers = fileSizeGB > 0.5 ? 2 : 3;
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
- maxWorkers = Math.max(1, Math.min(2, Math.floor(cores / 2)));
27
+ sharedBuffer = new SharedArrayBuffer(buffer.byteLength);
28
+ new Uint8Array(sharedBuffer).set(buffer);
74
29
  }
75
- const workerCount = Math.min(maxWorkers, totalJobs);
76
- const jobsPerWorker = Math.ceil(totalJobs / workerCount);
77
- const chunks = [];
78
- for (let i = 0; i < workerCount; i++) {
79
- const start = i * jobsPerWorker;
80
- const end = Math.min(start + jobsPerWorker, totalJobs);
81
- if (start < end)
82
- chunks.push([start, end]);
83
- }
84
- // Queue-based async generator: workers push batches, generator yields them
85
- const batchQueue = [];
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 workerError = null;
90
- const workers = [];
91
- for (let i = 0; i < chunks.length; i++) {
92
- const [jobStart, jobEnd] = chunks[i];
93
- if (jobStart >= jobEnd) {
94
- workersCompleted++;
95
- continue;
96
- }
97
- const workerJobs = jobsFlat.slice(jobStart * 3, jobEnd * 3);
98
- const worker = new Worker(new URL('./geometry.worker.ts', import.meta.url), { type: 'module' });
99
- workers.push(worker);
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
- // Convert transferable data back to MeshData[]
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
- batchQueue.push(meshes);
114
- if (resolveWaiting) {
115
- resolveWaiting();
116
- resolveWaiting = null;
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
- else if (msg.type === 'complete') {
121
- totalMeshes += msg.totalMeshes;
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
- if (resolveWaiting) {
125
- resolveWaiting();
126
- resolveWaiting = null;
127
- }
140
+ wake();
141
+ return;
128
142
  }
129
- else if (msg.type === 'error') {
143
+ if (msg.type === 'error') {
130
144
  workerError = new Error(`Geometry worker error: ${msg.message}`);
131
145
  workersCompleted++;
132
146
  worker.terminate();
133
- if (resolveWaiting) {
134
- resolveWaiting();
135
- resolveWaiting = null;
136
- }
147
+ wake();
148
+ return;
137
149
  }
138
150
  };
139
- worker.onerror = (e) => {
140
- workerError = new Error(`Geometry worker failed: ${e.message}`);
151
+ worker.onerror = (err) => {
152
+ workerError = new Error(`Geometry worker failed: ${err.message}`);
141
153
  workersCompleted++;
142
154
  worker.terminate();
143
- if (resolveWaiting) {
144
- resolveWaiting();
145
- resolveWaiting = null;
146
- }
155
+ wake();
147
156
  };
148
- // Send work — sharedBuffer is zero-copy, typed arrays are transferred
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: 'process',
151
- sharedBuffer,
152
- jobsFlat: workerJobs,
153
- unitScale,
154
- rtcX, rtcY, rtcZ,
155
- needsShift: effectiveNeedsShift,
156
- voidKeys, voidCounts, voidValues,
157
- styleIds, styleColors,
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
- // Yield batches as they arrive from any worker
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 (batchQueue.length > 0) {
163
- const batch = batchQueue.shift();
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 (workersCompleted >= chunks.length && batchQueue.length === 0) {
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 };