@gridspace/raster-path 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,27 +48,33 @@
48
48
  * ═══════════════════════════════════════════════════════════════════════════
49
49
  */
50
50
 
51
+ let config = {};
51
52
  let device = null;
53
+ let deviceCapabilities = null;
52
54
  let isInitialized = false;
53
55
  let cachedRasterizePipeline = null;
54
56
  let cachedRasterizeShaderModule = null;
55
57
  let cachedToolpathPipeline = null;
56
58
  let cachedToolpathShaderModule = null;
57
- let config = null;
58
- let deviceCapabilities = null;
59
+ let cachedRadialBatchPipeline = null;
60
+ let cachedRadialBatchShaderModule = null;
61
+ let lastlog;
59
62
 
60
63
  const EMPTY_CELL = -1e10;
61
- const log_pre = '[Raster Worker]';
62
-
63
- // url params to control logging
64
- let { search } = self.location;
65
- let verbose = search.indexOf('debug') >= 0;
66
- let quiet = search.indexOf('quiet') >= 0;
64
+ const log_pre = '[Worker]';
65
+ const diagnostic = false;
67
66
 
68
67
  const debug = {
69
68
  error: function() { console.error(log_pre, ...arguments) },
70
69
  warn: function() { console.warn(log_pre, ...arguments) },
71
- log: function() { !quiet && console.log(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
+ },
72
78
  ok: function() { console.log(log_pre, '✅', ...arguments) },
73
79
  };
74
80
 
@@ -140,6 +146,15 @@ async function initWebGPU() {
140
146
  compute: { module: cachedToolpathShaderModule, entryPoint: 'main' },
141
147
  });
142
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
+
143
158
  // Store device capabilities
