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