@gridspace/raster-path 1.0.4 → 1.0.6

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.
@@ -0,0 +1,651 @@
1
+ /**
2
+ * ═══════════════════════════════════════════════════════════════════════════
3
+ * Path Radial - Cylindrical Toolpath Generation
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * Generates toolpaths for cylindrical (lathe-like) parts by rasterizing at
7
+ * multiple rotation angles. Uses X-bucketing spatial partitioning and bucket
8
+ * batching to handle large models efficiently.
9
+ *
10
+ * EXPORTS:
11
+ * ────────
12
+ * Functions:
13
+ * - radialRasterize({triangles, bucketData, resolution, angleStep, ...})
14
+ * Rasterize model at multiple angles with X-bucketing
15
+ * - generateRadialToolpaths({triangles, bucketData, toolData, ...})
16
+ * Complete pipeline: rasterize + generate toolpaths for all angles
17
+ *
18
+ * ALGORITHM:
19
+ * ──────────
20
+ * For each angle θ:
21
+ * 1. Rotate coordinate frame by θ around Z-axis
22
+ * 2. For each X-bucket (spatial partition along X-axis):
23
+ * - Cast rays in YZ plane at this rotated angle
24
+ * - Test only triangles in this X-bucket (spatial culling)
25
+ * - Record max Z hit per (X, Y) grid cell
26
+ * 3. Stitch bucket results into complete angle strip
27
+ * 4. Generate toolpath by scanning sparse tool over strip
28
+ *
29
+ * X-BUCKETING:
30
+ * ────────────
31
+ * Triangles are pre-sorted into X-axis buckets (computed on main thread):
32
+ * - Each bucket covers a slice of X-range (e.g., 5mm wide)
33
+ * - Bucket contains indices of triangles overlapping that X-range
34
+ * - GPU processes one bucket at a time, testing only relevant triangles
35
+ * - Reduces triangle tests by ~10-100x depending on model geometry
36
+ *
37
+ * BUCKET BATCHING:
38
+ * ────────────────
39
+ * To avoid GPU timeouts on complex models:
40
+ * - Estimate work per bucket: numTriangles × numAngles × cellsPerBucket
41
+ * - Batch buckets to keep work under ~1M ray-triangle tests per dispatch
42
+ * - Typical: 4-20 buckets per batch, multiple dispatches per angle set
43
+ *
44
+ * ANGLE BATCHING:
45
+ * ───────────────
46
+ * To avoid GPU memory allocation failures:
47
+ * - Calculate memory needed: numAngles × gridWidth × gridHeight × 4 bytes
48
+ * - If exceeds limit (1.8GB), split into angle batches
49
+ * - Reuse triangle/bucket GPU buffers across angle batches
50
+ * - Process angle ranges sequentially (e.g., 0-180°, 180-360°)
51
+ *
52
+ * OUTPUT FORMAT:
53
+ * ──────────────
54
+ * Array of strips, one per angle:
55
+ * [{
56
+ * angle: number, // Rotation angle (degrees)
57
+ * pathData: Float32Array, // Z-heights, row-major
58
+ * numScanlines: number, // Number of Y scanlines
59
+ * pointsPerLine: number, // Number of X samples per scanline
60
+ * terrainBounds: {...} // Bounding box for this strip
61
+ * }, ...]
62
+ *
63
+ * ═══════════════════════════════════════════════════════════════════════════
64
+ */
65
+
66
+ import {
67
+ device, config, cachedRadialBatchPipeline, debug, diagnostic
68
+ } from './raster-config.js';
69
+ import { createSparseToolFromPoints } from './raster-tool.js';
70
+ import {
71
+ createReusableToolpathBuffers,
72
+ destroyReusableToolpathBuffers,
73
+ runToolpathComputeWithBuffers
74
+ } from './path-planar.js';
75
+
76
+ // Radial: Rasterize model with rotating ray planes and X-bucketing
77
+ export async function radialRasterize({
78
+ triangles,
79
+ bucketData,
80
+ resolution,
81
+ angleStep,
82
+ numAngles,
83
+ maxRadius,
84
+ toolWidth,
85
+ zFloor,
86
+ bounds,
87
+ startAngle = 0,
88
+ reusableBuffers = null,
89
+ returnBuffersForReuse = false,
90
+ batchInfo = {}
91
+ }) {
92
+ if (!device) {
93
+ throw new Error('WebGPU not initialized');
94
+ }
95
+
96
+ const timings = {
97
+ start: performance.now(),
98
+ prep: 0,
99
+ gpu: 0,
100
+ stitch: 0
101
+ };
102
+
103
+ // Calculate grid dimensions based on BUCKET range (not model bounds)
104
+ // Buckets may extend slightly beyond model bounds due to rounding
105
+ const bucketMinX = bucketData.buckets[0].minX;
106
+ const bucketMaxX = bucketData.buckets[bucketData.numBuckets - 1].maxX;
107
+ const gridWidth = Math.ceil((bucketMaxX - bucketMinX) / resolution);
108
+ const gridYHeight = Math.ceil(toolWidth / resolution);
109
+ const bucketGridWidth = Math.ceil((bucketData.buckets[0].maxX - bucketData.buckets[0].minX) / resolution);
110
+
111
+ // Calculate workgroup load distribution for timeout analysis
112
+ const bucketTriangleCounts = bucketData.buckets.map(b => b.count);
113
+ const minTriangles = Math.min(...bucketTriangleCounts);
114
+ const maxTriangles = Math.max(...bucketTriangleCounts);
115
+ const avgTriangles = bucketTriangleCounts.reduce((a, b) => a + b, 0) / bucketTriangleCounts.length;
116
+ const workPerWorkgroup = maxTriangles * numAngles * bucketGridWidth * gridYHeight;
117
+
118
+ // Determine bucket batching to avoid GPU timeouts AND respect thread limits
119
+ // Constraint 1: Keep work per batch under ~10B ray-triangle tests (work-based limit)
120
+ // Constraint 2: Keep concurrent threads under GPU watchdog limit (thread-based limit)
121
+ const maxWorkPerBatch = 1e10;
122
+ const estimatedWorkPerBucket = avgTriangles * numAngles * bucketGridWidth * gridYHeight;
123
+
124
+ // Calculate thread-based limit
125
+ // Each bucket batch dispatches: dispatchX × dispatchY × bucketsInBatch workgroups
126
+ // Workgroup size is 8×8×1 = 64 threads
127
+ // Total threads = (numAngles/8) × (gridYHeight/8) × bucketsInBatch × 64
128
+ const THREADS_PER_WORKGROUP = 64;
129
+ const maxConcurrentThreads = config.maxConcurrentThreads || 32768;
130
+ const dispatchX = Math.ceil(numAngles / 8);
131
+ const dispatchY = Math.ceil(gridYHeight / 8);
132
+ const threadsPerBucket = dispatchX * dispatchY * THREADS_PER_WORKGROUP;
133
+ const threadLimitBuckets = Math.max(1, Math.floor(maxConcurrentThreads / threadsPerBucket));
134
+
135
+ // Calculate buckets per batch, enforcing both work and thread limits
136
+ let maxBucketsPerBatch;
137
+ if (estimatedWorkPerBucket === 0) {
138
+ maxBucketsPerBatch = Math.min(threadLimitBuckets, bucketData.numBuckets); // Empty model
139
+ } else {
140
+ const workBasedLimit = Math.floor(maxWorkPerBatch / estimatedWorkPerBucket);
141
+
142
+ // Use the MORE RESTRICTIVE of work-based or thread-based limits
143
+ const idealBucketsPerBatch = Math.min(workBasedLimit, threadLimitBuckets);
144
+
145
+ // Apply minimum only if it doesn't violate thread limit
146
+ const minBucketsPerBatch = Math.min(4, bucketData.numBuckets, threadLimitBuckets);
147
+ maxBucketsPerBatch = Math.max(minBucketsPerBatch, idealBucketsPerBatch);
148
+
149
+ // Cap at total buckets
150
+ maxBucketsPerBatch = Math.min(maxBucketsPerBatch, bucketData.numBuckets);
151
+ }
152
+
153
+ const numBucketBatches = Math.ceil(bucketData.numBuckets / maxBucketsPerBatch);
154
+
155
+ if (diagnostic) {
156
+ debug.log(`Radial: ${gridWidth}x${gridYHeight} grid, ${numAngles} angles, ${bucketData.buckets.length} buckets`);
157
+ debug.log(`Load: min=${minTriangles} max=${maxTriangles} avg=${avgTriangles.toFixed(0)} (${(maxTriangles/avgTriangles).toFixed(2)}x imbalance, worst=${(workPerWorkgroup/1e6).toFixed(1)}M tests)`);
158
+ debug.log(`Thread limits: ${threadsPerBucket} threads/bucket, max ${threadLimitBuckets} buckets/dispatch (${maxConcurrentThreads} thread limit)`);
159
+ debug.log(`Estimated work/bucket: ${(estimatedWorkPerBucket/1e6).toFixed(1)}M tests`);
160
+ debug.log(`Bucket batching: ${numBucketBatches} batches of ${maxBucketsPerBatch} buckets (work limit: ${Math.floor(maxWorkPerBatch / estimatedWorkPerBucket)}, thread limit: ${threadLimitBuckets})`);
161
+ }
162
+
163
+ // Reuse buffers if provided, otherwise create new ones
164
+ let triangleBuffer, triangleIndicesBuffer;
165
+ let shouldCleanupBuffers = false;
166
+
167
+ if (reusableBuffers) {
168
+ // Reuse cached buffers from previous angle batch
169
+ triangleBuffer = reusableBuffers.triangleBuffer;
170
+ triangleIndicesBuffer = reusableBuffers.triangleIndicesBuffer;
171
+ } else {
172
+ // Create new GPU buffers (first batch or non-batched operation)
173
+ shouldCleanupBuffers = true;
174
+
175
+ triangleBuffer = device.createBuffer({
176
+ size: triangles.byteLength,
177
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
178
+ mappedAtCreation: true
179
+ });
180
+ new Float32Array(triangleBuffer.getMappedRange()).set(triangles);
181
+ triangleBuffer.unmap();
182
+
183
+ // Create triangle indices buffer
184
+ triangleIndicesBuffer = device.createBuffer({
185
+ size: bucketData.triangleIndices.byteLength,
186
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
187
+ mappedAtCreation: true
188
+ });
189
+ new Uint32Array(triangleIndicesBuffer.getMappedRange()).set(bucketData.triangleIndices);
190
+ triangleIndicesBuffer.unmap();
191
+ }
192
+
193
+ // Create output buffer (all angles, all buckets)
194
+ const outputSize = numAngles * bucketData.numBuckets * bucketGridWidth * gridYHeight * 4;
195
+ const outputBuffer = device.createBuffer({
196
+ size: outputSize,
197
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
198
+ });
199
+
200
+ // CRITICAL: Initialize output buffer with zFloor to avoid reading garbage data
201
+ const initData = new Float32Array(outputSize / 4);
202
+ initData.fill(zFloor);
203
+ device.queue.writeBuffer(outputBuffer, 0, initData);
204
+ // Note: No need to wait - GPU will execute writeBuffer before compute shader
205
+
206
+ // Prep complete, GPU starting
207
+ timings.prep = performance.now() - timings.start;
208
+ const gpuStart = performance.now();
209
+
210
+ // Use cached pipeline (created in initWebGPU)
211
+ const pipeline = cachedRadialBatchPipeline;
212
+
213
+ // Process buckets in batches to avoid GPU timeouts
214
+ const commandEncoder = device.createCommandEncoder();
215
+ const passEncoder = commandEncoder.beginComputePass();
216
+ passEncoder.setPipeline(pipeline);
217
+
218
+ // Collect buffers to destroy after GPU completes
219
+ const batchBuffersToDestroy = [];
220
+
221
+ for (let batchIdx = 0; batchIdx < numBucketBatches; batchIdx++) {
222
+ const startBucket = batchIdx * maxBucketsPerBatch;
223
+ const endBucket = Math.min(startBucket + maxBucketsPerBatch, bucketData.numBuckets);
224
+ const bucketsInBatch = endBucket - startBucket;
225
+
226
+ // Create bucket info buffer for this batch
227
+ const bucketInfoSize = bucketsInBatch * 16; // 4 fields * 4 bytes per bucket
228
+ const bucketInfoBuffer = device.createBuffer({
229
+ size: bucketInfoSize,
230
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
231
+ mappedAtCreation: true
232
+ });
233
+
234
+ const bucketView = new ArrayBuffer(bucketInfoSize);
235
+ const bucketFloatView = new Float32Array(bucketView);
236
+ const bucketUintView = new Uint32Array(bucketView);
237
+
238
+ for (let i = 0; i < bucketsInBatch; i++) {
239
+ const bucket = bucketData.buckets[startBucket + i];
240
+ const offset = i * 4;
241
+ bucketFloatView[offset] = bucket.minX; // f32
242
+ bucketFloatView[offset + 1] = bucket.maxX; // f32
243
+ bucketUintView[offset + 2] = bucket.startIndex; // u32
244
+ bucketUintView[offset + 3] = bucket.count; // u32
245
+ }
246
+
247
+ new Uint8Array(bucketInfoBuffer.getMappedRange()).set(new Uint8Array(bucketView));
248
+ bucketInfoBuffer.unmap();
249
+
250
+ // Create uniforms for this batch
251
+ // Struct layout: f32, f32, u32, f32, f32, u32, f32, u32, f32, f32, u32, u32, f32, u32
252
+ const uniformBuffer = device.createBuffer({
253
+ size: 56, // 14 fields * 4 bytes
254
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
255
+ mappedAtCreation: true
256
+ });
257
+
258
+ const uniformView = new ArrayBuffer(56);
259
+ const floatView = new Float32Array(uniformView);
260
+ const uintView = new Uint32Array(uniformView);
261
+
262
+ floatView[0] = resolution; // f32
263
+ floatView[1] = angleStep * (Math.PI / 180); // f32
264
+ uintView[2] = numAngles; // u32
265
+ floatView[3] = maxRadius; // f32
266
+ floatView[4] = toolWidth; // f32
267
+ uintView[5] = gridYHeight; // u32
268
+ floatView[6] = bucketData.buckets[0].maxX - bucketData.buckets[0].minX; // f32 bucketWidth
269
+ uintView[7] = bucketGridWidth; // u32
270
+ floatView[8] = bucketMinX; // f32 global_min_x
271
+ floatView[9] = zFloor; // f32
272
+ uintView[10] = 0; // u32 filterMode
273
+ uintView[11] = bucketData.numBuckets; // u32 (total buckets, for validation)
274
+ floatView[12] = startAngle * (Math.PI / 180); // f32 start_angle (radians)
275
+ uintView[13] = startBucket; // u32 bucket_offset
276
+
277
+ new Uint8Array(uniformBuffer.getMappedRange()).set(new Uint8Array(uniformView));
278
+ uniformBuffer.unmap();
279
+
280
+ // Create bind group for this batch
281
+ const bindGroup = device.createBindGroup({
282
+ layout: pipeline.getBindGroupLayout(0),
283
+ entries: [
284
+ { binding: 0, resource: { buffer: triangleBuffer } },
285
+ { binding: 1, resource: { buffer: outputBuffer } },
286
+ { binding: 2, resource: { buffer: uniformBuffer } },
287
+ { binding: 3, resource: { buffer: bucketInfoBuffer } },
288
+ { binding: 4, resource: { buffer: triangleIndicesBuffer } }
289
+ ]
290
+ });
291
+
292
+ // Dispatch this batch - just dispatch normally
293
+ // The XY dimensions are fixed by the problem (angles × gridYHeight)
294
+ // If this exceeds the thread limit, the real fix is to increase maxBucketsPerBatch
295
+ // in the work estimation code (lines 118-137) to create more, smaller batches
296
+ passEncoder.setBindGroup(0, bindGroup);
297
+ passEncoder.dispatchWorkgroups(dispatchX, dispatchY, bucketsInBatch);
298
+
299
+ if (diagnostic) {
300
+ const totalThreads = dispatchX * dispatchY * bucketsInBatch * THREADS_PER_WORKGROUP;
301
+ debug.log(` Batch ${batchIdx + 1}/${numBucketBatches}: (${dispatchX}, ${dispatchY}, ${bucketsInBatch}) = ${totalThreads} threads, buckets ${startBucket}-${endBucket - 1}`);
302
+ }
303
+
304
+ // Save buffers to destroy after GPU completes
305
+ batchBuffersToDestroy.push(uniformBuffer, bucketInfoBuffer);
306
+ }
307
+
308
+ passEncoder.end();
309
+
310
+ // Read back
311
+ const stagingBuffer = device.createBuffer({
312
+ size: outputSize,
313
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
314
+ });
315
+
316
+ commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize);
317
+ device.queue.submit([commandEncoder.finish()]);
318
+
319
+ // Wait for GPU to finish before reading results
320
+ await device.queue.onSubmittedWorkDone();
321
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
322
+ // const outputData = new Float32Array(stagingBuffer.getMappedRange());
323
+ // const outputCopy = new Float32Array(outputData);
324
+ const outputCopy = new Float32Array(stagingBuffer.getMappedRange().slice());
325
+ stagingBuffer.unmap();
326
+
327
+ // Now safe to destroy batch buffers (GPU has completed)
328
+ for (const buffer of batchBuffersToDestroy) {
329
+ buffer.destroy();
330
+ }
331
+
332
+ // Cleanup main buffers
333
+ outputBuffer.destroy();
334
+ stagingBuffer.destroy();
335
+
336
+ timings.gpu = performance.now() - gpuStart;
337
+
338
+ // Stitch strips
339
+ const stitchStart = performance.now();
340
+ const strips = [];
341
+
342
+ for (let angleIdx = 0; angleIdx < numAngles; angleIdx++) {
343
+ const stripData = new Float32Array(gridWidth * gridYHeight);
344
+ stripData.fill(zFloor); // Initialize with zFloor, not zeros!
345
+
346
+ // Gather from each bucket
347
+ for (let bucketIdx = 0; bucketIdx < bucketData.numBuckets; bucketIdx++) {
348
+ const bucket = bucketData.buckets[bucketIdx];
349
+ const bucketMinGridX = Math.floor((bucket.minX - bucketMinX) / resolution);
350
+
351
+ for (let localX = 0; localX < bucketGridWidth; localX++) {
352
+ const gridX = bucketMinGridX + localX;
353
+ if (gridX >= gridWidth) continue;
354
+
355
+ for (let gridY = 0; gridY < gridYHeight; gridY++) {
356
+ const srcIdx = bucketIdx * numAngles * bucketGridWidth * gridYHeight
357
+ + angleIdx * bucketGridWidth * gridYHeight
358
+ + gridY * bucketGridWidth
359
+ + localX;
360
+ const dstIdx = gridY * gridWidth + gridX;
361
+ stripData[dstIdx] = outputCopy[srcIdx];
362
+ }
363
+ }
364
+ }
365
+
366
+ // Keep as DENSE Z-only format (toolpath generator expects this!)
367
+ // Count valid points
368
+ let validCount = 0;
369
+ for (let i = 0; i < stripData.length; i++) {
370
+ if (stripData[i] !== zFloor) validCount++;
371
+ }
372
+
373
+ strips.push({
374
+ angle: startAngle + (angleIdx * angleStep),
375
+ positions: stripData, // DENSE Z-only format!
376
+ gridWidth,
377
+ gridHeight: gridYHeight,
378
+ pointCount: validCount, // Number of non-floor cells
379
+ bounds: {
380
+ min: { x: bucketMinX, y: 0, z: zFloor },
381
+ max: { x: bucketMaxX, y: toolWidth, z: bounds.max.z }
382
+ }
383
+ });
384
+ }
385
+
386
+ timings.stitch = performance.now() - stitchStart;
387
+ const totalTime = performance.now() - timings.start;
388
+
389
+ Object.assign(batchInfo, {
390
+ 'prep': (timings.prep | 0),
391
+ 'raster': (timings.gpu | 0),
392
+ 'stitch': (timings.stitch | 0)
393
+ });
394
+
395
+ const result = { strips, timings };
396
+
397
+ // Decide what to do with triangle/indices buffers
398
+ // Note: bucketInfoBuffer is now created/destroyed per bucket batch within the loop
399
+ if (returnBuffersForReuse && shouldCleanupBuffers) {
400
+ // First batch in multi-batch operation: return buffers for subsequent batches to reuse
401
+ result.reusableBuffers = {
402
+ triangleBuffer,
403
+ triangleIndicesBuffer
404
+ };
405
+ } else if (shouldCleanupBuffers) {
406
+ // Single batch operation OR we're NOT supposed to return buffers: destroy them now
407
+ triangleBuffer.destroy();
408
+ triangleIndicesBuffer.destroy();
409
+ }
410
+ // else: we're reusing buffers from a previous angle batch, don't destroy them (caller will destroy after all angle batches)
411
+
412
+ return result;
413
+ }
414
+
415
+ // Radial: Complete pipeline - rasterize model + generate toolpaths for all strips
416
+ export async function generateRadialToolpaths({
417
+ triangles,
418
+ bucketData,
419
+ toolData,
420
+ resolution,
421
+ angleStep,
422
+ numAngles,
423
+ maxRadius,
424
+ toolWidth,
425
+ zFloor,
426
+ bounds,
427
+ xStep,
428
+ yStep
429
+ }) {
430
+ debug.log('radial-generate-toolpaths', { triangles: triangles.length, numAngles, resolution });
431
+
432
+ // Batch processing: rasterize angle ranges to avoid memory allocation failure
433
+ // Calculate safe batch size based on available GPU memory
434
+ const MAX_BUFFER_SIZE_MB = 1800; // Stay under 2GB WebGPU limit with headroom
435
+ const bytesPerCell = 4; // f32
436
+
437
+ const xSize = bounds.max.x - bounds.min.x;
438
+ const ySize = bounds.max.y - bounds.min.y;
439
+ const gridXSize = Math.ceil(xSize / resolution);
440
+ const gridYHeight = Math.ceil(ySize / resolution);
441
+
442
+ // Calculate total memory requirement
443
+ const cellsPerAngle = gridXSize * gridYHeight;
444
+ const bytesPerAngle = cellsPerAngle * bytesPerCell;
445
+ const totalMemoryMB = (numAngles * bytesPerAngle) / (1024 * 1024);
446
+
447
+ // Only batch if total memory exceeds threshold
448
+ const batchDivisor = config?.batchDivisor || 1;
449
+ let ANGLES_PER_BATCH, numBatches;
450
+ if (totalMemoryMB > MAX_BUFFER_SIZE_MB) {
451
+ // Need to batch
452
+ const maxAnglesPerBatch = Math.floor((MAX_BUFFER_SIZE_MB * 1024 * 1024) / bytesPerAngle);
453
+ // Apply batch divisor for overhead testing
454
+ const adjustedMaxAngles = Math.floor(maxAnglesPerBatch / batchDivisor);
455
+
456
+ ANGLES_PER_BATCH = Math.max(1, Math.min(adjustedMaxAngles, numAngles));
457
+ numBatches = Math.ceil(numAngles / ANGLES_PER_BATCH);
458
+ const batchSizeMB = (ANGLES_PER_BATCH * bytesPerAngle / 1024 / 1024).toFixed(1);
459
+ debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
460
+ debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB exceeds limit, batching required`);
461
+ if (batchDivisor > 1) {
462
+ debug.log(`batchDivisor: ${batchDivisor}x (testing overhead: ${maxAnglesPerBatch} → ${adjustedMaxAngles} angles/batch)`);
463
+ }
464
+ debug.log(`Batch size: ${ANGLES_PER_BATCH} angles (~${batchSizeMB}MB per batch)`);
465
+ debug.log(`Processing ${numAngles} angles in ${numBatches} batch(es)`);
466
+ } else {
467
+ // Process all angles at once (but still respect batchDivisor for testing)
468
+ if (batchDivisor > 1) {
469
+ ANGLES_PER_BATCH = Math.max(10, Math.floor(numAngles / batchDivisor));
470
+ numBatches = Math.ceil(numAngles / ANGLES_PER_BATCH);
471
+ debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
472
+ debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB (fits in buffer normally)`);
473
+ debug.log(`batchDivisor: ${batchDivisor}x (artificially creating ${numBatches} batches for overhead testing)`);
474
+ } else {
475
+ ANGLES_PER_BATCH = numAngles;
476
+ numBatches = 1;
477
+ debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
478
+ debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB fits in buffer, processing all ${numAngles} angles in single batch`);
479
+ }
480
+ }
481
+
482
+ const allStripToolpaths = [];
483
+ let totalToolpathPoints = 0;
484
+ const pipelineStartTime = performance.now();
485
+
486
+ // Prepare sparse tool once
487
+ const sparseToolData = createSparseToolFromPoints(toolData.positions);
488
+ debug.log(`Created sparse tool: ${sparseToolData.count} points (reusing for all strips)`);
489
+
490
+ // Create reusable rasterization buffers if batching (numBatches > 1)
491
+ // These buffers (triangles, buckets, indices) don't change between batches
492
+ let batchReuseBuffers = null;
493
+ let batchTracking = [];
494
+
495
+ for (let batchIdx = 0; batchIdx < numBatches; batchIdx++) {
496
+ const batchStartTime = performance.now();
497
+ const startAngleIdx = batchIdx * ANGLES_PER_BATCH;
498
+ const endAngleIdx = Math.min(startAngleIdx + ANGLES_PER_BATCH, numAngles);
499
+ const batchNumAngles = endAngleIdx - startAngleIdx;
500
+ const batchStartAngle = startAngleIdx * angleStep;
501
+
502
+ const batchInfo = {
503
+ from: startAngleIdx,
504
+ to: endAngleIdx
505
+ };
506
+ batchTracking.push(batchInfo);
507
+
508
+ debug.log(`Batch ${batchIdx + 1}/${numBatches}: angles ${startAngleIdx}-${endAngleIdx - 1} (${batchNumAngles} angles), startAngle=${batchStartAngle.toFixed(1)}°`);
509
+
510
+ // Rasterize this batch of strips
511
+ const rasterStartTime = performance.now();
512
+ const shouldReturnBuffers = (batchIdx === 0 && numBatches > 1); // First batch of multi-batch operation
513
+ const batchModelResult = await radialRasterize({
514
+ triangles,
515
+ bucketData,
516
+ resolution,
517
+ angleStep,
518
+ numAngles: batchNumAngles,
519
+ maxRadius,
520
+ toolWidth,
521
+ zFloor,
522
+ bounds,
523
+ startAngle: batchStartAngle,
524
+ reusableBuffers: batchReuseBuffers,
525
+ returnBuffersForReuse: shouldReturnBuffers,
526
+ batchInfo
527
+ });
528
+
529
+ const rasterTime = performance.now() - rasterStartTime;
530
+
531
+ // Capture buffers from first batch for reuse
532
+ if (batchIdx === 0 && batchModelResult.reusableBuffers) {
533
+ batchReuseBuffers = batchModelResult.reusableBuffers;
534
+ }
535
+
536
+ // Find max dimensions for this batch
537
+ let maxStripWidth = 0;
538
+ let maxStripHeight = 0;
539
+ for (const strip of batchModelResult.strips) {
540
+ maxStripWidth = Math.max(maxStripWidth, strip.gridWidth);
541
+ maxStripHeight = Math.max(maxStripHeight, strip.gridHeight);
542
+ }
543
+
544
+ // Create reusable buffers for this batch
545
+ const reusableBuffers = createReusableToolpathBuffers(maxStripWidth, maxStripHeight, sparseToolData, xStep, maxStripHeight);
546
+
547
+ // Generate toolpaths for this batch
548
+ const toolpathStartTime = performance.now();
549
+
550
+ for (let i = 0; i < batchModelResult.strips.length; i++) {
551
+ const strip = batchModelResult.strips[i];
552
+ const globalStripIdx = startAngleIdx + i;
553
+
554
+ if (globalStripIdx % 10 === 0 || globalStripIdx === numAngles - 1) {
555
+ // Reserve final 2% for cleanup phase after strip processing
556
+ const stripProgress = ((globalStripIdx + 1) / numAngles) * 98;
557
+ self.postMessage({
558
+ type: 'toolpath-progress',
559
+ data: {
560
+ percent: Math.round(stripProgress),
561
+ current: globalStripIdx + 1,
562
+ total: numAngles,
563
+ layer: globalStripIdx + 1
564
+ }
565
+ });
566
+ }
567
+
568
+ if (!strip.positions || strip.positions.length === 0) continue;
569
+
570
+ // DEBUG: Diagnostic logging
571
+ if (diagnostic && (globalStripIdx === 0 || globalStripIdx === 360)) {
572
+ debug.log(`BUILD_ID_PLACEHOLDER | Strip ${globalStripIdx} (${strip.angle.toFixed(1)}°) INPUT terrain first 5 Z values: ${strip.positions.slice(0, 5).map(v => v.toFixed(3)).join(',')}`);
573
+ }
574
+
575
+ const stripToolpathResult = await runToolpathComputeWithBuffers(
576
+ strip.positions,
577
+ strip.gridWidth,
578
+ strip.gridHeight,
579
+ xStep,
580
+ strip.gridHeight,
581
+ zFloor,
582
+ reusableBuffers,
583
+ pipelineStartTime
584
+ );
585
+
586
+ // DEBUG: Verify toolpath generation output
587
+ if (diagnostic && (globalStripIdx === 0 || globalStripIdx === 360)) {
588
+ debug.log(`BUILD_ID_PLACEHOLDER | Strip ${globalStripIdx} (${strip.angle.toFixed(1)}°) OUTPUT toolpath first 5 Z values: ${stripToolpathResult.pathData.slice(0, 5).map(v => v.toFixed(3)).join(',')}`);
589
+ }
590
+
591
+ allStripToolpaths.push({
592
+ angle: strip.angle,
593
+ pathData: stripToolpathResult.pathData,
594
+ numScanlines: stripToolpathResult.numScanlines,
595
+ pointsPerLine: stripToolpathResult.pointsPerLine,
596
+ terrainBounds: strip.bounds
597
+ });
598
+
599
+ totalToolpathPoints += stripToolpathResult.pathData.length;
600
+ }
601
+ const toolpathTime = performance.now() - toolpathStartTime;
602
+
603
+ // Free batch terrain data
604
+ for (const strip of batchModelResult.strips) {
605
+ strip.positions = null;
606
+ }
607
+ destroyReusableToolpathBuffers(reusableBuffers);
608
+
609
+ const batchTotalTime = performance.now() - batchStartTime;
610
+
611
+ Object.assign(batchInfo, {
612
+ 'prep': batchInfo.prep || 0,
613
+ 'gpu': batchInfo.gpu || 0,
614
+ 'stitch': batchInfo.stitch || 0,
615
+ 'raster': batchInfo.raster || 0,
616
+ 'paths': (toolpathTime | 0),
617
+ 'strips': allStripToolpaths.length,
618
+ 'total': (batchTotalTime | 0)
619
+ });
620
+ }
621
+
622
+ console.table(batchTracking);
623
+
624
+ // Cleanup cached rasterization buffers after all batches complete
625
+ if (batchReuseBuffers) {
626
+ batchReuseBuffers.triangleBuffer.destroy();
627
+ batchReuseBuffers.triangleIndicesBuffer.destroy();
628
+ // Note: bucketInfoBuffer is no longer in reusableBuffers (created/destroyed per bucket batch)
629
+ debug.log(`Destroyed cached GPU buffers after all batches`);
630
+ }
631
+
632
+ const pipelineTotalTime = performance.now() - pipelineStartTime;
633
+ debug.log(`Complete radial toolpath: ${allStripToolpaths.length} strips, ${totalToolpathPoints} total points in ${pipelineTotalTime.toFixed(0)}ms`);
634
+
635
+ // Send final 100% progress after cleanup completes
636
+ self.postMessage({
637
+ type: 'toolpath-progress',
638
+ data: {
639
+ percent: 100,
640
+ current: numAngles,
641
+ total: numAngles,
642
+ layer: numAngles
643
+ }
644
+ });
645
+
646
+ return {
647
+ strips: allStripToolpaths,
648
+ totalPoints: totalToolpathPoints,
649
+ numStrips: allStripToolpaths.length
650
+ };
651
+ }