144
159
  deviceCapabilities = {
145
160
  maxStorageBufferBindingSize: device.limits.maxStorageBufferBindingSize,
@@ -158,13 +173,13 @@ async function initWebGPU() {
158
173
  }
159
174
 
160
175
  // Planar rasterization with spatial partitioning
161
- const rasterizeShaderCode = /*SHADER:planar-rasterize*/;
176
+ const rasterizeShaderCode = 'SHADER:planar-rasterize';
162
177
 
163
178
  // Planar toolpath generation
164
- const toolpathShaderCode = /*SHADER:planar-toolpath*/;
179
+ const toolpathShaderCode = 'SHADER:planar-toolpath';
165
180
 
166
- // Radial V2: Rasterization with rotating ray planes and X-bucketing
167
- const radialRasterizeV2ShaderCode = /*SHADER:radial-raster-v2*/;
181
+ // Radial: Rasterization with rotating ray planes and X-bucketing
182
+ const radialRasterizeShaderCode = 'SHADER:radial-raster';
168
183
 
169
184
  // Calculate bounding box from triangle vertices
170
185
  function calculateBounds(triangles) {
@@ -196,8 +211,6 @@ function buildSpatialGrid(triangles, bounds, cellSize = 5.0) {
196
211
  const gridHeight = Math.max(1, Math.ceil((bounds.max.y - bounds.min.y) / cellSize));
197
212
  const totalCells = gridWidth * gridHeight;
198
213
 
199
- // debug.log(`Building spatial grid ${gridWidth}x${gridHeight} (${cellSize}mm cells)`);
200
-
201
214
  const grid = new Array(totalCells);
202
215
  for (let i = 0; i < totalCells; i++) {
203
216
  grid[i] = [];
@@ -254,7 +267,13 @@ function buildSpatialGrid(triangles, bounds, cellSize = 5.0) {
254
267
  cellOffsets[totalCells] = currentOffset;
255
268
 
256
269
  const avgPerCell = totalTriangleRefs / totalCells;
257
- // debug.log(`Spatial grid: ${totalTriangleRefs} refs (avg ${avgPerCell.toFixed(1)} per cell)`);
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)`);
258
277
 
259
278
  return {
260
279
  gridWidth,
@@ -283,7 +302,7 @@ async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}
283
302
  debug.log(`First-time init: ${(initEnd - initStart).toFixed(1)}ms`);
284
303
  }
285
304
 
286
- // debug.log(`Rasterizing ${triangles.length / 9} triangles (step ${stepSize}mm, mode ${filterMode})...`);
305
+ // debug.log(`Raster ${triangles.length / 9} triangles (step ${stepSize}mm, mode ${filterMode})...`);
287
306
 
288
307
  // Extract options
289
308
  // boundsOverride: Optional manual bounds to avoid recalculating from triangles
@@ -314,14 +333,16 @@ async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}
314
333
  const floatsPerPoint = filterMode === 0 ? 1 : 3;
315
334
  const outputSize = totalGridPoints * floatsPerPoint * 4;
316
335
  const maxBufferSize = device.limits.maxBufferSize || 268435456; // 256MB default
317
- const modeStr = filterMode === 0 ? 'terrain (dense Z-only)' : 'tool (sparse XYZ)';
336
+ // const modeStr = filterMode === 0 ? 'terrain (dense Z-only)' : 'tool (sparse XYZ)';
318
337
  // debug.log(`Output buffer size: ${(outputSize / 1024 / 1024).toFixed(2)} MB for ${modeStr} (max: ${(maxBufferSize / 1024 / 1024).toFixed(2)} MB)`);
319
338
 
320
339
  if (outputSize > maxBufferSize) {
321
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.`);
322
341
  }
323
342
 
343
+ console.time(`${log_pre} Build Spatial Grid`);
324
344
  const spatialGrid = buildSpatialGrid(triangles, bounds);
345
+ console.timeEnd(`${log_pre} Build Spatial Grid`);
325
346
 
326
347
  // Create buffers
327
348
  const triangleBuffer = device.createBuffer({
@@ -462,7 +483,7 @@ async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}
462
483
  result = new Float32Array(outputData);
463
484
  pointCount = totalGridPoints;
464
485
 
465
- if (verbose) {
486
+ if (config.debug) {
466
487
  // Count valid points for logging (sentinel value = -1e10)
467
488
  let zeroCount = 0;
468
489
  let validCount = 0;
@@ -802,7 +823,6 @@ async function rasterizeMesh(triangles, stepSize, filterMode, options = {}) {
802
823
 
803
824
  const tileResult = await rasterizeMeshSingle(triangles, stepSize, filterMode, {
804
825
  ...tiles[i].bounds,
805
- rotationAngleDeg: options.rotationAngleDeg
806
826
  });
807
827
 
808
828
  const tileTime = performance.now() - tileStart;
@@ -1644,412 +1664,22 @@ function stitchToolpathTiles(tileResults, globalBounds, gridStep, xStep, yStep)
1644
1664
  };
1645
1665
  }
1646
1666
 
1647
- // Radial rasterization - two-pass tiled with bit-attention
1648
- // Workload budget: triangles × scanlines should stay under this to avoid timeouts
1649
- const MAX_WORKLOAD_PER_TILE = 10_000_000; // triangles × gridHeight budget per tile
1650
-
1651
- let cachedRadialCullPipeline = null;
1652
- let cachedRadialRasterizePipeline = null;
1653
-
1654
- async function radialRasterize(triangles, stepSize, rotationStepDegrees, zFloor = 0, boundsOverride = null, params = {}) {
1655
- const startTime = performance.now();
1656
-
1657
- if (!isInitialized) {
1658
- await initWebGPU();
1659
- }
1660
-
1661
- // Check if SharedArrayBuffer is available and triangle data is large (>1M triangles = 9M floats = 36MB)
1662
- const numTriangles = triangles.length / 9;
1663
- const useSharedBuffer = typeof SharedArrayBuffer !== 'undefined' &&
1664
- numTriangles > 1000000 &&
1665
- !(triangles.buffer instanceof SharedArrayBuffer);
1666
-
1667
- if (useSharedBuffer) {
1668
- debug.log(`Large dataset (${numTriangles.toLocaleString()} triangles), converting to SharedArrayBuffer`);
1669
- const sab = new SharedArrayBuffer(triangles.byteLength);
1670
- const sharedTriangles = new Float32Array(sab);
1671
- sharedTriangles.set(triangles);
1672
- triangles = sharedTriangles;
1673
- }
1674
-
1675
- const bounds = boundsOverride || calculateBounds(triangles);
1676
-
1677
- // Calculate max radius (distance from X-axis)
1678
- let maxRadius = 0;
1679
- for (let i = 0; i < triangles.length; i += 3) {
1680
- const y = triangles[i + 1];
1681
- const z = triangles[i + 2];
1682
- const radius = Math.sqrt(y * y + z * z);
1683
- maxRadius = Math.max(maxRadius, radius);
1684
- }
1685
-
1686
- // Add margin for ray origins to start outside mesh
1687
- maxRadius *= 1.2;
1688
-
1689
- const circumference = 2 * Math.PI * maxRadius;
1690
- const xRange = bounds.max.x - bounds.min.x;
1691
- const rotationStepRadians = rotationStepDegrees * (Math.PI / 180);
1692
-
1693
- // Calculate grid height (number of angular scanlines)
1694
- const gridHeight = Math.ceil(360 / rotationStepDegrees) + 1;
1695
-
1696
- // Calculate number of tiles based on user config or auto-calculation
1697
- let numTiles, trianglesPerTileTarget;
1698
-
1699
- if (params.trianglesPerTile) {
1700
- // User specified explicit triangles per tile
1701
- trianglesPerTileTarget = params.trianglesPerTile;
1702
- numTiles = Math.max(1, Math.ceil(numTriangles / trianglesPerTileTarget));
1703
- debug.log(`Radial: ${numTriangles} triangles, ${gridHeight} scanlines, ${numTiles} X-tiles (${trianglesPerTileTarget} target tri/tile)`);
1704
- } else {
1705
- // Auto-calculate based on workload budget
1706
- // Total work = numTriangles × gridHeight × culling_efficiency
1707
- // More tiles = smaller X-slices = better culling = less work per tile
1708
- const totalWorkload = numTriangles * gridHeight * 0.5; // 0.5 = expected culling efficiency
1709
- numTiles = Math.max(1, Math.ceil(totalWorkload / MAX_WORKLOAD_PER_TILE));
1710
- trianglesPerTileTarget = Math.ceil(numTriangles / numTiles);
1711
- debug.log(`Radial: ${numTriangles} triangles, ${gridHeight} scanlines, ${numTiles} X-tiles (${trianglesPerTileTarget} avg tri/tile, auto-calculated)`);
1712
- }
1713
-
1714
- const tileWidth = xRange / numTiles;
1715
-
1716
- // Create pipelines on first use
1717
- if (!cachedRadialCullPipeline) {
1718
- const cullShaderModule = device.createShaderModule({ code: radialCullShaderCode });
1719
- cachedRadialCullPipeline = device.createComputePipeline({
1720
- layout: 'auto',
1721
- compute: { module: cullShaderModule, entryPoint: 'main' }
1722
- });
1723
- debug.log('Created radial cull pipeline');
1724
- }
1725
-
1726
- if (!cachedRadialRasterizePipeline) {
1727
- const rasterShaderModule = device.createShaderModule({ code: radialRasterizeShaderCode });
1728
- cachedRadialRasterizePipeline = device.createComputePipeline({
1729
- layout: 'auto',
1730
- compute: { module: rasterShaderModule, entryPoint: 'main' }
1731
- });
1732
- debug.log('Created radial rasterize pipeline');
1733
- }
1734
-
1735
- // Create shared triangle buffer
1736
- const triangleBuffer = device.createBuffer({
1737
- size: triangles.byteLength,
1738
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1739
- });
1740
- device.queue.writeBuffer(triangleBuffer, 0, triangles);
1741
-
1742
- // Calculate attention bit array size
1743
- const numWords = Math.ceil(numTriangles / 32);
1744
- debug.log(`Attention array: ${numWords} words (${numWords * 4} bytes)`);
1745
-
1746
- // Helper function to process a single tile
1747
- async function processTile(tileIdx) {
1748
- const prefix = `Tile ${tileIdx + 1}/${numTiles}:`;
1749
- try {
1750
- const tile_min_x = bounds.min.x + tileIdx * tileWidth;
1751
- const tile_max_x = bounds.min.x + (tileIdx + 1) * tileWidth;
1752
-
1753
- debug.log(`${prefix} X=[${tile_min_x.toFixed(2)}, ${tile_max_x.toFixed(2)}]`);
1754
-
1755
- // Pass 1: Cull triangles for this X-tile
1756
- const tileStartTime = performance.now();
1757
-
1758
- const attentionBuffer = device.createBuffer({
1759
- size: numWords * 4,
1760
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
1761
- });
1762
-
1763
- // Clear attention buffer
1764
- const zeros = new Uint32Array(numWords);
1765
- device.queue.writeBuffer(attentionBuffer, 0, zeros);
1766
-
1767
- const cullUniformData = new Float32Array(4);
1768
- cullUniformData[0] = tile_min_x;
1769
- cullUniformData[1] = tile_max_x;
1770
- const cullUniformU32 = new Uint32Array(cullUniformData.buffer);
1771
- cullUniformU32[2] = numTriangles;
1772
-
1773
- const cullUniformBuffer = device.createBuffer({
1774
- size: 16,
1775
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1776
- });
1777
- device.queue.writeBuffer(cullUniformBuffer, 0, cullUniformData);
1778
-
1779
- // CRITICAL: Wait for writeBuffer operations before compute dispatch
1780
- await device.queue.onSubmittedWorkDone();
1781
-
1782
- const cullBindGroup = device.createBindGroup({
1783
- layout: cachedRadialCullPipeline.getBindGroupLayout(0),
1784
- entries: [
1785
- { binding: 0, resource: { buffer: triangleBuffer } },
1786
- { binding: 1, resource: { buffer: attentionBuffer } },
1787
- { binding: 2, resource: { buffer: cullUniformBuffer } },
1788
- ],
1789
- });
1790
-
1791
- const cullEncoder = device.createCommandEncoder();
1792
- const cullPass = cullEncoder.beginComputePass();
1793
- cullPass.setPipeline(cachedRadialCullPipeline);
1794
- cullPass.setBindGroup(0, cullBindGroup);
1795
- cullPass.dispatchWorkgroups(Math.ceil(numTriangles / 256));
1796
- cullPass.end();
1797
- device.queue.submit([cullEncoder.finish()]);
1798
- await device.queue.onSubmittedWorkDone();
1799
-
1800
- const cullTime = performance.now() - tileStartTime;
1801
- // debug.log(` Culling: ${cullTime.toFixed(1)}ms`);
1802
-
1803
- // Pass 1.5: Read back attention bits and compact to triangle index list
1804
- const compactStartTime = performance.now();
1805
-
1806
- // Create staging buffer to read attention bits
1807
- const attentionStagingBuffer = device.createBuffer({
1808
- size: numWords * 4,
1809
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1810
- });
1811
-
1812
- const copyEncoder = device.createCommandEncoder();
1813
- copyEncoder.copyBufferToBuffer(attentionBuffer, 0, attentionStagingBuffer, 0, numWords * 4);
1814
- device.queue.submit([copyEncoder.finish()]);
1815
- await device.queue.onSubmittedWorkDone();
1816
-
1817
- await attentionStagingBuffer.mapAsync(GPUMapMode.READ);
1818
- const attentionBits = new Uint32Array(attentionStagingBuffer.getMappedRange());
1819
-
1820
- // CPU compaction: build list of marked triangle indices
1821
- const compactIndices = [];
1822
- for (let triIdx = 0; triIdx < numTriangles; triIdx++) {
1823
- const wordIdx = Math.floor(triIdx / 32);
1824
- const bitIdx = triIdx % 32;
1825
- const isMarked = (attentionBits[wordIdx] & (1 << bitIdx)) !== 0;
1826
- if (isMarked) {
1827
- compactIndices.push(triIdx);
1828
- }
1829
- }
1830
-
1831
- attentionStagingBuffer.unmap();
1832
- attentionStagingBuffer.destroy();
1833
-
1834
- const compactTime = performance.now() - compactStartTime;
1835
- const cullingEfficiency = ((numTriangles - compactIndices.length) / numTriangles * 100).toFixed(1);
1836
- debug.log(`${prefix} Compacted ${numTriangles} → ${compactIndices.length} triangles (${cullingEfficiency}% culled) in ${compactTime.toFixed(1)}ms`);
1837
-
1838
- // Create compact triangle index buffer
1839
- const compactIndexBuffer = device.createBuffer({
1840
- size: Math.max(4, compactIndices.length * 4), // At least 4 bytes
1841
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1842
- });
1843
- if (compactIndices.length > 0) {
1844
- device.queue.writeBuffer(compactIndexBuffer, 0, new Uint32Array(compactIndices));
1845
- }
1846
-
1847
- // Pass 2: Rasterize this X-tile
1848
- const rasterStartTime = performance.now();
1849
-
1850
- const gridWidth = Math.ceil(tileWidth / stepSize) + 1;
1851
- const gridHeight = Math.ceil(360 / rotationStepDegrees) + 1; // Number of angular samples
1852
- const totalCells = gridWidth * gridHeight;
1853
-
1854
- debug.log(`${prefix} Grid: ${gridWidth}×${gridHeight} = ${totalCells} cells (rotationStep=${rotationStepDegrees}°)`);
1855
-
1856
- const outputBuffer = device.createBuffer({
1857
- size: totalCells * 4,
1858
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
1859
- });
1860
-
1861
- // Initialize with EMPTY_CELL
1862
- const initData = new Float32Array(totalCells);
1863
- initData.fill(EMPTY_CELL);
1864
- device.queue.writeBuffer(outputBuffer, 0, initData);
1865
-
1866
- const rotationOffsetRadians = (params.radialRotationOffset ?? 0) * (Math.PI / 180);
1867
-
1868
- const rasterUniformData = new Float32Array(16);
1869
- rasterUniformData[0] = tile_min_x;
1870
- rasterUniformData[1] = tile_max_x;
1871
- rasterUniformData[2] = maxRadius;
1872
- rasterUniformData[3] = rotationStepRadians;
1873
- rasterUniformData[4] = stepSize;
1874
- rasterUniformData[5] = zFloor;
1875
- rasterUniformData[6] = rotationOffsetRadians;
1876
- rasterUniformData[7] = 0; // padding
1877
- const rasterUniformU32 = new Uint32Array(rasterUniformData.buffer);
1878
- rasterUniformU32[8] = gridWidth;
1879
- rasterUniformU32[9] = gridHeight;
1880
- rasterUniformU32[10] = compactIndices.length; // Use compact count instead of numTriangles
1881
- rasterUniformU32[11] = 0; // No longer using attention words
1882
-
1883
- const rasterUniformBuffer = device.createBuffer({
1884
- size: 64,
1885
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1886
- });
1887
- device.queue.writeBuffer(rasterUniformBuffer, 0, rasterUniformData);
1888
-
1889
- // CRITICAL: Wait for writeBuffer operations before compute dispatch
1890
- await device.queue.onSubmittedWorkDone();
1891
-
1892
- const rasterBindGroup = device.createBindGroup({
1893
- layout: cachedRadialRasterizePipeline.getBindGroupLayout(0),
1894
- entries: [
1895
- { binding: 0, resource: { buffer: triangleBuffer } },
1896
- { binding: 1, resource: { buffer: compactIndexBuffer } }, // Use compact indices instead of attention bits
1897
- { binding: 2, resource: { buffer: outputBuffer } },
1898
- { binding: 3, resource: { buffer: rasterUniformBuffer } },
1899
- ],
1900
- });
1901
-
1902
- const rasterEncoder = device.createCommandEncoder();
1903
- const rasterPass = rasterEncoder.beginComputePass();
1904
- rasterPass.setPipeline(cachedRadialRasterizePipeline);
1905
- rasterPass.setBindGroup(0, rasterBindGroup);
1906
- const workgroupsX = Math.ceil(gridWidth / 16);
1907
- const workgroupsY = Math.ceil(gridHeight / 16);
1908
- rasterPass.dispatchWorkgroups(workgroupsX, workgroupsY);
1909
- rasterPass.end();
1910
-
1911
- const stagingBuffer = device.createBuffer({
1912
- size: totalCells * 4,
1913
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1914
- });
1915
-
1916
- rasterEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, totalCells * 4);
1917
- device.queue.submit([rasterEncoder.finish()]);
1918
- await device.queue.onSubmittedWorkDone();
1919
-
1920
- await stagingBuffer.mapAsync(GPUMapMode.READ);
1921
- const outputData = new Float32Array(stagingBuffer.getMappedRange());
1922
- const tileResult = new Float32Array(outputData);
1923
- stagingBuffer.unmap();
1924
-
1925
- const rasterTime = performance.now() - rasterStartTime;
1926
- debug.log(`${prefix} Rasterization: ${rasterTime.toFixed(1)}ms`);
1927
-
1928
- // Cleanup tile buffers
1929
- attentionBuffer.destroy();
1930
- cullUniformBuffer.destroy();
1931
- compactIndexBuffer.destroy();
1932
- outputBuffer.destroy();
1933
- rasterUniformBuffer.destroy();
1934
- stagingBuffer.destroy();
1935
-
1936
- return {
1937
- tileIdx,
1938
- data: tileResult,
1939
- gridWidth,
1940
- gridHeight,
1941
- minX: tile_min_x,
1942
- maxX: tile_max_x
1943
- };
1944
- } catch (error) {
1945
- debug.error(`Error processing tile ${tileIdx + 1}/${numTiles}:`, error);
1946
- throw new Error(`Tile ${tileIdx + 1} failed: ${error.message}`);
1947
- }
1948
- }
1949
-
1950
- // Process tiles with rolling window to maintain constant concurrency
1951
- // This keeps GPU busy while preventing browser from allocating too many buffers at once
1952
- const maxConcurrentTiles = params.maxConcurrentTiles ?? 50;
1953
- debug.log(`Processing ${numTiles} tiles (max ${maxConcurrentTiles} concurrent)...`);
1954
-
1955
- let completedTiles = 0;
1956
- let nextTileIdx = 0;
1957
- const activeTiles = new Map(); // promise -> tileIdx
1958
- const tileResults = new Array(numTiles);
1959
-
1960
- // Helper to start a tile and track it
1961
- const startTile = (tileIdx) => {
1962
- const promise = processTile(tileIdx).then(result => {
1963
- completedTiles++;
1964
- const percent = Math.round((completedTiles / numTiles) * 100);
1965
-
1966
- // Report progress
1967
- self.postMessage({
1968
- type: 'rasterize-progress',
1969
- data: {
1970
- percent,
1971
- current: completedTiles,
1972
- total: numTiles
1973
- }
1974
- });
1975
-
1976
- tileResults[tileIdx] = result;
1977
- activeTiles.delete(promise);
1978
- return result;
1979
- });
1980
- activeTiles.set(promise, tileIdx);
1981
- return promise;
1982
- };
1983
-
1984
- // Start initial window of tiles
1985
- while (nextTileIdx < numTiles && activeTiles.size < maxConcurrentTiles) {
1986
- startTile(nextTileIdx++);
1987
- }
1988
-
1989
- // As tiles complete, start new ones to maintain window size
1990
- while (activeTiles.size > 0) {
1991
- // Wait for at least one tile to complete
1992
- await Promise.race(activeTiles.keys());
1993
-
1994
- // Start as many new tiles as needed to fill window
1995
- while (nextTileIdx < numTiles && activeTiles.size < maxConcurrentTiles) {
1996
- startTile(nextTileIdx++);
1997
- }
1998
- }
1999
-
2000
- triangleBuffer.destroy();
2001
-
2002
- const totalTime = performance.now() - startTime;
2003
- debug.log(`Radial complete in ${totalTime.toFixed(1)}ms`);
2004
-
2005
- // Stitch tiles together into a single dense array
2006
- const fullGridHeight = Math.ceil(360 / rotationStepDegrees) + 1; // Number of angular samples
2007
- const fullGridWidth = Math.ceil(xRange / stepSize) + 1;
2008
- const stitchedData = new Float32Array(fullGridWidth * fullGridHeight);
2009
- stitchedData.fill(EMPTY_CELL);
2010
-
2011
- for (const tile of tileResults) {
2012
- const tileXOffset = Math.round((tile.minX - bounds.min.x) / stepSize);
2013
-
2014
- for (let ty = 0; ty < tile.gridHeight; ty++) {
2015
- for (let tx = 0; tx < tile.gridWidth; tx++) {
2016
- const tileIdx = ty * tile.gridWidth + tx;
2017
- const fullX = tileXOffset + tx;
2018
- const fullY = ty;
2019
-
2020
- if (fullX >= 0 && fullX < fullGridWidth && fullY >= 0 && fullY < fullGridHeight) {
2021
- const fullIdx = fullY * fullGridWidth + fullX;
2022
- stitchedData[fullIdx] = tile.data[tileIdx];
2023
- }
2024
- }
2025
- }
2026
- }
2027
-
2028
- // For toolpath generation, bounds.max.y must match the actual grid dimensions
2029
- // so that createHeightMapFromPoints calculates the correct height:
2030
- // height = ceil((max.y - min.y) / stepSize) + 1 = gridHeight
2031
- // Therefore: max.y = (gridHeight - 1) * stepSize
2032
- const boundsMaxY = (fullGridHeight - 1) * stepSize;
2033
-
2034
- return {
2035
- positions: stitchedData,
2036
- pointCount: stitchedData.length,
2037
- bounds: {
2038
- min: { x: bounds.min.x, y: 0, z: 0 },
2039
- max: { x: bounds.max.x, y: boundsMaxY, z: maxRadius }
2040
- },
2041
- conversionTime: totalTime,
2042
- gridWidth: fullGridWidth,
2043
- gridHeight: fullGridHeight,
2044
- isDense: true,
2045
- maxRadius,
2046
- circumference,
2047
- rotationStepDegrees // NEW: needed for wrapping and toolpath generation
2048
- };
2049
- }
2050
-
2051
- // Radial V2: Rasterize model with rotating ray planes and X-bucketing
2052
- async function radialRasterizeV2(triangles, bucketData, resolution, angleStep, numAngles, maxRadius, toolWidth, zFloor, bounds, startAngle = 0) {
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
+ }) {
2053
1683
  if (!device) {
2054
1684
  throw new Error('WebGPU not initialized');
2055
1685
  }
@@ -2076,50 +1706,65 @@ async function radialRasterizeV2(triangles, bucketData, resolution, angleStep, n
2076
1706
  const avgTriangles = bucketTriangleCounts.reduce((a, b) => a + b, 0) / bucketTriangleCounts.length;
2077
1707
  const workPerWorkgroup = maxTriangles * numAngles * bucketGridWidth * gridYHeight;
2078
1708
 
2079
- debug.log(`[Worker] Radial V2: ${gridWidth}x${gridYHeight} grid, ${numAngles} angles, ${bucketData.buckets.length} buckets`);
2080
- debug.log(`[Worker] Load: min=${minTriangles} max=${maxTriangles} avg=${avgTriangles.toFixed(0)} (${(maxTriangles/avgTriangles).toFixed(2)}x imbalance, worst=${(workPerWorkgroup/1e6).toFixed(1)}M tests)`);
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
+ }
2081
1727
 
2082
- // Create GPU buffers
2083
- const triangleBuffer = device.createBuffer({
2084
- size: triangles.byteLength,
2085
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2086
- mappedAtCreation: true
2087
- });
2088
- new Float32Array(triangleBuffer.getMappedRange()).set(triangles);
2089
- triangleBuffer.unmap();
1728
+ const numBucketBatches = Math.ceil(bucketData.numBuckets / maxBucketsPerBatch);
2090
1729
 
2091
- // Create bucket info buffer (f32, f32, u32, u32 per bucket)
2092
- const bucketInfoSize = bucketData.buckets.length * 16; // 4 fields * 4 bytes
2093
- const bucketInfoBuffer = device.createBuffer({
2094
- size: bucketInfoSize,
2095
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2096
- mappedAtCreation: true
2097
- });
2098
-
2099
- const bucketView = new ArrayBuffer(bucketInfoSize);
2100
- const bucketFloatView = new Float32Array(bucketView);
2101
- const bucketUintView = new Uint32Array(bucketView);
2102
-
2103
- for (let i = 0; i < bucketData.buckets.length; i++) {
2104
- const bucket = bucketData.buckets[i];
2105
- const offset = i * 4;
2106
- bucketFloatView[offset] = bucket.minX; // f32
2107
- bucketFloatView[offset + 1] = bucket.maxX; // f32
2108
- bucketUintView[offset + 2] = bucket.startIndex; // u32
2109
- bucketUintView[offset + 3] = bucket.count; // u32
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
+ }
2110
1737
  }
2111
1738
 
2112
- new Uint8Array(bucketInfoBuffer.getMappedRange()).set(new Uint8Array(bucketView));
2113
- bucketInfoBuffer.unmap();
1739
+ // Reuse buffers if provided, otherwise create new ones
1740
+ let triangleBuffer, triangleIndicesBuffer;
1741
+ let shouldCleanupBuffers = false;
2114
1742
 
2115
- // Create triangle indices buffer
2116
- const triangleIndicesBuffer = device.createBuffer({
2117
- size: bucketData.triangleIndices.byteLength,
2118
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2119
- mappedAtCreation: true
2120
- });
2121
- new Uint32Array(triangleIndicesBuffer.getMappedRange()).set(bucketData.triangleIndices);
2122
- triangleIndicesBuffer.unmap();
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
+ }
2123
1768
 
2124
1769
  // Create output buffer (all angles, all buckets)
2125
1770
  const outputSize = numAngles * bucketData.numBuckets * bucketGridWidth * gridYHeight * 4;
@@ -2129,84 +1774,115 @@ async function radialRasterizeV2(triangles, bucketData, resolution, angleStep, n
2129
1774
  });
