@ifc-lite/geometry 2.1.0 → 2.3.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.
@@ -98,6 +98,13 @@ existingSab, options) {
98
98
  normals: m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals),
99
99
  indices: m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices),
100
100
  color: m.color,
101
+ // #961: carry per-vertex UVs + decoded surface texture through to the
102
+ // renderer (transferables; already typed arrays from the worker).
103
+ ...(m.uvs ? { uvs: m.uvs } : {}),
104
+ ...(m.texture ? { texture: m.texture } : {}),
105
+ // Carry the model-diff fingerprint through the worker boundary
106
+ // (issue #924); undefined when hashing is off.
107
+ ...(m.geometryHash !== undefined ? { geometryHash: m.geometryHash } : {}),
101
108
  }));
102
109
  if (meshes.length > 0) {
103
110
  // Update totalMeshes per batch so consumers see a live
@@ -183,6 +190,12 @@ existingSab, options) {
183
190
  type: 'set-merge-layers',
184
191
  enabled: options?.mergeLayers === true,
185
192
  });
193
+ // Issue #924: forward the geometry-hash tolerance the same way — always
194
+ // sent so the controller path stays uniform; null is a cheap no-op.
195
+ worker.postMessage({
196
+ type: 'set-compute-geometry-hashes',
197
+ tolerance: options?.geometryHashTolerance ?? null,
198
+ });
186
199
  }
