@ifc-lite/geometry 1.16.2 → 1.16.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -42,16 +42,32 @@ export function calculateDynamicBatchSize(batchNumber, initialBatchSize = 50, ma
42
42
  return maxBatchSize; // Full throughput earlier
43
43
  }
44
44
  }
45
+ const MAX_NATIVE_STREAM_QUEUE_EVENTS = 8;
46
+ const MAX_NATIVE_STREAM_QUEUE_MESHES = 32768;
47
+ const MAX_NATIVE_STREAM_EVENTS_PER_TURN = 4;
48
+ const MAX_NATIVE_STREAM_MESHES_PER_TURN = 8192;
49
+ const MAX_NATIVE_STREAM_DRAIN_MS = 10;
50
+ function yieldToEventLoop() {
51
+ const maybeScheduler = globalThis.scheduler;
52
+ if (typeof maybeScheduler?.yield === 'function') {
53
+ return maybeScheduler.yield();
54
+ }
55
+ return new Promise((resolve) => {
56
+ globalThis.setTimeout(resolve, 0);
57
+ });
58
+ }
45
59
  export class GeometryProcessor {
60
+ static largeFileByteStreamingThreshold = 256 * 1024 * 1024;
46
61
  bridge = null;
47
62
  platformBridge = null;
48
63
  bufferBuilder;
49
64
  coordinateHandler;
50
65
  isNative = false;
66
+ lastNativeStats = null;
51
67
  constructor(options = {}) {
52
68
  this.bufferBuilder = new BufferBuilder();
53
69
  this.coordinateHandler = new CoordinateHandler();
54
- this.isNative = isTauri();
70
+ this.isNative = options.preferNative !== false && isTauri();
55
71
  // Note: options accepted for API compatibility
56
72
  void options.quality;
57
73
  if (!this.isNative) {
@@ -88,9 +104,7 @@ export class GeometryProcessor {
88
104
  if (this.isNative && this.platformBridge) {
89
105
  // NATIVE PATH - Use Tauri commands
90
106
  console.time('[GeometryProcessor] native-processing');
91
- const decoder = new TextDecoder();
92
- const content = decoder.decode(buffer);
93
- const result = await this.platformBridge.processGeometry(content);
107
+ const result = await this.platformBridge.processGeometry(buffer);
94
108
  meshes = result.meshes;
95
109
  console.timeEnd('[GeometryProcessor] native-processing');
96
110
  }
@@ -132,6 +146,30 @@ export class GeometryProcessor {
132
146
  };
133
147
  return result;
134
148
  }
149
+ /**
150
+ * Process IFC geometry directly from a filesystem path in native desktop
151
+ * hosts. This avoids copying IFC content through JS when the host already
152
+ * has the file path.
153
+ */
154
+ async processPath(path) {
155
+ if (!this.isNative) {
156
+ throw new Error('Path-based geometry processing is only available in native desktop builds');
157
+ }
158
+ if (!this.platformBridge) {
159
+ await this.init();
160
+ }
161
+ if (!this.platformBridge?.processGeometryPath) {
162
+ throw new Error('Native platform bridge does not support file-path geometry processing');
163
+ }
164
+ const result = await this.platformBridge.processGeometryPath(path);
165
+ const coordinateInfo = this.coordinateHandler.processMeshes(result.meshes);
166
+ return {
167
+ meshes: result.meshes,
168
+ totalTriangles: result.totalTriangles,
169
+ totalVertices: result.totalVertices,
170
+ coordinateInfo,
171
+ };
172
+ }
135
173
  /**
136
174
  * Collect meshes on main thread using IFC-Lite WASM
137
175
  */
@@ -147,6 +185,182 @@ export class GeometryProcessor {
147
185
  const buildingRotation = collector.getBuildingRotation();
148
186
  return { meshes, buildingRotation };
149
187
  }
188
+ getStreamingBatchSize(buffer, batchConfig) {
189
+ if (typeof batchConfig === 'number') {
190
+ return batchConfig;
191
+ }
192
+ const fileSizeMB = batchConfig.fileSizeMB
193
+ ? batchConfig.fileSizeMB
194
+ : buffer.length / (1024 * 1024);
195
+ return fileSizeMB < 10 ? 100
196
+ : fileSizeMB < 50 ? 200
197
+ : fileSizeMB < 100 ? 300
198
+ : fileSizeMB < 300 ? 500
199
+ : fileSizeMB < 500 ? 1500
200
+ : 3000;
201
+ }
202
+ convertMeshCollectionToBatch(collection) {
203
+ const batch = [];
204
+ try {
205
+ for (let i = 0; i < collection.length; i++) {
206
+ const mesh = collection.get(i);
207
+ if (!mesh)
208
+ continue;
209
+ try {
210
+ batch.push({
211
+ expressId: mesh.expressId,
212
+ ifcType: mesh.ifcType,
213
+ positions: mesh.positions,
214
+ normals: mesh.normals,
215
+ indices: mesh.indices,
216
+ color: [mesh.color[0], mesh.color[1], mesh.color[2], mesh.color[3]],
217
+ });
218
+ }
219
+ finally {
220
+ mesh.free();
221
+ }
222
+ }
223
+ }
224
+ finally {
225
+ collection.free();
226
+ }
227
+ return batch;
228
+ }
229
+ withBuildingRotation(coordinateInfo, buildingRotation) {
230
+ return buildingRotation !== undefined
231
+ ? { ...coordinateInfo, buildingRotation }
232
+ : coordinateInfo;
233
+ }
234
+ convertInstancedCollectionToBatch(collection) {
235
+ const batch = [];
236
+ try {
237
+ for (let i = 0; i < collection.length; i++) {
238
+ const geometry = collection.get(i);
239
+ if (geometry) {
240
+ batch.push(geometry);
241
+ }
242
+ }
243
+ }
244
+ finally {
245
+ collection.free();
246
+ }
247
+ return batch;
248
+ }
249
+ async *processStreamingBytes(buffer, batchConfig) {
250
+ if (!this.bridge) {
251
+ throw new Error('WASM bridge not initialized');
252
+ }
253
+ const api = this.bridge.getApi();
254
+ const prePass = api.buildPrePassOnce(buffer);
255
+ yield { type: 'model-open', modelID: 0 };
256
+ if (prePass.rtcOffset) {
257
+ yield {
258
+ type: 'rtcOffset',
259
+ rtcOffset: {
260
+ x: prePass.rtcOffset[0] ?? 0,
261
+ y: prePass.rtcOffset[1] ?? 0,
262
+ z: prePass.rtcOffset[2] ?? 0,
263
+ },
264
+ hasRtc: Boolean(prePass.needsShift),
265
+ };
266
+ }
267
+ const buildingRotation = prePass.buildingRotation ?? undefined;
268
+ if (!prePass.jobs || prePass.totalJobs === 0) {
269
+ const coordinateInfo = this.withBuildingRotation(this.coordinateHandler.getFinalCoordinateInfo(), buildingRotation);
270
+ yield { type: 'complete', totalMeshes: 0, coordinateInfo };
271
+ return;
272
+ }
273
+ const batchSize = this.getStreamingBatchSize(buffer, batchConfig);
274
+ let totalMeshes = 0;
275
+ for (let startJob = 0; startJob < prePass.totalJobs; startJob += batchSize) {
276
+ const endJob = Math.min(startJob + batchSize, prePass.totalJobs);
277
+ const jobSlice = prePass.jobs.slice(startJob * 3, endJob * 3);
278
+ const collection = api.processGeometryBatch(buffer, jobSlice, prePass.unitScale, prePass.rtcOffset?.[0] ?? 0, prePass.rtcOffset?.[1] ?? 0, prePass.rtcOffset?.[2] ?? 0, prePass.needsShift, prePass.voidKeys, prePass.voidCounts, prePass.voidValues, prePass.styleIds, prePass.styleColors);
279
+ const batch = this.convertMeshCollectionToBatch(collection);
280
+ if (batch.length === 0) {
281
+ await new Promise(resolve => setTimeout(resolve, 0));
282
+ continue;
283
+ }
284
+ this.coordinateHandler.processMeshesIncremental(batch);
285
+ totalMeshes += batch.length;
286
+ const currentCoordinateInfo = this.coordinateHandler.getCurrentCoordinateInfo();
287
+ const coordinateInfo = currentCoordinateInfo
288
+ ? this.withBuildingRotation(currentCoordinateInfo, buildingRotation)
289
+ : null;
290
+ yield {
291
+ type: 'batch',
292
+ meshes: batch,
293
+ totalSoFar: totalMeshes,
294
+ coordinateInfo: coordinateInfo || undefined,
295
+ };
296
+ await new Promise(resolve => setTimeout(resolve, 0));
297
+ }
298
+ const coordinateInfo = this.withBuildingRotation(this.coordinateHandler.getFinalCoordinateInfo(), buildingRotation);
299
+ yield { type: 'complete', totalMeshes, coordinateInfo };
300
+ }
301
+ async *processInstancedStreamingBytes(buffer, batchSize) {
302
+ if (!this.bridge) {
303
+ throw new Error('WASM bridge not initialized');
304
+ }
305
+ const api = this.bridge.getApi();
306
+ const prePass = api.buildPrePassOnce(buffer);
307
+ const buildingRotation = prePass.buildingRotation ?? undefined;
308
+ yield { type: 'model-open', modelID: 0 };
309
+ if (!prePass.jobs || prePass.totalJobs === 0) {
310
+ const coordinateInfo = this.withBuildingRotation(this.coordinateHandler.getFinalCoordinateInfo(), buildingRotation);
311
+ yield { type: 'complete', totalGeometries: 0, totalInstances: 0, coordinateInfo };
312
+ return;
313
+ }
314
+ let totalGeometries = 0;
315
+ let totalInstances = 0;
316
+ for (let startJob = 0; startJob < prePass.totalJobs; startJob += batchSize) {
317
+ const endJob = Math.min(startJob + batchSize, prePass.totalJobs);
318
+ const jobSlice = prePass.jobs.slice(startJob * 3, endJob * 3);
319
+ const collection = api.processInstancedGeometryBatch(buffer, jobSlice, prePass.unitScale, prePass.rtcOffset?.[0] ?? 0, prePass.rtcOffset?.[1] ?? 0, prePass.rtcOffset?.[2] ?? 0, prePass.needsShift, prePass.styleIds, prePass.styleColors);
320
+ const batch = this.convertInstancedCollectionToBatch(collection);
321
+ if (batch.length === 0) {
322
+ await new Promise(resolve => setTimeout(resolve, 0));
323
+ continue;
324
+ }
325
+ const meshDataBatch = [];
326
+ for (const geom of batch) {
327
+ const positions = geom.positions;
328
+ const normals = geom.normals;
329
+ const indices = geom.indices;
330
+ if (geom.instance_count > 0) {
331
+ const firstInstance = geom.get_instance(0);
332
+ if (firstInstance) {
333
+ const color = firstInstance.color;
334
+ meshDataBatch.push({
335
+ expressId: firstInstance.expressId,
336
+ positions,
337
+ normals,
338
+ indices,
339
+ color: [color[0], color[1], color[2], color[3]],
340
+ });
341
+ }
342
+ }
343
+ }
344
+ if (meshDataBatch.length > 0) {
345
+ this.coordinateHandler.processMeshesIncremental(meshDataBatch);
346
+ }
347
+ totalGeometries += batch.length;
348
+ totalInstances += batch.reduce((sum, geometry) => sum + geometry.instance_count, 0);
349
+ const currentCoordinateInfo = this.coordinateHandler.getCurrentCoordinateInfo();
350
+ const coordinateInfo = currentCoordinateInfo
351
+ ? this.withBuildingRotation(currentCoordinateInfo, buildingRotation)
352
+ : null;
353
+ yield {
354
+ type: 'batch',
355
+ geometries: batch,
356
+ totalSoFar: totalGeometries,
357
+ coordinateInfo: coordinateInfo || undefined,
358
+ };
359
+ await new Promise(resolve => setTimeout(resolve, 0));
360
+ }
361
+ const coordinateInfo = this.withBuildingRotation(this.coordinateHandler.getFinalCoordinateInfo(), buildingRotation);
362
+ yield { type: 'complete', totalGeometries, totalInstances, coordinateInfo };
363
+ }
150
364
  /**
151
365
  * Process IFC file with streaming output for progressive rendering
152
366
  * Uses native Rust in Tauri, WASM in browser
@@ -168,23 +382,75 @@ export class GeometryProcessor {
168
382
  this.coordinateHandler.reset();
169
383
  // Yield start event FIRST so UI can update before heavy processing
170
384
  yield { type: 'start', totalEstimate: buffer.length / 1000 };
171
- // Yield to main thread before heavy decode operation
385
+ // Yield to main thread before heavy processing begins
172
386
  await new Promise(resolve => setTimeout(resolve, 0));
173
- // Convert buffer to string (IFC files are text)
174
- const decoder = new TextDecoder();
175
- const content = decoder.decode(buffer);
176
- yield { type: 'model-open', modelID: 0 };
177
387
  if (this.isNative && this.platformBridge) {
388
+ yield { type: 'model-open', modelID: 0 };
178
389
  // NATIVE PATH - Use Tauri streaming
179
390
  console.time('[GeometryProcessor] native-streaming');
180
- // For native, we do a single batch for now (streaming via events is complex)
181
- // TODO: Implement proper streaming with Tauri events
182
- const result = await this.platformBridge.processGeometry(content);
183
- const totalMeshes = result.meshes.length;
184
- this.coordinateHandler.processMeshesIncremental(result.meshes);
391
+ const queuedEvents = [];
392
+ let resolvePending = null;
393
+ let completed = false;
394
+ let streamError = null;
395
+ let completedTotalMeshes;
396
+ let totalMeshes = 0;
397
+ const wake = () => {
398
+ if (resolvePending) {
399
+ resolvePending();
400
+ resolvePending = null;
401
+ }
402
+ };
403
+ const streamingPromise = this.platformBridge.processGeometryStreaming(buffer, {
404
+ onBatch: (batch) => {
405
+ queuedEvents.push({ type: 'batch', meshes: batch.meshes, nativeTelemetry: batch.nativeTelemetry });
406
+ wake();
407
+ },
408
+ onColorUpdate: (updates) => {
409
+ queuedEvents.push({ type: 'colorUpdate', updates: new Map(updates) });
410
+ wake();
411
+ },
412
+ onComplete: (stats) => {
413
+ this.lastNativeStats = stats;
414
+ completedTotalMeshes = stats.totalMeshes;
415
+ completed = true;
416
+ wake();
417
+ },
418
+ onError: (error) => {
419
+ streamError = error;
420
+ completed = true;
421
+ wake();
422
+ },
423
+ });
424
+ while (!completed || queuedEvents.length > 0) {
425
+ while (queuedEvents.length > 0) {
426
+ const event = queuedEvents.shift();
427
+ if (event.type === 'colorUpdate') {
428
+ yield { type: 'colorUpdate', updates: event.updates };
429
+ continue;
430
+ }
431
+ this.coordinateHandler.processMeshesIncremental(event.meshes);
432
+ totalMeshes += event.meshes.length;
433
+ const coordinateInfo = this.coordinateHandler.getCurrentCoordinateInfo();
434
+ yield {
435
+ type: 'batch',
436
+ meshes: event.meshes,
437
+ totalSoFar: totalMeshes,
438
+ coordinateInfo: coordinateInfo || undefined,
439
+ nativeTelemetry: event.nativeTelemetry,
440
+ };
441
+ }
442
+ if (streamError) {
443
+ throw streamError;
444
+ }
445
+ if (!completed) {
446
+ await new Promise((resolve) => {
447
+ resolvePending = resolve;
448
+ });
449
+ }
450
+ }
451
+ await streamingPromise;
185
452
  const coordinateInfo = this.coordinateHandler.getFinalCoordinateInfo();
186
- yield { type: 'batch', meshes: result.meshes, totalSoFar: totalMeshes, coordinateInfo: coordinateInfo || undefined };
187
- yield { type: 'complete', totalMeshes, coordinateInfo };
453
+ yield { type: 'complete', totalMeshes: completedTotalMeshes ?? totalMeshes, coordinateInfo };
188
454
  console.timeEnd('[GeometryProcessor] native-streaming');
189
455
  }
190
456
  else {
@@ -192,19 +458,18 @@ export class GeometryProcessor {
192
458
  if (!this.bridge) {
193
459
  throw new Error('WASM bridge not initialized');
194
460
  }
461
+ if (buffer.length >= GeometryProcessor.largeFileByteStreamingThreshold) {
462
+ yield* this.processStreamingBytes(buffer, batchConfig);
463
+ return;
464
+ }
465
+ // Convert buffer to string (IFC files are text)
466
+ const decoder = new TextDecoder();
467
+ const content = decoder.decode(buffer);
468
+ yield { type: 'model-open', modelID: 0 };
195
469
  const collector = new IfcLiteMeshCollector(this.bridge.getApi(), content);
196
470
  let totalMeshes = 0;
197
471
  let extractedBuildingRotation = undefined;
198
- // Determine optimal WASM batch size based on file size
199
- // Larger batches = fewer callbacks = faster processing
200
- const fileSizeMB = typeof batchConfig !== 'number' && batchConfig.fileSizeMB
201
- ? batchConfig.fileSizeMB
202
- : buffer.length / (1024 * 1024);
203
- // Use WASM batches directly - no JS accumulation layer
204
- // WASM already prioritizes simple geometry (walls, slabs) for fast first frame
205
- // PERF: Larger batches dramatically reduce WASM↔JS boundary crossing overhead.
206
- // 487MB file: batch 500→1500 cut WASM wait from 79s to 39s.
207
- const wasmBatchSize = fileSizeMB < 10 ? 100 : fileSizeMB < 50 ? 200 : fileSizeMB < 100 ? 300 : fileSizeMB < 300 ? 500 : fileSizeMB < 500 ? 1500 : 3000;
472
+ const wasmBatchSize = this.getStreamingBatchSize(buffer, batchConfig);
208
473
  // Use WASM batches directly for maximum throughput
209
474
  for await (const item of collector.collectMeshesStreaming(wasmBatchSize)) {
210
475
  // Handle color update events
@@ -239,6 +504,34 @@ export class GeometryProcessor {
239
504
  yield { type: 'complete', totalMeshes, coordinateInfo: finalCoordinateInfo };
240
505
  }
241
506
  }
507
+ /**
508
+ * Stream geometry directly from a filesystem path in native desktop hosts.
509
+ * This avoids copying very large IFC files through JS and Tauri IPC.
510
+ */
511
+ async *processStreamingPath(path, estimatedBytes = 0, cacheKey) {
512
+ if (!this.isNative) {
513
+ throw new Error('File-path geometry streaming is only available in native desktop builds');
514
+ }
515
+ if (!this.platformBridge) {
516
+ await this.init();
517
+ }
518
+ if (!this.platformBridge?.processGeometryStreamingPath) {
519
+ throw new Error('Native platform bridge does not support file-path streaming');
520
+ }
521
+ yield* this.streamNativeGeometry((options) => this.platformBridge.processGeometryStreamingPath(path, options, cacheKey), estimatedBytes > 0 ? estimatedBytes / 1000 : 0);
522
+ }
523
+ async *processStreamingCache(cacheKey) {
524
+ if (!this.isNative) {
525
+ throw new Error('Native cached geometry streaming is only available in native desktop builds');
526
+ }
527
+ if (!this.platformBridge) {
528
+ await this.init();
529
+ }
530
+ if (!this.platformBridge?.processGeometryStreamingCache) {
531
+ throw new Error('Native platform bridge does not support cached geometry streaming');
532
+ }
533
+ yield* this.streamNativeGeometry((options) => this.platformBridge.processGeometryStreamingCache(cacheKey, options), 0);
534
+ }
242
535
  /**
243
536
  * Process IFC file with streaming instanced geometry output for progressive rendering
244
537
  * Groups identical geometries by hash (before transformation) for GPU instancing
@@ -261,6 +554,15 @@ export class GeometryProcessor {
261
554
  // Reset coordinate handler for new file
262
555
  this.coordinateHandler.reset();
263
556
  yield { type: 'start', totalEstimate: buffer.length / 1000 };
557
+ // Adapt batch size for large files to reduce callback overhead
558
+ // Larger batches = fewer callbacks = less overhead for huge models
559
+ const fileSizeMB = buffer.length / (1024 * 1024);
560
+ const effectiveBatchSize = fileSizeMB < 50 ? batchSize : fileSizeMB < 200 ? Math.max(batchSize, 50) : fileSizeMB < 300 ? Math.max(batchSize, 100) : Math.max(batchSize, 200);
561
+ const byteBatchSize = Math.max(effectiveBatchSize, this.getStreamingBatchSize(buffer, batchSize));
562
+ if (buffer.length >= GeometryProcessor.largeFileByteStreamingThreshold) {
563
+ yield* this.processInstancedStreamingBytes(buffer, byteBatchSize);
564
+ return;
565
+ }
264
566
  // Convert buffer to string (IFC files are text)
265
567
  const decoder = new TextDecoder();
266
568
  const content = decoder.decode(buffer);
@@ -269,10 +571,6 @@ export class GeometryProcessor {
269
571
  const collector = new IfcLiteMeshCollector(this.bridge.getApi(), content);
270
572
  let totalGeometries = 0;
271
573
  let totalInstances = 0;
272
- // Adapt batch size for large files to reduce callback overhead
273
- // Larger batches = fewer callbacks = less overhead for huge models
274
- const fileSizeMB = buffer.length / (1024 * 1024);
275
- const effectiveBatchSize = fileSizeMB < 50 ? batchSize : fileSizeMB < 200 ? Math.max(batchSize, 50) : fileSizeMB < 300 ? Math.max(batchSize, 100) : Math.max(batchSize, 200);
276
574
  for await (const batch of collector.collectInstancedGeometryStreaming(effectiveBatchSize)) {
277
575
  // For instanced geometry, we need to extract mesh data from instances for coordinate handling
278
576
  // Convert InstancedGeometry to MeshData[] for coordinate handler
@@ -526,20 +824,19 @@ export class GeometryProcessor {
526
824
  // Small files: Load all at once (sync)
527
825
  if (buffer.length < sizeThreshold) {
528
826
  yield { type: 'start', totalEstimate: buffer.length / 1000 };
529
- // Convert buffer to string (IFC files are text)
530
- const decoder = new TextDecoder();
531
- const content = decoder.decode(buffer);
532
827
  yield { type: 'model-open', modelID: 0 };
533
828
  let allMeshes;
534
829
  if (this.isNative && this.platformBridge) {
535
830
  // NATIVE PATH - single batch processing
536
831
  console.time('[GeometryProcessor] native-adaptive-sync');
537
- const result = await this.platformBridge.processGeometry(content);
832
+ const result = await this.platformBridge.processGeometry(buffer);
538
833
  allMeshes = result.meshes;
539
834
  console.timeEnd('[GeometryProcessor] native-adaptive-sync');
540
835
  }
541
836
  else {
542
837
  // WASM PATH
838
+ const decoder = new TextDecoder();
839
+ const content = decoder.decode(buffer);
543
840
  const collector = new IfcLiteMeshCollector(this.bridge.getApi(), content);
544
841
  allMeshes = collector.collectMeshes();
545
842
  }
@@ -578,6 +875,128 @@ export class GeometryProcessor {
578
875
  }
579
876
  return this.bridge.getApi();
580
877
  }
878
+ getLastNativeStats() {
879
+ return this.lastNativeStats;
880
+ }
881
+ enqueueNativeStreamingEvent(queuedEvents, event, queueState) {
882
+ if (event.type === 'colorUpdate') {
883
+ const lastEvent = queuedEvents[queuedEvents.length - 1];
884
+ if (lastEvent?.type === 'colorUpdate') {
885
+ for (const [expressId, color] of event.updates) {
886
+ lastEvent.updates.set(expressId, color);
887
+ }
888
+ return;
889
+ }
890
+ queuedEvents.push(event);
891
+ return;
892
+ }
893
+ const lastEvent = queuedEvents[queuedEvents.length - 1];
894
+ const shouldCoalesce = lastEvent?.type === 'batch' &&
895
+ (queuedEvents.length >= MAX_NATIVE_STREAM_QUEUE_EVENTS || queueState.queuedMeshes >= MAX_NATIVE_STREAM_QUEUE_MESHES);
896
+ if (shouldCoalesce) {
897
+ for (let i = 0; i < event.meshes.length; i++) {
898
+ lastEvent.meshes.push(event.meshes[i]);
899
+ }
900
+ lastEvent.nativeTelemetry = event.nativeTelemetry;
901
+ queueState.coalescedBatchCount += 1;
902
+ }
903
+ else {
904
+ queuedEvents.push(event);
905
+ }
906
+ queueState.queuedMeshes += event.meshes.length;
907
+ }
908
+ async *streamNativeGeometry(startStream, totalEstimate) {
909
+ this.coordinateHandler.reset();
910
+ yield { type: 'start', totalEstimate };
911
+ await yieldToEventLoop();
912
+ yield { type: 'model-open', modelID: 0 };
913
+ const queuedEvents = [];
914
+ const queueState = { queuedMeshes: 0, coalescedBatchCount: 0 };
915
+ let resolvePending = null;
916
+ let completed = false;
917
+ let streamError = null;
918
+ let completedTotalMeshes;
919
+ let totalMeshes = 0;
920
+ const wake = () => {
921
+ if (resolvePending) {
922
+ resolvePending();
923
+ resolvePending = null;
924
+ }
925
+ };
926
+ const streamingPromise = startStream({
927
+ onBatch: (batch) => {
928
+ this.enqueueNativeStreamingEvent(queuedEvents, { type: 'batch', meshes: batch.meshes, nativeTelemetry: batch.nativeTelemetry }, queueState);
929
+ wake();
930
+ },
931
+ onColorUpdate: (updates) => {
932
+ this.enqueueNativeStreamingEvent(queuedEvents, { type: 'colorUpdate', updates: new Map(updates) }, queueState);
933
+ wake();
934
+ },
935
+ onComplete: (stats) => {
936
+ this.lastNativeStats = stats;
937
+ completedTotalMeshes = stats.totalMeshes;
938
+ completed = true;
939
+ wake();
940
+ },
941
+ onError: (error) => {
942
+ streamError = error;
943
+ completed = true;
944
+ wake();
945
+ },
946
+ });
947
+ while (!completed || queuedEvents.length > 0) {
948
+ let drainedEventCount = 0;
949
+ let drainedMeshCount = 0;
950
+ let drainStartedAt = performance.now();
951
+ while (queuedEvents.length > 0) {
952
+ const event = queuedEvents.shift();
953
+ if (event.type === 'colorUpdate') {
954
+ yield { type: 'colorUpdate', updates: event.updates };
955
+ continue;
956
+ }
957
+ queueState.queuedMeshes = Math.max(0, queueState.queuedMeshes - event.meshes.length);
958
+ // Native desktop streaming already produces site-local geometry, so
959
+ // avoid the generic JS RTC/outlier scan on every streamed batch.
960
+ this.coordinateHandler.processTrustedMeshesIncremental(event.meshes);
961
+ totalMeshes += event.meshes.length;
962
+ const coordinateInfo = this.coordinateHandler.getCurrentCoordinateInfo();
963
+ yield {
964
+ type: 'batch',
965
+ meshes: event.meshes,
966
+ totalSoFar: totalMeshes,
967
+ coordinateInfo: coordinateInfo || undefined,
968
+ nativeTelemetry: event.nativeTelemetry,
969
+ };
970
+ drainedEventCount += 1;
971
+ drainedMeshCount += event.meshes.length;
972
+ if (queuedEvents.length > 0) {
973
+ const shouldYield = drainedEventCount >= MAX_NATIVE_STREAM_EVENTS_PER_TURN ||
974
+ drainedMeshCount >= MAX_NATIVE_STREAM_MESHES_PER_TURN ||
975
+ performance.now() - drainStartedAt >= MAX_NATIVE_STREAM_DRAIN_MS;
976
+ if (shouldYield) {
977
+ await yieldToEventLoop();
978
+ drainedEventCount = 0;
979
+ drainedMeshCount = 0;
980
+ drainStartedAt = performance.now();
981
+ }
982
+ }
983
+ }
984
+ if (streamError) {
985
+ throw streamError;
986
+ }
987
+ if (!completed) {
988
+ await new Promise((resolve) => {
989
+ resolvePending = resolve;
990
+ });
991
+ }
992
+ }
993
+ await streamingPromise;
994
+ if (queueState.coalescedBatchCount > 0) {
995
+ console.info(`[GeometryProcessor] Coalesced ${queueState.coalescedBatchCount} native batches while JS drained the queue`);
996
+ }
997
+ const coordinateInfo = this.coordinateHandler.getFinalCoordinateInfo();
998
+ yield { type: 'complete', totalMeshes: completedTotalMeshes ?? totalMeshes, coordinateInfo };
999
+ }
581
1000
  /**
582
1001
  * Parse symbolic representations (Plan, Annotation, FootPrint) from IFC content
583
1002
  * These are pre-authored 2D curves for architectural drawings (door swings, window cuts, etc.)