2130
1775
 
2131
1776
  // CRITICAL: Initialize output buffer with zFloor to avoid reading garbage data
2132
- // Use mappedAtCreation for deterministic initialization (not writeBuffer!)
2133
- const initEncoder = device.createCommandEncoder();
2134
1777
  const initData = new Float32Array(outputSize / 4);
2135
1778
  initData.fill(zFloor);
2136
1779
  device.queue.writeBuffer(outputBuffer, 0, initData);
2137
- // Force initialization to complete
2138
- device.queue.submit([initEncoder.finish()]);
2139
- await device.queue.onSubmittedWorkDone();
2140
-
2141
- // Create uniforms with proper alignment (f32 and u32 mixed)
2142
- // Struct layout: f32, f32, u32, f32, f32, u32, f32, u32, f32, f32, u32, u32, f32
2143
- const uniformBuffer = device.createBuffer({
2144
- size: 52, // 13 fields * 4 bytes
2145
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2146
- mappedAtCreation: true
2147
- });
1780
+ // Note: No need to wait - GPU will execute writeBuffer before compute shader
2148
1781
 
2149
- const uniformView = new ArrayBuffer(52);
2150
- const floatView = new Float32Array(uniformView);
2151
- const uintView = new Uint32Array(uniformView);
2152
-
2153
- floatView[0] = resolution; // f32
2154
- floatView[1] = angleStep * (Math.PI / 180); // f32
2155
- uintView[2] = numAngles; // u32
2156
- floatView[3] = maxRadius; // f32
2157
- floatView[4] = toolWidth; // f32
2158
- uintView[5] = gridYHeight; // u32
2159
- floatView[6] = bucketData.buckets[0].maxX - bucketData.buckets[0].minX; // f32 bucketWidth
2160
- uintView[7] = bucketGridWidth; // u32
2161
- floatView[8] = bucketMinX; // f32 global_min_x (use bucket range)
2162
- floatView[9] = zFloor; // f32
2163
- uintView[10] = 0; // u32 filterMode
2164
- uintView[11] = bucketData.numBuckets; // u32
2165
- floatView[12] = startAngle * (Math.PI / 180); // f32 start_angle (radians)
2166
-
2167
- new Uint8Array(uniformBuffer.getMappedRange()).set(new Uint8Array(uniformView));
2168
- uniformBuffer.unmap();
2169
-
2170
- // Create shader and pipeline
2171
- const shaderModule = device.createShaderModule({ code: radialRasterizeV2ShaderCode });
2172
- const pipeline = device.createComputePipeline({
2173
- layout: 'auto',
2174
- compute: {
2175
- module: shaderModule,
2176
- entryPoint: 'main'
2177
- }
2178
- });
1782
+ // Prep complete, GPU starting
1783
+ timings.prep = performance.now() - timings.start;
1784
+ const gpuStart = performance.now();
2179
1785
 