187
200
  const sendStreamEnd = () => {
188
201
  if (endSentToWorkers)
@@ -204,6 +217,10 @@ existingSab, options) {
204
217
  const rtcY = useSharedRtc ? sharedRtcOffset.y : prepassMeta.rtcOffset[1];
205
218
  const rtcZ = useSharedRtc ? sharedRtcOffset.z : prepassMeta.rtcOffset[2];
206
219
  const effectiveNeedsShift = useSharedRtc ? true : prepassMeta.needsShift;
220
+ // Surface the world→render metadata (unit scale + the effective applied
221
+ // RTC, which is the shared offset under federation) on coordinateInfo for
222
+ // downstream consumers (issue #945).
223
+ coordinator.setWasmMetadata(prepassMeta.unitScale, effectiveNeedsShift ? { x: rtcX, y: rtcY, z: rtcZ } : null);
207
224
  eventQueue.push({
208
225
  type: 'rtcOffset',
209
226
  rtcOffset: { x: rtcX, y: rtcY, z: rtcZ },
@@ -284,285 +301,306 @@ existingSab, options) {
284
301
  const elapsed = () => Math.round(performance.now() - t0);
285
302
  console.log(`[stream] processParallel start, fileSizeMB=${fileSizeMB.toFixed(1)} workerCount=${workerCount}`);
286
303
  const prepassWorker = makePrepassWorker();
287
- // Forward the consumer-supplied wasm URL to the pre-pass worker so it
288
- // doesn't fall back to wasm-bindgen's `import.meta.url` default. The
289
- // pre-pass worker uses the same `geometry.worker.js` bundle and the
290
- // legacy (non-threaded) wasm, so `wasmUrls.wasm` is the right key.
291
- // Skipped entirely when no URL was provided keeps Vite/webpack
292
- // consumers on the bundler-native resolution path.
293
- if (options?.wasmUrls?.wasm) {
294
- prepassWorker.postMessage({ type: 'init', wasmUrl: options.wasmUrls.wasm });
295
- }
296
- let chunkArrivals = 0;
297
- let totalDispatchedJobs = 0;
298
- let firstChunkAt = -1;
299
- prepassWorker.onmessage = (e) => {
300
- const data = e.data;
301
- if (data.type === 'prepass-progress') {
302
- eventQueue.push({ type: 'progress', phase: 'prepass' });
303
- wake();
304
- return;
304
+ // Wrap the rest of the pipeline so worker teardown runs not only on
305
+ // normal completion / error / zero-jobs branches, but also when the
306
+ // consumer abandons the generator via `.return()` / `.throw()` while it
307
+ // is suspended at a `yield` or the `resolveWaiting` await. The viewer's
308
+ // `watchedGeometryStream` relies on this `finally` to tear down workers
309
+ // on break / abort / watchdog (see boundedIteratorReturn). The existing
310
+ // branch-local `terminate()` calls remain — `terminate()` is idempotent.
311
+ try {
312
+ // Forward the consumer-supplied wasm URL to the pre-pass worker so it
313
+ // doesn't fall back to wasm-bindgen's `import.meta.url` default. The
314
+ // pre-pass worker uses the same `geometry.worker.js` bundle and the
315
+ // legacy (non-threaded) wasm, so `wasmUrls.wasm` is the right key.
316
+ // Skipped entirely when no URL was provided — keeps Vite/webpack
317
+ // consumers on the bundler-native resolution path.
318
+ if (options?.wasmUrls?.wasm) {
319
+ prepassWorker.postMessage({ type: 'init', wasmUrl: options.wasmUrls.wasm });
305
320
  }
306
- if (data.type === 'prepass-stream') {
307
- const evt = data.event;
308
- if (evt.type === 'meta') {
309
- prepassMeta = {
310
- unitScale: evt.unitScale,
311
- rtcOffset: evt.rtcOffset,
312
- needsShift: evt.needsShift,
313
- buildingRotation: evt.buildingRotation ?? null,
314
- };
315
- 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)}]`);
316
- sendStreamStartIfReady();
321
+ let chunkArrivals = 0;
322
+ let totalDispatchedJobs = 0;
323
+ let firstChunkAt = -1;
324
+ prepassWorker.onmessage = (e) => {
325
+ const data = e.data;
326
+ if (data.type === 'prepass-progress') {
327
+ eventQueue.push({ type: 'progress', phase: 'prepass' });
317
328
  wake();
329
+ return;
318
330
  }
319
- else if (evt.type === 'jobs') {
320
- const jobsArr = evt.jobs;
321
- const jobCount = Math.floor(jobsArr.length / 3);
322
- chunkArrivals++;
323
- totalDispatchedJobs += jobCount;
324
- if (firstChunkAt < 0) {
325
- firstChunkAt = elapsed();
326
- console.log(`[stream] first jobs chunk @ ${firstChunkAt}ms (${jobCount} jobs)`);
327
- }
328
- if (chunkArrivals % 10 === 1 || jobCount < 1000) {
329
- console.log(`[stream] chunk #${chunkArrivals} @ ${elapsed()}ms (+${jobCount} jobs, total ${totalDispatchedJobs})`);
331
+ if (data.type === 'prepass-stream') {
332
+ const evt = data.event;
333
+ if (evt.type === 'meta') {
334
+ prepassMeta = {
335
+ unitScale: evt.unitScale,
336
+ rtcOffset: evt.rtcOffset,
337
+ needsShift: evt.needsShift,
338
+ buildingRotation: evt.buildingRotation ?? null,
339
+ };
340
+ 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)}]`);
341
+ sendStreamStartIfReady();
342
+ wake();
330
343
  }
331
- dispatchJobsChunk(jobsArr);
332
- }
333
- else if (evt.type === 'styles') {
334
- // Streaming pre-pass resolved styles + voids after its main scan.
335
- // Push them into every worker, then drain any chunks that were
336
- // held waiting for styles. Workers will process every chunk with
337
- // resolved colors — uniform shading across the whole stream.
338
- const styleIds = evt.styleIds;
339
- const styleColors = evt.styleColors;
340
- const voidKeys = evt.voidKeys;
341
- const voidCounts = evt.voidCounts;
342
- const voidValues = evt.voidValues;
343
- console.log(`[stream] styles @ ${elapsed()}ms (${styleIds.length} styled, ${voidKeys.length} void hosts), draining ${queuedChunks.length} queued chunks`);
344
- for (const w of workers) {
345
- // Slice each typed array per-worker so each can be in its own
346
- // transfer list without conflict. The slice cost is bounded by
347
- // `styleIds.length * 4` bytes — under 1 MB for ~250K styles.
348
- try {
349
- const sIds = styleIds.slice();
350
- const sColors = styleColors.slice();
351
- const vKeys = voidKeys.slice();
352
- const vCounts = voidCounts.slice();
353
- const vValues = voidValues.slice();
354
- w.postMessage({
355
- type: 'set-styles',
356
- styleIds: sIds,
357
- styleColors: sColors,
358
- voidKeys: vKeys,
359
- voidCounts: vCounts,
360
- voidValues: vValues,
361
- }, [sIds.buffer, sColors.buffer, vKeys.buffer, vCounts.buffer, vValues.buffer]);
344
+ else if (evt.type === 'jobs') {
345
+ const jobsArr = evt.jobs;
346
+ const jobCount = Math.floor(jobsArr.length / 3);
347
+ chunkArrivals++;
348
+ totalDispatchedJobs += jobCount;
349
+ if (firstChunkAt < 0) {
350
+ firstChunkAt = elapsed();
351
+ console.log(`[stream] first jobs chunk @ ${firstChunkAt}ms (${jobCount} jobs)`);
362
352
  }
363
- catch (err) {
364
- console.warn('[stream] set-styles dispatch failed:', err);
353
+ if (chunkArrivals % 10 === 1 || jobCount < 1000) {
354
+ console.log(`[stream] chunk #${chunkArrivals} @ ${elapsed()}ms (+${jobCount} jobs, total ${totalDispatchedJobs})`);
365
355
  }
356
+ dispatchJobsChunk(jobsArr);
366
357
  }
367
- stylesReceived = true;
368
- // Drain only when ALL gates are open (entity-index too). The
369
- // worker's tail-promise serialiser ensures any set-* runs
370
- // before any subsequent stream-chunk.
371
- drainQueuedChunksIfReady();
372
- }
373
- else if (evt.type === 'entity-index') {
374
- // Pre-pass exported its built entity_index. Forward to every
375
- // worker so they skip the ~5 s file re-scan in Rust's lazy
376
- // build path. SAB sharing for zero-copy distribution to N
377
- // workers each gets a Uint32Array view over the same buffer.
378
- const ids = evt.ids;
379
- const starts = evt.starts;
380
- const lengths = evt.lengths;
381
- console.log(`[stream] entity-index @ ${elapsed()}ms (${ids.length} entries)`);
382
- if (typeof SharedArrayBuffer !== 'undefined') {
383
- // Allocate one SAB triple, copy data once, share across all
384
- // workers without postMessage clone cost.
385
- const idsBytes = ids.byteLength;
386
- const startsBytes = starts.byteLength;
387
- const lengthsBytes = lengths.byteLength;
388
- const sabIds = new SharedArrayBuffer(idsBytes);
389
- const sabStarts = new SharedArrayBuffer(startsBytes);
390
- const sabLengths = new SharedArrayBuffer(lengthsBytes);
391
- new Uint32Array(sabIds).set(ids);
392
- new Uint32Array(sabStarts).set(starts);
393
- new Uint32Array(sabLengths).set(lengths);
358
+ else if (evt.type === 'styles') {
359
+ // Streaming pre-pass resolved styles + voids after its main scan.
360
+ // Push them into every worker, then drain any chunks that were
361
+ // held waiting for styles. Workers will process every chunk with
362
+ // resolved colors — uniform shading across the whole stream.
363
+ const styleIds = evt.styleIds;
364
+ const styleColors = evt.styleColors;
365
+ const voidKeys = evt.voidKeys;
366
+ const voidCounts = evt.voidCounts;
367
+ const voidValues = evt.voidValues;
368
+ console.log(`[stream] styles @ ${elapsed()}ms (${styleIds.length} styled, ${voidKeys.length} void hosts), draining ${queuedChunks.length} queued chunks`);
394
369
  for (const w of workers) {
370
+ // Slice each typed array per-worker so each can be in its own
371
+ // transfer list without conflict. The slice cost is bounded by
372
+ // `styleIds.length * 4` bytes — under 1 MB for ~250K styles.
395
373
  try {
374
+ const sIds = styleIds.slice();
375
+ const sColors = styleColors.slice();
376
+ const vKeys = voidKeys.slice();
377
+ const vCounts = voidCounts.slice();
378
+ const vValues = voidValues.slice();
396
379
  w.postMessage({
397
- type: 'set-entity-index',
398
- ids: new Uint32Array(sabIds),
399
- starts: new Uint32Array(sabStarts),
400
- lengths: new Uint32Array(sabLengths),
401
- });
402
- }
403
- catch (err) {
404
- console.warn('[stream] set-entity-index dispatch failed:', err);
405
- }
406
- }
407
- // Hand the same SAB triple to the parser worker (or any other
408
- // listener) so it can skip its own `scanEntitiesFastBytes` call.
409
- // Each consumer gets its own Uint32Array view over the shared
410
- // buffers — no extra copy.
411
- if (options?.onEntityIndex) {
412
- try {
413
- options.onEntityIndex(new Uint32Array(sabIds), new Uint32Array(sabStarts), new Uint32Array(sabLengths));
380
+ type: 'set-styles',
381
+ styleIds: sIds,
382
+ styleColors: sColors,
383
+ voidKeys: vKeys,
384
+ voidCounts: vCounts,
385
+ voidValues: vValues,
386
+ }, [sIds.buffer, sColors.buffer, vKeys.buffer, vCounts.buffer, vValues.buffer]);
414
387
  }
415
388
  catch (err) {
416
- console.warn('[stream] onEntityIndex callback failed:', err);
389
+ console.warn('[stream] set-styles dispatch failed:', err);
417
390
  }
418
391
  }
392
+ stylesReceived = true;
393
+ // Drain only when ALL gates are open (entity-index too). The
394
+ // worker's tail-promise serialiser ensures any set-* runs
395
+ // before any subsequent stream-chunk.
396
+ drainQueuedChunksIfReady();
419
397
  }
420
- else {
421
- // SAB unavailable clone per worker via structured clone.
422
- for (const w of workers) {
423
- try {
424
- w.postMessage({
425
- type: 'set-entity-index',
426
- ids: ids.slice(),
427
- starts: starts.slice(),
428
- lengths: lengths.slice(),
429
- });
398
+ else if (evt.type === 'entity-index') {
399
+ // Pre-pass exported its built entity_index. Forward to every
400
+ // worker so they skip the ~5 s file re-scan in Rust's lazy
401
+ // build path. SAB sharing for zero-copy distribution to N
402
+ // workers — each gets a Uint32Array view over the same buffer.
403
+ const ids = evt.ids;
404
+ const starts = evt.starts;
405
+ const lengths = evt.lengths;
406
+ console.log(`[stream] entity-index @ ${elapsed()}ms (${ids.length} entries)`);
407
+ if (typeof SharedArrayBuffer !== 'undefined') {
408
+ // Allocate one SAB triple, copy data once, share across all
409
+ // workers without postMessage clone cost.
410
+ const idsBytes = ids.byteLength;
411
+ const startsBytes = starts.byteLength;
412
+ const lengthsBytes = lengths.byteLength;
413
+ const sabIds = new SharedArrayBuffer(idsBytes);
414
+ const sabStarts = new SharedArrayBuffer(startsBytes);
415
+ const sabLengths = new SharedArrayBuffer(lengthsBytes);
416
+ new Uint32Array(sabIds).set(ids);
417
+ new Uint32Array(sabStarts).set(starts);
418
+ new Uint32Array(sabLengths).set(lengths);
419
+ for (const w of workers) {
420
+ try {
421
+ w.postMessage({
422
+ type: 'set-entity-index',
423
+ ids: new Uint32Array(sabIds),
424
+ starts: new Uint32Array(sabStarts),
425
+ lengths: new Uint32Array(sabLengths),
426
+ });
427
+ }
428
+ catch (err) {
429
+ console.warn('[stream] set-entity-index dispatch failed:', err);
430
+ }
430
431
  }
431
- catch (err) {
432
- console.warn('[stream] set-entity-index dispatch failed:', err);
432
+ // Hand the same SAB triple to the parser worker (or any other
433
+ // listener) so it can skip its own `scanEntitiesFastBytes` call.
434
+ // Each consumer gets its own Uint32Array view over the shared
435
+ // buffers — no extra copy.
436
+ if (options?.onEntityIndex) {
437
+ try {
438
+ options.onEntityIndex(new Uint32Array(sabIds), new Uint32Array(sabStarts), new Uint32Array(sabLengths));
439
+ }
440
+ catch (err) {
441
+ console.warn('[stream] onEntityIndex callback failed:', err);
442
+ }
433
443
  }
434
444
  }
435
- if (options?.onEntityIndex) {
436
- try {
437
- options.onEntityIndex(ids.slice(), starts.slice(), lengths.slice());
445
+ else {
446
+ // SAB unavailable — clone per worker via structured clone.
447
+ for (const w of workers) {
448
+ try {
449
+ w.postMessage({
450
+ type: 'set-entity-index',
451
+ ids: ids.slice(),
452
+ starts: starts.slice(),
453
+ lengths: lengths.slice(),
454
+ });
455
+ }
456
+ catch (err) {
457
+ console.warn('[stream] set-entity-index dispatch failed:', err);
458
+ }
438
459
  }
439
- catch (err) {
440
- console.warn('[stream] onEntityIndex callback failed:', err);
460
+ if (options?.onEntityIndex) {
461
+ try {
462
+ options.onEntityIndex(ids.slice(), starts.slice(), lengths.slice());
463
+ }
464
+ catch (err) {
465
+ console.warn('[stream] onEntityIndex callback failed:', err);
466
+ }
441
467
  }
442
468
  }
469
+ entityIndexReceived = true;
470
+ drainQueuedChunksIfReady();
443
471
  }
444
- entityIndexReceived = true;
445
- drainQueuedChunksIfReady();
446
- }
447
- else if (evt.type === 'complete') {
448
- prepassJobsTotal = evt.totalJobs;
449
- console.log(`[stream] prepass complete @ ${elapsed()}ms totalJobs=${prepassJobsTotal} chunks=${chunkArrivals}`);
450
- // Unconditionally drive the prepass-complete handler here.
451
- // The outer loop's `prepassJobsTotal > 0` gate would skip
452
- // zero-geometry files (no IFC geometry entities), causing
453
- // the generator to wait forever. Calling here ensures
454
- // prepassDone flips even when totalJobs === 0.
455
- if (!prepassCompleteSeen) {
456
- prepassCompleteSeen = true;
457
- onPrepassComplete();
472
+ else if (evt.type === 'complete') {
473
+ prepassJobsTotal = evt.totalJobs;
474
+ console.log(`[stream] prepass complete @ ${elapsed()}ms totalJobs=${prepassJobsTotal} chunks=${chunkArrivals}`);
475
+ // Unconditionally drive the prepass-complete handler here.
476
+ // The outer loop's `prepassJobsTotal > 0` gate would skip
477
+ // zero-geometry files (no IFC geometry entities), causing
478
+ // the generator to wait forever. Calling here ensures
479
+ // prepassDone flips even when totalJobs === 0.
480
+ if (!prepassCompleteSeen) {
481
+ prepassCompleteSeen = true;
482
+ onPrepassComplete();
483
+ }
458
484
  }
485
+ return;
459
486
  }
460
- return;
461
- }
462
- if (data.type === 'error') {
463
- prepassError = new Error(data.message);
487
+ if (data.type === 'error') {
488
+ prepassError = new Error(data.message);
489
+ prepassDone = true;
490
+ prepassWorker.terminate();
491
+ wake();
492
+ return;
493
+ }
494
+ // The streaming variant doesn't emit `prepass-result` — the streaming
495
+ // worker exits naturally after the JS callback returns from
496
+ // `buildPrePassStreaming`. We treat unknown messages as no-ops.
497
+ };
498
+ prepassWorker.onerror = (e) => {
499
+ prepassError = new Error(`Pre-pass worker failed: ${e.message}`);
464
500
  prepassDone = true;
465
501
  prepassWorker.terminate();
466
502
  wake();
467
- return;
468
- }
469
- // The streaming variant doesn't emit `prepass-result` the streaming
470
- // worker exits naturally after the JS callback returns from
471
- // `buildPrePassStreaming`. We treat unknown messages as no-ops.
472
- };
473
- prepassWorker.onerror = (e) => {
474
- prepassError = new Error(`Pre-pass worker failed: ${e.message}`);
475
- prepassDone = true;
476
- prepassWorker.terminate();
477
- wake();
478
- };
479
- // Track when the pre-pass worker finishes by listening for either a
480
- // synthesized "complete" event from the Rust side OR a worker exit. The
481
- // Rust side currently doesn't post anything after `complete` (it returns
482
- // from JS), so we close the worker via terminate-on-complete in the host.
483
- // After we see the Rust `complete` event we can sendStreamEnd.
484
- const onPrepassComplete = () => {
485
- prepassDone = true;
486
- // Only signal stream-end to workers if they actually got
487
- // stream-start (which gates on `meta`). Zero-geometry files
488
- // never trigger meta workers never start no stream-end
489
- // needed. The dedicated zero-jobs branch in the outer loop
490
- // handles their teardown.
491
- if (streamStartSentToWorkers) {
492
- sendStreamEnd();
493
- }
494
- prepassWorker.terminate();
495
- wake();
496
- };
497
- // Dispatch the streaming pre-pass.
498
- // chunk_size = 50K is a deliberate compromise:
499
- // small enough that the FIRST chunk (always a tiny one — bounded by
500
- // RTC_SAMPLE_THRESHOLD 50 jobs from the Rust side) reaches workers
501
- // within ~1.5 s for fast TTFG;
502
- // large enough that subsequent chunks make few Rust→JS callbacks
503
- // and few worker postMessages — each call into processGeometryBatch
504
- // has fixed setup cost that compounds badly when invoked 30+ times.
505
- // Per-chunk fan-out (see `dispatchJobsChunkInternal`) splits each chunk
506
- // evenly across all workers so parallelism is preserved at every chunk.
507
- prepassWorker.postMessage({ type: 'prepass-streaming', sharedBuffer, chunkSize: 50_000 });
508
- // Drain the event queue until the pre-pass and all process workers complete.
509
- // The pre-pass `complete` event is captured inside the message handler
510
- // (we set prepassJobsTotal there) but the worker stays alive briefly
511
- // while the JS callback returns. Detect end-of-stream by:
512
- // a) `prepassJobsTotal > 0` (or zero-jobs file): pre-pass emitted complete
513
- // b) all workers reported `complete`
514
- let prepassCompleteSeen = false;
515
- while (true) {
516
- while (eventQueue.length > 0) {
517
- yield eventQueue.shift();
518
- }
519
- if (workerError) {
520
- for (const w of workers) {
503
+ };
504
+ // Track when the pre-pass worker finishes by listening for either a
505
+ // synthesized "complete" event from the Rust side OR a worker exit. The
506
+ // Rust side currently doesn't post anything after `complete` (it returns
507
+ // from JS), so we close the worker via terminate-on-complete in the host.
508
+ // After we see the Rust `complete` event we can sendStreamEnd.
509
+ const onPrepassComplete = () => {
510
+ prepassDone = true;
511
+ // Only signal stream-end to workers if they actually got
512
+ // stream-start (which gates on `meta`). Zero-geometry files
513
+ // never trigger meta → workers never start → no stream-end
514
+ // needed. The dedicated zero-jobs branch in the outer loop
515
+ // handles their teardown.
516
+ if (streamStartSentToWorkers) {
517
+ sendStreamEnd();
518
+ }
519
+ prepassWorker.terminate();
520
+ wake();
521
+ };
522
+ // Dispatch the streaming pre-pass.
523
+ // chunk_size = 50K is a deliberate compromise:
524
+ // small enough that the FIRST chunk (always a tiny one — bounded by
525
+ // RTC_SAMPLE_THRESHOLD 50 jobs from the Rust side) reaches workers
526
+ // within ~1.5 s for fast TTFG;
527
+ // • large enough that subsequent chunks make few Rust→JS callbacks
528
+ // and few worker postMessages — each call into processGeometryBatch
529
+ // has fixed setup cost that compounds badly when invoked 30+ times.
530
+ // Per-chunk fan-out (see `dispatchJobsChunkInternal`) splits each chunk
531
+ // evenly across all workers so parallelism is preserved at every chunk.
532
+ prepassWorker.postMessage({ type: 'prepass-streaming', sharedBuffer, chunkSize: 50_000 });
533
+ // Drain the event queue until the pre-pass and all process workers complete.
534
+ // The pre-pass `complete` event is captured inside the message handler
535
+ // (we set prepassJobsTotal there) but the worker stays alive briefly
536
+ // while the JS callback returns. Detect end-of-stream by:
537
+ // a) `prepassJobsTotal > 0` (or zero-jobs file): pre-pass emitted complete
538
+ // b) all workers reported `complete`
539
+ let prepassCompleteSeen = false;
540
+ while (true) {
541
+ while (eventQueue.length > 0) {
542
+ yield eventQueue.shift();
543
+ }
544
+ if (workerError) {
545
+ for (const w of workers) {
546
+ try {
547
+ w.terminate();
548
+ }
549
+ catch { /* cleanup — safe to ignore */ }
550
+ }
521
551
  try {
522
- w.terminate();
552
+ prepassWorker.terminate();
523
553
  }
524
554
  catch { /* cleanup — safe to ignore */ }
555
+ throw workerError;
525
556
  }
526
- try {
527
- prepassWorker.terminate();
557
+ if (prepassError) {
558
+ for (const w of workers) {
559
+ try {
560
+ w.terminate();
561
+ }
562
+ catch { /* cleanup — safe to ignore */ }
563
+ }
564
+ throw prepassError;
528
565
  }
529
- catch { /* cleanup safe to ignore */ }
530
- throw workerError;
531
- }
532
- if (prepassError) {
533
- for (const w of workers) {
534
- try {
535
- w.terminate();
566
+ // Edge case: pre-pass for a file with zero geometry. The Rust side
567
+ // emits `complete { totalJobs: 0 }`; meta never fired so workers
568
+ // never received stream-start. Tear them down explicitly and yield
569
+ // `complete`. Workers were pre-spawned with `init` so they need an
570
+ // explicit terminate to exit.
571
+ if (prepassDone && !streamStartSentToWorkers && prepassJobsTotal === 0) {
572
+ for (const w of workers) {
573
+ try {
574
+ w.terminate();
575
+ }
576
+ catch { /* cleanup — safe to ignore */ }
536
577
  }
537
- catch { /* cleanup — safe to ignore */ }
578
+ const coordinateInfo = coordinator.getFinalCoordinateInfo();
579
+ yield { type: 'complete', totalMeshes: 0, coordinateInfo };
580
+ return;
538
581
  }
539
- throw prepassError;
582
+ if (prepassDone
583
+ && streamStartSentToWorkers
584
+ && workersCompleted >= workers.length
585
+ && eventQueue.length === 0) {
586
+ break;
587
+ }
588
+ await new Promise((resolve) => { resolveWaiting = resolve; });
540
589
  }
541
- // Edge case: pre-pass for a file with zero geometry. The Rust side
542
- // emits `complete { totalJobs: 0 }`; meta never fired so workers
543
- // never received stream-start. Tear them down explicitly and yield
544
- // `complete`. Workers were pre-spawned with `init` so they need an
545
- // explicit terminate to exit.
546
- if (prepassDone && !streamStartSentToWorkers && prepassJobsTotal === 0) {
547
- for (const w of workers) {
548
- try {
549
- w.terminate();
550
- }
551
- catch { /* cleanup — safe to ignore */ }
590
+ const coordinateInfo = coordinator.getFinalCoordinateInfo();
591
+ yield { type: 'complete', totalMeshes, coordinateInfo };
592
+ }
593
+ finally {
594
+ for (const w of workers) {
595
+ try {
596
+ w.terminate();
552
597
  }
553
- const coordinateInfo = coordinator.getFinalCoordinateInfo();
554
- yield { type: 'complete', totalMeshes: 0, coordinateInfo };
555
- return;
598
+ catch { /* cleanup — safe to ignore */ }
556
599
  }
557
- if (prepassDone
558
- && streamStartSentToWorkers
559
- && workersCompleted >= workers.length
560
- && eventQueue.length === 0) {
561
- break;
600
+ try {
601
+ prepassWorker.terminate();
562
602
  }
563
- await new Promise((resolve) => { resolveWaiting = resolve; });
603
+ catch { /* cleanup safe to ignore */ }
564
604
  }
565
- const coordinateInfo = coordinator.getFinalCoordinateInfo();
566
- yield { type: 'complete', totalMeshes, coordinateInfo };
567
605
  }
568
606
  //# sourceMappingURL=geometry-parallel.js.map