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