2180
- // Create bind group
2181
- const bindGroup = device.createBindGroup({
2182
- layout: pipeline.getBindGroupLayout(0),
2183
- entries: [
2184
- { binding: 0, resource: { buffer: triangleBuffer } },
2185
- { binding: 1, resource: { buffer: outputBuffer } },
2186
- { binding: 2, resource: { buffer: uniformBuffer } },
2187
- { binding: 3, resource: { buffer: bucketInfoBuffer } },
2188
- { binding: 4, resource: { buffer: triangleIndicesBuffer } }
2189
- ]
2190
- });
1786
+ // Use cached pipeline (created in initWebGPU)
1787
+ const pipeline = cachedRadialBatchPipeline;
2191
1788
 
2192
- console.time('RADIAL COMPUTE');
2193
- // Dispatch
1789
+ // Process buckets in batches to avoid GPU timeouts
2194
1790
  const commandEncoder = device.createCommandEncoder();
2195
1791
  const passEncoder = commandEncoder.beginComputePass();
2196
1792
  passEncoder.setPipeline(pipeline);
2197
- passEncoder.setBindGroup(0, bindGroup);
2198
-
2199
- // Prep complete, GPU starting
2200
- timings.prep = performance.now() - timings.start;
2201
- const gpuStart = performance.now();
2202
1793
 
