@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,788 @@
1
+ /**
2
+ * ═══════════════════════════════════════════════════════════════════════════
3
+ * Path Planar - Planar Toolpath Generation
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * Generates CNC toolpaths by scanning a sparse tool representation over a
7
+ * dense terrain height map. Computes Z-heights where tool contacts terrain.
8
+ *
9
+ * EXPORTS:
10
+ * ────────
11
+ * Functions:
12
+ * - generateToolpath(terrainPoints, toolPoints, xStep, yStep, zFloor, ...)
13
+ * Main API - generates toolpath with automatic tiling
14
+ * - generateToolpathWithSparseTools(terrainPoints, sparseToolData, ...)
15
+ * Batch-optimized version with pre-created sparse tool
16
+ * - createReusableToolpathBuffers(width, height, sparseToolData, ...)
17
+ * Create GPU buffers for reuse across multiple tiles
18
+ * - destroyReusableToolpathBuffers(buffers)
19
+ * Cleanup GPU buffers
20
+ * - runToolpathComputeWithBuffers(terrainData, ...)
21
+ * Run toolpath compute shader with pre-allocated buffers
22
+ *
23
+ * ALGORITHM:
24
+ * ──────────
25
+ * For each output point (i, j) sampled at (xStep, yStep):
26
+ * 1. Position tool center at terrain grid cell (i, j)
27
+ * 2. For each point in sparse tool:
28
+ * - Calculate terrain sample position: terrain[i + xOffset, j + yOffset]
29
+ * - Calculate tool collision Z: terrainZ - toolZ
30
+ * 3. Output maximum collision Z (highest point tool must be raised)
31
+ *
32
+ * TILING SUPPORT:
33
+ * ───────────────
34
+ * For large terrains exceeding GPU memory:
35
+ * 1. Calculate tool dimensions to determine required overlap
36
+ * 2. Subdivide terrain into tiles with tool-radius overlap
37
+ * 3. Pre-generate all tile terrain arrays (CPU side)
38
+ * 4. Create reusable GPU buffers sized for largest tile
39
+ * 5. Process each tile, reusing buffers
40
+ * 6. Stitch results, dropping overlap regions
41
+ *
42
+ * MEMORY OPTIMIZATION:
43
+ * ────────────────────
44
+ * - Tool buffer created once, reused for all tiles
45
+ * - Terrain buffer updated per tile (GPU DMA)
46
+ * - Output buffer reused, read back per tile
47
+ * - Reduces GPU memory pressure by 10-100x for large models
48
+ *
49
+ * OUTPUT FORMAT:
50
+ * ──────────────
51
+ * Float32Array of Z-heights, row-major order:
52
+ * [z(0,0), z(1,0), z(2,0), ..., z(0,1), z(1,1), ...]
53
+ * Dimensions: (terrain.width / xStep) × (terrain.height / yStep)
54
+ *
55
+ * ═══════════════════════════════════════════════════════════════════════════
56
+ */
57
+
58
+ import {
59
+ device, deviceCapabilities, isInitialized, config,
60
+ cachedToolpathPipeline, EMPTY_CELL, debug, initWebGPU
61
+ } from './raster-config.js';
62
+ import { createHeightMapFromPoints } from './raster-planar.js';
63
+ import { createSparseToolFromPoints } from './raster-tool.js';
64
+
65
+ // Generate toolpath with pre-created sparse tool (for batch operations)
66
+ export async function generateToolpathWithSparseTools(terrainPoints, sparseToolData, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
67
+ const startTime = performance.now();
68
+
69
+ try {
70
+ // Create height map from terrain points (use terrain bounds if provided)
71
+ const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
72
+
73
+ // Run WebGPU compute with pre-created sparse tool
74
+ const result = await runToolpathCompute(
75
+ terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
76
+ );
77
+
78
+ return result;
79
+ } catch (error) {
80
+ debug.error('Error generating toolpath:', error);
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ // Generate toolpath for a single region (internal)
86
+ async function generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null) {
87
+ const startTime = performance.now();
88
+ debug.log('Generating toolpath...');
89
+ debug.log(`Input: terrain ${terrainPoints.length/3} points, tool ${toolPoints.length/3} points, steps (${xStep}, ${yStep}), oobZ ${oobZ}, gridStep ${gridStep}`);
90
+
91
+ if (terrainBounds) {
92
+ debug.log(`Using terrain bounds: min(${terrainBounds.min.x.toFixed(2)}, ${terrainBounds.min.y.toFixed(2)}, ${terrainBounds.min.z.toFixed(2)}) max(${terrainBounds.max.x.toFixed(2)}, ${terrainBounds.max.y.toFixed(2)}, ${terrainBounds.max.z.toFixed(2)})`);
93
+ }
94
+
95
+ try {
96
+ // Create height map from terrain points (use terrain bounds if provided)
97
+ const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
98
+ debug.log(`Created terrain map: ${terrainMapData.width}x${terrainMapData.height}`);
99
+
100
+ // Create sparse tool representation
101
+ const sparseToolData = createSparseToolFromPoints(toolPoints);
102
+ debug.log(`Created sparse tool: ${sparseToolData.count} points`);
103
+
104
+ // Run WebGPU compute
105
+ const result = await runToolpathCompute(
106
+ terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
107
+ );
108
+
109
+ return result;
110
+ } catch (error) {
111
+ debug.error('Error generating toolpath:', error);
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ async function runToolpathCompute(terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime) {
117
+ if (!isInitialized) {
118
+ const success = await initWebGPU();
119
+ if (!success) {
120
+ throw new Error('WebGPU not available');
121
+ }
122
+ }
123
+
124
+ // Use WASM-generated terrain grid
125
+ const terrainBuffer = device.createBuffer({
126
+ size: terrainMapData.grid.byteLength,
127
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
128
+ });
129
+ device.queue.writeBuffer(terrainBuffer, 0, terrainMapData.grid);
130
+
131
+ // Use WASM-generated sparse tool
132
+ const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
133
+ const toolBufferI32 = new Int32Array(toolBufferData);
134
+ const toolBufferF32 = new Float32Array(toolBufferData);
135
+
136
+ for (let i = 0; i < sparseToolData.count; i++) {
137
+ toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
138
+ toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
139
+ toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
140
+ toolBufferF32[i * 4 + 3] = 0;
141
+ }
142
+
143
+ const toolBuffer = device.createBuffer({
144
+ size: toolBufferData.byteLength,
145
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
146
+ });
147
+ device.queue.writeBuffer(toolBuffer, 0, toolBufferData);
148
+
149
+ // Calculate output dimensions
150
+ const pointsPerLine = Math.ceil(terrainMapData.width / xStep);
151
+ const numScanlines = Math.ceil(terrainMapData.height / yStep);
152
+ const outputSize = pointsPerLine * numScanlines;
153
+
154
+ const outputBuffer = device.createBuffer({
155
+ size: outputSize * 4,
156
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
157
+ });
158
+
159
+ const uniformData = new Uint32Array([
160
+ terrainMapData.width,
161
+ terrainMapData.height,
162
+ sparseToolData.count,
163
+ xStep,
164
+ yStep,
165
+ 0,
166
+ pointsPerLine,
167
+ numScanlines,
168
+ 0, // y_offset (default 0 for planar mode)
169
+ ]);
170
+ const uniformDataFloat = new Float32Array(uniformData.buffer);
171
+ uniformDataFloat[5] = oobZ;
172
+
173
+ const uniformBuffer = device.createBuffer({
174
+ size: uniformData.byteLength,
175
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
176
+ });
177
+ device.queue.writeBuffer(uniformBuffer, 0, uniformData);
178
+
179
+ // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
180
+ await device.queue.onSubmittedWorkDone();
181
+
182
+ // Use cached pipeline
183
+ const bindGroup = device.createBindGroup({
184
+ layout: cachedToolpathPipeline.getBindGroupLayout(0),
185
+ entries: [
186
+ { binding: 0, resource: { buffer: terrainBuffer } },
187
+ { binding: 1, resource: { buffer: toolBuffer } },
188
+ { binding: 2, resource: { buffer: outputBuffer } },
189
+ { binding: 3, resource: { buffer: uniformBuffer } },
190
+ ],
191
+ });
192
+
193
+ const commandEncoder = device.createCommandEncoder();
194
+ const passEncoder = commandEncoder.beginComputePass();
195
+ passEncoder.setPipeline(cachedToolpathPipeline);
196
+ passEncoder.setBindGroup(0, bindGroup);
197
+
198
+ const workgroupsX = Math.ceil(pointsPerLine / 16);
199
+ const workgroupsY = Math.ceil(numScanlines / 16);
200
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
201
+ passEncoder.end();
202
+
203
+ const stagingBuffer = device.createBuffer({
204
+ size: outputSize * 4,
205
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
206
+ });
207
+
208
+ commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize * 4);
209
+
210
+ device.queue.submit([commandEncoder.finish()]);
211
+
212
+ // CRITICAL: Wait for GPU to finish before reading results
213
+ await device.queue.onSubmittedWorkDone();
214
+
215
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
216
+
217
+ const outputData = new Float32Array(stagingBuffer.getMappedRange());
218
+ const result = new Float32Array(outputData);
219
+ stagingBuffer.unmap();
220
+
221
+ terrainBuffer.destroy();
222
+ toolBuffer.destroy();
223
+ outputBuffer.destroy();
224
+ uniformBuffer.destroy();
225
+ stagingBuffer.destroy();
226
+
227
+ const endTime = performance.now();
228
+
229
+ return {
230
+ pathData: result,
231
+ numScanlines,
232
+ pointsPerLine,
233
+ generationTime: endTime - startTime
234
+ };
235
+ }
236
+
237
+ // Create reusable GPU buffers for tiled toolpath generation
238
+ export function createReusableToolpathBuffers(terrainWidth, terrainHeight, sparseToolData, xStep, yStep) {
239
+ const pointsPerLine = Math.ceil(terrainWidth / xStep);
240
+ const numScanlines = Math.ceil(terrainHeight / yStep);
241
+ const outputSize = pointsPerLine * numScanlines;
242
+
243
+ // Create terrain buffer (will be updated for each tile)
244
+ const terrainBuffer = device.createBuffer({
245
+ size: terrainWidth * terrainHeight * 4,
246
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
247
+ });
248
+
249
+ // Create tool buffer (STATIC - same for all tiles!)
250
+ const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
251
+ const toolBufferI32 = new Int32Array(toolBufferData);
252
+ const toolBufferF32 = new Float32Array(toolBufferData);
253
+
254
+ for (let i = 0; i < sparseToolData.count; i++) {
255
+ toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
256
+ toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
257
+ toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
258
+ toolBufferF32[i * 4 + 3] = 0;
259
+ }
260
+
261
+ const toolBuffer = device.createBuffer({
262
+ size: toolBufferData.byteLength,
263
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
264
+ });
265
+ device.queue.writeBuffer(toolBuffer, 0, toolBufferData); // Write once!
266
+
267
+ // Create output buffer (will be read for each tile)
268
+ const outputBuffer = device.createBuffer({
269
+ size: outputSize * 4,
270
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
271
+ });
272
+
273
+ // Create uniform buffer (will be updated for each tile)
274
+ const uniformBuffer = device.createBuffer({
275
+ size: 36, // 9 fields × 4 bytes (added y_offset field)
276
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
277
+ });
278
+
279
+ // Create staging buffer (will be reused for readback)
280
+ const stagingBuffer = device.createBuffer({
281
+ size: outputSize * 4,
282
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
283
+ });
284
+
285
+ return {
286
+ terrainBuffer,
287
+ toolBuffer,
288
+ outputBuffer,
289
+ uniformBuffer,
290
+ stagingBuffer,
291
+ maxOutputSize: outputSize,
292
+ maxTerrainWidth: terrainWidth,
293
+ maxTerrainHeight: terrainHeight,
294
+ sparseToolData
295
+ };
296
+ }
297
+
298
+ // Destroy reusable GPU buffers
299
+ export function destroyReusableToolpathBuffers(buffers) {
300
+ buffers.terrainBuffer.destroy();
301
+ buffers.toolBuffer.destroy();
302
+ buffers.outputBuffer.destroy();
303
+ buffers.uniformBuffer.destroy();
304
+ buffers.stagingBuffer.destroy();
305
+ }
306
+
307
+ // Run toolpath compute using pre-created reusable buffers
308
+ export async function runToolpathComputeWithBuffers(terrainData, terrainWidth, terrainHeight, xStep, yStep, oobZ, buffers, startTime) {
309
+ // Update terrain buffer with new tile data
310
+ device.queue.writeBuffer(buffers.terrainBuffer, 0, terrainData);
311
+
312
+ // Calculate output dimensions
313
+ const pointsPerLine = Math.ceil(terrainWidth / xStep);
314
+ const numScanlines = Math.ceil(terrainHeight / yStep);
315
+ const outputSize = pointsPerLine * numScanlines;
316
+
317
+ // Calculate Y offset for single-scanline radial mode
318
+ // When numScanlines=1 and terrainHeight > 1, center the tool at the midline
319
+ const yOffset = (numScanlines === 1 && terrainHeight > 1) ? Math.floor(terrainHeight / 2) : 0;
320
+
321
+ // Update uniforms for this tile
322
+ const uniformData = new Uint32Array([
323
+ terrainWidth,
324
+ terrainHeight,
325
+ buffers.sparseToolData.count,
326
+ xStep,
327
+ yStep,
328
+ 0,
329
+ pointsPerLine,
330
+ numScanlines,
331
+ yOffset, // y_offset for radial single-scanline mode
332
+ ]);
333
+ const uniformDataFloat = new Float32Array(uniformData.buffer);
334
+ uniformDataFloat[5] = oobZ;
335
+ device.queue.writeBuffer(buffers.uniformBuffer, 0, uniformData);
336
+
337
+ // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
338
+ // Without this, compute shader may read stale/incomplete buffer data
339
+ await device.queue.onSubmittedWorkDone();
340
+
341
+ // Create bind group (reusing cached pipeline)
342
+ const bindGroup = device.createBindGroup({
343
+ layout: cachedToolpathPipeline.getBindGroupLayout(0),
344
+ entries: [
345
+ { binding: 0, resource: { buffer: buffers.terrainBuffer } },
346
+ { binding: 1, resource: { buffer: buffers.toolBuffer } },
347
+ { binding: 2, resource: { buffer: buffers.outputBuffer } },
348
+ { binding: 3, resource: { buffer: buffers.uniformBuffer } },
349
+ ],
350
+ });
351
+
352
+ // Dispatch compute shader
353
+ const commandEncoder = device.createCommandEncoder();
354
+ const passEncoder = commandEncoder.beginComputePass();
355
+ passEncoder.setPipeline(cachedToolpathPipeline);
356
+ passEncoder.setBindGroup(0, bindGroup);
357
+
358
+ const workgroupsX = Math.ceil(pointsPerLine / 16);
359
+ const workgroupsY = Math.ceil(numScanlines / 16);
360
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
361
+ passEncoder.end();
362
+
363
+ // Copy to staging buffer
364
+ commandEncoder.copyBufferToBuffer(buffers.outputBuffer, 0, buffers.stagingBuffer, 0, outputSize * 4);
365
+
366
+ device.queue.submit([commandEncoder.finish()]);
367
+
368
+ // CRITICAL: Wait for GPU to finish before reading results
369
+ await device.queue.onSubmittedWorkDone();
370
+
371
+ await buffers.stagingBuffer.mapAsync(GPUMapMode.READ);
372
+
373
+ // Create a true copy using slice() - new Float32Array(typedArray) only creates a view!
374
+ const outputData = new Float32Array(buffers.stagingBuffer.getMappedRange(), 0, outputSize);
375
+ const result = outputData.slice(); // slice() creates a new ArrayBuffer with copied data
376
+ buffers.stagingBuffer.unmap();
377
+
378
+ const endTime = performance.now();
379
+
380
+ // Debug: Log first few Z values to detect non-determinism
381
+ if (result.length > 0) {
382
+ const samples = [];
383
+ for (let i = 0; i < Math.min(10, result.length); i++) {
384
+ samples.push(result[i].toFixed(3));
385
+ }
386
+ // debug.log(`[Toolpath] Output samples (${result.length} total): ${samples.join(', ')}`);
387
+ }
388
+
389
+ return {
390
+ pathData: result,
391
+ numScanlines,
392
+ pointsPerLine,
393
+ generationTime: endTime - startTime
394
+ };
395
+ }
396
+
397
+ // Generate toolpath with tiling support (public API)
398
+ export async function generateToolpath(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
399
+ // Calculate bounds if not provided
400
+ if (!terrainBounds) {
401
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
402
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
403
+ for (let i = 0; i < terrainPoints.length; i += 3) {
404
+ minX = Math.min(minX, terrainPoints[i]);
405
+ maxX = Math.max(maxX, terrainPoints[i]);
406
+ minY = Math.min(minY, terrainPoints[i + 1]);
407
+ maxY = Math.max(maxY, terrainPoints[i + 1]);
408
+ minZ = Math.min(minZ, terrainPoints[i + 2]);
409
+ maxZ = Math.max(maxZ, terrainPoints[i + 2]);
410
+ }
411
+ terrainBounds = {
412
+ min: { x: minX, y: minY, z: minZ },
413
+ max: { x: maxX, y: maxY, z: maxZ }
414
+ };
415
+ }
416
+
417
+ // Note: singleScanline mode means OUTPUT only centerline, but terrain bounds stay full
418
+ // This ensures all terrain Y values contribute to tool interference at the centerline
419
+
420
+ // Debug tool bounds and center
421
+ for (let i=0; i<toolPoints.length; i += 3) {
422
+ if (toolPoints[i] === 0 && toolPoints[i+1] === 0) {
423
+ debug.log('[WebGPU Worker]', { TOOL_CENTER: toolPoints[i+2] });
424
+ }
425
+ }
426
+ debug.log('[WebGPU Worker]',
427
+ 'toolZMin:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.min(a,b), Infinity)),
428
+ 'toolZMax:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.max(a,b), -Infinity))
429
+ );
430
+
431
+ // Calculate tool dimensions for overlap
432
+ // Tool points are [gridX, gridY, Z] where X/Y are grid indices (not mm)
433
+ let toolMinX = Infinity, toolMaxX = -Infinity;
434
+ let toolMinY = Infinity, toolMaxY = -Infinity;
435
+ for (let i = 0; i < toolPoints.length; i += 3) {
436
+ toolMinX = Math.min(toolMinX, toolPoints[i]);
437
+ toolMaxX = Math.max(toolMaxX, toolPoints[i]);
438
+ toolMinY = Math.min(toolMinY, toolPoints[i + 1]);
439
+ toolMaxY = Math.max(toolMaxY, toolPoints[i + 1]);
440
+ }
441
+ // Tool dimensions in grid cells
442
+ const toolWidthCells = toolMaxX - toolMinX;
443
+ const toolHeightCells = toolMaxY - toolMinY;
444
+ // Convert to mm for logging
445
+ const toolWidthMm = toolWidthCells * gridStep;
446
+ const toolHeightMm = toolHeightCells * gridStep;
447
+
448
+ // Check if tiling is needed based on output grid size
449
+ const outputWidth = Math.ceil((terrainBounds.max.x - terrainBounds.min.x) / gridStep) + 1;
450
+ const outputHeight = Math.ceil((terrainBounds.max.y - terrainBounds.min.y) / gridStep) + 1;
451
+ const outputPoints = Math.ceil(outputWidth / xStep) * Math.ceil(outputHeight / yStep);
452
+ const outputMemory = outputPoints * 4; // 4 bytes per float
453
+
454
+ const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
455
+ const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
456
+ const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
457
+
458
+ if (outputMemory <= maxSafeSize) {
459
+ // No tiling needed
460
+ return await generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds);
461
+ }
462
+
463
+ // Tiling needed (terrain is ALWAYS dense)
464
+ const tilingStartTime = performance.now();
465
+ debug.log('Using tiled toolpath generation');
466
+ debug.log(`Terrain: DENSE (${terrainPoints.length} cells = ${outputWidth}x${outputHeight})`);
467
+ debug.log(`Tool dimensions: ${toolWidthMm.toFixed(2)}mm × ${toolHeightMm.toFixed(2)}mm (${toolWidthCells}×${toolHeightCells} cells)`);
468
+
469
+ // Create tiles with tool-size overlap (pass dimensions in grid cells)
470
+ const { tiles, maxTileGridWidth, maxTileGridHeight } = createToolpathTiles(terrainBounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxSafeSize);
471
+ debug.log(`Created ${tiles.length} tiles`);
472
+
473
+ // Pre-generate all tile terrain point arrays
474
+ const pregenStartTime = performance.now();
475
+ debug.log(`Pre-generating ${tiles.length} tile terrain arrays...`);
476
+ const allTileTerrainPoints = [];
477
+
478
+ for (let i = 0; i < tiles.length; i++) {
479
+ const tile = tiles[i];
480
+
481
+ // Extract terrain sub-grid for this tile (terrain is ALWAYS dense)
482
+ const tileMinGridX = Math.floor((tile.bounds.min.x - terrainBounds.min.x) / gridStep);
483
+ const tileMaxGridX = Math.ceil((tile.bounds.max.x - terrainBounds.min.x) / gridStep);
484
+ const tileMinGridY = Math.floor((tile.bounds.min.y - terrainBounds.min.y) / gridStep);
485
+ const tileMaxGridY = Math.ceil((tile.bounds.max.y - terrainBounds.min.y) / gridStep);
486
+
487
+ const tileWidth = tileMaxGridX - tileMinGridX + 1;
488
+ const tileHeight = tileMaxGridY - tileMinGridY + 1;
489
+
490
+ // Pad to max dimensions for consistent buffer sizing
491
+ const paddedTileTerrainPoints = new Float32Array(maxTileGridWidth * maxTileGridHeight);
492
+ paddedTileTerrainPoints.fill(EMPTY_CELL);
493
+
494
+ // Copy relevant sub-grid from full terrain into top-left of padded array
495
+ for (let ty = 0; ty < tileHeight; ty++) {
496
+ const globalY = tileMinGridY + ty;
497
+ if (globalY < 0 || globalY >= outputHeight) continue;
498
+
499
+ for (let tx = 0; tx < tileWidth; tx++) {
500
+ const globalX = tileMinGridX + tx;
501
+ if (globalX < 0 || globalX >= outputWidth) continue;
502
+
503
+ const globalIdx = globalY * outputWidth + globalX;
504
+ const tileIdx = ty * maxTileGridWidth + tx; // Use maxTileGridWidth for stride
505
+ paddedTileTerrainPoints[tileIdx] = terrainPoints[globalIdx];
506
+ }
507
+ }
508
+
509
+ allTileTerrainPoints.push({
510
+ data: paddedTileTerrainPoints,
511
+ actualWidth: tileWidth,
512
+ actualHeight: tileHeight
513
+ });
514
+ }
515
+
516
+ const pregenTime = performance.now() - pregenStartTime;
517
+ debug.log(`Pre-generation complete in ${pregenTime.toFixed(1)}ms`);
518
+
519
+ // Create reusable GPU buffers (sized for maximum tile)
520
+ if (!isInitialized) {
521
+ const success = await initWebGPU();
522
+ if (!success) {
523
+ throw new Error('WebGPU not available');
524
+ }
525
+ }
526
+
527
+ const sparseToolData = createSparseToolFromPoints(toolPoints);
528
+ const reusableBuffers = createReusableToolpathBuffers(maxTileGridWidth, maxTileGridHeight, sparseToolData, xStep, yStep);
529
+ debug.log(`Created reusable GPU buffers for ${maxTileGridWidth}x${maxTileGridHeight} tiles`);
530
+
531
+ // Process each tile with reusable buffers
532
+ const tileResults = [];
533
+ let totalTileTime = 0;
534
+ for (let i = 0; i < tiles.length; i++) {
535
+ const tile = tiles[i];
536
+ const tileStartTime = performance.now();
537
+ debug.log(`Processing tile ${i + 1}/${tiles.length}...`);
538
+
539
+ // Report progress
540
+ const percent = Math.round(((i + 1) / tiles.length) * 100);
541
+ self.postMessage({
542
+ type: 'toolpath-progress',
543
+ data: {
544
+ percent,
545
+ current: i + 1,
546
+ total: tiles.length,
547
+ layer: i + 1 // Using tile index as "layer" for consistency
548
+ }
549
+ });
550
+
551
+ debug.log(`Tile ${i+1} using pre-generated terrain: ${allTileTerrainPoints[i].actualWidth}x${allTileTerrainPoints[i].actualHeight} (padded to ${maxTileGridWidth}x${maxTileGridHeight})`);
552
+
553
+ // Generate toolpath for this tile using reusable buffers
554
+ const tileToolpathResult = await runToolpathComputeWithBuffers(
555
+ allTileTerrainPoints[i].data,
556
+ maxTileGridWidth,
557
+ maxTileGridHeight,
558
+ xStep,
559
+ yStep,
560
+ oobZ,
561
+ reusableBuffers,
562
+ tileStartTime
563
+ );
564
+
565
+ const tileTime = performance.now() - tileStartTime;
566
+ totalTileTime += tileTime;
567
+
568
+ tileResults.push({
569
+ pathData: tileToolpathResult.pathData,
570
+ numScanlines: tileToolpathResult.numScanlines,
571
+ pointsPerLine: tileToolpathResult.pointsPerLine,
572
+ tile: tile
573
+ });
574
+
575
+ debug.log(`Tile ${i + 1}/${tiles.length} complete: ${tileToolpathResult.numScanlines}×${tileToolpathResult.pointsPerLine} in ${tileTime.toFixed(1)}ms`);
576
+ }
577
+
578
+ // Cleanup reusable buffers
579
+ destroyReusableToolpathBuffers(reusableBuffers);
580
+
581
+ debug.log(`All tiles processed in ${totalTileTime.toFixed(1)}ms (avg ${(totalTileTime/tiles.length).toFixed(1)}ms per tile)`);
582
+
583
+ // Stitch tiles together, dropping overlap regions
584
+ const stitchStartTime = performance.now();
585
+ const stitchedResult = stitchToolpathTiles(tileResults, terrainBounds, gridStep, xStep, yStep);
586
+ const stitchTime = performance.now() - stitchStartTime;
587
+
588
+ const totalTime = performance.now() - tilingStartTime;
589
+ debug.log(`Stitching took ${stitchTime.toFixed(1)}ms`);
590
+ debug.log(`Tiled toolpath complete: ${stitchedResult.numScanlines}×${stitchedResult.pointsPerLine} in ${totalTime.toFixed(1)}ms total`);
591
+
592
+ // Update generation time to reflect total tiled time
593
+ stitchedResult.generationTime = totalTime;
594
+
595
+ return stitchedResult;
596
+ }
597
+
598
+ // Create tiles for toolpath generation with overlap (using integer grid coordinates)
599
+ // toolWidth and toolHeight are in grid cells (not mm)
600
+ function createToolpathTiles(bounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxMemoryBytes) {
601
+ // Calculate global grid dimensions
602
+ const globalGridWidth = Math.ceil((bounds.max.x - bounds.min.x) / gridStep) + 1;
603
+ const globalGridHeight = Math.ceil((bounds.max.y - bounds.min.y) / gridStep) + 1;
604
+
605
+ // Calculate tool overlap in grid cells (use radius = half diameter)
606
+ // Tool centered at tile boundary needs terrain extending half tool width beyond boundary
607
+ const toolOverlapX = Math.ceil(toolWidthCells / 2);
608
+ const toolOverlapY = Math.ceil(toolHeightCells / 2);
609
+
610
+ // Binary search for optimal tile size in grid cells
611
+ let low = Math.max(toolOverlapX, toolOverlapY) * 2; // At least 2x tool size
612
+ let high = Math.max(globalGridWidth, globalGridHeight);
613
+ let bestTileGridSize = high;
614
+
615
+ while (low <= high) {
616
+ const mid = Math.floor((low + high) / 2);
617
+ const outputW = Math.ceil(mid / xStep);
618
+ const outputH = Math.ceil(mid / yStep);
619
+ const memoryNeeded = outputW * outputH * 4;
620
+
621
+ if (memoryNeeded <= maxMemoryBytes) {
622
+ bestTileGridSize = mid;
623
+ low = mid + 1;
624
+ } else {
625
+ high = mid - 1;
626
+ }
627
+ }
628
+
629
+ const tilesX = Math.ceil(globalGridWidth / bestTileGridSize);
630
+ const tilesY = Math.ceil(globalGridHeight / bestTileGridSize);
631
+ const coreGridWidth = Math.ceil(globalGridWidth / tilesX);
632
+ const coreGridHeight = Math.ceil(globalGridHeight / tilesY);
633
+
634
+ // Calculate maximum tile dimensions (for buffer sizing)
635
+ const maxTileGridWidth = coreGridWidth + 2 * toolOverlapX;
636
+ const maxTileGridHeight = coreGridHeight + 2 * toolOverlapY;
637
+
638
+ debug.log(`Creating ${tilesX}×${tilesY} tiles (${coreGridWidth}×${coreGridHeight} cells core + ${toolOverlapX}×${toolOverlapY} cells overlap)`);
639
+ debug.log(`Max tile dimensions: ${maxTileGridWidth}×${maxTileGridHeight} cells (for buffer sizing)`);
640
+
641
+ const tiles = [];
642
+ for (let ty = 0; ty < tilesY; ty++) {
643
+ for (let tx = 0; tx < tilesX; tx++) {
644
+ // Core tile in grid coordinates
645
+ const coreGridStartX = tx * coreGridWidth;
646
+ const coreGridStartY = ty * coreGridHeight;
647
+ const coreGridEndX = Math.min((tx + 1) * coreGridWidth, globalGridWidth) - 1;
648
+ const coreGridEndY = Math.min((ty + 1) * coreGridHeight, globalGridHeight) - 1;
649
+
650
+ // Extended tile with overlap in grid coordinates
651
+ let extGridStartX = coreGridStartX;
652
+ let extGridStartY = coreGridStartY;
653
+ let extGridEndX = coreGridEndX;
654
+ let extGridEndY = coreGridEndY;
655
+
656
+ // Add overlap on sides that aren't at global boundary
657
+ if (tx > 0) extGridStartX -= toolOverlapX;
658
+ if (ty > 0) extGridStartY -= toolOverlapY;
659
+ if (tx < tilesX - 1) extGridEndX += toolOverlapX;
660
+ if (ty < tilesY - 1) extGridEndY += toolOverlapY;
661
+
662
+ // Clamp to global bounds
663
+ extGridStartX = Math.max(0, extGridStartX);
664
+ extGridStartY = Math.max(0, extGridStartY);
665
+ extGridEndX = Math.min(globalGridWidth - 1, extGridEndX);
666
+ extGridEndY = Math.min(globalGridHeight - 1, extGridEndY);
667
+
668
+ // Calculate actual dimensions for this tile
669
+ const tileGridWidth = extGridEndX - extGridStartX + 1;
670
+ const tileGridHeight = extGridEndY - extGridStartY + 1;
671
+
672
+ // Convert grid coordinates to world coordinates
673
+ const extMinX = bounds.min.x + extGridStartX * gridStep;
674
+ const extMinY = bounds.min.y + extGridStartY * gridStep;
675
+ const extMaxX = bounds.min.x + extGridEndX * gridStep;
676
+ const extMaxY = bounds.min.y + extGridEndY * gridStep;
677
+
678
+ const coreMinX = bounds.min.x + coreGridStartX * gridStep;
679
+ const coreMinY = bounds.min.y + coreGridStartY * gridStep;
680
+ const coreMaxX = bounds.min.x + coreGridEndX * gridStep;
681
+ const coreMaxY = bounds.min.y + coreGridEndY * gridStep;
682
+
683
+ tiles.push({
684
+ id: `tile_${tx}_${ty}`,
685
+ tx, ty,
686
+ tilesX, tilesY,
687
+ gridWidth: tileGridWidth,
688
+ gridHeight: tileGridHeight,
689
+ bounds: {
690
+ min: { x: extMinX, y: extMinY, z: bounds.min.z },
691
+ max: { x: extMaxX, y: extMaxY, z: bounds.max.z }
692
+ },
693
+ core: {
694
+ gridStart: { x: coreGridStartX, y: coreGridStartY },
695
+ gridEnd: { x: coreGridEndX, y: coreGridEndY },
696
+ min: { x: coreMinX, y: coreMinY },
697
+ max: { x: coreMaxX, y: coreMaxY }
698
+ }
699
+ });
700
+ }
701
+ }
702
+
703
+ return { tiles, maxTileGridWidth, maxTileGridHeight };
704
+ }
705
+
706
+ // Stitch toolpath tiles together, dropping overlap regions (using integer grid coordinates)
707
+ function stitchToolpathTiles(tileResults, globalBounds, gridStep, xStep, yStep) {
708
+ // Calculate global output dimensions
709
+ const globalWidth = Math.ceil((globalBounds.max.x - globalBounds.min.x) / gridStep) + 1;
710
+ const globalHeight = Math.ceil((globalBounds.max.y - globalBounds.min.y) / gridStep) + 1;
711
+ const globalPointsPerLine = Math.ceil(globalWidth / xStep);
712
+ const globalNumScanlines = Math.ceil(globalHeight / yStep);
713
+
714
+ debug.log(`Stitching toolpath: global grid ${globalWidth}x${globalHeight}, output ${globalPointsPerLine}x${globalNumScanlines}`);
715
+
716
+ const result = new Float32Array(globalPointsPerLine * globalNumScanlines);
717
+ result.fill(NaN);
718
+
719
+ // Fast path for 1x1 stepping: use bulk row copying
720
+ const use1x1FastPath = (xStep === 1 && yStep === 1);
721
+
722
+ for (const tileResult of tileResults) {
723
+ const tile = tileResult.tile;
724
+ const tileData = tileResult.pathData;
725
+
726
+ // Use the pre-calculated integer grid coordinates from tile.core
727
+ const coreGridStartX = tile.core.gridStart.x;
728
+ const coreGridStartY = tile.core.gridStart.y;
729
+ const coreGridEndX = tile.core.gridEnd.x;
730
+ const coreGridEndY = tile.core.gridEnd.y;
731
+
732
+ // Calculate tile's extended grid coordinates
733
+ const extGridStartX = Math.round((tile.bounds.min.x - globalBounds.min.x) / gridStep);
734
+ const extGridStartY = Math.round((tile.bounds.min.y - globalBounds.min.y) / gridStep);
735
+
736
+ let copiedCount = 0;
737
+
738
+ // Calculate output coordinate ranges for this tile's core
739
+ // Core region in grid coordinates
740
+ const coreGridWidth = coreGridEndX - coreGridStartX + 1;
741
+ const coreGridHeight = coreGridEndY - coreGridStartY + 1;
742
+
743
+ // Core region in output coordinates (sampled by xStep/yStep)
744
+ const coreOutStartX = Math.floor(coreGridStartX / xStep);
745
+ const coreOutStartY = Math.floor(coreGridStartY / yStep);
746
+ const coreOutEndX = Math.floor(coreGridEndX / xStep);
747
+ const coreOutEndY = Math.floor(coreGridEndY / yStep);
748
+ const coreOutWidth = coreOutEndX - coreOutStartX + 1;
749
+ const coreOutHeight = coreOutEndY - coreOutStartY + 1;
750
+
751
+ // Tile's extended region start in grid coordinates
752
+ const extOutStartX = Math.floor(extGridStartX / xStep);
753
+ const extOutStartY = Math.floor(extGridStartY / yStep);
754
+
755
+ // Copy entire rows at once (works for all stepping values)
756
+ for (let outY = 0; outY < coreOutHeight; outY++) {
757
+ const globalOutY = coreOutStartY + outY;
758
+ const tileOutY = globalOutY - extOutStartY;
759
+
760
+ if (globalOutY >= 0 && globalOutY < globalNumScanlines &&
761
+ tileOutY >= 0 && tileOutY < tileResult.numScanlines) {
762
+
763
+ const globalRowStart = globalOutY * globalPointsPerLine + coreOutStartX;
764
+ const tileRowStart = tileOutY * tileResult.pointsPerLine + (coreOutStartX - extOutStartX);
765
+
766
+ // Bulk copy entire row of output values
767
+ result.set(tileData.subarray(tileRowStart, tileRowStart + coreOutWidth), globalRowStart);
768
+ copiedCount += coreOutWidth;
769
+ }
770
+ }
771
+
772
+ debug.log(` Tile ${tile.id}: copied ${copiedCount} values`);
773
+ }
774
+
775
+ // Count how many output values are still NaN (gaps)
776
+ let nanCount = 0;
777
+ for (let i = 0; i < result.length; i++) {
778
+ if (isNaN(result[i])) nanCount++;
779
+ }
780
+ debug.log(`Stitching complete: ${result.length} total values, ${nanCount} still NaN`);
781
+
782
+ return {
783
+ pathData: result,
784
+ numScanlines: globalNumScanlines,
785
+ pointsPerLine: globalPointsPerLine,
786
+ generationTime: 0 // Sum from tiles if needed
787
+ };
788
+ }