@gridspace/raster-path 1.0.4 → 1.0.5

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.
@@ -1,2303 +0,0 @@
1
- /**
2
- * ═══════════════════════════════════════════════════════════════════════════
3
- * WebGPU Worker - GPU Compute Operations
4
- * ═══════════════════════════════════════════════════════════════════════════
5
- *
6
- * Offloads all WebGPU compute operations to a worker thread to prevent UI blocking.
7
- * Handles both planar (XY grid) and radial (cylindrical) rasterization modes.
8
- *
9
- * MESSAGE PROTOCOL:
10
- * ─────────────────
11
- * Main Thread → Worker:
12
- * 'init' - Initialize WebGPU device
13
- * 'rasterize-planar' - Rasterize geometry to XY grid
14
- * 'generate-toolpath-planar' - Generate planar toolpath from rasters
15
- * 'radial-generate-toolpaths'- Generate radial toolpaths (does rasterization + toolpath)
16
- *
17
- * Worker → Main Thread:
18
- * 'webgpu-ready' - Initialization complete
19
- * 'rasterize-complete' - Planar rasterization complete
20
- * 'rasterize-progress' - Progress update (0-1)
21
- * 'toolpath-complete' - Planar toolpath complete
22
- * 'toolpath-progress' - Progress update (0-1)
23
- * 'radial-toolpaths-complete' - Radial toolpaths complete
24
- *
25
- * ARCHITECTURE:
26
- * ─────────────
27
- * 1. PLANAR MODE:
28
- * - Rasterize terrain: XY grid, keep max Z per cell
29
- * - Rasterize tool: XY grid, keep min Z per cell
30
- * - Generate toolpath: Scan tool over terrain, compute Z-heights
31
- *
32
- * 2. RADIAL MODE:
33
- * - Batched processing: 360 angles per batch
34
- * - X-bucketing: Spatial partitioning to reduce triangle tests
35
- * - For each angle:
36
- * * Cast ray from origin
37
- * * Rasterize terrain triangles along ray
38
- * * Calculate tool-terrain collision
39
- * * Output Z-heights along X-axis
40
- *
41
- * MEMORY MANAGEMENT:
42
- * ──────────────────
43
- * - All GPU buffers are preallocated to known maximum sizes
44
- * - Triangle data transferred once per operation
45
- * - Output buffers mapped asynchronously to avoid blocking
46
- * - Worker maintains pipeline cache to avoid recompilation
47
- *
48
- * ═══════════════════════════════════════════════════════════════════════════
49
- */
50
-
51
- let config = {};
52
- let device = null;
53
- let deviceCapabilities = null;
54
- let isInitialized = false;
55
- let cachedRasterizePipeline = null;
56
- let cachedRasterizeShaderModule = null;
57
- let cachedToolpathPipeline = null;
58
- let cachedToolpathShaderModule = null;
59
- let cachedRadialBatchPipeline = null;
60
- let cachedRadialBatchShaderModule = null;
61
- let lastlog;
62
-
63
- const EMPTY_CELL = -1e10;
64
- const log_pre = '[Worker]';
65
- const diagnostic = false;
66
-
67
- const debug = {
68
- error: function() { console.error(log_pre, ...arguments) },
69
- warn: function() { console.warn(log_pre, ...arguments) },
70
- log: function() {
71
- if (!config.quiet) {
72
- let now = performance.now();
73
- let since = ((now - (lastlog ?? now)) | 0).toString().padStart(4,' ');
74
- console.log(log_pre, `[${since}]`, ...arguments);
75
- lastlog = now;
76
- }
77
- },
78
- ok: function() { console.log(log_pre, '✅', ...arguments) },
79
- };
80
-
81
- function round(v, d = 1) {
82
- return parseFloat(v.toFixed(d));
83
- }
84
-
85
- // Global error handler for uncaught errors in worker
86
- self.addEventListener('error', (event) => {
87
- debug.error('Uncaught error:', event.error || event.message);
88
- debug.error('Stack:', event.error?.stack);
89
- });
90
-
91
- self.addEventListener('unhandledrejection', (event) => {
92
- debug.error('Unhandled promise rejection:', event.reason);
93
- });
94
-
95
- // Initialize WebGPU device in worker context
96
- async function initWebGPU() {
97
- if (isInitialized) return true;
98
-
99
- if (!navigator.gpu) {
100
- debug.warn('WebGPU not supported');
101
- return false;
102
- }
103
-
104
- try {
105
- const adapter = await navigator.gpu.requestAdapter();
106
- if (!adapter) {
107
- debug.warn('WebGPU adapter not available');
108
- return false;
109
- }
110
-
111
- // Request device with higher limits for large meshes
112
- const adapterLimits = adapter.limits;
113
- debug.log('Adapter limits:', {
114
- maxStorageBufferBindingSize: adapterLimits.maxStorageBufferBindingSize,
115
- maxBufferSize: adapterLimits.maxBufferSize
116
- });
117
-
118
- device = await adapter.requestDevice({
119
- requiredLimits: {
120
- maxStorageBufferBindingSize: Math.min(
121
- adapterLimits.maxStorageBufferBindingSize,
122
- 1024 * 1024 * 1024 // Request up to 1GB
123
- ),
124
- maxBufferSize: Math.min(
125
- adapterLimits.maxBufferSize,
126
- 1024 * 1024 * 1024 // Request up to 1GB
127
- )
128
- }
129
- });
130
-
131
- // Pre-compile rasterize shader module (expensive operation)
132
- cachedRasterizeShaderModule = device.createShaderModule({ code: rasterizeShaderCode });
133
-
134
- // Pre-create rasterize pipeline (very expensive operation)
135
- cachedRasterizePipeline = device.createComputePipeline({
136
- layout: 'auto',
137
- compute: { module: cachedRasterizeShaderModule, entryPoint: 'main' },
138
- });
139
-
140
- // Pre-compile toolpath shader module
141
- cachedToolpathShaderModule = device.createShaderModule({ code: toolpathShaderCode });
142
-
143
- // Pre-create toolpath pipeline
144
- cachedToolpathPipeline = device.createComputePipeline({
145
- layout: 'auto',
146
- compute: { module: cachedToolpathShaderModule, entryPoint: 'main' },
147
- });
148
-
149
- // Pre-compile radial batch shader module
150
- cachedRadialBatchShaderModule = device.createShaderModule({ code: radialRasterizeShaderCode });
151
-
152
- // Pre-create radial batch pipeline
153
- cachedRadialBatchPipeline = device.createComputePipeline({
154
- layout: 'auto',
155
- compute: { module: cachedRadialBatchShaderModule, entryPoint: 'main' },
156
- });
157
-
158
- // Store device capabilities
159
- deviceCapabilities = {
160
- maxStorageBufferBindingSize: device.limits.maxStorageBufferBindingSize,
161
- maxBufferSize: device.limits.maxBufferSize,
162
- maxComputeWorkgroupSizeX: device.limits.maxComputeWorkgroupSizeX,
163
- maxComputeWorkgroupSizeY: device.limits.maxComputeWorkgroupSizeY,
164
- };
165
-
166
- isInitialized = true;
167
- debug.log('Initialized (pipelines cached)');
168
- return true;
169
- } catch (error) {
170
- debug.error('Failed to initialize:', error);
171
- return false;
172
- }
173
- }
174
-
175
- // Planar rasterization with spatial partitioning
176
- const rasterizeShaderCode = 'SHADER:planar-rasterize';
177
-
178
- // Planar toolpath generation
179
- const toolpathShaderCode = 'SHADER:planar-toolpath';
180
-
181
- // Radial: Rasterization with rotating ray planes and X-bucketing
182
- const radialRasterizeShaderCode = 'SHADER:radial-raster';
183
-
184
- // Calculate bounding box from triangle vertices
185
- function calculateBounds(triangles) {
186
- let min_x = Infinity, min_y = Infinity, min_z = Infinity;
187
- let max_x = -Infinity, max_y = -Infinity, max_z = -Infinity;
188
-
189
- for (let i = 0; i < triangles.length; i += 3) {
190
- const x = triangles[i];
191
- const y = triangles[i + 1];
192
- const z = triangles[i + 2];
193
-
194
- if (x < min_x) min_x = x;
195
- if (y < min_y) min_y = y;
196
- if (z < min_z) min_z = z;
197
- if (x > max_x) max_x = x;
198
- if (y > max_y) max_y = y;
199
- if (z > max_z) max_z = z;
200
- }
201
-
202
- return {
203
- min: { x: min_x, y: min_y, z: min_z },
204
- max: { x: max_x, y: max_y, z: max_z }
205
- };
206
- }
207
-
208
- // Build spatial grid for efficient triangle culling
209
- function buildSpatialGrid(triangles, bounds, cellSize = 5.0) {
210
- const gridWidth = Math.max(1, Math.ceil((bounds.max.x - bounds.min.x) / cellSize));
211
- const gridHeight = Math.max(1, Math.ceil((bounds.max.y - bounds.min.y) / cellSize));
212
- const totalCells = gridWidth * gridHeight;
213
-
214
- const grid = new Array(totalCells);
215
- for (let i = 0; i < totalCells; i++) {
216
- grid[i] = [];
217
- }
218
-
219
- const triangleCount = triangles.length / 9;
220
- for (let t = 0; t < triangleCount; t++) {
221
- const base = t * 9;
222
-
223
- const v0x = triangles[base], v0y = triangles[base + 1];
224
- const v1x = triangles[base + 3], v1y = triangles[base + 4];
225
- const v2x = triangles[base + 6], v2y = triangles[base + 7];
226
-
227
- // Add small epsilon to catch triangles near cell boundaries
228
- const epsilon = cellSize * 0.01; // 1% of cell size
229
- const minX = Math.min(v0x, v1x, v2x) - epsilon;
230
- const maxX = Math.max(v0x, v1x, v2x) + epsilon;
231
- const minY = Math.min(v0y, v1y, v2y) - epsilon;
232
- const maxY = Math.max(v0y, v1y, v2y) + epsilon;
233
-
234
- let minCellX = Math.floor((minX - bounds.min.x) / cellSize);
235
- let maxCellX = Math.floor((maxX - bounds.min.x) / cellSize);
236
- let minCellY = Math.floor((minY - bounds.min.y) / cellSize);
237
- let maxCellY = Math.floor((maxY - bounds.min.y) / cellSize);
238
-
239
- minCellX = Math.max(0, Math.min(gridWidth - 1, minCellX));
240
- maxCellX = Math.max(0, Math.min(gridWidth - 1, maxCellX));
241
- minCellY = Math.max(0, Math.min(gridHeight - 1, minCellY));
242
- maxCellY = Math.max(0, Math.min(gridHeight - 1, maxCellY));
243
-
244
- for (let cy = minCellY; cy <= maxCellY; cy++) {
245
- for (let cx = minCellX; cx <= maxCellX; cx++) {
246
- const cellIdx = cy * gridWidth + cx;
247
- grid[cellIdx].push(t);
248
- }
249
- }
250
- }
251
-
252
- let totalTriangleRefs = 0;
253
- for (let i = 0; i < totalCells; i++) {
254
- totalTriangleRefs += grid[i].length;
255
- }
256
-
257
- const cellOffsets = new Uint32Array(totalCells + 1);
258
- const triangleIndices = new Uint32Array(totalTriangleRefs);
259
-
260
- let currentOffset = 0;
261
- for (let i = 0; i < totalCells; i++) {
262
- cellOffsets[i] = currentOffset;
263
- for (let j = 0; j < grid[i].length; j++) {
264
- triangleIndices[currentOffset++] = grid[i][j];
265
- }
266
- }
267
- cellOffsets[totalCells] = currentOffset;
268
-
269
- const avgPerCell = totalTriangleRefs / totalCells;
270
-
271
- // Calculate actual tool diameter from bounds for logging
272
- const toolWidth = bounds.max.x - bounds.min.x;
273
- const toolHeight = bounds.max.y - bounds.min.y;
274
- const toolDiameter = Math.max(toolWidth, toolHeight);
275
-
276
- debug.log(`Spatial grid: ${gridWidth}x${gridHeight} ${totalTriangleRefs} tri-refs ~${avgPerCell.toFixed(0)}/${cellSize}mm cell (tool: ${toolDiameter.toFixed(2)}mm)`);
277
-
278
- return {
279
- gridWidth,
280
- gridHeight,
281
- cellSize,
282
- cellOffsets,
283
- triangleIndices,
284
- avgTrianglesPerCell: avgPerCell
285
- };
286
- }
287
-
288
- // Create reusable GPU buffers for multiple rasterization passes (e.g., radial rotations)
289
-
290
- // Rasterize mesh to point cloud
291
- // Internal function - rasterize without tiling (do not modify this function!)
292
- async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}) {
293
- const startTime = performance.now();
294
-
295
- if (!isInitialized) {
296
- const initStart = performance.now();
297
- const success = await initWebGPU();
298
- if (!success) {
299
- throw new Error('WebGPU not available');
300
- }
301
- const initEnd = performance.now();
302
- debug.log(`First-time init: ${(initEnd - initStart).toFixed(1)}ms`);
303
- }
304
-
305
- // debug.log(`Raster ${triangles.length / 9} triangles (step ${stepSize}mm, mode ${filterMode})...`);
306
-
307
- // Extract options
308
- // boundsOverride: Optional manual bounds to avoid recalculating from triangles
309
- // Useful when bounds are already known (e.g., from tiling operations)
310
- const boundsOverride = options.bounds || options.min ? options : null;
311
-
312
- // Use bounds override if provided, otherwise calculate from triangles
313
- const bounds = boundsOverride || calculateBounds(triangles);
314
-
315
- if (boundsOverride) {
316
- // debug.log(`Using bounds override: min(${bounds.min.x.toFixed(2)}, ${bounds.min.y.toFixed(2)}, ${bounds.min.z.toFixed(2)}) max(${bounds.max.x.toFixed(2)}, ${bounds.max.y.toFixed(2)}, ${bounds.max.z.toFixed(2)})`);
317
-
318
- // Validate bounds
319
- if (bounds.min.x >= bounds.max.x || bounds.min.y >= bounds.max.y || bounds.min.z >= bounds.max.z) {
320
- throw new Error(`Invalid bounds: min must be less than max. Got min(${bounds.min.x}, ${bounds.min.y}, ${bounds.min.z}) max(${bounds.max.x}, ${bounds.max.y}, ${bounds.max.z})`);
321
- }
322
- }
323
-
324
- const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
325
- const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
326
- const totalGridPoints = gridWidth * gridHeight;
327
-
328
- // debug.log(`Grid: ${gridWidth}x${gridHeight} = ${totalGridPoints.toLocaleString()} points`);
329
-
330
- // Calculate buffer size based on filter mode
331
- // filterMode 0 (terrain): Dense Z-only output (1 float per grid cell)
332
- // filterMode 1 (tool): Sparse X,Y,Z output (3 floats per grid cell)
333
- const floatsPerPoint = filterMode === 0 ? 1 : 3;
334
- const outputSize = totalGridPoints * floatsPerPoint * 4;
335
- const maxBufferSize = device.limits.maxBufferSize || 268435456; // 256MB default
336
- // const modeStr = filterMode === 0 ? 'terrain (dense Z-only)' : 'tool (sparse XYZ)';
337
- // debug.log(`Output buffer size: ${(outputSize / 1024 / 1024).toFixed(2)} MB for ${modeStr} (max: ${(maxBufferSize / 1024 / 1024).toFixed(2)} MB)`);
338
-
339
- if (outputSize > maxBufferSize) {
340
- throw new Error(`Output buffer too large: ${(outputSize / 1024 / 1024).toFixed(2)} MB exceeds device limit of ${(maxBufferSize / 1024 / 1024).toFixed(2)} MB. Try a larger step size.`);
341
- }
342
-
343
- console.time(`${log_pre} Build Spatial Grid`);
344
- const spatialGrid = buildSpatialGrid(triangles, bounds);
345
- console.timeEnd(`${log_pre} Build Spatial Grid`);
346
-
347
- // Create buffers
348
- const triangleBuffer = device.createBuffer({
349
- size: triangles.byteLength,
350
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
351
- });
352
- device.queue.writeBuffer(triangleBuffer, 0, triangles);
353
-
354
- // Create and INITIALIZE output buffer (GPU buffers contain garbage by default!)
355
- const outputBuffer = device.createBuffer({
356
- size: outputSize,
357
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
358
- });
359
-
360
- // Initialize output buffer with sentinel value for terrain, zeros for tool
361
- if (filterMode === 0) {
362
- // Terrain: initialize with EMPTY_CELL sentinel value
363
- const initData = new Float32Array(totalGridPoints);
364
- initData.fill(EMPTY_CELL);
365
- device.queue.writeBuffer(outputBuffer, 0, initData);
366
- }
367
- // Tool mode: zeros are fine (will check valid mask)
368
-
369
- const validMaskBuffer = device.createBuffer({
370
- size: totalGridPoints * 4,
371
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
372
- });
373
-
374
- const spatialCellOffsetsBuffer = device.createBuffer({
375
- size: spatialGrid.cellOffsets.byteLength,
376
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
377
- });
378
- device.queue.writeBuffer(spatialCellOffsetsBuffer, 0, spatialGrid.cellOffsets);
379
-
380
- const spatialTriangleIndicesBuffer = device.createBuffer({
381
- size: spatialGrid.triangleIndices.byteLength,
382
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
383
- });
384
- device.queue.writeBuffer(spatialTriangleIndicesBuffer, 0, spatialGrid.triangleIndices);
385
-
386
- // Uniforms
387
- const uniformData = new Float32Array([
388
- bounds.min.x, bounds.min.y, bounds.min.z,
389
- bounds.max.x, bounds.max.y, bounds.max.z,
390
- stepSize,
391
- 0, 0, 0, 0, 0, 0, 0 // Padding for alignment
392
- ]);
393
- const uniformDataU32 = new Uint32Array(uniformData.buffer);
394
- uniformDataU32[7] = gridWidth;
395
- uniformDataU32[8] = gridHeight;
396
- uniformDataU32[9] = triangles.length / 9;
397
- uniformDataU32[10] = filterMode;
398
- uniformDataU32[11] = spatialGrid.gridWidth;
399
- uniformDataU32[12] = spatialGrid.gridHeight;
400
- const uniformDataF32 = new Float32Array(uniformData.buffer);
401
- uniformDataF32[13] = spatialGrid.cellSize;
402
-
403
- // Check for u32 overflow
404
- const maxU32 = 4294967295;
405
- if (gridWidth > maxU32 || gridHeight > maxU32) {
406
- throw new Error(`Grid dimensions exceed u32 max: ${gridWidth}x${gridHeight}`);
407
- }
408
-
409
- // debug.log(`Uniforms: gridWidth=${gridWidth}, gridHeight=${gridHeight}, triangles=${triangles.length / 9}`);
410
-
411
- const uniformBuffer = device.createBuffer({
412
- size: uniformData.byteLength,
413
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
414
- });
415
- device.queue.writeBuffer(uniformBuffer, 0, uniformData);
416
-
417
- // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
418
- await device.queue.onSubmittedWorkDone();
419
-
420
- // Use cached pipeline
421
- const bindGroup = device.createBindGroup({
422
- layout: cachedRasterizePipeline.getBindGroupLayout(0),
423
- entries: [
424
- { binding: 0, resource: { buffer: triangleBuffer } },
425
- { binding: 1, resource: { buffer: outputBuffer } },
426
- { binding: 2, resource: { buffer: validMaskBuffer } },
427
- { binding: 3, resource: { buffer: uniformBuffer } },
428
- { binding: 4, resource: { buffer: spatialCellOffsetsBuffer } },
429
- { binding: 5, resource: { buffer: spatialTriangleIndicesBuffer } },
430
- ],
431
- });
432
-
433
- // Dispatch compute shader
434
- const commandEncoder = device.createCommandEncoder();
435
- const passEncoder = commandEncoder.beginComputePass();
436
- passEncoder.setPipeline(cachedRasterizePipeline);
437
- passEncoder.setBindGroup(0, bindGroup);
438
-
439
- const workgroupsX = Math.ceil(gridWidth / 16);
440
- const workgroupsY = Math.ceil(gridHeight / 16);
441
-
442
- // Check dispatch limits
443
- const maxWorkgroupsPerDim = device.limits.maxComputeWorkgroupsPerDimension || 65535;
444
-
445
- if (workgroupsX > maxWorkgroupsPerDim || workgroupsY > maxWorkgroupsPerDim) {
446
- throw new Error(`Workgroup dispatch too large: ${workgroupsX}x${workgroupsY} exceeds limit of ${maxWorkgroupsPerDim}. Try a larger step size.`);
447
- }
448
-
449
- passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
450
- passEncoder.end();
451
-
452
- // Create staging buffers for readback
453
- const stagingOutputBuffer = device.createBuffer({
454
- size: outputSize,
455
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
456
- });
457
-
458
- const stagingValidMaskBuffer = device.createBuffer({
459
- size: totalGridPoints * 4,
460
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
461
- });
462
-
463
- commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingOutputBuffer, 0, outputSize);
464
- commandEncoder.copyBufferToBuffer(validMaskBuffer, 0, stagingValidMaskBuffer, 0, totalGridPoints * 4);
465
-
466
- device.queue.submit([commandEncoder.finish()]);
467
-
468
- // Wait for GPU to finish
469
- await device.queue.onSubmittedWorkDone();
470
-
471
- // Read back results
472
- await stagingOutputBuffer.mapAsync(GPUMapMode.READ);
473
- await stagingValidMaskBuffer.mapAsync(GPUMapMode.READ);
474
-
475
- const outputData = new Float32Array(stagingOutputBuffer.getMappedRange());
476
- const validMaskData = new Uint32Array(stagingValidMaskBuffer.getMappedRange());
477
-
478
- let result, pointCount;
479
-
480
- if (filterMode === 0) {
481
- // Terrain: Dense output (Z-only), no compaction needed
482
- // Copy the full array (already has NaN for empty cells)
483
- result = new Float32Array(outputData);
484
- pointCount = totalGridPoints;
485
-
486
- if (config.debug) {
487
- // Count valid points for logging (sentinel value = -1e10)
488
- let zeroCount = 0;
489
- let validCount = 0;
490
- for (let i = 0; i < totalGridPoints; i++) {
491
- if (result[i] > EMPTY_CELL + 1) validCount++; // Any value significantly above sentinel
492
- if (result[i] === 0) zeroCount++;
493
- }
494
-
495
- let percentHit = validCount/totalGridPoints;
496
- if (zeroCount > 0 || percentHit < 0.5 ) {
497
- debug.log(totalGridPoints, 'cells,', round(percentHit*100), '% coverage,', zeroCount, 'zeros');
498
- }
499
- }
500
- } else {
501
- // Tool: Sparse output (X,Y,Z triplets), compact to remove invalid points
502
- const validPoints = [];
503
- for (let i = 0; i < totalGridPoints; i++) {
504
- if (validMaskData[i] === 1) {
505
- validPoints.push(
506
- outputData[i * 3],
507
- outputData[i * 3 + 1],
508
- outputData[i * 3 + 2]
509
- );
510
- }
511
- }
512
- result = new Float32Array(validPoints);
513
- pointCount = validPoints.length / 3;
514
- }
515
-
516
- stagingOutputBuffer.unmap();
517
- stagingValidMaskBuffer.unmap();
518
-
519
- // Cleanup
520
- triangleBuffer.destroy();
521
- outputBuffer.destroy();
522
- validMaskBuffer.destroy();
523
- uniformBuffer.destroy();
524
- spatialCellOffsetsBuffer.destroy();
525
- spatialTriangleIndicesBuffer.destroy();
526
- stagingOutputBuffer.destroy();
527
- stagingValidMaskBuffer.destroy();
528
-
529
- const endTime = performance.now();
530
- const conversionTime = endTime - startTime;
531
- // debug.log(`Rasterize complete: ${pointCount} points in ${conversionTime.toFixed(1)}ms`);
532
- // debug.log(`Bounds: min(${bounds.min.x.toFixed(2)}, ${bounds.min.y.toFixed(2)}, ${bounds.min.z.toFixed(2)}) max(${bounds.max.x.toFixed(2)}, ${bounds.max.y.toFixed(2)}, ${bounds.max.z.toFixed(2)})`);
533
-
534
- // Verify result data integrity
535
- if (filterMode === 0) {
536
- // Terrain: Dense Z-only format
537
- if (result.length > 0) {
538
- const firstZ = result[0] <= EMPTY_CELL + 1 ? 'EMPTY' : result[0].toFixed(3);
539
- const lastZ = result[result.length-1] <= EMPTY_CELL + 1 ? 'EMPTY' : result[result.length-1].toFixed(3);
540
- // debug.log(`First Z: ${firstZ}, Last Z: ${lastZ}`);
541
- }
542
- } else {
543
- // Tool: Sparse X,Y,Z format
544
- if (result.length > 0) {
545
- const firstPoint = `(${result[0].toFixed(3)}, ${result[1].toFixed(3)}, ${result[2].toFixed(3)})`;
546
- const lastIdx = result.length - 3;
547
- const lastPoint = `(${result[lastIdx].toFixed(3)}, ${result[lastIdx+1].toFixed(3)}, ${result[lastIdx+2].toFixed(3)})`;
548
- // debug.log(`First point: ${firstPoint}, Last point: ${lastPoint}`);
549
- }
550
- }
551
-
552
- return {
553
- positions: result,
554
- pointCount: pointCount,
555
- bounds: bounds,
556
- conversionTime: conversionTime,
557
- gridWidth: gridWidth,
558
- gridHeight: gridHeight,
559
- isDense: filterMode === 0 // True for terrain (dense), false for tool (sparse)
560
- };
561
- }
562
-
563
- // Create tiles for tiled rasterization
564
- function createTiles(bounds, stepSize, maxMemoryBytes) {
565
- const width = bounds.max.x - bounds.min.x;
566
- const height = bounds.max.y - bounds.min.y;
567
- const aspectRatio = width / height;
568
-
569
- // Calculate how many grid points we can fit in one tile
570
- // Terrain uses dense Z-only format: (gridW * gridH * 1 * 4) for output
571
- // This is 4x more efficient than the old sparse format (16 bytes → 4 bytes per point)
572
- const bytesPerPoint = 1 * 4; // 4 bytes per grid point (Z-only)
573
- const maxPointsPerTile = Math.floor(maxMemoryBytes / bytesPerPoint);
574
- debug.log(`Dense terrain format: ${bytesPerPoint} bytes/point (was 16), can fit ${(maxPointsPerTile/1e6).toFixed(1)}M points per tile`);
575
-
576
- // Calculate optimal tile grid dimensions while respecting aspect ratio
577
- // We want: tileGridW * tileGridH <= maxPointsPerTile
578
- // And: tileGridW / tileGridH ≈ aspectRatio
579
-
580
- let tileGridW, tileGridH;
581
- if (aspectRatio >= 1) {
582
- // Width >= Height
583
- tileGridH = Math.floor(Math.sqrt(maxPointsPerTile / aspectRatio));
584
- tileGridW = Math.floor(tileGridH * aspectRatio);
585
- } else {
586
- // Height > Width
587
- tileGridW = Math.floor(Math.sqrt(maxPointsPerTile * aspectRatio));
588
- tileGridH = Math.floor(tileGridW / aspectRatio);
589
- }
590
-
591
- // Ensure we don't exceed limits
592
- while (tileGridW * tileGridH * bytesPerPoint > maxMemoryBytes) {
593
- if (tileGridW > tileGridH) {
594
- tileGridW--;
595
- } else {
596
- tileGridH--;
597
- }
598
- }
599
-
600
- // Convert grid dimensions to world dimensions
601
- const tileWidth = tileGridW * stepSize;
602
- const tileHeight = tileGridH * stepSize;
603
-
604
- // Calculate number of tiles needed
605
- const tilesX = Math.ceil(width / tileWidth);
606
- const tilesY = Math.ceil(height / tileHeight);
607
-
608
- // Calculate actual tile dimensions (distribute evenly)
609
- const actualTileWidth = width / tilesX;
610
- const actualTileHeight = height / tilesY;
611
-
612
- debug.log(`Creating ${tilesX}x${tilesY} = ${tilesX * tilesY} tiles (${actualTileWidth.toFixed(2)}mm × ${actualTileHeight.toFixed(2)}mm each)`);
613
- debug.log(`Tile grid: ${Math.ceil(actualTileWidth / stepSize)}x${Math.ceil(actualTileHeight / stepSize)} points per tile`);
614
-
615
- const tiles = [];
616
- const overlap = stepSize * 2; // Overlap by 2 grid cells to ensure no gaps
617
-
618
- for (let ty = 0; ty < tilesY; ty++) {
619
- for (let tx = 0; tx < tilesX; tx++) {
620
- // Calculate base tile bounds (no overlap)
621
- let tileMinX = bounds.min.x + (tx * actualTileWidth);
622
- let tileMinY = bounds.min.y + (ty * actualTileHeight);
623
- let tileMaxX = Math.min(bounds.max.x, tileMinX + actualTileWidth);
624
- let tileMaxY = Math.min(bounds.max.y, tileMinY + actualTileHeight);
625
-
626
- // Add overlap (except at outer edges) - but DON'T extend beyond global bounds
627
- if (tx > 0) tileMinX = Math.max(bounds.min.x, tileMinX - overlap);
628
- if (ty > 0) tileMinY = Math.max(bounds.min.y, tileMinY - overlap);
629
- if (tx < tilesX - 1) tileMaxX = Math.min(bounds.max.x, tileMaxX + overlap);
630
- if (ty < tilesY - 1) tileMaxY = Math.min(bounds.max.y, tileMaxY + overlap);
631
-
632
- tiles.push({
633
- id: `tile_${tx}_${ty}`,
634
- bounds: {
635
- min: { x: tileMinX, y: tileMinY, z: bounds.min.z },
636
- max: { x: tileMaxX, y: tileMaxY, z: bounds.max.z }
637
- }
638
- });
639
- }
640
- }
641
-
642
- return { tiles, tilesX, tilesY };
643
- }
644
-
645
- // Stitch tiles from multiple rasterization passes
646
- function stitchTiles(tileResults, fullBounds, stepSize) {
647
- if (tileResults.length === 0) {
648
- throw new Error('No tile results to stitch');
649
- }
650
-
651
- // Check if results are dense (terrain) or sparse (tool)
652
- const isDense = tileResults[0].isDense;
653
-
654
- if (isDense) {
655
- // DENSE TERRAIN STITCHING: Simple array copying (Z-only format)
656
- debug.log(`Stitching ${tileResults.length} dense terrain tiles...`);
657
-
658
- // Calculate global grid dimensions
659
- const globalWidth = Math.ceil((fullBounds.max.x - fullBounds.min.x) / stepSize) + 1;
660
- const globalHeight = Math.ceil((fullBounds.max.y - fullBounds.min.y) / stepSize) + 1;
661
- const totalGridCells = globalWidth * globalHeight;
662
-
663
- // Allocate global dense grid (Z-only), initialize to sentinel value
664
- const globalGrid = new Float32Array(totalGridCells);
665
- globalGrid.fill(EMPTY_CELL);
666
-
667
- debug.log(`Global grid: ${globalWidth}x${globalHeight} = ${totalGridCells.toLocaleString()} cells`);
668
-
669
- // Copy each tile's Z-values to the correct position in global grid
670
- for (const tile of tileResults) {
671
- // Calculate tile's position in global grid
672
- const tileOffsetX = Math.round((tile.tileBounds.min.x - fullBounds.min.x) / stepSize);
673
- const tileOffsetY = Math.round((tile.tileBounds.min.y - fullBounds.min.y) / stepSize);
674
-
675
- const tileWidth = tile.gridWidth;
676
- const tileHeight = tile.gridHeight;
677
-
678
- // Copy Z-values row by row
679
- for (let ty = 0; ty < tileHeight; ty++) {
680
- const globalY = tileOffsetY + ty;
681
- if (globalY >= globalHeight) continue;
682
-
683
- for (let tx = 0; tx < tileWidth; tx++) {
684
- const globalX = tileOffsetX + tx;
685
- if (globalX >= globalWidth) continue;
686
-
687
- const tileIdx = ty * tileWidth + tx;
688
- const globalIdx = globalY * globalWidth + globalX;
689
- const tileZ = tile.positions[tileIdx];
690
-
691
- // For overlapping cells, keep max Z (terrain surface)
692
- // Skip empty cells (sentinel value)
693
- if (tileZ > EMPTY_CELL + 1) {
694
- const existingZ = globalGrid[globalIdx];
695
- if (existingZ <= EMPTY_CELL + 1 || tileZ > existingZ) {
696
- globalGrid[globalIdx] = tileZ;
697
- }
698
- }
699
- }
700
- }
701
- }
702
-
703
- // Count valid cells (above sentinel value)
704
- let validCount = 0;
705
- for (let i = 0; i < totalGridCells; i++) {
706
- if (globalGrid[i] > EMPTY_CELL + 1) validCount++;
707
- }
708
-
709
- debug.log(`Stitched: ${totalGridCells} total cells, ${validCount} with geometry (${(validCount/totalGridCells*100).toFixed(1)}% coverage)`);
710
-
711
- return {
712
- positions: globalGrid,
713
- pointCount: totalGridCells,
714
- bounds: fullBounds,
715
- gridWidth: globalWidth,
716
- gridHeight: globalHeight,
717
- isDense: true,
718
- conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
719
- tileCount: tileResults.length
720
- };
721
-
722
- } else {
723
- // SPARSE TOOL STITCHING: Keep existing deduplication logic (X,Y,Z triplets)
724
- debug.log(`Stitching ${tileResults.length} sparse tool tiles...`);
725
-
726
- const pointMap = new Map();
727
-
728
- for (const result of tileResults) {
729
- const positions = result.positions;
730
-
731
- // Calculate offset from tile origin to global origin (in grid cells)
732
- const tileOffsetX = Math.round((result.tileBounds.min.x - fullBounds.min.x) / stepSize);
733
- const tileOffsetY = Math.round((result.tileBounds.min.y - fullBounds.min.y) / stepSize);
734
-
735
- // Convert each point from tile-local to global grid coordinates
736
- for (let i = 0; i < positions.length; i += 3) {
737
- const localGridX = positions[i];
738
- const localGridY = positions[i + 1];
739
- const z = positions[i + 2];
740
-
741
- // Convert local grid indices to global grid indices
742
- const globalGridX = localGridX + tileOffsetX;
743
- const globalGridY = localGridY + tileOffsetY;
744
-
745
- const key = `${globalGridX},${globalGridY}`;
746
- const existing = pointMap.get(key);
747
-
748
- // Keep lowest Z value (for tool)
749
- if (!existing || z < existing.z) {
750
- pointMap.set(key, { x: globalGridX, y: globalGridY, z });
751
- }
752
- }
753
- }
754
-
755
- // Convert Map to flat array
756
- const finalPointCount = pointMap.size;
757
- const allPositions = new Float32Array(finalPointCount * 3);
758
- let writeOffset = 0;
759
-
760
- for (const point of pointMap.values()) {
761
- allPositions[writeOffset++] = point.x;
762
- allPositions[writeOffset++] = point.y;
763
- allPositions[writeOffset++] = point.z;
764
- }
765
-
766
- debug.log(`Stitched: ${finalPointCount} unique sparse points`);
767
-
768
- return {
769
- positions: allPositions,
770
- pointCount: finalPointCount,
771
- bounds: fullBounds,
772
- isDense: false,
773
- conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
774
- tileCount: tileResults.length
775
- };
776
- }
777
- }
778
-
779
- // Check if tiling is needed (only called for terrain, which uses dense format)
780
- function shouldUseTiling(bounds, stepSize) {
781
- if (!config || !config.autoTiling) return false;
782
- if (!deviceCapabilities) return false;
783
-
784
- const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
785
- const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
786
- const totalPoints = gridWidth * gridHeight;
787
-
788
- // Terrain uses dense Z-only format: 1 float (4 bytes) per grid cell
789
- const gpuOutputBuffer = totalPoints * 1 * 4;
790
- const totalGPUMemory = gpuOutputBuffer; // No mask needed for dense output
791
-
792
- // Use the smaller of configured limit or device capability
793
- const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
794
- const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
795
- const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
796
-
797
- return totalGPUMemory > maxSafeSize;
798
- }
799
-
800
- // Rasterize mesh - wrapper that handles automatic tiling if needed
801
- async function rasterizeMesh(triangles, stepSize, filterMode, options = {}) {
802
- const boundsOverride = options.bounds || options.min ? options : null; // Support old and new format
803
- const bounds = boundsOverride || calculateBounds(triangles);
804
-
805
- // Check if tiling is needed
806
- if (shouldUseTiling(bounds, stepSize)) {
807
- debug.log('Tiling required - switching to tiled rasterization');
808
-
809
- // Calculate max safe size per tile
810
- const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
811
- const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
812
- const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
813
-
814
- // Create tiles
815
- const { tiles } = createTiles(bounds, stepSize, maxSafeSize);
816
-
817
- // Rasterize each tile
818
- const tileResults = [];
819
- for (let i = 0; i < tiles.length; i++) {
820
- const tileStart = performance.now();
821
- debug.log(`Processing tile ${i + 1}/${tiles.length}: ${tiles[i].id}`);
822
- debug.log(` Tile bounds: min(${tiles[i].bounds.min.x.toFixed(2)}, ${tiles[i].bounds.min.y.toFixed(2)}) max(${tiles[i].bounds.max.x.toFixed(2)}, ${tiles[i].bounds.max.y.toFixed(2)})`);
823
-
824
- const tileResult = await rasterizeMeshSingle(triangles, stepSize, filterMode, {
825
- ...tiles[i].bounds,
826
- });
827
-
828
- const tileTime = performance.now() - tileStart;
829
- debug.log(` Tile ${i + 1} complete: ${tileResult.pointCount} points in ${tileTime.toFixed(1)}ms`);
830
-
831
- // Store tile bounds with result for coordinate conversion during stitching
832
- tileResult.tileBounds = tiles[i].bounds;
833
- tileResults.push(tileResult);
834
- }
835
-
836
- // Stitch tiles together (pass full bounds and step size for coordinate conversion)
837
- return stitchTiles(tileResults, bounds, stepSize);
838
- } else {
839
- // Single-pass rasterization
840
- return await rasterizeMeshSingle(triangles, stepSize, filterMode, options);
841
- }
842
- }
843
-
844
- // Helper: Create height map from dense terrain points (Z-only array)
845
- // Terrain is ALWAYS dense (Z-only), never sparse
846
- function createHeightMapFromPoints(points, gridStep, bounds = null) {
847
- if (!points || points.length === 0) {
848
- throw new Error('No points provided');
849
- }
850
-
851
- // Calculate dimensions from bounds
852
- if (!bounds) {
853
- throw new Error('Bounds required for height map creation');
854
- }
855
-
856
- const minX = bounds.min.x;
857
- const minY = bounds.min.y;
858
- const minZ = bounds.min.z;
859
- const maxX = bounds.max.x;
860
- const maxY = bounds.max.y;
861
- const maxZ = bounds.max.z;
862
- const width = Math.ceil((maxX - minX) / gridStep) + 1;
863
- const height = Math.ceil((maxY - minY) / gridStep) + 1;
864
-
865
- // Terrain is ALWAYS dense (Z-only format from GPU rasterizer)
866
- // debug.log(`Terrain dense format: ${width}x${height} = ${points.length} cells`);
867
-
868
- return {
869
- grid: points, // Dense Z-only array
870
- width,
871
- height,
872
- minX,
873
- minY,
874
- minZ,
875
- maxX,
876
- maxY,
877
- maxZ
878
- };
879
- }
880
-
881
- // Helper: Create sparse tool representation
882
- // Points come from GPU as [gridX, gridY, Z] - pure integer grid coordinates for X/Y
883
- function createSparseToolFromPoints(points) {
884
- if (!points || points.length === 0) {
885
- throw new Error('No tool points provided');
886
- }
887
-
888
- // Points are [gridX, gridY, Z] where gridX/gridY are grid indices (floats but integer values)
889
- // Find bounds in grid space and tool tip Z
890
- let minGridX = Infinity, minGridY = Infinity, minZ = Infinity;
891
- let maxGridX = -Infinity, maxGridY = -Infinity;
892
-
893
- for (let i = 0; i < points.length; i += 3) {
894
- const gridX = points[i]; // Already a grid index
895
- const gridY = points[i + 1]; // Already a grid index
896
- const z = points[i + 2];
897
-
898
- minGridX = Math.min(minGridX, gridX);
899
- maxGridX = Math.max(maxGridX, gridX);
900
- minGridY = Math.min(minGridY, gridY);
901
- maxGridY = Math.max(maxGridY, gridY);
902
- minZ = Math.min(minZ, z);
903
- }
904
-
905
- // Calculate tool center in grid coordinates (pure integer)
906
- const width = Math.floor(maxGridX - minGridX) + 1;
907
- const height = Math.floor(maxGridY - minGridY) + 1;
908
- const centerX = Math.floor(minGridX) + Math.floor(width / 2);
909
- const centerY = Math.floor(minGridY) + Math.floor(height / 2);
910
-
911
- // Convert each point to offset from center (integer arithmetic only)
912
- const xOffsets = [];
913
- const yOffsets = [];
914
- const zValues = [];
915
-
916
- for (let i = 0; i < points.length; i += 3) {
917
- const gridX = Math.floor(points[i]); // Grid index (ensure integer)
918
- const gridY = Math.floor(points[i + 1]); // Grid index (ensure integer)
919
- const z = points[i + 2];
920
-
921
- // Calculate offset from tool center (pure integer arithmetic)
922
- const xOffset = gridX - centerX;
923
- const yOffset = gridY - centerY;
924
- // Z relative to tool tip: tip=0, points above tip are positive
925
- // minZ is the lowest Z (tip), so z - minZ gives positive offsets upward
926
- const zValue = z;// - minZ;
927
-
928
- xOffsets.push(xOffset);
929
- yOffsets.push(yOffset);
930
- zValues.push(zValue);
931
- }
932
-
933
- return {
934
- count: xOffsets.length,
935
- xOffsets: new Int32Array(xOffsets),
936
- yOffsets: new Int32Array(yOffsets),
937
- zValues: new Float32Array(zValues),
938
- referenceZ: minZ
939
- };
940
- }
941
-
942
- // Generate toolpath with pre-created sparse tool (for batch operations)
943
- async function generateToolpathWithSparseTools(terrainPoints, sparseToolData, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
944
- const startTime = performance.now();
945
-
946
- try {
947
- // Create height map from terrain points (use terrain bounds if provided)
948
- const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
949
-
950
- // Run WebGPU compute with pre-created sparse tool
951
- const result = await runToolpathCompute(
952
- terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
953
- );
954
-
955
- return result;
956
- } catch (error) {
957
- debug.error('Error generating toolpath:', error);
958
- throw error;
959
- }
960
- }
961
-
962
- // Generate toolpath for a single region (internal)
963
- async function generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null) {
964
- const startTime = performance.now();
965
- debug.log('Generating toolpath...');
966
- debug.log(`Input: terrain ${terrainPoints.length/3} points, tool ${toolPoints.length/3} points, steps (${xStep}, ${yStep}), oobZ ${oobZ}, gridStep ${gridStep}`);
967
-
968
- if (terrainBounds) {
969
- 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)})`);
970
- }
971
-
972
- try {
973
- // Create height map from terrain points (use terrain bounds if provided)
974
- const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
975
- debug.log(`Created terrain map: ${terrainMapData.width}x${terrainMapData.height}`);
976
-
977
- // Create sparse tool representation
978
- const sparseToolData = createSparseToolFromPoints(toolPoints);
979
- debug.log(`Created sparse tool: ${sparseToolData.count} points`);
980
-
981
- // Run WebGPU compute
982
- const result = await runToolpathCompute(
983
- terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
984
- );
985
-
986
- return result;
987
- } catch (error) {
988
- debug.error('Error generating toolpath:', error);
989
- throw error;
990
- }
991
- }
992
-
993
- async function runToolpathCompute(terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime) {
994
- if (!isInitialized) {
995
- const success = await initWebGPU();
996
- if (!success) {
997
- throw new Error('WebGPU not available');
998
- }
999
- }
1000
-
1001
- // Use WASM-generated terrain grid
1002
- const terrainBuffer = device.createBuffer({
1003
- size: terrainMapData.grid.byteLength,
1004
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1005
- });
1006
- device.queue.writeBuffer(terrainBuffer, 0, terrainMapData.grid);
1007
-
1008
- // Use WASM-generated sparse tool
1009
- const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
1010
- const toolBufferI32 = new Int32Array(toolBufferData);
1011
- const toolBufferF32 = new Float32Array(toolBufferData);
1012
-
1013
- for (let i = 0; i < sparseToolData.count; i++) {
1014
- toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
1015
- toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
1016
- toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
1017
- toolBufferF32[i * 4 + 3] = 0;
1018
- }
1019
-
1020
- const toolBuffer = device.createBuffer({
1021
- size: toolBufferData.byteLength,
1022
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1023
- });
1024
- device.queue.writeBuffer(toolBuffer, 0, toolBufferData);
1025
-
1026
- // Calculate output dimensions
1027
- const pointsPerLine = Math.ceil(terrainMapData.width / xStep);
1028
- const numScanlines = Math.ceil(terrainMapData.height / yStep);
1029
- const outputSize = pointsPerLine * numScanlines;
1030
-
1031
- const outputBuffer = device.createBuffer({
1032
- size: outputSize * 4,
1033
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
1034
- });
1035
-
1036
- const uniformData = new Uint32Array([
1037
- terrainMapData.width,
1038
- terrainMapData.height,
1039
- sparseToolData.count,
1040
- xStep,
1041
- yStep,
1042
- 0,
1043
- pointsPerLine,
1044
- numScanlines,
1045
- 0, // y_offset (default 0 for planar mode)
1046
- ]);
1047
- const uniformDataFloat = new Float32Array(uniformData.buffer);
1048
- uniformDataFloat[5] = oobZ;
1049
-
1050
- const uniformBuffer = device.createBuffer({
1051
- size: uniformData.byteLength,
1052
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1053
- });
1054
- device.queue.writeBuffer(uniformBuffer, 0, uniformData);
1055
-
1056
- // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
1057
- await device.queue.onSubmittedWorkDone();
1058
-
1059
- // Use cached pipeline
1060
- const bindGroup = device.createBindGroup({
1061
- layout: cachedToolpathPipeline.getBindGroupLayout(0),
1062
- entries: [
1063
- { binding: 0, resource: { buffer: terrainBuffer } },
1064
- { binding: 1, resource: { buffer: toolBuffer } },
1065
- { binding: 2, resource: { buffer: outputBuffer } },
1066
- { binding: 3, resource: { buffer: uniformBuffer } },
1067
- ],
1068
- });
1069
-
1070
- const commandEncoder = device.createCommandEncoder();
1071
- const passEncoder = commandEncoder.beginComputePass();
1072
- passEncoder.setPipeline(cachedToolpathPipeline);
1073
- passEncoder.setBindGroup(0, bindGroup);
1074
-
1075
- const workgroupsX = Math.ceil(pointsPerLine / 16);
1076
- const workgroupsY = Math.ceil(numScanlines / 16);
1077
- passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
1078
- passEncoder.end();
1079
-
1080
- const stagingBuffer = device.createBuffer({
1081
- size: outputSize * 4,
1082
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1083
- });
1084
-
1085
- commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize * 4);
1086
-
1087
- device.queue.submit([commandEncoder.finish()]);
1088
-
1089
- // CRITICAL: Wait for GPU to finish before reading results
1090
- await device.queue.onSubmittedWorkDone();
1091
-
1092
- await stagingBuffer.mapAsync(GPUMapMode.READ);
1093
-
1094
- const outputData = new Float32Array(stagingBuffer.getMappedRange());
1095
- const result = new Float32Array(outputData);
1096
- stagingBuffer.unmap();
1097
-
1098
- terrainBuffer.destroy();
1099
- toolBuffer.destroy();
1100
- outputBuffer.destroy();
1101
- uniformBuffer.destroy();
1102
- stagingBuffer.destroy();
1103
-
1104
- const endTime = performance.now();
1105
-
1106
- return {
1107
- pathData: result,
1108
- numScanlines,
1109
- pointsPerLine,
1110
- generationTime: endTime - startTime
1111
- };
1112
- }
1113
-
1114
- // Create reusable GPU buffers for tiled toolpath generation
1115
- function createReusableToolpathBuffers(terrainWidth, terrainHeight, sparseToolData, xStep, yStep) {
1116
- const pointsPerLine = Math.ceil(terrainWidth / xStep);
1117
- const numScanlines = Math.ceil(terrainHeight / yStep);
1118
- const outputSize = pointsPerLine * numScanlines;
1119
-
1120
- // Create terrain buffer (will be updated for each tile)
1121
- const terrainBuffer = device.createBuffer({
1122
- size: terrainWidth * terrainHeight * 4,
1123
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1124
- });
1125
-
1126
- // Create tool buffer (STATIC - same for all tiles!)
1127
- const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
1128
- const toolBufferI32 = new Int32Array(toolBufferData);
1129
- const toolBufferF32 = new Float32Array(toolBufferData);
1130
-
1131
- for (let i = 0; i < sparseToolData.count; i++) {
1132
- toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
1133
- toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
1134
- toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
1135
- toolBufferF32[i * 4 + 3] = 0;
1136
- }
1137
-
1138
- const toolBuffer = device.createBuffer({
1139
- size: toolBufferData.byteLength,
1140
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1141
- });
1142
- device.queue.writeBuffer(toolBuffer, 0, toolBufferData); // Write once!
1143
-
1144
- // Create output buffer (will be read for each tile)
1145
- const outputBuffer = device.createBuffer({
1146
- size: outputSize * 4,
1147
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
1148
- });
1149
-
1150
- // Create uniform buffer (will be updated for each tile)
1151
- const uniformBuffer = device.createBuffer({
1152
- size: 36, // 9 fields × 4 bytes (added y_offset field)
1153
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1154
- });
1155
-
1156
- // Create staging buffer (will be reused for readback)
1157
- const stagingBuffer = device.createBuffer({
1158
- size: outputSize * 4,
1159
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1160
- });
1161
-
1162
- return {
1163
- terrainBuffer,
1164
- toolBuffer,
1165
- outputBuffer,
1166
- uniformBuffer,
1167
- stagingBuffer,
1168
- maxOutputSize: outputSize,
1169
- maxTerrainWidth: terrainWidth,
1170
- maxTerrainHeight: terrainHeight,
1171
- sparseToolData
1172
- };
1173
- }
1174
-
1175
- // Destroy reusable GPU buffers
1176
- function destroyReusableToolpathBuffers(buffers) {
1177
- buffers.terrainBuffer.destroy();
1178
- buffers.toolBuffer.destroy();
1179
- buffers.outputBuffer.destroy();
1180
- buffers.uniformBuffer.destroy();
1181
- buffers.stagingBuffer.destroy();
1182
- }
1183
-
1184
- // Run toolpath compute using pre-created reusable buffers
1185
- async function runToolpathComputeWithBuffers(terrainData, terrainWidth, terrainHeight, xStep, yStep, oobZ, buffers, startTime) {
1186
- // Update terrain buffer with new tile data
1187
- device.queue.writeBuffer(buffers.terrainBuffer, 0, terrainData);
1188
-
1189
- // Calculate output dimensions
1190
- const pointsPerLine = Math.ceil(terrainWidth / xStep);
1191
- const numScanlines = Math.ceil(terrainHeight / yStep);
1192
- const outputSize = pointsPerLine * numScanlines;
1193
-
1194
- // Calculate Y offset for single-scanline radial mode
1195
- // When numScanlines=1 and terrainHeight > 1, center the tool at the midline
1196
- const yOffset = (numScanlines === 1 && terrainHeight > 1) ? Math.floor(terrainHeight / 2) : 0;
1197
-
1198
- // Update uniforms for this tile
1199
- const uniformData = new Uint32Array([
1200
- terrainWidth,
1201
- terrainHeight,
1202
- buffers.sparseToolData.count,
1203
- xStep,
1204
- yStep,
1205
- 0,
1206
- pointsPerLine,
1207
- numScanlines,
1208
- yOffset, // y_offset for radial single-scanline mode
1209
- ]);
1210
- const uniformDataFloat = new Float32Array(uniformData.buffer);
1211
- uniformDataFloat[5] = oobZ;
1212
- device.queue.writeBuffer(buffers.uniformBuffer, 0, uniformData);
1213
-
1214
- // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
1215
- // Without this, compute shader may read stale/incomplete buffer data
1216
- await device.queue.onSubmittedWorkDone();
1217
-
1218
- // Create bind group (reusing cached pipeline)
1219
- const bindGroup = device.createBindGroup({
1220
- layout: cachedToolpathPipeline.getBindGroupLayout(0),
1221
- entries: [
1222
- { binding: 0, resource: { buffer: buffers.terrainBuffer } },
1223
- { binding: 1, resource: { buffer: buffers.toolBuffer } },
1224
- { binding: 2, resource: { buffer: buffers.outputBuffer } },
1225
- { binding: 3, resource: { buffer: buffers.uniformBuffer } },
1226
- ],
1227
- });
1228
-
1229
- // Dispatch compute shader
1230
- const commandEncoder = device.createCommandEncoder();
1231
- const passEncoder = commandEncoder.beginComputePass();
1232
- passEncoder.setPipeline(cachedToolpathPipeline);
1233
- passEncoder.setBindGroup(0, bindGroup);
1234
-
1235
- const workgroupsX = Math.ceil(pointsPerLine / 16);
1236
- const workgroupsY = Math.ceil(numScanlines / 16);
1237
- passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
1238
- passEncoder.end();
1239
-
1240
- // Copy to staging buffer
1241
- commandEncoder.copyBufferToBuffer(buffers.outputBuffer, 0, buffers.stagingBuffer, 0, outputSize * 4);
1242
-
1243
- device.queue.submit([commandEncoder.finish()]);
1244
-
1245
- // CRITICAL: Wait for GPU to finish before reading results
1246
- await device.queue.onSubmittedWorkDone();
1247
-
1248
- await buffers.stagingBuffer.mapAsync(GPUMapMode.READ);
1249
-
1250
- // Create a true copy using slice() - new Float32Array(typedArray) only creates a view!
1251
- const outputData = new Float32Array(buffers.stagingBuffer.getMappedRange(), 0, outputSize);
1252
- const result = outputData.slice(); // slice() creates a new ArrayBuffer with copied data
1253
- buffers.stagingBuffer.unmap();
1254
-
1255
- const endTime = performance.now();
1256
-
1257
- // Debug: Log first few Z values to detect non-determinism
1258
- if (result.length > 0) {
1259
- const samples = [];
1260
- for (let i = 0; i < Math.min(10, result.length); i++) {
1261
- samples.push(result[i].toFixed(3));
1262
- }
1263
- // debug.log(`[Toolpath] Output samples (${result.length} total): ${samples.join(', ')}`);
1264
- }
1265
-
1266
- return {
1267
- pathData: result,
1268
- numScanlines,
1269
- pointsPerLine,
1270
- generationTime: endTime - startTime
1271
- };
1272
- }
1273
-
1274
- // Generate toolpath with tiling support (public API)
1275
- async function generateToolpath(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
1276
- // Calculate bounds if not provided
1277
- if (!terrainBounds) {
1278
- let minX = Infinity, minY = Infinity, minZ = Infinity;
1279
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
1280
- for (let i = 0; i < terrainPoints.length; i += 3) {
1281
- minX = Math.min(minX, terrainPoints[i]);
1282
- maxX = Math.max(maxX, terrainPoints[i]);
1283
- minY = Math.min(minY, terrainPoints[i + 1]);
1284
- maxY = Math.max(maxY, terrainPoints[i + 1]);
1285
- minZ = Math.min(minZ, terrainPoints[i + 2]);
1286
- maxZ = Math.max(maxZ, terrainPoints[i + 2]);
1287
- }
1288
- terrainBounds = {
1289
- min: { x: minX, y: minY, z: minZ },
1290
- max: { x: maxX, y: maxY, z: maxZ }
1291
- };
1292
- }
1293
-
1294
- // Note: singleScanline mode means OUTPUT only centerline, but terrain bounds stay full
1295
- // This ensures all terrain Y values contribute to tool interference at the centerline
1296
-
1297
- // Debug tool bounds and center
1298
- for (let i=0; i<toolPoints.length; i += 3) {
1299
- if (toolPoints[i] === 0 && toolPoints[i+1] === 0) {
1300
- debug.log('[WebGPU Worker]', { TOOL_CENTER: toolPoints[i+2] });
1301
- }
1302
- }
1303
- debug.log('[WebGPU Worker]',
1304
- 'toolZMin:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.min(a,b), Infinity)),
1305
- 'toolZMax:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.max(a,b), -Infinity))
1306
- );
1307
-
1308
- // Calculate tool dimensions for overlap
1309
- // Tool points are [gridX, gridY, Z] where X/Y are grid indices (not mm)
1310
- let toolMinX = Infinity, toolMaxX = -Infinity;
1311
- let toolMinY = Infinity, toolMaxY = -Infinity;
1312
- for (let i = 0; i < toolPoints.length; i += 3) {
1313
- toolMinX = Math.min(toolMinX, toolPoints[i]);
1314
- toolMaxX = Math.max(toolMaxX, toolPoints[i]);
1315
- toolMinY = Math.min(toolMinY, toolPoints[i + 1]);
1316
- toolMaxY = Math.max(toolMaxY, toolPoints[i + 1]);
1317
- }
1318
- // Tool dimensions in grid cells
1319
- const toolWidthCells = toolMaxX - toolMinX;
1320
- const toolHeightCells = toolMaxY - toolMinY;
1321
- // Convert to mm for logging
1322
- const toolWidthMm = toolWidthCells * gridStep;
1323
- const toolHeightMm = toolHeightCells * gridStep;
1324
-
1325
- // Check if tiling is needed based on output grid size
1326
- const outputWidth = Math.ceil((terrainBounds.max.x - terrainBounds.min.x) / gridStep) + 1;
1327
- const outputHeight = Math.ceil((terrainBounds.max.y - terrainBounds.min.y) / gridStep) + 1;
1328
- const outputPoints = Math.ceil(outputWidth / xStep) * Math.ceil(outputHeight / yStep);
1329
- const outputMemory = outputPoints * 4; // 4 bytes per float
1330
-
1331
- const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
1332
- const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
1333
- const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
1334
-
1335
- if (outputMemory <= maxSafeSize) {
1336
- // No tiling needed
1337
- return await generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds);
1338
- }
1339
-
1340
- // Tiling needed (terrain is ALWAYS dense)
1341
- const tilingStartTime = performance.now();
1342
- debug.log('Using tiled toolpath generation');
1343
- debug.log(`Terrain: DENSE (${terrainPoints.length} cells = ${outputWidth}x${outputHeight})`);
1344
- debug.log(`Tool dimensions: ${toolWidthMm.toFixed(2)}mm × ${toolHeightMm.toFixed(2)}mm (${toolWidthCells}×${toolHeightCells} cells)`);
1345
-
1346
- // Create tiles with tool-size overlap (pass dimensions in grid cells)
1347
- const { tiles, maxTileGridWidth, maxTileGridHeight } = createToolpathTiles(terrainBounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxSafeSize);
1348
- debug.log(`Created ${tiles.length} tiles`);
1349
-
1350
- // Pre-generate all tile terrain point arrays
1351
- const pregenStartTime = performance.now();
1352
- debug.log(`Pre-generating ${tiles.length} tile terrain arrays...`);
1353
- const allTileTerrainPoints = [];
1354
-
1355
- for (let i = 0; i < tiles.length; i++) {
1356
- const tile = tiles[i];
1357
-
1358
- // Extract terrain sub-grid for this tile (terrain is ALWAYS dense)
1359
- const tileMinGridX = Math.floor((tile.bounds.min.x - terrainBounds.min.x) / gridStep);
1360
- const tileMaxGridX = Math.ceil((tile.bounds.max.x - terrainBounds.min.x) / gridStep);
1361
- const tileMinGridY = Math.floor((tile.bounds.min.y - terrainBounds.min.y) / gridStep);
1362
- const tileMaxGridY = Math.ceil((tile.bounds.max.y - terrainBounds.min.y) / gridStep);
1363
-
1364
- const tileWidth = tileMaxGridX - tileMinGridX + 1;
1365
- const tileHeight = tileMaxGridY - tileMinGridY + 1;
1366
-
1367
- // Pad to max dimensions for consistent buffer sizing
1368
- const paddedTileTerrainPoints = new Float32Array(maxTileGridWidth * maxTileGridHeight);
1369
- paddedTileTerrainPoints.fill(EMPTY_CELL);
1370
-
1371
- // Copy relevant sub-grid from full terrain into top-left of padded array
1372
- for (let ty = 0; ty < tileHeight; ty++) {
1373
- const globalY = tileMinGridY + ty;
1374
- if (globalY < 0 || globalY >= outputHeight) continue;
1375
-
1376
- for (let tx = 0; tx < tileWidth; tx++) {
1377
- const globalX = tileMinGridX + tx;
1378
- if (globalX < 0 || globalX >= outputWidth) continue;
1379
-
1380
- const globalIdx = globalY * outputWidth + globalX;
1381
- const tileIdx = ty * maxTileGridWidth + tx; // Use maxTileGridWidth for stride
1382
- paddedTileTerrainPoints[tileIdx] = terrainPoints[globalIdx];
1383
- }
1384
- }
1385
-
1386
- allTileTerrainPoints.push({
1387
- data: paddedTileTerrainPoints,
1388
- actualWidth: tileWidth,
1389
- actualHeight: tileHeight
1390
- });
1391
- }
1392
-
1393
- const pregenTime = performance.now() - pregenStartTime;
1394
- debug.log(`Pre-generation complete in ${pregenTime.toFixed(1)}ms`);
1395
-
1396
- // Create reusable GPU buffers (sized for maximum tile)
1397
- if (!isInitialized) {
1398
- const success = await initWebGPU();
1399
- if (!success) {
1400
- throw new Error('WebGPU not available');
1401
- }
1402
- }
1403
-
1404
- const sparseToolData = createSparseToolFromPoints(toolPoints);
1405
- const reusableBuffers = createReusableToolpathBuffers(maxTileGridWidth, maxTileGridHeight, sparseToolData, xStep, yStep);
1406
- debug.log(`Created reusable GPU buffers for ${maxTileGridWidth}x${maxTileGridHeight} tiles`);
1407
-
1408
- // Process each tile with reusable buffers
1409
- const tileResults = [];
1410
- let totalTileTime = 0;
1411
- for (let i = 0; i < tiles.length; i++) {
1412
- const tile = tiles[i];
1413
- const tileStartTime = performance.now();
1414
- debug.log(`Processing tile ${i + 1}/${tiles.length}...`);
1415
-
1416
- // Report progress
1417
- const percent = Math.round(((i + 1) / tiles.length) * 100);
1418
- self.postMessage({
1419
- type: 'toolpath-progress',
1420
- data: {
1421
- percent,
1422
- current: i + 1,
1423
- total: tiles.length,
1424
- layer: i + 1 // Using tile index as "layer" for consistency
1425
- }
1426
- });
1427
-
1428
- debug.log(`Tile ${i+1} using pre-generated terrain: ${allTileTerrainPoints[i].actualWidth}x${allTileTerrainPoints[i].actualHeight} (padded to ${maxTileGridWidth}x${maxTileGridHeight})`);
1429
-
1430
- // Generate toolpath for this tile using reusable buffers
1431
- const tileToolpathResult = await runToolpathComputeWithBuffers(
1432
- allTileTerrainPoints[i].data,
1433
- maxTileGridWidth,
1434
- maxTileGridHeight,
1435
- xStep,
1436
- yStep,
1437
- oobZ,
1438
- reusableBuffers,
1439
- tileStartTime
1440
- );
1441
-
1442
- const tileTime = performance.now() - tileStartTime;
1443
- totalTileTime += tileTime;
1444
-
1445
- tileResults.push({
1446
- pathData: tileToolpathResult.pathData,
1447
- numScanlines: tileToolpathResult.numScanlines,
1448
- pointsPerLine: tileToolpathResult.pointsPerLine,
1449
- tile: tile
1450
- });
1451
-
1452
- debug.log(`Tile ${i + 1}/${tiles.length} complete: ${tileToolpathResult.numScanlines}×${tileToolpathResult.pointsPerLine} in ${tileTime.toFixed(1)}ms`);
1453
- }
1454
-
1455
- // Cleanup reusable buffers
1456
- destroyReusableToolpathBuffers(reusableBuffers);
1457
-
1458
- debug.log(`All tiles processed in ${totalTileTime.toFixed(1)}ms (avg ${(totalTileTime/tiles.length).toFixed(1)}ms per tile)`);
1459
-
1460
- // Stitch tiles together, dropping overlap regions
1461
- const stitchStartTime = performance.now();
1462
- const stitchedResult = stitchToolpathTiles(tileResults, terrainBounds, gridStep, xStep, yStep);
1463
- const stitchTime = performance.now() - stitchStartTime;
1464
-
1465
- const totalTime = performance.now() - tilingStartTime;
1466
- debug.log(`Stitching took ${stitchTime.toFixed(1)}ms`);
1467
- debug.log(`Tiled toolpath complete: ${stitchedResult.numScanlines}×${stitchedResult.pointsPerLine} in ${totalTime.toFixed(1)}ms total`);
1468
-
1469
- // Update generation time to reflect total tiled time
1470
- stitchedResult.generationTime = totalTime;
1471
-
1472
- return stitchedResult;
1473
- }
1474
-
1475
- // Create tiles for toolpath generation with overlap (using integer grid coordinates)
1476
- // toolWidth and toolHeight are in grid cells (not mm)
1477
- function createToolpathTiles(bounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxMemoryBytes) {
1478
- // Calculate global grid dimensions
1479
- const globalGridWidth = Math.ceil((bounds.max.x - bounds.min.x) / gridStep) + 1;
1480
- const globalGridHeight = Math.ceil((bounds.max.y - bounds.min.y) / gridStep) + 1;
1481
-
1482
- // Calculate tool overlap in grid cells (use radius = half diameter)
1483
- // Tool centered at tile boundary needs terrain extending half tool width beyond boundary
1484
- const toolOverlapX = Math.ceil(toolWidthCells / 2);
1485
- const toolOverlapY = Math.ceil(toolHeightCells / 2);
1486
-
1487
- // Binary search for optimal tile size in grid cells
1488
- let low = Math.max(toolOverlapX, toolOverlapY) * 2; // At least 2x tool size
1489
- let high = Math.max(globalGridWidth, globalGridHeight);
1490
- let bestTileGridSize = high;
1491
-
1492
- while (low <= high) {
1493
- const mid = Math.floor((low + high) / 2);
1494
- const outputW = Math.ceil(mid / xStep);
1495
- const outputH = Math.ceil(mid / yStep);
1496
- const memoryNeeded = outputW * outputH * 4;
1497
-
1498
- if (memoryNeeded <= maxMemoryBytes) {
1499
- bestTileGridSize = mid;
1500
- low = mid + 1;
1501
- } else {
1502
- high = mid - 1;
1503
- }
1504
- }
1505
-
1506
- const tilesX = Math.ceil(globalGridWidth / bestTileGridSize);
1507
- const tilesY = Math.ceil(globalGridHeight / bestTileGridSize);
1508
- const coreGridWidth = Math.ceil(globalGridWidth / tilesX);
1509
- const coreGridHeight = Math.ceil(globalGridHeight / tilesY);
1510
-
1511
- // Calculate maximum tile dimensions (for buffer sizing)
1512
- const maxTileGridWidth = coreGridWidth + 2 * toolOverlapX;
1513
- const maxTileGridHeight = coreGridHeight + 2 * toolOverlapY;
1514
-
1515
- debug.log(`Creating ${tilesX}×${tilesY} tiles (${coreGridWidth}×${coreGridHeight} cells core + ${toolOverlapX}×${toolOverlapY} cells overlap)`);
1516
- debug.log(`Max tile dimensions: ${maxTileGridWidth}×${maxTileGridHeight} cells (for buffer sizing)`);
1517
-
1518
- const tiles = [];
1519
- for (let ty = 0; ty < tilesY; ty++) {
1520
- for (let tx = 0; tx < tilesX; tx++) {
1521
- // Core tile in grid coordinates
1522
- const coreGridStartX = tx * coreGridWidth;
1523
- const coreGridStartY = ty * coreGridHeight;
1524
- const coreGridEndX = Math.min((tx + 1) * coreGridWidth, globalGridWidth) - 1;
1525
- const coreGridEndY = Math.min((ty + 1) * coreGridHeight, globalGridHeight) - 1;
1526
-
1527
- // Extended tile with overlap in grid coordinates
1528
- let extGridStartX = coreGridStartX;
1529
- let extGridStartY = coreGridStartY;
1530
- let extGridEndX = coreGridEndX;
1531
- let extGridEndY = coreGridEndY;
1532
-
1533
- // Add overlap on sides that aren't at global boundary
1534
- if (tx > 0) extGridStartX -= toolOverlapX;
1535
- if (ty > 0) extGridStartY -= toolOverlapY;
1536
- if (tx < tilesX - 1) extGridEndX += toolOverlapX;
1537
- if (ty < tilesY - 1) extGridEndY += toolOverlapY;
1538
-
1539
- // Clamp to global bounds
1540
- extGridStartX = Math.max(0, extGridStartX);
1541
- extGridStartY = Math.max(0, extGridStartY);
1542
- extGridEndX = Math.min(globalGridWidth - 1, extGridEndX);
1543
- extGridEndY = Math.min(globalGridHeight - 1, extGridEndY);
1544
-
1545
- // Calculate actual dimensions for this tile
1546
- const tileGridWidth = extGridEndX - extGridStartX + 1;
1547
- const tileGridHeight = extGridEndY - extGridStartY + 1;
1548
-
1549
- // Convert grid coordinates to world coordinates
1550
- const extMinX = bounds.min.x + extGridStartX * gridStep;
1551
- const extMinY = bounds.min.y + extGridStartY * gridStep;
1552
- const extMaxX = bounds.min.x + extGridEndX * gridStep;
1553
- const extMaxY = bounds.min.y + extGridEndY * gridStep;
1554
-
1555
- const coreMinX = bounds.min.x + coreGridStartX * gridStep;
1556
- const coreMinY = bounds.min.y + coreGridStartY * gridStep;
1557
- const coreMaxX = bounds.min.x + coreGridEndX * gridStep;
1558
- const coreMaxY = bounds.min.y + coreGridEndY * gridStep;
1559
-
1560
- tiles.push({
1561
- id: `tile_${tx}_${ty}`,
1562
- tx, ty,
1563
- tilesX, tilesY,
1564
- gridWidth: tileGridWidth,
1565
- gridHeight: tileGridHeight,
1566
- bounds: {
1567
- min: { x: extMinX, y: extMinY, z: bounds.min.z },
1568
- max: { x: extMaxX, y: extMaxY, z: bounds.max.z }
1569
- },
1570
- core: {
1571
- gridStart: { x: coreGridStartX, y: coreGridStartY },
1572
- gridEnd: { x: coreGridEndX, y: coreGridEndY },
1573
- min: { x: coreMinX, y: coreMinY },
1574
- max: { x: coreMaxX, y: coreMaxY }
1575
- }
1576
- });
1577
- }
1578
- }
1579
-
1580
- return { tiles, maxTileGridWidth, maxTileGridHeight };
1581
- }
1582
-
1583
- // Stitch toolpath tiles together, dropping overlap regions (using integer grid coordinates)
1584
- function stitchToolpathTiles(tileResults, globalBounds, gridStep, xStep, yStep) {
1585
- // Calculate global output dimensions
1586
- const globalWidth = Math.ceil((globalBounds.max.x - globalBounds.min.x) / gridStep) + 1;
1587
- const globalHeight = Math.ceil((globalBounds.max.y - globalBounds.min.y) / gridStep) + 1;
1588
- const globalPointsPerLine = Math.ceil(globalWidth / xStep);
1589
- const globalNumScanlines = Math.ceil(globalHeight / yStep);
1590
-
1591
- debug.log(`Stitching toolpath: global grid ${globalWidth}x${globalHeight}, output ${globalPointsPerLine}x${globalNumScanlines}`);
1592
-
1593
- const result = new Float32Array(globalPointsPerLine * globalNumScanlines);
1594
- result.fill(NaN);
1595
-
1596
- // Fast path for 1x1 stepping: use bulk row copying
1597
- const use1x1FastPath = (xStep === 1 && yStep === 1);
1598
-
1599
- for (const tileResult of tileResults) {
1600
- const tile = tileResult.tile;
1601
- const tileData = tileResult.pathData;
1602
-
1603
- // Use the pre-calculated integer grid coordinates from tile.core
1604
- const coreGridStartX = tile.core.gridStart.x;
1605
- const coreGridStartY = tile.core.gridStart.y;
1606
- const coreGridEndX = tile.core.gridEnd.x;
1607
- const coreGridEndY = tile.core.gridEnd.y;
1608
-
1609
- // Calculate tile's extended grid coordinates
1610
- const extGridStartX = Math.round((tile.bounds.min.x - globalBounds.min.x) / gridStep);
1611
- const extGridStartY = Math.round((tile.bounds.min.y - globalBounds.min.y) / gridStep);
1612
-
1613
- let copiedCount = 0;
1614
-
1615
- // Calculate output coordinate ranges for this tile's core
1616
- // Core region in grid coordinates
1617
- const coreGridWidth = coreGridEndX - coreGridStartX + 1;
1618
- const coreGridHeight = coreGridEndY - coreGridStartY + 1;
1619
-
1620
- // Core region in output coordinates (sampled by xStep/yStep)
1621
- const coreOutStartX = Math.floor(coreGridStartX / xStep);
1622
- const coreOutStartY = Math.floor(coreGridStartY / yStep);
1623
- const coreOutEndX = Math.floor(coreGridEndX / xStep);
1624
- const coreOutEndY = Math.floor(coreGridEndY / yStep);
1625
- const coreOutWidth = coreOutEndX - coreOutStartX + 1;
1626
- const coreOutHeight = coreOutEndY - coreOutStartY + 1;
1627
-
1628
- // Tile's extended region start in grid coordinates
1629
- const extOutStartX = Math.floor(extGridStartX / xStep);
1630
- const extOutStartY = Math.floor(extGridStartY / yStep);
1631
-
1632
- // Copy entire rows at once (works for all stepping values)
1633
- for (let outY = 0; outY < coreOutHeight; outY++) {
1634
- const globalOutY = coreOutStartY + outY;
1635
- const tileOutY = globalOutY - extOutStartY;
1636
-
1637
- if (globalOutY >= 0 && globalOutY < globalNumScanlines &&
1638
- tileOutY >= 0 && tileOutY < tileResult.numScanlines) {
1639
-
1640
- const globalRowStart = globalOutY * globalPointsPerLine + coreOutStartX;
1641
- const tileRowStart = tileOutY * tileResult.pointsPerLine + (coreOutStartX - extOutStartX);
1642
-
1643
- // Bulk copy entire row of output values
1644
- result.set(tileData.subarray(tileRowStart, tileRowStart + coreOutWidth), globalRowStart);
1645
- copiedCount += coreOutWidth;
1646
- }
1647
- }
1648
-
1649
- debug.log(` Tile ${tile.id}: copied ${copiedCount} values`);
1650
- }
1651
-
1652
- // Count how many output values are still NaN (gaps)
1653
- let nanCount = 0;
1654
- for (let i = 0; i < result.length; i++) {
1655
- if (isNaN(result[i])) nanCount++;
1656
- }
1657
- debug.log(`Stitching complete: ${result.length} total values, ${nanCount} still NaN`);
1658
-
1659
- return {
1660
- pathData: result,
1661
- numScanlines: globalNumScanlines,
1662
- pointsPerLine: globalPointsPerLine,
1663
- generationTime: 0 // Sum from tiles if needed
1664
- };
1665
- }
1666
-
1667
- // Radial: Rasterize model with rotating ray planes and X-bucketing
1668
- async function radialRasterize({
1669
- triangles,
1670
- bucketData,
1671
- resolution,
1672
- angleStep,
1673
- numAngles,
1674
- maxRadius,
1675
- toolWidth,
1676
- zFloor,
1677
- bounds,
1678
- startAngle = 0,
1679
- reusableBuffers = null,
1680
- returnBuffersForReuse = false,
1681
- batchInfo = {}
1682
- }) {
1683
- if (!device) {
1684
- throw new Error('WebGPU not initialized');
1685
- }
1686
-
1687
- const timings = {
1688
- start: performance.now(),
1689
- prep: 0,
1690
- gpu: 0,
1691
- stitch: 0
1692
- };
1693
-
1694
- // Calculate grid dimensions based on BUCKET range (not model bounds)
1695
- // Buckets may extend slightly beyond model bounds due to rounding
1696
- const bucketMinX = bucketData.buckets[0].minX;
1697
- const bucketMaxX = bucketData.buckets[bucketData.numBuckets - 1].maxX;
1698
- const gridWidth = Math.ceil((bucketMaxX - bucketMinX) / resolution);
1699
- const gridYHeight = Math.ceil(toolWidth / resolution);
1700
- const bucketGridWidth = Math.ceil((bucketData.buckets[0].maxX - bucketData.buckets[0].minX) / resolution);
1701
-
1702
- // Calculate workgroup load distribution for timeout analysis
1703
- const bucketTriangleCounts = bucketData.buckets.map(b => b.count);
1704
- const minTriangles = Math.min(...bucketTriangleCounts);
1705
- const maxTriangles = Math.max(...bucketTriangleCounts);
1706
- const avgTriangles = bucketTriangleCounts.reduce((a, b) => a + b, 0) / bucketTriangleCounts.length;
1707
- const workPerWorkgroup = maxTriangles * numAngles * bucketGridWidth * gridYHeight;
1708
-
1709
- // Determine bucket batching to avoid GPU timeouts
1710
- // Target: keep max work per batch under ~1M ray-triangle tests
1711
- const maxWorkPerBatch = 1e6;
1712
- const estimatedWorkPerBucket = avgTriangles * numAngles * bucketGridWidth * gridYHeight;
1713
-
1714
- // Calculate buckets per batch, but enforce reasonable limits
1715
- // - Minimum: 10 buckets per batch (unless total < 10)
1716
- // - Maximum: all buckets if work is reasonable
1717
- let maxBucketsPerBatch;
1718
- if (estimatedWorkPerBucket === 0) {
1719
- maxBucketsPerBatch = bucketData.numBuckets; // Empty model
1720
- } else {
1721
- const idealBucketsPerBatch = Math.floor(maxWorkPerBatch / estimatedWorkPerBucket);
1722
- const minBucketsPerBatch = Math.min(4, bucketData.numBuckets);
1723
- maxBucketsPerBatch = Math.max(minBucketsPerBatch, idealBucketsPerBatch);
1724
- // Cap at total buckets
1725
- maxBucketsPerBatch = Math.min(maxBucketsPerBatch, bucketData.numBuckets);
1726
- }
1727
-
1728
- const numBucketBatches = Math.ceil(bucketData.numBuckets / maxBucketsPerBatch);
1729
-
1730
- if (diagnostic) {
1731
- debug.log(`Radial: ${gridWidth}x${gridYHeight} grid, ${numAngles} angles, ${bucketData.buckets.length} buckets`);
1732
- debug.log(`Load: min=${minTriangles} max=${maxTriangles} avg=${avgTriangles.toFixed(0)} (${(maxTriangles/avgTriangles).toFixed(2)}x imbalance, worst=${(workPerWorkgroup/1e6).toFixed(1)}M tests)`);
1733
- debug.log(`Estimated work/bucket: ${(estimatedWorkPerBucket/1e6).toFixed(1)}M tests`);
1734
- if (numBucketBatches > 1) {
1735
- debug.log(`Bucket batching: ${numBucketBatches} batches of ~${maxBucketsPerBatch} buckets to avoid timeout`);
1736
- }
1737
- }
1738
-
1739
- // Reuse buffers if provided, otherwise create new ones
1740
- let triangleBuffer, triangleIndicesBuffer;
1741
- let shouldCleanupBuffers = false;
1742
-
1743
- if (reusableBuffers) {
1744
- // Reuse cached buffers from previous angle batch
1745
- triangleBuffer = reusableBuffers.triangleBuffer;
1746
- triangleIndicesBuffer = reusableBuffers.triangleIndicesBuffer;
1747
- } else {
1748
- // Create new GPU buffers (first batch or non-batched operation)
1749
- shouldCleanupBuffers = true;
1750
-
1751
- triangleBuffer = device.createBuffer({
1752
- size: triangles.byteLength,
1753
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1754
- mappedAtCreation: true
1755
- });
1756
- new Float32Array(triangleBuffer.getMappedRange()).set(triangles);
1757
- triangleBuffer.unmap();
1758
-
1759
- // Create triangle indices buffer
1760
- triangleIndicesBuffer = device.createBuffer({
1761
- size: bucketData.triangleIndices.byteLength,
1762
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1763
- mappedAtCreation: true
1764
- });
1765
- new Uint32Array(triangleIndicesBuffer.getMappedRange()).set(bucketData.triangleIndices);
1766
- triangleIndicesBuffer.unmap();
1767
- }
1768
-
1769
- // Create output buffer (all angles, all buckets)
1770
- const outputSize = numAngles * bucketData.numBuckets * bucketGridWidth * gridYHeight * 4;
1771
- const outputBuffer = device.createBuffer({
1772
- size: outputSize,
1773
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
1774
- });
1775
-
1776
- // CRITICAL: Initialize output buffer with zFloor to avoid reading garbage data
1777
- const initData = new Float32Array(outputSize / 4);
1778
- initData.fill(zFloor);
1779
- device.queue.writeBuffer(outputBuffer, 0, initData);
1780
- // Note: No need to wait - GPU will execute writeBuffer before compute shader
1781
-
1782
- // Prep complete, GPU starting
1783
- timings.prep = performance.now() - timings.start;
1784
- const gpuStart = performance.now();
1785
-
1786
- // Use cached pipeline (created in initWebGPU)
1787
- const pipeline = cachedRadialBatchPipeline;
1788
-
1789
- // Process buckets in batches to avoid GPU timeouts
1790
- const commandEncoder = device.createCommandEncoder();
1791
- const passEncoder = commandEncoder.beginComputePass();
1792
- passEncoder.setPipeline(pipeline);
1793
-
1794
- const dispatchX = Math.ceil(numAngles / 8);
1795
- const dispatchY = Math.ceil(gridYHeight / 8);
1796
-
1797
- // Collect buffers to destroy after GPU completes
1798
- const batchBuffersToDestroy = [];
1799
- debug.log(`Dispatch (${dispatchX}, ${dispatchY}, ${maxBucketsPerBatch}) in ${numBucketBatches} Chunks`);
1800
-
1801
- for (let batchIdx = 0; batchIdx < numBucketBatches; batchIdx++) {
1802
- const startBucket = batchIdx * maxBucketsPerBatch;
1803
- const endBucket = Math.min(startBucket + maxBucketsPerBatch, bucketData.numBuckets);
1804
- const bucketsInBatch = endBucket - startBucket;
1805
-
1806
- // Create bucket info buffer for this batch
1807
- const bucketInfoSize = bucketsInBatch * 16; // 4 fields * 4 bytes per bucket
1808
- const bucketInfoBuffer = device.createBuffer({
1809
- size: bucketInfoSize,
1810
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1811
- mappedAtCreation: true
1812
- });
1813
-
1814
- const bucketView = new ArrayBuffer(bucketInfoSize);
1815
- const bucketFloatView = new Float32Array(bucketView);
1816
- const bucketUintView = new Uint32Array(bucketView);
1817
-
1818
- for (let i = 0; i < bucketsInBatch; i++) {
1819
- const bucket = bucketData.buckets[startBucket + i];
1820
- const offset = i * 4;
1821
- bucketFloatView[offset] = bucket.minX; // f32
1822
- bucketFloatView[offset + 1] = bucket.maxX; // f32
1823
- bucketUintView[offset + 2] = bucket.startIndex; // u32
1824
- bucketUintView[offset + 3] = bucket.count; // u32
1825
- }
1826
-
1827
- new Uint8Array(bucketInfoBuffer.getMappedRange()).set(new Uint8Array(bucketView));
1828
- bucketInfoBuffer.unmap();
1829
-
1830
- // Create uniforms for this batch
1831
- // Struct layout: f32, f32, u32, f32, f32, u32, f32, u32, f32, f32, u32, u32, f32, u32
1832
- const uniformBuffer = device.createBuffer({
1833
- size: 56, // 14 fields * 4 bytes
1834
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1835
- mappedAtCreation: true
1836
- });
1837
-
1838
- const uniformView = new ArrayBuffer(56);
1839
- const floatView = new Float32Array(uniformView);
1840
- const uintView = new Uint32Array(uniformView);
1841
-
1842
- floatView[0] = resolution; // f32
1843
- floatView[1] = angleStep * (Math.PI / 180); // f32
1844
- uintView[2] = numAngles; // u32
1845
- floatView[3] = maxRadius; // f32
1846
- floatView[4] = toolWidth; // f32
1847
- uintView[5] = gridYHeight; // u32
1848
- floatView[6] = bucketData.buckets[0].maxX - bucketData.buckets[0].minX; // f32 bucketWidth
1849
- uintView[7] = bucketGridWidth; // u32
1850
- floatView[8] = bucketMinX; // f32 global_min_x
1851
- floatView[9] = zFloor; // f32
1852
- uintView[10] = 0; // u32 filterMode
1853
- uintView[11] = bucketData.numBuckets; // u32 (total buckets, for validation)
1854
- floatView[12] = startAngle * (Math.PI / 180); // f32 start_angle (radians)
1855
- uintView[13] = startBucket; // u32 bucket_offset
1856
-
1857
- new Uint8Array(uniformBuffer.getMappedRange()).set(new Uint8Array(uniformView));
1858
- uniformBuffer.unmap();
1859
-
1860
- // Create bind group for this batch
1861
- const bindGroup = device.createBindGroup({
1862
- layout: pipeline.getBindGroupLayout(0),
1863
- entries: [
1864
- { binding: 0, resource: { buffer: triangleBuffer } },
1865
- { binding: 1, resource: { buffer: outputBuffer } },
1866
- { binding: 2, resource: { buffer: uniformBuffer } },
1867
- { binding: 3, resource: { buffer: bucketInfoBuffer } },
1868
- { binding: 4, resource: { buffer: triangleIndicesBuffer } }
1869
- ]
1870
- });
1871
-
1872
- passEncoder.setBindGroup(0, bindGroup);
1873
-
1874
- // Dispatch for this batch
1875
- const dispatchZ = bucketsInBatch;
1876
- if (diagnostic) {
1877
- debug.log(` Batch ${batchIdx + 1}/${numBucketBatches}: Dispatch (${dispatchX}, ${dispatchY}, ${dispatchZ}) = buckets ${startBucket}-${endBucket - 1}`);
1878
- }
1879
-
1880
- passEncoder.dispatchWorkgroups(dispatchX, dispatchY, dispatchZ);
1881
-
1882
- // Save buffers to destroy after GPU completes
1883
- batchBuffersToDestroy.push(uniformBuffer, bucketInfoBuffer);
1884
- }
1885
-
1886
- passEncoder.end();
1887
-
1888
- // Read back
1889
- const stagingBuffer = device.createBuffer({
1890
- size: outputSize,
1891
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
1892
- });
1893
-
1894
- commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize);
1895
- device.queue.submit([commandEncoder.finish()]);
1896
-
1897
- // Wait for GPU to finish before reading results
1898
- await device.queue.onSubmittedWorkDone();
1899
- await stagingBuffer.mapAsync(GPUMapMode.READ);
1900
- // const outputData = new Float32Array(stagingBuffer.getMappedRange());
1901
- // const outputCopy = new Float32Array(outputData);
1902
- const outputCopy = new Float32Array(stagingBuffer.getMappedRange().slice());
1903
- stagingBuffer.unmap();
1904
-
1905
- // Now safe to destroy batch buffers (GPU has completed)
1906
- for (const buffer of batchBuffersToDestroy) {
1907
- buffer.destroy();
1908
- }
1909
-
1910
- // Cleanup main buffers
1911
- outputBuffer.destroy();
1912
- stagingBuffer.destroy();
1913
-
1914
- timings.gpu = performance.now() - gpuStart;
1915
-
1916
- // Stitch strips
1917
- const stitchStart = performance.now();
1918
- const strips = [];
1919
-
1920
- for (let angleIdx = 0; angleIdx < numAngles; angleIdx++) {
1921
- const stripData = new Float32Array(gridWidth * gridYHeight);
1922
- stripData.fill(zFloor); // Initialize with zFloor, not zeros!
1923
-
1924
- // Gather from each bucket
1925
- for (let bucketIdx = 0; bucketIdx < bucketData.numBuckets; bucketIdx++) {
1926
- const bucket = bucketData.buckets[bucketIdx];
1927
- const bucketMinGridX = Math.floor((bucket.minX - bucketMinX) / resolution);
1928
-
1929
- for (let localX = 0; localX < bucketGridWidth; localX++) {
1930
- const gridX = bucketMinGridX + localX;
1931
- if (gridX >= gridWidth) continue;
1932
-
1933
- for (let gridY = 0; gridY < gridYHeight; gridY++) {
1934
- const srcIdx = bucketIdx * numAngles * bucketGridWidth * gridYHeight
1935
- + angleIdx * bucketGridWidth * gridYHeight
1936
- + gridY * bucketGridWidth
1937
- + localX;
1938
- const dstIdx = gridY * gridWidth + gridX;
1939
- stripData[dstIdx] = outputCopy[srcIdx];
1940
- }
1941
- }
1942
- }
1943
-
1944
- // Keep as DENSE Z-only format (toolpath generator expects this!)
1945
- // Count valid points
1946
- let validCount = 0;
1947
- for (let i = 0; i < stripData.length; i++) {
1948
- if (stripData[i] !== zFloor) validCount++;
1949
- }
1950
-
1951
- strips.push({
1952
- angle: startAngle + (angleIdx * angleStep),
1953
- positions: stripData, // DENSE Z-only format!
1954
- gridWidth,
1955
- gridHeight: gridYHeight,
1956
- pointCount: validCount, // Number of non-floor cells
1957
- bounds: {
1958
- min: { x: bucketMinX, y: 0, z: zFloor },
1959
- max: { x: bucketMaxX, y: toolWidth, z: bounds.max.z }
1960
- }
1961
- });
1962
- }
1963
-
1964
- timings.stitch = performance.now() - stitchStart;
1965
- const totalTime = performance.now() - timings.start;
1966
-
1967
- Object.assign(batchInfo, {
1968
- 'prep': (timings.prep | 0),
1969
- 'raster': (timings.gpu | 0),
1970
- 'stitch': (timings.stitch | 0)
1971
- });
1972
-
1973
- const result = { strips, timings };
1974
-
1975
- // Decide what to do with triangle/indices buffers
1976
- // Note: bucketInfoBuffer is now created/destroyed per bucket batch within the loop
1977
- if (returnBuffersForReuse && shouldCleanupBuffers) {
1978
- // First batch in multi-batch operation: return buffers for subsequent batches to reuse
1979
- result.reusableBuffers = {
1980
- triangleBuffer,
1981
- triangleIndicesBuffer
1982
- };
1983
- } else if (shouldCleanupBuffers) {
1984
- // Single batch operation OR we're NOT supposed to return buffers: destroy them now
1985
- triangleBuffer.destroy();
1986
- triangleIndicesBuffer.destroy();
1987
- }
1988
- // else: we're reusing buffers from a previous angle batch, don't destroy them (caller will destroy after all angle batches)
1989
-
1990
- return result;
1991
- }
1992
-
1993
- // Radial: Complete pipeline - rasterize model + generate toolpaths for all strips
1994
- async function generateRadialToolpaths({
1995
- triangles,
1996
- bucketData,
1997
- toolData,
1998
- resolution,
1999
- angleStep,
2000
- numAngles,
2001
- maxRadius,
2002
- toolWidth,
2003
- zFloor,
2004
- bounds,
2005
- xStep,
2006
- yStep
2007
- }) {
2008
- debug.log('radial-generate-toolpaths', { triangles: triangles.length, numAngles, resolution });
2009
-
2010
- // Batch processing: rasterize angle ranges to avoid memory allocation failure
2011
- // Calculate safe batch size based on available GPU memory
2012
- const MAX_BUFFER_SIZE_MB = 1800; // Stay under 2GB WebGPU limit with headroom
2013
- const bytesPerCell = 4; // f32
2014
-
2015
- const xSize = bounds.max.x - bounds.min.x;
2016
- const ySize = bounds.max.y - bounds.min.y;
2017
- const gridXSize = Math.ceil(xSize / resolution);
2018
- const gridYHeight = Math.ceil(ySize / resolution);
2019
-
2020
- // Calculate total memory requirement
2021
- const cellsPerAngle = gridXSize * gridYHeight;
2022
- const bytesPerAngle = cellsPerAngle * bytesPerCell;
2023
- const totalMemoryMB = (numAngles * bytesPerAngle) / (1024 * 1024);
2024
-
2025
- // Only batch if total memory exceeds threshold
2026
- const batchDivisor = config?.batchDivisor || 1;
2027
- let ANGLES_PER_BATCH, numBatches;
2028
- if (totalMemoryMB > MAX_BUFFER_SIZE_MB) {
2029
- // Need to batch
2030
- const maxAnglesPerBatch = Math.floor((MAX_BUFFER_SIZE_MB * 1024 * 1024) / bytesPerAngle);
2031
- // Apply batch divisor for overhead testing
2032
- const adjustedMaxAngles = Math.floor(maxAnglesPerBatch / batchDivisor);
2033
-
2034
- ANGLES_PER_BATCH = Math.max(1, Math.min(adjustedMaxAngles, numAngles));
2035
- numBatches = Math.ceil(numAngles / ANGLES_PER_BATCH);
2036
- const batchSizeMB = (ANGLES_PER_BATCH * bytesPerAngle / 1024 / 1024).toFixed(1);
2037
- debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
2038
- debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB exceeds limit, batching required`);
2039
- if (batchDivisor > 1) {
2040
- debug.log(`batchDivisor: ${batchDivisor}x (testing overhead: ${maxAnglesPerBatch} → ${adjustedMaxAngles} angles/batch)`);
2041
- }
2042
- debug.log(`Batch size: ${ANGLES_PER_BATCH} angles (~${batchSizeMB}MB per batch)`);
2043
- debug.log(`Processing ${numAngles} angles in ${numBatches} batch(es)`);
2044
- } else {
2045
- // Process all angles at once (but still respect batchDivisor for testing)
2046
- if (batchDivisor > 1) {
2047
- ANGLES_PER_BATCH = Math.max(10, Math.floor(numAngles / batchDivisor));
2048
- numBatches = Math.ceil(numAngles / ANGLES_PER_BATCH);
2049
- debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
2050
- debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB (fits in buffer normally)`);
2051
- debug.log(`batchDivisor: ${batchDivisor}x (artificially creating ${numBatches} batches for overhead testing)`);
2052
- } else {
2053
- ANGLES_PER_BATCH = numAngles;
2054
- numBatches = 1;
2055
- debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
2056
- debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB fits in buffer, processing all ${numAngles} angles in single batch`);
2057
- }
2058
- }
2059
-
2060
- const allStripToolpaths = [];
2061
- let totalToolpathPoints = 0;
2062
- const pipelineStartTime = performance.now();
2063
-
2064
- // Prepare sparse tool once
2065
- const sparseToolData = createSparseToolFromPoints(toolData.positions);
2066
- debug.log(`Created sparse tool: ${sparseToolData.count} points (reusing for all strips)`);
2067
-
2068
- // Create reusable rasterization buffers if batching (numBatches > 1)
2069
- // These buffers (triangles, buckets, indices) don't change between batches
2070
- let batchReuseBuffers = null;
2071
- let batchTracking = [];
2072
-
2073
- for (let batchIdx = 0; batchIdx < numBatches; batchIdx++) {
2074
- const batchStartTime = performance.now();
2075
- const startAngleIdx = batchIdx * ANGLES_PER_BATCH;
2076
- const endAngleIdx = Math.min(startAngleIdx + ANGLES_PER_BATCH, numAngles);
2077
- const batchNumAngles = endAngleIdx - startAngleIdx;
2078
- const batchStartAngle = startAngleIdx * angleStep;
2079
-
2080
- const batchInfo = {
2081
- from: startAngleIdx,
2082
- to: endAngleIdx
2083
- };
2084
- batchTracking.push(batchInfo);
2085
-
2086
- debug.log(`Batch ${batchIdx + 1}/${numBatches}: angles ${startAngleIdx}-${endAngleIdx - 1} (${batchNumAngles} angles), startAngle=${batchStartAngle.toFixed(1)}°`);
2087
-
2088
- // Rasterize this batch of strips
2089
- const rasterStartTime = performance.now();
2090
- const shouldReturnBuffers = (batchIdx === 0 && numBatches > 1); // First batch of multi-batch operation
2091
- const batchModelResult = await radialRasterize({
2092
- triangles,
2093
- bucketData,
2094
- resolution,
2095
- angleStep,
2096
- numAngles: batchNumAngles,
2097
- maxRadius,
2098
- toolWidth,
2099
- zFloor,
2100
- bounds,
2101
- startAngle: batchStartAngle,
2102
- reusableBuffers: batchReuseBuffers,
2103
- returnBuffersForReuse: shouldReturnBuffers,
2104
- batchInfo
2105
- });
2106
-
2107
- const rasterTime = performance.now() - rasterStartTime;
2108
-
2109
- // Capture buffers from first batch for reuse
2110
- if (batchIdx === 0 && batchModelResult.reusableBuffers) {
2111
- batchReuseBuffers = batchModelResult.reusableBuffers;
2112
- }
2113
-
2114
- // Find max dimensions for this batch
2115
- let maxStripWidth = 0;
2116
- let maxStripHeight = 0;
2117
- for (const strip of batchModelResult.strips) {
2118
- maxStripWidth = Math.max(maxStripWidth, strip.gridWidth);
2119
- maxStripHeight = Math.max(maxStripHeight, strip.gridHeight);
2120
- }
2121
-
2122
- // Create reusable buffers for this batch
2123
- const reusableBuffers = createReusableToolpathBuffers(maxStripWidth, maxStripHeight, sparseToolData, xStep, maxStripHeight);
2124
-
2125
- // Generate toolpaths for this batch
2126
- const toolpathStartTime = performance.now();
2127
-
2128
- for (let i = 0; i < batchModelResult.strips.length; i++) {
2129
- const strip = batchModelResult.strips[i];
2130
- const globalStripIdx = startAngleIdx + i;
2131
-
2132
- if (globalStripIdx % 10 === 0 || globalStripIdx === numAngles - 1) {
2133
- self.postMessage({
2134
- type: 'toolpath-progress',
2135
- data: {
2136
- percent: Math.round(((globalStripIdx + 1) / numAngles) * 100),
2137
- current: globalStripIdx + 1,
2138
- total: numAngles,
2139
- layer: globalStripIdx + 1
2140
- }
2141
- });
2142
- }
2143
-
2144
- if (!strip.positions || strip.positions.length === 0) continue;
2145
-
2146
- // DEBUG: Diagnostic logging
2147
- if (diagnostic && (globalStripIdx === 0 || globalStripIdx === 360)) {
2148
- 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(',')}`);
2149
- }
2150
-
2151
- const stripToolpathResult = await runToolpathComputeWithBuffers(
2152
- strip.positions,
2153
- strip.gridWidth,
2154
- strip.gridHeight,
2155
- xStep,
2156
- strip.gridHeight,
2157
- zFloor,
2158
- reusableBuffers,
2159
- pipelineStartTime
2160
- );
2161
-
2162
- // DEBUG: Verify toolpath generation output
2163
- if (diagnostic && (globalStripIdx === 0 || globalStripIdx === 360)) {
2164
- 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(',')}`);
2165
- }
2166
-
2167
- allStripToolpaths.push({
2168
- angle: strip.angle,
2169
- pathData: stripToolpathResult.pathData,
2170
- numScanlines: stripToolpathResult.numScanlines,
2171
- pointsPerLine: stripToolpathResult.pointsPerLine,
2172
- terrainBounds: strip.bounds
2173
- });
2174
-
2175
- totalToolpathPoints += stripToolpathResult.pathData.length;
2176
- }
2177
- const toolpathTime = performance.now() - toolpathStartTime;
2178
-
2179
- // Free batch terrain data
2180
- for (const strip of batchModelResult.strips) {
2181
- strip.positions = null;
2182
- }
2183
- destroyReusableToolpathBuffers(reusableBuffers);
2184
-
2185
- const batchTotalTime = performance.now() - batchStartTime;
2186
-
2187
- Object.assign(batchInfo, {
2188
- 'prep': batchInfo.prep || 0,
2189
- 'gpu': batchInfo.gpu || 0,
2190
- 'stitch': batchInfo.stitch || 0,
2191
- 'raster': batchInfo.raster || 0,
2192
- 'mkbuf': 0,
2193
- 'paths': (toolpathTime | 0),
2194
- 'strips': allStripToolpaths.length,
2195
- 'total': (batchTotalTime | 0)
2196
- });
2197
- }
2198
-
2199
- console.table(batchTracking);
2200
-
2201
- // Cleanup cached rasterization buffers after all batches complete
2202
- if (batchReuseBuffers) {
2203
- batchReuseBuffers.triangleBuffer.destroy();
2204
- batchReuseBuffers.triangleIndicesBuffer.destroy();
2205
- // Note: bucketInfoBuffer is no longer in reusableBuffers (created/destroyed per bucket batch)
2206
- debug.log(`Destroyed cached GPU buffers after all batches`);
2207
- }
2208
-
2209
- const pipelineTotalTime = performance.now() - pipelineStartTime;
2210
- debug.log(`Complete radial toolpath: ${allStripToolpaths.length} strips, ${totalToolpathPoints} total points in ${pipelineTotalTime.toFixed(0)}ms`);
2211
-
2212
- return {
2213
- strips: allStripToolpaths,
2214
- totalPoints: totalToolpathPoints,
2215
- numStrips: allStripToolpaths.length
2216
- };
2217
- }
2218
-
2219
- // Handle messages from main thread
2220
- self.onmessage = async function(e) {
2221
- const { type, data } = e.data;
2222
-
2223
- try {
2224
- switch (type) {
2225
- case 'init':
2226
- // Store config
2227
- config = data?.config || {
2228
- maxGPUMemoryMB: 256,
2229
- gpuMemorySafetyMargin: 0.8,
2230
- tileOverlapMM: 10,
2231
- autoTiling: true,
2232
- minTileSize: 50,
2233
- batchDivisor: 1 // For testing batching overhead: 1=optimal, 2=2x batches, 4=4x batches, etc.
2234
- };
2235
- const success = await initWebGPU();
2236
- self.postMessage({
2237
- type: 'webgpu-ready',
2238
- data: {
2239
- success,
2240
- capabilities: deviceCapabilities
2241
- }
2242
- });
2243
- break;
2244
-
2245
- case 'update-config':
2246
- config = data.config;
2247
- debug.log('Config updated:', config);
2248
- break;
2249
-
2250
- case 'rasterize':
2251
- const { triangles, stepSize, filterMode, boundsOverride } = data;
2252
- const rasterOptions = boundsOverride || {};
2253
- const rasterResult = await rasterizeMesh(triangles, stepSize, filterMode, rasterOptions);
2254
- self.postMessage({
2255
- type: 'rasterize-complete',
2256
- data: rasterResult,
2257
- }, [rasterResult.positions.buffer]);
2258
- break;
2259
-
2260
- case 'generate-toolpath':
2261
- const { terrainPositions, toolPositions, xStep, yStep, zFloor, gridStep, terrainBounds, singleScanline } = data;
2262
- const toolpathResult = await generateToolpath(
2263
- terrainPositions, toolPositions, xStep, yStep, zFloor, gridStep, terrainBounds, singleScanline
2264
- );
2265
- self.postMessage({
2266
- type: 'toolpath-complete',
2267
- data: toolpathResult
2268
- }, [toolpathResult.pathData.buffer]);
2269
- break;
2270
-
2271
- case 'radial-rasterize':
2272
- const { triangles: radialTriangles, stepSize: radialStep, rotationStep: radialRotationStep, zFloor: radialZFloor = 0, boundsOverride: radialBounds, maxConcurrentTiles, trianglesPerTile, radialRotationOffset } = data;
2273
- const radialResult = await radialRasterize(radialTriangles, radialStep, radialRotationStep, radialZFloor, radialBounds, { maxConcurrentTiles, trianglesPerTile, radialRotationOffset });
2274
- self.postMessage({
2275
- type: 'radial-rasterize-complete',
2276
- data: radialResult
2277
- }, [radialResult.positions.buffer]);
2278
- break;
2279
-
2280
- case 'radial-generate-toolpaths':
2281
- const radialToolpathResult = await generateRadialToolpaths(data);
2282
- const toolpathTransferBuffers = radialToolpathResult.strips.map(strip => strip.pathData.buffer);
2283
- self.postMessage({
2284
- type: 'radial-toolpaths-complete',
2285
- data: radialToolpathResult
2286
- }, toolpathTransferBuffers);
2287
- break;
2288
-
2289
- default:
2290
- self.postMessage({
2291
- type: 'error',
2292
- message: 'Unknown message type: ' + type
2293
- });
2294
- }
2295
- } catch (error) {
2296
- debug.error('Error:', error);
2297
- self.postMessage({
2298
- type: 'error',
2299
- message: error.message,
2300
- stack: error.stack
2301
- });
2302
- }
2303
- };