2203
- // Dispatch: (numAngles/8, gridYHeight/8, numBuckets)
2204
1794
  const dispatchX = Math.ceil(numAngles / 8);
2205
1795
  const dispatchY = Math.ceil(gridYHeight / 8);
2206
- const dispatchZ = bucketData.numBuckets;
2207
- debug.log(`[Worker] Dispatch: (${dispatchX}, ${dispatchY}, ${dispatchZ}) = ${dispatchX * 8} angles, ${dispatchY * 8} Y cells, ${dispatchZ} buckets`);
2208
1796
 
2209
- passEncoder.dispatchWorkgroups(dispatchX, dispatchY, dispatchZ);
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
+
2210
1886
  passEncoder.end();
2211
1887
 
2212
1888
  // Read back
@@ -2218,21 +1894,21 @@ async function radialRasterizeV2(triangles, bucketData, resolution, angleStep, n
2218
1894
  commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize);
2219
1895
  device.queue.submit([commandEncoder.finish()]);
2220
1896
 
2221
- // CRITICAL: Wait for GPU to finish before reading results
1897
+ // Wait for GPU to finish before reading results
2222
1898
  await device.queue.onSubmittedWorkDone();
2223
- console.timeEnd('RADIAL COMPUTE');
2224
-
2225
1899
  await stagingBuffer.mapAsync(GPUMapMode.READ);
2226
- const outputData = new Float32Array(stagingBuffer.getMappedRange());
2227
- const outputCopy = new Float32Array(outputData);
1900
+ // const outputData = new Float32Array(stagingBuffer.getMappedRange());
1901
+ // const outputCopy = new Float32Array(outputData);
1902
+ const outputCopy = new Float32Array(stagingBuffer.getMappedRange().slice());
2228
1903
  stagingBuffer.unmap();
2229
1904
 
2230
- // Cleanup
2231
- triangleBuffer.destroy();
2232
- bucketInfoBuffer.destroy();
2233
- triangleIndicesBuffer.destroy();
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
2234
1911
  outputBuffer.destroy();
2235
- uniformBuffer.destroy();
2236
1912
  stagingBuffer.destroy();
2237
1913
 
2238
1914
  timings.gpu = performance.now() - gpuStart;
@@ -2288,12 +1964,256 @@ async function radialRasterizeV2(triangles, bucketData, resolution, angleStep, n
2288
1964
  timings.stitch = performance.now() - stitchStart;
2289
1965
  const totalTime = performance.now() - timings.start;
2290
1966
 
2291
- debug.log(`[Worker] Radial V2 complete: ${totalTime.toFixed(0)}ms`);
2292
- debug.log(`[Worker] Prep: ${timings.prep.toFixed(0)}ms (${(timings.prep/totalTime*100).toFixed(0)}%)`);
2293
- debug.log(`[Worker] GPU: ${timings.gpu.toFixed(0)}ms (${(timings.gpu/totalTime*100).toFixed(0)}%)`);
2294
- debug.log(`[Worker] Stitch: ${timings.stitch.toFixed(0)}ms (${(timings.stitch/totalTime*100).toFixed(0)}%)`);
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;
2295
2178
 
2296
- return { strips, timings };
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
+ };
2297
2217
  }
2298
2218
 
2299
2219
  // Handle messages from main thread
@@ -2309,7 +2229,8 @@ self.onmessage = async function(e) {
2309
2229
  gpuMemorySafetyMargin: 0.8,
2310
2230
  tileOverlapMM: 10,
2311
2231
  autoTiling: true,
2312
- minTileSize: 50
2232
+ minTileSize: 50,
2233
+ batchDivisor: 1 // For testing batching overhead: 1=optimal, 2=2x batches, 4=4x batches, etc.
2313
2234
  };
2314
2235
  const success = await initWebGPU();
2315
2236
  self.postMessage({
@@ -2357,149 +2278,11 @@ self.onmessage = async function(e) {
2357
2278
  break;
2358
2279
 
2359
2280
  case 'radial-generate-toolpaths':
2360
- // Complete radial pipeline: rasterize model + generate toolpaths for all strips
2361
- const {
2362
- triangles: radialModelTriangles,
2363
- bucketData: radialBucketData,
2364
- toolData: radialToolData,
2365
- resolution: radialResolution,
2366
- angleStep: radialAngleStep,
2367
- numAngles: radialNumAngles,
2368
- maxRadius: radialMaxRadius,
2369
- toolWidth: radialToolWidth,
2370
- zFloor: radialToolpathZFloor,
2371
- bounds: radialToolpathBounds,
2372
- xStep: radialXStep,
2373
- yStep: radialYStep,
2374
- gridStep: radialGridStep
2375
- } = data;
2376
-
2377
- debug.log('[Worker] Starting complete radial toolpath pipeline...');
2378
-
2379
- // Batch processing: rasterize angle ranges to avoid memory allocation failure
2380
- const ANGLES_PER_BATCH = 360; // Process 360 angles at a time
2381
- const numBatches = Math.ceil(radialNumAngles / ANGLES_PER_BATCH);
2382
-
2383
- debug.log(`[Worker] Processing ${radialNumAngles} angles in ${numBatches} batch(es) of up to ${ANGLES_PER_BATCH} angles`);
2384
-
2385
- const allStripToolpaths = [];
2386
- let totalToolpathPoints = 0;
2387
- const pipelineStartTime = performance.now();
2388
-
2389
- // Prepare sparse tool once
2390
- const sparseToolData = createSparseToolFromPoints(radialToolData.positions);
2391
- debug.log(`[Worker] Created sparse tool: ${sparseToolData.count} points (reusing for all strips)`);
2392
-
2393
- for (let batchIdx = 0; batchIdx < numBatches; batchIdx++) {
2394
- const startAngleIdx = batchIdx * ANGLES_PER_BATCH;
2395
- const endAngleIdx = Math.min(startAngleIdx + ANGLES_PER_BATCH, radialNumAngles);
2396
- const batchNumAngles = endAngleIdx - startAngleIdx;
2397
- const batchStartAngle = startAngleIdx * radialAngleStep;
2398
-
2399
- debug.log(`[Worker] Batch ${batchIdx + 1}/${numBatches}: angles ${startAngleIdx}-${endAngleIdx - 1} (${batchNumAngles} angles), startAngle=${batchStartAngle.toFixed(1)}°`);
2400
-
2401
- // Rasterize this batch of strips
2402
- const batchModelResult = await radialRasterizeV2(
2403
- radialModelTriangles,
2404
- radialBucketData,
2405
- radialResolution,
2406
- radialAngleStep,
2407
- batchNumAngles,
2408
- radialMaxRadius,
2409
- radialToolWidth,
2410
- radialToolpathZFloor,
2411
- radialToolpathBounds,
2412
- batchStartAngle // Start angle for this batch
2413
- );
2414
-
2415
- debug.log(`[Worker] Batch ${batchIdx + 1}: Rasterized ${batchModelResult.strips.length} strips, first angle=${batchModelResult.strips[0]?.angle.toFixed(1)}°, last angle=${batchModelResult.strips[batchModelResult.strips.length - 1]?.angle.toFixed(1)}°`);
2416
-
2417
- // Find max dimensions for this batch
2418
- let maxStripWidth = 0;
2419
- let maxStripHeight = 0;
2420
- for (const strip of batchModelResult.strips) {
2421
- maxStripWidth = Math.max(maxStripWidth, strip.gridWidth);
2422
- maxStripHeight = Math.max(maxStripHeight, strip.gridHeight);
2423
- }
2424
-
2425
- // Create reusable buffers for this batch
2426
- const reusableBuffers = createReusableToolpathBuffers(maxStripWidth, maxStripHeight, sparseToolData, radialXStep, maxStripHeight);
2427
-
2428
- // Generate toolpaths for this batch
2429
- debug.log(`[Worker] Batch ${batchIdx + 1}: Generating toolpaths for ${batchModelResult.strips.length} strips...`);
2430
- for (let i = 0; i < batchModelResult.strips.length; i++) {
2431
- const strip = batchModelResult.strips[i];
2432
- const globalStripIdx = startAngleIdx + i;
2433
-
2434
- if (globalStripIdx % 10 === 0 || globalStripIdx === radialNumAngles - 1) {
2435
- self.postMessage({
2436
- type: 'toolpath-progress',
2437
- data: {
2438
- percent: Math.round(((globalStripIdx + 1) / radialNumAngles) * 100),
2439
- current: globalStripIdx + 1,
2440
- total: radialNumAngles,
2441
- layer: globalStripIdx + 1
2442
- }
2443
- });
2444
- }
2445
-
2446
- if (!strip.positions || strip.positions.length === 0) continue;
2447
-
2448
- // DEBUG: Diagnostic logging (BUILD_ID gets injected during build)
2449
- // Used to trace data flow through radial toolpath pipeline
2450
- if (globalStripIdx === 0 || globalStripIdx === 360) {
2451
- debug.log(`[Worker] 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(',')}`);
2452
- }
2453
-
2454
- const stripToolpathResult = await runToolpathComputeWithBuffers(
2455
- strip.positions,
2456
- strip.gridWidth,
2457
- strip.gridHeight,
2458
- radialXStep,
2459
- strip.gridHeight,
2460
- radialToolpathZFloor,
2461
- reusableBuffers,
2462
- pipelineStartTime
2463
- );
2464
-
2465
- // DEBUG: Verify toolpath generation output
2466
- if (globalStripIdx === 0 || globalStripIdx === 360) {
2467
- debug.log(`[Worker] 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(',')}`);
2468
- }
2469
-
2470
- allStripToolpaths.push({
2471
- angle: strip.angle,
2472
- pathData: stripToolpathResult.pathData,
2473
- numScanlines: stripToolpathResult.numScanlines,
2474
- pointsPerLine: stripToolpathResult.pointsPerLine,
2475
- terrainBounds: strip.bounds // Include terrain bounds for display
2476
- });
2477
-
2478
- totalToolpathPoints += stripToolpathResult.pathData.length;
2479
- }
2480
-
2481
- destroyReusableToolpathBuffers(reusableBuffers);
2482
-
2483
- debug.log(`[Worker] Batch ${batchIdx + 1}: Completed, allStripToolpaths now has ${allStripToolpaths.length} strips total`);
2484
-
2485
- // Free batch terrain data
2486
- for (const strip of batchModelResult.strips) {
2487
- strip.positions = null;
2488
- }
2489
- }
2490
-
2491
- const pipelineTotalTime = performance.now() - pipelineStartTime;
2492
- debug.log(`[Worker] Complete radial toolpath: ${allStripToolpaths.length} strips, ${totalToolpathPoints} total points in ${pipelineTotalTime.toFixed(0)}ms`);
2493
-
2494
- const toolpathTransferBuffers = allStripToolpaths.map(strip => strip.pathData.buffer);
2495
-
2281
+ const radialToolpathResult = await generateRadialToolpaths(data);
2282
+ const toolpathTransferBuffers = radialToolpathResult.strips.map(strip => strip.pathData.buffer);
2496
2283
  self.postMessage({
2497
2284
  type: 'radial-toolpaths-complete',
2498
- data: {
2499
- strips: allStripToolpaths,
2500
- totalPoints: totalToolpathPoints,
2501
- numStrips: allStripToolpaths.length
2502
- }
2285
+ data: radialToolpathResult
2503
2286
  }, toolpathTransferBuffers);
2504
2287
  break;
2505
2288