@gridspace/raster-path 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +292 -0
- package/build/app.js +1254 -0
- package/build/index.html +92 -0
- package/build/parse-stl.js +114 -0
- package/build/raster-path.js +688 -0
- package/build/serve.json +12 -0
- package/build/style.css +158 -0
- package/build/webgpu-worker.js +3011 -0
- package/package.json +58 -0
- package/scripts/build-shaders.js +65 -0
- package/src/index.js +688 -0
- package/src/shaders/planar-rasterize.wgsl +213 -0
- package/src/shaders/planar-toolpath.wgsl +83 -0
- package/src/shaders/radial-raster-v2.wgsl +195 -0
- package/src/web/app.js +1254 -0
- package/src/web/parse-stl.js +114 -0
- package/src/web/webgpu-worker.js +2520 -0
|
@@ -0,0 +1,2520 @@
|
|
|
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 device = null;
|
|
52
|
+
let isInitialized = false;
|
|
53
|
+
let cachedRasterizePipeline = null;
|
|
54
|
+
let cachedRasterizeShaderModule = null;
|
|
55
|
+
let cachedToolpathPipeline = null;
|
|
56
|
+
let cachedToolpathShaderModule = null;
|
|
57
|
+
let config = null;
|
|
58
|
+
let deviceCapabilities = null;
|
|
59
|
+
|
|
60
|
+
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;
|
|
67
|
+
|
|
68
|
+
const debug = {
|
|
69
|
+
error: function() { console.error(log_pre, ...arguments) },
|
|
70
|
+
warn: function() { console.warn(log_pre, ...arguments) },
|
|
71
|
+
log: function() { !quiet && console.log(log_pre, ...arguments) },
|
|
72
|
+
ok: function() { console.log(log_pre, '✅', ...arguments) },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function round(v, d = 1) {
|
|
76
|
+
return parseFloat(v.toFixed(d));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Global error handler for uncaught errors in worker
|
|
80
|
+
self.addEventListener('error', (event) => {
|
|
81
|
+
debug.error('Uncaught error:', event.error || event.message);
|
|
82
|
+
debug.error('Stack:', event.error?.stack);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
self.addEventListener('unhandledrejection', (event) => {
|
|
86
|
+
debug.error('Unhandled promise rejection:', event.reason);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Initialize WebGPU device in worker context
|
|
90
|
+
async function initWebGPU() {
|
|
91
|
+
if (isInitialized) return true;
|
|
92
|
+
|
|
93
|
+
if (!navigator.gpu) {
|
|
94
|
+
debug.warn('WebGPU not supported');
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
100
|
+
if (!adapter) {
|
|
101
|
+
debug.warn('WebGPU adapter not available');
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Request device with higher limits for large meshes
|
|
106
|
+
const adapterLimits = adapter.limits;
|
|
107
|
+
debug.log('Adapter limits:', {
|
|
108
|
+
maxStorageBufferBindingSize: adapterLimits.maxStorageBufferBindingSize,
|
|
109
|
+
maxBufferSize: adapterLimits.maxBufferSize
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
device = await adapter.requestDevice({
|
|
113
|
+
requiredLimits: {
|
|
114
|
+
maxStorageBufferBindingSize: Math.min(
|
|
115
|
+
adapterLimits.maxStorageBufferBindingSize,
|
|
116
|
+
1024 * 1024 * 1024 // Request up to 1GB
|
|
117
|
+
),
|
|
118
|
+
maxBufferSize: Math.min(
|
|
119
|
+
adapterLimits.maxBufferSize,
|
|
120
|
+
1024 * 1024 * 1024 // Request up to 1GB
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Pre-compile rasterize shader module (expensive operation)
|
|
126
|
+
cachedRasterizeShaderModule = device.createShaderModule({ code: rasterizeShaderCode });
|
|
127
|
+
|
|
128
|
+
// Pre-create rasterize pipeline (very expensive operation)
|
|
129
|
+
cachedRasterizePipeline = device.createComputePipeline({
|
|
130
|
+
layout: 'auto',
|
|
131
|
+
compute: { module: cachedRasterizeShaderModule, entryPoint: 'main' },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Pre-compile toolpath shader module
|
|
135
|
+
cachedToolpathShaderModule = device.createShaderModule({ code: toolpathShaderCode });
|
|
136
|
+
|
|
137
|
+
// Pre-create toolpath pipeline
|
|
138
|
+
cachedToolpathPipeline = device.createComputePipeline({
|
|
139
|
+
layout: 'auto',
|
|
140
|
+
compute: { module: cachedToolpathShaderModule, entryPoint: 'main' },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Store device capabilities
|
|
144
|
+
deviceCapabilities = {
|
|
145
|
+
maxStorageBufferBindingSize: device.limits.maxStorageBufferBindingSize,
|
|
146
|
+
maxBufferSize: device.limits.maxBufferSize,
|
|
147
|
+
maxComputeWorkgroupSizeX: device.limits.maxComputeWorkgroupSizeX,
|
|
148
|
+
maxComputeWorkgroupSizeY: device.limits.maxComputeWorkgroupSizeY,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
isInitialized = true;
|
|
152
|
+
debug.log('Initialized (pipelines cached)');
|
|
153
|
+
return true;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
debug.error('Failed to initialize:', error);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Planar rasterization with spatial partitioning
|
|
161
|
+
const rasterizeShaderCode = /*SHADER:planar-rasterize*/;
|
|
162
|
+
|
|
163
|
+
// Planar toolpath generation
|
|
164
|
+
const toolpathShaderCode = /*SHADER:planar-toolpath*/;
|
|
165
|
+
|
|
166
|
+
// Radial V2: Rasterization with rotating ray planes and X-bucketing
|
|
167
|
+
const radialRasterizeV2ShaderCode = /*SHADER:radial-raster-v2*/;
|
|
168
|
+
|
|
169
|
+
// Calculate bounding box from triangle vertices
|
|
170
|
+
function calculateBounds(triangles) {
|
|
171
|
+
let min_x = Infinity, min_y = Infinity, min_z = Infinity;
|
|
172
|
+
let max_x = -Infinity, max_y = -Infinity, max_z = -Infinity;
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
175
|
+
const x = triangles[i];
|
|
176
|
+
const y = triangles[i + 1];
|
|
177
|
+
const z = triangles[i + 2];
|
|
178
|
+
|
|
179
|
+
if (x < min_x) min_x = x;
|
|
180
|
+
if (y < min_y) min_y = y;
|
|
181
|
+
if (z < min_z) min_z = z;
|
|
182
|
+
if (x > max_x) max_x = x;
|
|
183
|
+
if (y > max_y) max_y = y;
|
|
184
|
+
if (z > max_z) max_z = z;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
min: { x: min_x, y: min_y, z: min_z },
|
|
189
|
+
max: { x: max_x, y: max_y, z: max_z }
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Build spatial grid for efficient triangle culling
|
|
194
|
+
function buildSpatialGrid(triangles, bounds, cellSize = 5.0) {
|
|
195
|
+
const gridWidth = Math.max(1, Math.ceil((bounds.max.x - bounds.min.x) / cellSize));
|
|
196
|
+
const gridHeight = Math.max(1, Math.ceil((bounds.max.y - bounds.min.y) / cellSize));
|
|
197
|
+
const totalCells = gridWidth * gridHeight;
|
|
198
|
+
|
|
199
|
+
// debug.log(`Building spatial grid ${gridWidth}x${gridHeight} (${cellSize}mm cells)`);
|
|
200
|
+
|
|
201
|
+
const grid = new Array(totalCells);
|
|
202
|
+
for (let i = 0; i < totalCells; i++) {
|
|
203
|
+
grid[i] = [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const triangleCount = triangles.length / 9;
|
|
207
|
+
for (let t = 0; t < triangleCount; t++) {
|
|
208
|
+
const base = t * 9;
|
|
209
|
+
|
|
210
|
+
const v0x = triangles[base], v0y = triangles[base + 1];
|
|
211
|
+
const v1x = triangles[base + 3], v1y = triangles[base + 4];
|
|
212
|
+
const v2x = triangles[base + 6], v2y = triangles[base + 7];
|
|
213
|
+
|
|
214
|
+
// Add small epsilon to catch triangles near cell boundaries
|
|
215
|
+
const epsilon = cellSize * 0.01; // 1% of cell size
|
|
216
|
+
const minX = Math.min(v0x, v1x, v2x) - epsilon;
|
|
217
|
+
const maxX = Math.max(v0x, v1x, v2x) + epsilon;
|
|
218
|
+
const minY = Math.min(v0y, v1y, v2y) - epsilon;
|
|
219
|
+
const maxY = Math.max(v0y, v1y, v2y) + epsilon;
|
|
220
|
+
|
|
221
|
+
let minCellX = Math.floor((minX - bounds.min.x) / cellSize);
|
|
222
|
+
let maxCellX = Math.floor((maxX - bounds.min.x) / cellSize);
|
|
223
|
+
let minCellY = Math.floor((minY - bounds.min.y) / cellSize);
|
|
224
|
+
let maxCellY = Math.floor((maxY - bounds.min.y) / cellSize);
|
|
225
|
+
|
|
226
|
+
minCellX = Math.max(0, Math.min(gridWidth - 1, minCellX));
|
|
227
|
+
maxCellX = Math.max(0, Math.min(gridWidth - 1, maxCellX));
|
|
228
|
+
minCellY = Math.max(0, Math.min(gridHeight - 1, minCellY));
|
|
229
|
+
maxCellY = Math.max(0, Math.min(gridHeight - 1, maxCellY));
|
|
230
|
+
|
|
231
|
+
for (let cy = minCellY; cy <= maxCellY; cy++) {
|
|
232
|
+
for (let cx = minCellX; cx <= maxCellX; cx++) {
|
|
233
|
+
const cellIdx = cy * gridWidth + cx;
|
|
234
|
+
grid[cellIdx].push(t);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let totalTriangleRefs = 0;
|
|
240
|
+
for (let i = 0; i < totalCells; i++) {
|
|
241
|
+
totalTriangleRefs += grid[i].length;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const cellOffsets = new Uint32Array(totalCells + 1);
|
|
245
|
+
const triangleIndices = new Uint32Array(totalTriangleRefs);
|
|
246
|
+
|
|
247
|
+
let currentOffset = 0;
|
|
248
|
+
for (let i = 0; i < totalCells; i++) {
|
|
249
|
+
cellOffsets[i] = currentOffset;
|
|
250
|
+
for (let j = 0; j < grid[i].length; j++) {
|
|
251
|
+
triangleIndices[currentOffset++] = grid[i][j];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
cellOffsets[totalCells] = currentOffset;
|
|
255
|
+
|
|
256
|
+
const avgPerCell = totalTriangleRefs / totalCells;
|
|
257
|
+
// debug.log(`Spatial grid: ${totalTriangleRefs} refs (avg ${avgPerCell.toFixed(1)} per cell)`);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
gridWidth,
|
|
261
|
+
gridHeight,
|
|
262
|
+
cellSize,
|
|
263
|
+
cellOffsets,
|
|
264
|
+
triangleIndices,
|
|
265
|
+
avgTrianglesPerCell: avgPerCell
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Create reusable GPU buffers for multiple rasterization passes (e.g., radial rotations)
|
|
270
|
+
|
|
271
|
+
// Rasterize mesh to point cloud
|
|
272
|
+
// Internal function - rasterize without tiling (do not modify this function!)
|
|
273
|
+
async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}) {
|
|
274
|
+
const startTime = performance.now();
|
|
275
|
+
|
|
276
|
+
if (!isInitialized) {
|
|
277
|
+
const initStart = performance.now();
|
|
278
|
+
const success = await initWebGPU();
|
|
279
|
+
if (!success) {
|
|
280
|
+
throw new Error('WebGPU not available');
|
|
281
|
+
}
|
|
282
|
+
const initEnd = performance.now();
|
|
283
|
+
debug.log(`First-time init: ${(initEnd - initStart).toFixed(1)}ms`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// debug.log(`Rasterizing ${triangles.length / 9} triangles (step ${stepSize}mm, mode ${filterMode})...`);
|
|
287
|
+
|
|
288
|
+
// Extract options
|
|
289
|
+
// boundsOverride: Optional manual bounds to avoid recalculating from triangles
|
|
290
|
+
// Useful when bounds are already known (e.g., from tiling operations)
|
|
291
|
+
const boundsOverride = options.bounds || options.min ? options : null;
|
|
292
|
+
|
|
293
|
+
// Use bounds override if provided, otherwise calculate from triangles
|
|
294
|
+
const bounds = boundsOverride || calculateBounds(triangles);
|
|
295
|
+
|
|
296
|
+
if (boundsOverride) {
|
|
297
|
+
// 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)})`);
|
|
298
|
+
|
|
299
|
+
// Validate bounds
|
|
300
|
+
if (bounds.min.x >= bounds.max.x || bounds.min.y >= bounds.max.y || bounds.min.z >= bounds.max.z) {
|
|
301
|
+
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})`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
|
|
306
|
+
const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
|
|
307
|
+
const totalGridPoints = gridWidth * gridHeight;
|
|
308
|
+
|
|
309
|
+
// debug.log(`Grid: ${gridWidth}x${gridHeight} = ${totalGridPoints.toLocaleString()} points`);
|
|
310
|
+
|
|
311
|
+
// Calculate buffer size based on filter mode
|
|
312
|
+
// filterMode 0 (terrain): Dense Z-only output (1 float per grid cell)
|
|
313
|
+
// filterMode 1 (tool): Sparse X,Y,Z output (3 floats per grid cell)
|
|
314
|
+
const floatsPerPoint = filterMode === 0 ? 1 : 3;
|
|
315
|
+
const outputSize = totalGridPoints * floatsPerPoint * 4;
|
|
316
|
+
const maxBufferSize = device.limits.maxBufferSize || 268435456; // 256MB default
|
|
317
|
+
const modeStr = filterMode === 0 ? 'terrain (dense Z-only)' : 'tool (sparse XYZ)';
|
|
318
|
+
// debug.log(`Output buffer size: ${(outputSize / 1024 / 1024).toFixed(2)} MB for ${modeStr} (max: ${(maxBufferSize / 1024 / 1024).toFixed(2)} MB)`);
|
|
319
|
+
|
|
320
|
+
if (outputSize > maxBufferSize) {
|
|
321
|
+
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
|
+
}
|
|
323
|
+
|
|
324
|
+
const spatialGrid = buildSpatialGrid(triangles, bounds);
|
|
325
|
+
|
|
326
|
+
// Create buffers
|
|
327
|
+
const triangleBuffer = device.createBuffer({
|
|
328
|
+
size: triangles.byteLength,
|
|
329
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
330
|
+
});
|
|
331
|
+
device.queue.writeBuffer(triangleBuffer, 0, triangles);
|
|
332
|
+
|
|
333
|
+
// Create and INITIALIZE output buffer (GPU buffers contain garbage by default!)
|
|
334
|
+
const outputBuffer = device.createBuffer({
|
|
335
|
+
size: outputSize,
|
|
336
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Initialize output buffer with sentinel value for terrain, zeros for tool
|
|
340
|
+
if (filterMode === 0) {
|
|
341
|
+
// Terrain: initialize with EMPTY_CELL sentinel value
|
|
342
|
+
const initData = new Float32Array(totalGridPoints);
|
|
343
|
+
initData.fill(EMPTY_CELL);
|
|
344
|
+
device.queue.writeBuffer(outputBuffer, 0, initData);
|
|
345
|
+
}
|
|
346
|
+
// Tool mode: zeros are fine (will check valid mask)
|
|
347
|
+
|
|
348
|
+
const validMaskBuffer = device.createBuffer({
|
|
349
|
+
size: totalGridPoints * 4,
|
|
350
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const spatialCellOffsetsBuffer = device.createBuffer({
|
|
354
|
+
size: spatialGrid.cellOffsets.byteLength,
|
|
355
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
356
|
+
});
|
|
357
|
+
device.queue.writeBuffer(spatialCellOffsetsBuffer, 0, spatialGrid.cellOffsets);
|
|
358
|
+
|
|
359
|
+
const spatialTriangleIndicesBuffer = device.createBuffer({
|
|
360
|
+
size: spatialGrid.triangleIndices.byteLength,
|
|
361
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
362
|
+
});
|
|
363
|
+
device.queue.writeBuffer(spatialTriangleIndicesBuffer, 0, spatialGrid.triangleIndices);
|
|
364
|
+
|
|
365
|
+
// Uniforms
|
|
366
|
+
const uniformData = new Float32Array([
|
|
367
|
+
bounds.min.x, bounds.min.y, bounds.min.z,
|
|
368
|
+
bounds.max.x, bounds.max.y, bounds.max.z,
|
|
369
|
+
stepSize,
|
|
370
|
+
0, 0, 0, 0, 0, 0, 0 // Padding for alignment
|
|
371
|
+
]);
|
|
372
|
+
const uniformDataU32 = new Uint32Array(uniformData.buffer);
|
|
373
|
+
uniformDataU32[7] = gridWidth;
|
|
374
|
+
uniformDataU32[8] = gridHeight;
|
|
375
|
+
uniformDataU32[9] = triangles.length / 9;
|
|
376
|
+
uniformDataU32[10] = filterMode;
|
|
377
|
+
uniformDataU32[11] = spatialGrid.gridWidth;
|
|
378
|
+
uniformDataU32[12] = spatialGrid.gridHeight;
|
|
379
|
+
const uniformDataF32 = new Float32Array(uniformData.buffer);
|
|
380
|
+
uniformDataF32[13] = spatialGrid.cellSize;
|
|
381
|
+
|
|
382
|
+
// Check for u32 overflow
|
|
383
|
+
const maxU32 = 4294967295;
|
|
384
|
+
if (gridWidth > maxU32 || gridHeight > maxU32) {
|
|
385
|
+
throw new Error(`Grid dimensions exceed u32 max: ${gridWidth}x${gridHeight}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// debug.log(`Uniforms: gridWidth=${gridWidth}, gridHeight=${gridHeight}, triangles=${triangles.length / 9}`);
|
|
389
|
+
|
|
390
|
+
const uniformBuffer = device.createBuffer({
|
|
391
|
+
size: uniformData.byteLength,
|
|
392
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
393
|
+
});
|
|
394
|
+
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
|
|
395
|
+
|
|
396
|
+
// CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
|
|
397
|
+
await device.queue.onSubmittedWorkDone();
|
|
398
|
+
|
|
399
|
+
// Use cached pipeline
|
|
400
|
+
const bindGroup = device.createBindGroup({
|
|
401
|
+
layout: cachedRasterizePipeline.getBindGroupLayout(0),
|
|
402
|
+
entries: [
|
|
403
|
+
{ binding: 0, resource: { buffer: triangleBuffer } },
|
|
404
|
+
{ binding: 1, resource: { buffer: outputBuffer } },
|
|
405
|
+
{ binding: 2, resource: { buffer: validMaskBuffer } },
|
|
406
|
+
{ binding: 3, resource: { buffer: uniformBuffer } },
|
|
407
|
+
{ binding: 4, resource: { buffer: spatialCellOffsetsBuffer } },
|
|
408
|
+
{ binding: 5, resource: { buffer: spatialTriangleIndicesBuffer } },
|
|
409
|
+
],
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Dispatch compute shader
|
|
413
|
+
const commandEncoder = device.createCommandEncoder();
|
|
414
|
+
const passEncoder = commandEncoder.beginComputePass();
|
|
415
|
+
passEncoder.setPipeline(cachedRasterizePipeline);
|
|
416
|
+
passEncoder.setBindGroup(0, bindGroup);
|
|
417
|
+
|
|
418
|
+
const workgroupsX = Math.ceil(gridWidth / 16);
|
|
419
|
+
const workgroupsY = Math.ceil(gridHeight / 16);
|
|
420
|
+
|
|
421
|
+
// Check dispatch limits
|
|
422
|
+
const maxWorkgroupsPerDim = device.limits.maxComputeWorkgroupsPerDimension || 65535;
|
|
423
|
+
|
|
424
|
+
if (workgroupsX > maxWorkgroupsPerDim || workgroupsY > maxWorkgroupsPerDim) {
|
|
425
|
+
throw new Error(`Workgroup dispatch too large: ${workgroupsX}x${workgroupsY} exceeds limit of ${maxWorkgroupsPerDim}. Try a larger step size.`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
|
|
429
|
+
passEncoder.end();
|
|
430
|
+
|
|
431
|
+
// Create staging buffers for readback
|
|
432
|
+
const stagingOutputBuffer = device.createBuffer({
|
|
433
|
+
size: outputSize,
|
|
434
|
+
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const stagingValidMaskBuffer = device.createBuffer({
|
|
438
|
+
size: totalGridPoints * 4,
|
|
439
|
+
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingOutputBuffer, 0, outputSize);
|
|
443
|
+
commandEncoder.copyBufferToBuffer(validMaskBuffer, 0, stagingValidMaskBuffer, 0, totalGridPoints * 4);
|
|
444
|
+
|
|
445
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
446
|
+
|
|
447
|
+
// Wait for GPU to finish
|
|
448
|
+
await device.queue.onSubmittedWorkDone();
|
|
449
|
+
|
|
450
|
+
// Read back results
|
|
451
|
+
await stagingOutputBuffer.mapAsync(GPUMapMode.READ);
|
|
452
|
+
await stagingValidMaskBuffer.mapAsync(GPUMapMode.READ);
|
|
453
|
+
|
|
454
|
+
const outputData = new Float32Array(stagingOutputBuffer.getMappedRange());
|
|
455
|
+
const validMaskData = new Uint32Array(stagingValidMaskBuffer.getMappedRange());
|
|
456
|
+
|
|
457
|
+
let result, pointCount;
|
|
458
|
+
|
|
459
|
+
if (filterMode === 0) {
|
|
460
|
+
// Terrain: Dense output (Z-only), no compaction needed
|
|
461
|
+
// Copy the full array (already has NaN for empty cells)
|
|
462
|
+
result = new Float32Array(outputData);
|
|
463
|
+
pointCount = totalGridPoints;
|
|
464
|
+
|
|
465
|
+
if (verbose) {
|
|
466
|
+
// Count valid points for logging (sentinel value = -1e10)
|
|
467
|
+
let zeroCount = 0;
|
|
468
|
+
let validCount = 0;
|
|
469
|
+
for (let i = 0; i < totalGridPoints; i++) {
|
|
470
|
+
if (result[i] > EMPTY_CELL + 1) validCount++; // Any value significantly above sentinel
|
|
471
|
+
if (result[i] === 0) zeroCount++;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let percentHit = validCount/totalGridPoints;
|
|
475
|
+
if (zeroCount > 0 || percentHit < 0.5 ) {
|
|
476
|
+
debug.log(totalGridPoints, 'cells,', round(percentHit*100), '% coverage,', zeroCount, 'zeros');
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
// Tool: Sparse output (X,Y,Z triplets), compact to remove invalid points
|
|
481
|
+
const validPoints = [];
|
|
482
|
+
for (let i = 0; i < totalGridPoints; i++) {
|
|
483
|
+
if (validMaskData[i] === 1) {
|
|
484
|
+
validPoints.push(
|
|
485
|
+
outputData[i * 3],
|
|
486
|
+
outputData[i * 3 + 1],
|
|
487
|
+
outputData[i * 3 + 2]
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
result = new Float32Array(validPoints);
|
|
492
|
+
pointCount = validPoints.length / 3;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
stagingOutputBuffer.unmap();
|
|
496
|
+
stagingValidMaskBuffer.unmap();
|
|
497
|
+
|
|
498
|
+
// Cleanup
|
|
499
|
+
triangleBuffer.destroy();
|
|
500
|
+
outputBuffer.destroy();
|
|
501
|
+
validMaskBuffer.destroy();
|
|
502
|
+
uniformBuffer.destroy();
|
|
503
|
+
spatialCellOffsetsBuffer.destroy();
|
|
504
|
+
spatialTriangleIndicesBuffer.destroy();
|
|
505
|
+
stagingOutputBuffer.destroy();
|
|
506
|
+
stagingValidMaskBuffer.destroy();
|
|
507
|
+
|
|
508
|
+
const endTime = performance.now();
|
|
509
|
+
const conversionTime = endTime - startTime;
|
|
510
|
+
// debug.log(`Rasterize complete: ${pointCount} points in ${conversionTime.toFixed(1)}ms`);
|
|
511
|
+
// 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)})`);
|
|
512
|
+
|
|
513
|
+
// Verify result data integrity
|
|
514
|
+
if (filterMode === 0) {
|
|
515
|
+
// Terrain: Dense Z-only format
|
|
516
|
+
if (result.length > 0) {
|
|
517
|
+
const firstZ = result[0] <= EMPTY_CELL + 1 ? 'EMPTY' : result[0].toFixed(3);
|
|
518
|
+
const lastZ = result[result.length-1] <= EMPTY_CELL + 1 ? 'EMPTY' : result[result.length-1].toFixed(3);
|
|
519
|
+
// debug.log(`First Z: ${firstZ}, Last Z: ${lastZ}`);
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
// Tool: Sparse X,Y,Z format
|
|
523
|
+
if (result.length > 0) {
|
|
524
|
+
const firstPoint = `(${result[0].toFixed(3)}, ${result[1].toFixed(3)}, ${result[2].toFixed(3)})`;
|
|
525
|
+
const lastIdx = result.length - 3;
|
|
526
|
+
const lastPoint = `(${result[lastIdx].toFixed(3)}, ${result[lastIdx+1].toFixed(3)}, ${result[lastIdx+2].toFixed(3)})`;
|
|
527
|
+
// debug.log(`First point: ${firstPoint}, Last point: ${lastPoint}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
positions: result,
|
|
533
|
+
pointCount: pointCount,
|
|
534
|
+
bounds: bounds,
|
|
535
|
+
conversionTime: conversionTime,
|
|
536
|
+
gridWidth: gridWidth,
|
|
537
|
+
gridHeight: gridHeight,
|
|
538
|
+
isDense: filterMode === 0 // True for terrain (dense), false for tool (sparse)
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Create tiles for tiled rasterization
|
|
543
|
+
function createTiles(bounds, stepSize, maxMemoryBytes) {
|
|
544
|
+
const width = bounds.max.x - bounds.min.x;
|
|
545
|
+
const height = bounds.max.y - bounds.min.y;
|
|
546
|
+
const aspectRatio = width / height;
|
|
547
|
+
|
|
548
|
+
// Calculate how many grid points we can fit in one tile
|
|
549
|
+
// Terrain uses dense Z-only format: (gridW * gridH * 1 * 4) for output
|
|
550
|
+
// This is 4x more efficient than the old sparse format (16 bytes → 4 bytes per point)
|
|
551
|
+
const bytesPerPoint = 1 * 4; // 4 bytes per grid point (Z-only)
|
|
552
|
+
const maxPointsPerTile = Math.floor(maxMemoryBytes / bytesPerPoint);
|
|
553
|
+
debug.log(`Dense terrain format: ${bytesPerPoint} bytes/point (was 16), can fit ${(maxPointsPerTile/1e6).toFixed(1)}M points per tile`);
|
|
554
|
+
|
|
555
|
+
// Calculate optimal tile grid dimensions while respecting aspect ratio
|
|
556
|
+
// We want: tileGridW * tileGridH <= maxPointsPerTile
|
|
557
|
+
// And: tileGridW / tileGridH ≈ aspectRatio
|
|
558
|
+
|
|
559
|
+
let tileGridW, tileGridH;
|
|
560
|
+
if (aspectRatio >= 1) {
|
|
561
|
+
// Width >= Height
|
|
562
|
+
tileGridH = Math.floor(Math.sqrt(maxPointsPerTile / aspectRatio));
|
|
563
|
+
tileGridW = Math.floor(tileGridH * aspectRatio);
|
|
564
|
+
} else {
|
|
565
|
+
// Height > Width
|
|
566
|
+
tileGridW = Math.floor(Math.sqrt(maxPointsPerTile * aspectRatio));
|
|
567
|
+
tileGridH = Math.floor(tileGridW / aspectRatio);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Ensure we don't exceed limits
|
|
571
|
+
while (tileGridW * tileGridH * bytesPerPoint > maxMemoryBytes) {
|
|
572
|
+
if (tileGridW > tileGridH) {
|
|
573
|
+
tileGridW--;
|
|
574
|
+
} else {
|
|
575
|
+
tileGridH--;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Convert grid dimensions to world dimensions
|
|
580
|
+
const tileWidth = tileGridW * stepSize;
|
|
581
|
+
const tileHeight = tileGridH * stepSize;
|
|
582
|
+
|
|
583
|
+
// Calculate number of tiles needed
|
|
584
|
+
const tilesX = Math.ceil(width / tileWidth);
|
|
585
|
+
const tilesY = Math.ceil(height / tileHeight);
|
|
586
|
+
|
|
587
|
+
// Calculate actual tile dimensions (distribute evenly)
|
|
588
|
+
const actualTileWidth = width / tilesX;
|
|
589
|
+
const actualTileHeight = height / tilesY;
|
|
590
|
+
|
|
591
|
+
debug.log(`Creating ${tilesX}x${tilesY} = ${tilesX * tilesY} tiles (${actualTileWidth.toFixed(2)}mm × ${actualTileHeight.toFixed(2)}mm each)`);
|
|
592
|
+
debug.log(`Tile grid: ${Math.ceil(actualTileWidth / stepSize)}x${Math.ceil(actualTileHeight / stepSize)} points per tile`);
|
|
593
|
+
|
|
594
|
+
const tiles = [];
|
|
595
|
+
const overlap = stepSize * 2; // Overlap by 2 grid cells to ensure no gaps
|
|
596
|
+
|
|
597
|
+
for (let ty = 0; ty < tilesY; ty++) {
|
|
598
|
+
for (let tx = 0; tx < tilesX; tx++) {
|
|
599
|
+
// Calculate base tile bounds (no overlap)
|
|
600
|
+
let tileMinX = bounds.min.x + (tx * actualTileWidth);
|
|
601
|
+
let tileMinY = bounds.min.y + (ty * actualTileHeight);
|
|
602
|
+
let tileMaxX = Math.min(bounds.max.x, tileMinX + actualTileWidth);
|
|
603
|
+
let tileMaxY = Math.min(bounds.max.y, tileMinY + actualTileHeight);
|
|
604
|
+
|
|
605
|
+
// Add overlap (except at outer edges) - but DON'T extend beyond global bounds
|
|
606
|
+
if (tx > 0) tileMinX = Math.max(bounds.min.x, tileMinX - overlap);
|
|
607
|
+
if (ty > 0) tileMinY = Math.max(bounds.min.y, tileMinY - overlap);
|
|
608
|
+
if (tx < tilesX - 1) tileMaxX = Math.min(bounds.max.x, tileMaxX + overlap);
|
|
609
|
+
if (ty < tilesY - 1) tileMaxY = Math.min(bounds.max.y, tileMaxY + overlap);
|
|
610
|
+
|
|
611
|
+
tiles.push({
|
|
612
|
+
id: `tile_${tx}_${ty}`,
|
|
613
|
+
bounds: {
|
|
614
|
+
min: { x: tileMinX, y: tileMinY, z: bounds.min.z },
|
|
615
|
+
max: { x: tileMaxX, y: tileMaxY, z: bounds.max.z }
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return { tiles, tilesX, tilesY };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Stitch tiles from multiple rasterization passes
|
|
625
|
+
function stitchTiles(tileResults, fullBounds, stepSize) {
|
|
626
|
+
if (tileResults.length === 0) {
|
|
627
|
+
throw new Error('No tile results to stitch');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Check if results are dense (terrain) or sparse (tool)
|
|
631
|
+
const isDense = tileResults[0].isDense;
|
|
632
|
+
|
|
633
|
+
if (isDense) {
|
|
634
|
+
// DENSE TERRAIN STITCHING: Simple array copying (Z-only format)
|
|
635
|
+
debug.log(`Stitching ${tileResults.length} dense terrain tiles...`);
|
|
636
|
+
|
|
637
|
+
// Calculate global grid dimensions
|
|
638
|
+
const globalWidth = Math.ceil((fullBounds.max.x - fullBounds.min.x) / stepSize) + 1;
|
|
639
|
+
const globalHeight = Math.ceil((fullBounds.max.y - fullBounds.min.y) / stepSize) + 1;
|
|
640
|
+
const totalGridCells = globalWidth * globalHeight;
|
|
641
|
+
|
|
642
|
+
// Allocate global dense grid (Z-only), initialize to sentinel value
|
|
643
|
+
const globalGrid = new Float32Array(totalGridCells);
|
|
644
|
+
globalGrid.fill(EMPTY_CELL);
|
|
645
|
+
|
|
646
|
+
debug.log(`Global grid: ${globalWidth}x${globalHeight} = ${totalGridCells.toLocaleString()} cells`);
|
|
647
|
+
|
|
648
|
+
// Copy each tile's Z-values to the correct position in global grid
|
|
649
|
+
for (const tile of tileResults) {
|
|
650
|
+
// Calculate tile's position in global grid
|
|
651
|
+
const tileOffsetX = Math.round((tile.tileBounds.min.x - fullBounds.min.x) / stepSize);
|
|
652
|
+
const tileOffsetY = Math.round((tile.tileBounds.min.y - fullBounds.min.y) / stepSize);
|
|
653
|
+
|
|
654
|
+
const tileWidth = tile.gridWidth;
|
|
655
|
+
const tileHeight = tile.gridHeight;
|
|
656
|
+
|
|
657
|
+
// Copy Z-values row by row
|
|
658
|
+
for (let ty = 0; ty < tileHeight; ty++) {
|
|
659
|
+
const globalY = tileOffsetY + ty;
|
|
660
|
+
if (globalY >= globalHeight) continue;
|
|
661
|
+
|
|
662
|
+
for (let tx = 0; tx < tileWidth; tx++) {
|
|
663
|
+
const globalX = tileOffsetX + tx;
|
|
664
|
+
if (globalX >= globalWidth) continue;
|
|
665
|
+
|
|
666
|
+
const tileIdx = ty * tileWidth + tx;
|
|
667
|
+
const globalIdx = globalY * globalWidth + globalX;
|
|
668
|
+
const tileZ = tile.positions[tileIdx];
|
|
669
|
+
|
|
670
|
+
// For overlapping cells, keep max Z (terrain surface)
|
|
671
|
+
// Skip empty cells (sentinel value)
|
|
672
|
+
if (tileZ > EMPTY_CELL + 1) {
|
|
673
|
+
const existingZ = globalGrid[globalIdx];
|
|
674
|
+
if (existingZ <= EMPTY_CELL + 1 || tileZ > existingZ) {
|
|
675
|
+
globalGrid[globalIdx] = tileZ;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Count valid cells (above sentinel value)
|
|
683
|
+
let validCount = 0;
|
|
684
|
+
for (let i = 0; i < totalGridCells; i++) {
|
|
685
|
+
if (globalGrid[i] > EMPTY_CELL + 1) validCount++;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
debug.log(`Stitched: ${totalGridCells} total cells, ${validCount} with geometry (${(validCount/totalGridCells*100).toFixed(1)}% coverage)`);
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
positions: globalGrid,
|
|
692
|
+
pointCount: totalGridCells,
|
|
693
|
+
bounds: fullBounds,
|
|
694
|
+
gridWidth: globalWidth,
|
|
695
|
+
gridHeight: globalHeight,
|
|
696
|
+
isDense: true,
|
|
697
|
+
conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
|
|
698
|
+
tileCount: tileResults.length
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
} else {
|
|
702
|
+
// SPARSE TOOL STITCHING: Keep existing deduplication logic (X,Y,Z triplets)
|
|
703
|
+
debug.log(`Stitching ${tileResults.length} sparse tool tiles...`);
|
|
704
|
+
|
|
705
|
+
const pointMap = new Map();
|
|
706
|
+
|
|
707
|
+
for (const result of tileResults) {
|
|
708
|
+
const positions = result.positions;
|
|
709
|
+
|
|
710
|
+
// Calculate offset from tile origin to global origin (in grid cells)
|
|
711
|
+
const tileOffsetX = Math.round((result.tileBounds.min.x - fullBounds.min.x) / stepSize);
|
|
712
|
+
const tileOffsetY = Math.round((result.tileBounds.min.y - fullBounds.min.y) / stepSize);
|
|
713
|
+
|
|
714
|
+
// Convert each point from tile-local to global grid coordinates
|
|
715
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
716
|
+
const localGridX = positions[i];
|
|
717
|
+
const localGridY = positions[i + 1];
|
|
718
|
+
const z = positions[i + 2];
|
|
719
|
+
|
|
720
|
+
// Convert local grid indices to global grid indices
|
|
721
|
+
const globalGridX = localGridX + tileOffsetX;
|
|
722
|
+
const globalGridY = localGridY + tileOffsetY;
|
|
723
|
+
|
|
724
|
+
const key = `${globalGridX},${globalGridY}`;
|
|
725
|
+
const existing = pointMap.get(key);
|
|
726
|
+
|
|
727
|
+
// Keep lowest Z value (for tool)
|
|
728
|
+
if (!existing || z < existing.z) {
|
|
729
|
+
pointMap.set(key, { x: globalGridX, y: globalGridY, z });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Convert Map to flat array
|
|
735
|
+
const finalPointCount = pointMap.size;
|
|
736
|
+
const allPositions = new Float32Array(finalPointCount * 3);
|
|
737
|
+
let writeOffset = 0;
|
|
738
|
+
|
|
739
|
+
for (const point of pointMap.values()) {
|
|
740
|
+
allPositions[writeOffset++] = point.x;
|
|
741
|
+
allPositions[writeOffset++] = point.y;
|
|
742
|
+
allPositions[writeOffset++] = point.z;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
debug.log(`Stitched: ${finalPointCount} unique sparse points`);
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
positions: allPositions,
|
|
749
|
+
pointCount: finalPointCount,
|
|
750
|
+
bounds: fullBounds,
|
|
751
|
+
isDense: false,
|
|
752
|
+
conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
|
|
753
|
+
tileCount: tileResults.length
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Check if tiling is needed (only called for terrain, which uses dense format)
|
|
759
|
+
function shouldUseTiling(bounds, stepSize) {
|
|
760
|
+
if (!config || !config.autoTiling) return false;
|
|
761
|
+
if (!deviceCapabilities) return false;
|
|
762
|
+
|
|
763
|
+
const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
|
|
764
|
+
const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
|
|
765
|
+
const totalPoints = gridWidth * gridHeight;
|
|
766
|
+
|
|
767
|
+
// Terrain uses dense Z-only format: 1 float (4 bytes) per grid cell
|
|
768
|
+
const gpuOutputBuffer = totalPoints * 1 * 4;
|
|
769
|
+
const totalGPUMemory = gpuOutputBuffer; // No mask needed for dense output
|
|
770
|
+
|
|
771
|
+
// Use the smaller of configured limit or device capability
|
|
772
|
+
const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
|
|
773
|
+
const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
|
|
774
|
+
const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
|
|
775
|
+
|
|
776
|
+
return totalGPUMemory > maxSafeSize;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Rasterize mesh - wrapper that handles automatic tiling if needed
|
|
780
|
+
async function rasterizeMesh(triangles, stepSize, filterMode, options = {}) {
|
|
781
|
+
const boundsOverride = options.bounds || options.min ? options : null; // Support old and new format
|
|
782
|
+
const bounds = boundsOverride || calculateBounds(triangles);
|
|
783
|
+
|
|
784
|
+
// Check if tiling is needed
|
|
785
|
+
if (shouldUseTiling(bounds, stepSize)) {
|
|
786
|
+
debug.log('Tiling required - switching to tiled rasterization');
|
|
787
|
+
|
|
788
|
+
// Calculate max safe size per tile
|
|
789
|
+
const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
|
|
790
|
+
const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
|
|
791
|
+
const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
|
|
792
|
+
|
|
793
|
+
// Create tiles
|
|
794
|
+
const { tiles } = createTiles(bounds, stepSize, maxSafeSize);
|
|
795
|
+
|
|
796
|
+
// Rasterize each tile
|
|
797
|
+
const tileResults = [];
|
|
798
|
+
for (let i = 0; i < tiles.length; i++) {
|
|
799
|
+
const tileStart = performance.now();
|
|
800
|
+
debug.log(`Processing tile ${i + 1}/${tiles.length}: ${tiles[i].id}`);
|
|
801
|
+
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)})`);
|
|
802
|
+
|
|
803
|
+
const tileResult = await rasterizeMeshSingle(triangles, stepSize, filterMode, {
|
|
804
|
+
...tiles[i].bounds,
|
|
805
|
+
rotationAngleDeg: options.rotationAngleDeg
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const tileTime = performance.now() - tileStart;
|
|
809
|
+
debug.log(` Tile ${i + 1} complete: ${tileResult.pointCount} points in ${tileTime.toFixed(1)}ms`);
|
|
810
|
+
|
|
811
|
+
// Store tile bounds with result for coordinate conversion during stitching
|
|
812
|
+
tileResult.tileBounds = tiles[i].bounds;
|
|
813
|
+
tileResults.push(tileResult);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Stitch tiles together (pass full bounds and step size for coordinate conversion)
|
|
817
|
+
return stitchTiles(tileResults, bounds, stepSize);
|
|
818
|
+
} else {
|
|
819
|
+
// Single-pass rasterization
|
|
820
|
+
return await rasterizeMeshSingle(triangles, stepSize, filterMode, options);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Helper: Create height map from dense terrain points (Z-only array)
|
|
825
|
+
// Terrain is ALWAYS dense (Z-only), never sparse
|
|
826
|
+
function createHeightMapFromPoints(points, gridStep, bounds = null) {
|
|
827
|
+
if (!points || points.length === 0) {
|
|
828
|
+
throw new Error('No points provided');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Calculate dimensions from bounds
|
|
832
|
+
if (!bounds) {
|
|
833
|
+
throw new Error('Bounds required for height map creation');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const minX = bounds.min.x;
|
|
837
|
+
const minY = bounds.min.y;
|
|
838
|
+
const minZ = bounds.min.z;
|
|
839
|
+
const maxX = bounds.max.x;
|
|
840
|
+
const maxY = bounds.max.y;
|
|
841
|
+
const maxZ = bounds.max.z;
|
|
842
|
+
const width = Math.ceil((maxX - minX) / gridStep) + 1;
|
|
843
|
+
const height = Math.ceil((maxY - minY) / gridStep) + 1;
|
|
844
|
+
|
|
845
|
+
// Terrain is ALWAYS dense (Z-only format from GPU rasterizer)
|
|
846
|
+
// debug.log(`Terrain dense format: ${width}x${height} = ${points.length} cells`);
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
grid: points, // Dense Z-only array
|
|
850
|
+
width,
|
|
851
|
+
height,
|
|
852
|
+
minX,
|
|
853
|
+
minY,
|
|
854
|
+
minZ,
|
|
855
|
+
maxX,
|
|
856
|
+
maxY,
|
|
857
|
+
maxZ
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Helper: Create sparse tool representation
|
|
862
|
+
// Points come from GPU as [gridX, gridY, Z] - pure integer grid coordinates for X/Y
|
|
863
|
+
function createSparseToolFromPoints(points) {
|
|
864
|
+
if (!points || points.length === 0) {
|
|
865
|
+
throw new Error('No tool points provided');
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Points are [gridX, gridY, Z] where gridX/gridY are grid indices (floats but integer values)
|
|
869
|
+
// Find bounds in grid space and tool tip Z
|
|
870
|
+
let minGridX = Infinity, minGridY = Infinity, minZ = Infinity;
|
|
871
|
+
let maxGridX = -Infinity, maxGridY = -Infinity;
|
|
872
|
+
|
|
873
|
+
for (let i = 0; i < points.length; i += 3) {
|
|
874
|
+
const gridX = points[i]; // Already a grid index
|
|
875
|
+
const gridY = points[i + 1]; // Already a grid index
|
|
876
|
+
const z = points[i + 2];
|
|
877
|
+
|
|
878
|
+
minGridX = Math.min(minGridX, gridX);
|
|
879
|
+
maxGridX = Math.max(maxGridX, gridX);
|
|
880
|
+
minGridY = Math.min(minGridY, gridY);
|
|
881
|
+
maxGridY = Math.max(maxGridY, gridY);
|
|
882
|
+
minZ = Math.min(minZ, z);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Calculate tool center in grid coordinates (pure integer)
|
|
886
|
+
const width = Math.floor(maxGridX - minGridX) + 1;
|
|
887
|
+
const height = Math.floor(maxGridY - minGridY) + 1;
|
|
888
|
+
const centerX = Math.floor(minGridX) + Math.floor(width / 2);
|
|
889
|
+
const centerY = Math.floor(minGridY) + Math.floor(height / 2);
|
|
890
|
+
|
|
891
|
+
// Convert each point to offset from center (integer arithmetic only)
|
|
892
|
+
const xOffsets = [];
|
|
893
|
+
const yOffsets = [];
|
|
894
|
+
const zValues = [];
|
|
895
|
+
|
|
896
|
+
for (let i = 0; i < points.length; i += 3) {
|
|
897
|
+
const gridX = Math.floor(points[i]); // Grid index (ensure integer)
|
|
898
|
+
const gridY = Math.floor(points[i + 1]); // Grid index (ensure integer)
|
|
899
|
+
const z = points[i + 2];
|
|
900
|
+
|
|
901
|
+
// Calculate offset from tool center (pure integer arithmetic)
|
|
902
|
+
const xOffset = gridX - centerX;
|
|
903
|
+
const yOffset = gridY - centerY;
|
|
904
|
+
// Z relative to tool tip: tip=0, points above tip are positive
|
|
905
|
+
// minZ is the lowest Z (tip), so z - minZ gives positive offsets upward
|
|
906
|
+
const zValue = z;// - minZ;
|
|
907
|
+
|
|
908
|
+
xOffsets.push(xOffset);
|
|
909
|
+
yOffsets.push(yOffset);
|
|
910
|
+
zValues.push(zValue);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return {
|
|
914
|
+
count: xOffsets.length,
|
|
915
|
+
xOffsets: new Int32Array(xOffsets),
|
|
916
|
+
yOffsets: new Int32Array(yOffsets),
|
|
917
|
+
zValues: new Float32Array(zValues),
|
|
918
|
+
referenceZ: minZ
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Generate toolpath with pre-created sparse tool (for batch operations)
|
|
923
|
+
async function generateToolpathWithSparseTools(terrainPoints, sparseToolData, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
|
|
924
|
+
const startTime = performance.now();
|
|
925
|
+
|
|
926
|
+
try {
|
|
927
|
+
// Create height map from terrain points (use terrain bounds if provided)
|
|
928
|
+
const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
|
|
929
|
+
|
|
930
|
+
// Run WebGPU compute with pre-created sparse tool
|
|
931
|
+
const result = await runToolpathCompute(
|
|
932
|
+
terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
return result;
|
|
936
|
+
} catch (error) {
|
|
937
|
+
debug.error('Error generating toolpath:', error);
|
|
938
|
+
throw error;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Generate toolpath for a single region (internal)
|
|
943
|
+
async function generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null) {
|
|
944
|
+
const startTime = performance.now();
|
|
945
|
+
debug.log('Generating toolpath...');
|
|
946
|
+
debug.log(`Input: terrain ${terrainPoints.length/3} points, tool ${toolPoints.length/3} points, steps (${xStep}, ${yStep}), oobZ ${oobZ}, gridStep ${gridStep}`);
|
|
947
|
+
|
|
948
|
+
if (terrainBounds) {
|
|
949
|
+
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)})`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
try {
|
|
953
|
+
// Create height map from terrain points (use terrain bounds if provided)
|
|
954
|
+
const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
|
|
955
|
+
debug.log(`Created terrain map: ${terrainMapData.width}x${terrainMapData.height}`);
|
|
956
|
+
|
|
957
|
+
// Create sparse tool representation
|
|
958
|
+
const sparseToolData = createSparseToolFromPoints(toolPoints);
|
|
959
|
+
debug.log(`Created sparse tool: ${sparseToolData.count} points`);
|
|
960
|
+
|
|
961
|
+
// Run WebGPU compute
|
|
962
|
+
const result = await runToolpathCompute(
|
|
963
|
+
terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
return result;
|
|
967
|
+
} catch (error) {
|
|
968
|
+
debug.error('Error generating toolpath:', error);
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function runToolpathCompute(terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime) {
|
|
974
|
+
if (!isInitialized) {
|
|
975
|
+
const success = await initWebGPU();
|
|
976
|
+
if (!success) {
|
|
977
|
+
throw new Error('WebGPU not available');
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Use WASM-generated terrain grid
|
|
982
|
+
const terrainBuffer = device.createBuffer({
|
|
983
|
+
size: terrainMapData.grid.byteLength,
|
|
984
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
985
|
+
});
|
|
986
|
+
device.queue.writeBuffer(terrainBuffer, 0, terrainMapData.grid);
|
|
987
|
+
|
|
988
|
+
// Use WASM-generated sparse tool
|
|
989
|
+
const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
|
|
990
|
+
const toolBufferI32 = new Int32Array(toolBufferData);
|
|
991
|
+
const toolBufferF32 = new Float32Array(toolBufferData);
|
|
992
|
+
|
|
993
|
+
for (let i = 0; i < sparseToolData.count; i++) {
|
|
994
|
+
toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
|
|
995
|
+
toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
|
|
996
|
+
toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
|
|
997
|
+
toolBufferF32[i * 4 + 3] = 0;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const toolBuffer = device.createBuffer({
|
|
1001
|
+
size: toolBufferData.byteLength,
|
|
1002
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1003
|
+
});
|
|
1004
|
+
device.queue.writeBuffer(toolBuffer, 0, toolBufferData);
|
|
1005
|
+
|
|
1006
|
+
// Calculate output dimensions
|
|
1007
|
+
const pointsPerLine = Math.ceil(terrainMapData.width / xStep);
|
|
1008
|
+
const numScanlines = Math.ceil(terrainMapData.height / yStep);
|
|
1009
|
+
const outputSize = pointsPerLine * numScanlines;
|
|
1010
|
+
|
|
1011
|
+
const outputBuffer = device.createBuffer({
|
|
1012
|
+
size: outputSize * 4,
|
|
1013
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
const uniformData = new Uint32Array([
|
|
1017
|
+
terrainMapData.width,
|
|
1018
|
+
terrainMapData.height,
|
|
1019
|
+
sparseToolData.count,
|
|
1020
|
+
xStep,
|
|
1021
|
+
yStep,
|
|
1022
|
+
0,
|
|
1023
|
+
pointsPerLine,
|
|
1024
|
+
numScanlines,
|
|
1025
|
+
0, // y_offset (default 0 for planar mode)
|
|
1026
|
+
]);
|
|
1027
|
+
const uniformDataFloat = new Float32Array(uniformData.buffer);
|
|
1028
|
+
uniformDataFloat[5] = oobZ;
|
|
1029
|
+
|
|
1030
|
+
const uniformBuffer = device.createBuffer({
|
|
1031
|
+
size: uniformData.byteLength,
|
|
1032
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1033
|
+
});
|
|
1034
|
+
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
|
|
1035
|
+
|
|
1036
|
+
// CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
|
|
1037
|
+
await device.queue.onSubmittedWorkDone();
|
|
1038
|
+
|
|
1039
|
+
// Use cached pipeline
|
|
1040
|
+
const bindGroup = device.createBindGroup({
|
|
1041
|
+
layout: cachedToolpathPipeline.getBindGroupLayout(0),
|
|
1042
|
+
entries: [
|
|
1043
|
+
{ binding: 0, resource: { buffer: terrainBuffer } },
|
|
1044
|
+
{ binding: 1, resource: { buffer: toolBuffer } },
|
|
1045
|
+
{ binding: 2, resource: { buffer: outputBuffer } },
|
|
1046
|
+
{ binding: 3, resource: { buffer: uniformBuffer } },
|
|
1047
|
+
],
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const commandEncoder = device.createCommandEncoder();
|
|
1051
|
+
const passEncoder = commandEncoder.beginComputePass();
|
|
1052
|
+
passEncoder.setPipeline(cachedToolpathPipeline);
|
|
1053
|
+
passEncoder.setBindGroup(0, bindGroup);
|
|
1054
|
+
|
|
1055
|
+
const workgroupsX = Math.ceil(pointsPerLine / 16);
|
|
1056
|
+
const workgroupsY = Math.ceil(numScanlines / 16);
|
|
1057
|
+
passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
|
|
1058
|
+
passEncoder.end();
|
|
1059
|
+
|
|
1060
|
+
const stagingBuffer = device.createBuffer({
|
|
1061
|
+
size: outputSize * 4,
|
|
1062
|
+
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize * 4);
|
|
1066
|
+
|
|
1067
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
1068
|
+
|
|
1069
|
+
// CRITICAL: Wait for GPU to finish before reading results
|
|
1070
|
+
await device.queue.onSubmittedWorkDone();
|
|
1071
|
+
|
|
1072
|
+
await stagingBuffer.mapAsync(GPUMapMode.READ);
|
|
1073
|
+
|
|
1074
|
+
const outputData = new Float32Array(stagingBuffer.getMappedRange());
|
|
1075
|
+
const result = new Float32Array(outputData);
|
|
1076
|
+
stagingBuffer.unmap();
|
|
1077
|
+
|
|
1078
|
+
terrainBuffer.destroy();
|
|
1079
|
+
toolBuffer.destroy();
|
|
1080
|
+
outputBuffer.destroy();
|
|
1081
|
+
uniformBuffer.destroy();
|
|
1082
|
+
stagingBuffer.destroy();
|
|
1083
|
+
|
|
1084
|
+
const endTime = performance.now();
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
pathData: result,
|
|
1088
|
+
numScanlines,
|
|
1089
|
+
pointsPerLine,
|
|
1090
|
+
generationTime: endTime - startTime
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Create reusable GPU buffers for tiled toolpath generation
|
|
1095
|
+
function createReusableToolpathBuffers(terrainWidth, terrainHeight, sparseToolData, xStep, yStep) {
|
|
1096
|
+
const pointsPerLine = Math.ceil(terrainWidth / xStep);
|
|
1097
|
+
const numScanlines = Math.ceil(terrainHeight / yStep);
|
|
1098
|
+
const outputSize = pointsPerLine * numScanlines;
|
|
1099
|
+
|
|
1100
|
+
// Create terrain buffer (will be updated for each tile)
|
|
1101
|
+
const terrainBuffer = device.createBuffer({
|
|
1102
|
+
size: terrainWidth * terrainHeight * 4,
|
|
1103
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// Create tool buffer (STATIC - same for all tiles!)
|
|
1107
|
+
const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
|
|
1108
|
+
const toolBufferI32 = new Int32Array(toolBufferData);
|
|
1109
|
+
const toolBufferF32 = new Float32Array(toolBufferData);
|
|
1110
|
+
|
|
1111
|
+
for (let i = 0; i < sparseToolData.count; i++) {
|
|
1112
|
+
toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
|
|
1113
|
+
toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
|
|
1114
|
+
toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
|
|
1115
|
+
toolBufferF32[i * 4 + 3] = 0;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const toolBuffer = device.createBuffer({
|
|
1119
|
+
size: toolBufferData.byteLength,
|
|
1120
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
1121
|
+
});
|
|
1122
|
+
device.queue.writeBuffer(toolBuffer, 0, toolBufferData); // Write once!
|
|
1123
|
+
|
|
1124
|
+
// Create output buffer (will be read for each tile)
|
|
1125
|
+
const outputBuffer = device.createBuffer({
|
|
1126
|
+
size: outputSize * 4,
|
|
1127
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
// Create uniform buffer (will be updated for each tile)
|
|
1131
|
+
const uniformBuffer = device.createBuffer({
|
|
1132
|
+
size: 36, // 9 fields × 4 bytes (added y_offset field)
|
|
1133
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// Create staging buffer (will be reused for readback)
|
|
1137
|
+
const stagingBuffer = device.createBuffer({
|
|
1138
|
+
size: outputSize * 4,
|
|
1139
|
+
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
terrainBuffer,
|
|
1144
|
+
toolBuffer,
|
|
1145
|
+
outputBuffer,
|
|
1146
|
+
uniformBuffer,
|
|
1147
|
+
stagingBuffer,
|
|
1148
|
+
maxOutputSize: outputSize,
|
|
1149
|
+
maxTerrainWidth: terrainWidth,
|
|
1150
|
+
maxTerrainHeight: terrainHeight,
|
|
1151
|
+
sparseToolData
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Destroy reusable GPU buffers
|
|
1156
|
+
function destroyReusableToolpathBuffers(buffers) {
|
|
1157
|
+
buffers.terrainBuffer.destroy();
|
|
1158
|
+
buffers.toolBuffer.destroy();
|
|
1159
|
+
buffers.outputBuffer.destroy();
|
|
1160
|
+
buffers.uniformBuffer.destroy();
|
|
1161
|
+
buffers.stagingBuffer.destroy();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Run toolpath compute using pre-created reusable buffers
|
|
1165
|
+
async function runToolpathComputeWithBuffers(terrainData, terrainWidth, terrainHeight, xStep, yStep, oobZ, buffers, startTime) {
|
|
1166
|
+
// Update terrain buffer with new tile data
|
|
1167
|
+
device.queue.writeBuffer(buffers.terrainBuffer, 0, terrainData);
|
|
1168
|
+
|
|
1169
|
+
// Calculate output dimensions
|
|
1170
|
+
const pointsPerLine = Math.ceil(terrainWidth / xStep);
|
|
1171
|
+
const numScanlines = Math.ceil(terrainHeight / yStep);
|
|
1172
|
+
const outputSize = pointsPerLine * numScanlines;
|
|
1173
|
+
|
|
1174
|
+
// Calculate Y offset for single-scanline radial mode
|
|
1175
|
+
// When numScanlines=1 and terrainHeight > 1, center the tool at the midline
|
|
1176
|
+
const yOffset = (numScanlines === 1 && terrainHeight > 1) ? Math.floor(terrainHeight / 2) : 0;
|
|
1177
|
+
|
|
1178
|
+
// Update uniforms for this tile
|
|
1179
|
+
const uniformData = new Uint32Array([
|
|
1180
|
+
terrainWidth,
|
|
1181
|
+
terrainHeight,
|
|
1182
|
+
buffers.sparseToolData.count,
|
|
1183
|
+
xStep,
|
|
1184
|
+
yStep,
|
|
1185
|
+
0,
|
|
1186
|
+
pointsPerLine,
|
|
1187
|
+
numScanlines,
|
|
1188
|
+
yOffset, // y_offset for radial single-scanline mode
|
|
1189
|
+
]);
|
|
1190
|
+
const uniformDataFloat = new Float32Array(uniformData.buffer);
|
|
1191
|
+
uniformDataFloat[5] = oobZ;
|
|
1192
|
+
device.queue.writeBuffer(buffers.uniformBuffer, 0, uniformData);
|
|
1193
|
+
|
|
1194
|
+
// CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
|
|
1195
|
+
// Without this, compute shader may read stale/incomplete buffer data
|
|
1196
|
+
await device.queue.onSubmittedWorkDone();
|
|
1197
|
+
|
|
1198
|
+
// Create bind group (reusing cached pipeline)
|
|
1199
|
+
const bindGroup = device.createBindGroup({
|
|
1200
|
+
layout: cachedToolpathPipeline.getBindGroupLayout(0),
|
|
1201
|
+
entries: [
|
|
1202
|
+
{ binding: 0, resource: { buffer: buffers.terrainBuffer } },
|
|
1203
|
+
{ binding: 1, resource: { buffer: buffers.toolBuffer } },
|
|
1204
|
+
{ binding: 2, resource: { buffer: buffers.outputBuffer } },
|
|
1205
|
+
{ binding: 3, resource: { buffer: buffers.uniformBuffer } },
|
|
1206
|
+
],
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// Dispatch compute shader
|
|
1210
|
+
const commandEncoder = device.createCommandEncoder();
|
|
1211
|
+
const passEncoder = commandEncoder.beginComputePass();
|
|
1212
|
+
passEncoder.setPipeline(cachedToolpathPipeline);
|
|
1213
|
+
passEncoder.setBindGroup(0, bindGroup);
|
|
1214
|
+
|
|
1215
|
+
const workgroupsX = Math.ceil(pointsPerLine / 16);
|
|
1216
|
+
const workgroupsY = Math.ceil(numScanlines / 16);
|
|
1217
|
+
passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
|
|
1218
|
+
passEncoder.end();
|
|
1219
|
+
|
|
1220
|
+
// Copy to staging buffer
|
|
1221
|
+
commandEncoder.copyBufferToBuffer(buffers.outputBuffer, 0, buffers.stagingBuffer, 0, outputSize * 4);
|
|
1222
|
+
|
|
1223
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
1224
|
+
|
|
1225
|
+
// CRITICAL: Wait for GPU to finish before reading results
|
|
1226
|
+
await device.queue.onSubmittedWorkDone();
|
|
1227
|
+
|
|
1228
|
+
await buffers.stagingBuffer.mapAsync(GPUMapMode.READ);
|
|
1229
|
+
|
|
1230
|
+
// Create a true copy using slice() - new Float32Array(typedArray) only creates a view!
|
|
1231
|
+
const outputData = new Float32Array(buffers.stagingBuffer.getMappedRange(), 0, outputSize);
|
|
1232
|
+
const result = outputData.slice(); // slice() creates a new ArrayBuffer with copied data
|
|
1233
|
+
buffers.stagingBuffer.unmap();
|
|
1234
|
+
|
|
1235
|
+
const endTime = performance.now();
|
|
1236
|
+
|
|
1237
|
+
// Debug: Log first few Z values to detect non-determinism
|
|
1238
|
+
if (result.length > 0) {
|
|
1239
|
+
const samples = [];
|
|
1240
|
+
for (let i = 0; i < Math.min(10, result.length); i++) {
|
|
1241
|
+
samples.push(result[i].toFixed(3));
|
|
1242
|
+
}
|
|
1243
|
+
// debug.log(`[Toolpath] Output samples (${result.length} total): ${samples.join(', ')}`);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
return {
|
|
1247
|
+
pathData: result,
|
|
1248
|
+
numScanlines,
|
|
1249
|
+
pointsPerLine,
|
|
1250
|
+
generationTime: endTime - startTime
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Generate toolpath with tiling support (public API)
|
|
1255
|
+
async function generateToolpath(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
|
|
1256
|
+
// Calculate bounds if not provided
|
|
1257
|
+
if (!terrainBounds) {
|
|
1258
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
1259
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
1260
|
+
for (let i = 0; i < terrainPoints.length; i += 3) {
|
|
1261
|
+
minX = Math.min(minX, terrainPoints[i]);
|
|
1262
|
+
maxX = Math.max(maxX, terrainPoints[i]);
|
|
1263
|
+
minY = Math.min(minY, terrainPoints[i + 1]);
|
|
1264
|
+
maxY = Math.max(maxY, terrainPoints[i + 1]);
|
|
1265
|
+
minZ = Math.min(minZ, terrainPoints[i + 2]);
|
|
1266
|
+
maxZ = Math.max(maxZ, terrainPoints[i + 2]);
|
|
1267
|
+
}
|
|
1268
|
+
terrainBounds = {
|
|
1269
|
+
min: { x: minX, y: minY, z: minZ },
|
|
1270
|
+
max: { x: maxX, y: maxY, z: maxZ }
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Note: singleScanline mode means OUTPUT only centerline, but terrain bounds stay full
|
|
1275
|
+
// This ensures all terrain Y values contribute to tool interference at the centerline
|
|
1276
|
+
|
|
1277
|
+
// Debug tool bounds and center
|
|
1278
|
+
for (let i=0; i<toolPoints.length; i += 3) {
|
|
1279
|
+
if (toolPoints[i] === 0 && toolPoints[i+1] === 0) {
|
|
1280
|
+
debug.log('[WebGPU Worker]', { TOOL_CENTER: toolPoints[i+2] });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
debug.log('[WebGPU Worker]',
|
|
1284
|
+
'toolZMin:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.min(a,b), Infinity)),
|
|
1285
|
+
'toolZMax:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.max(a,b), -Infinity))
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
// Calculate tool dimensions for overlap
|
|
1289
|
+
// Tool points are [gridX, gridY, Z] where X/Y are grid indices (not mm)
|
|
1290
|
+
let toolMinX = Infinity, toolMaxX = -Infinity;
|
|
1291
|
+
let toolMinY = Infinity, toolMaxY = -Infinity;
|
|
1292
|
+
for (let i = 0; i < toolPoints.length; i += 3) {
|
|
1293
|
+
toolMinX = Math.min(toolMinX, toolPoints[i]);
|
|
1294
|
+
toolMaxX = Math.max(toolMaxX, toolPoints[i]);
|
|
1295
|
+
toolMinY = Math.min(toolMinY, toolPoints[i + 1]);
|
|
1296
|
+
toolMaxY = Math.max(toolMaxY, toolPoints[i + 1]);
|
|
1297
|
+
}
|
|
1298
|
+
// Tool dimensions in grid cells
|
|
1299
|
+
const toolWidthCells = toolMaxX - toolMinX;
|
|
1300
|
+
const toolHeightCells = toolMaxY - toolMinY;
|
|
1301
|
+
// Convert to mm for logging
|
|
1302
|
+
const toolWidthMm = toolWidthCells * gridStep;
|
|
1303
|
+
const toolHeightMm = toolHeightCells * gridStep;
|
|
1304
|
+
|
|
1305
|
+
// Check if tiling is needed based on output grid size
|
|
1306
|
+
const outputWidth = Math.ceil((terrainBounds.max.x - terrainBounds.min.x) / gridStep) + 1;
|
|
1307
|
+
const outputHeight = Math.ceil((terrainBounds.max.y - terrainBounds.min.y) / gridStep) + 1;
|
|
1308
|
+
const outputPoints = Math.ceil(outputWidth / xStep) * Math.ceil(outputHeight / yStep);
|
|
1309
|
+
const outputMemory = outputPoints * 4; // 4 bytes per float
|
|
1310
|
+
|
|
1311
|
+
const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
|
|
1312
|
+
const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
|
|
1313
|
+
const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
|
|
1314
|
+
|
|
1315
|
+
if (outputMemory <= maxSafeSize) {
|
|
1316
|
+
// No tiling needed
|
|
1317
|
+
return await generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Tiling needed (terrain is ALWAYS dense)
|
|
1321
|
+
const tilingStartTime = performance.now();
|
|
1322
|
+
debug.log('Using tiled toolpath generation');
|
|
1323
|
+
debug.log(`Terrain: DENSE (${terrainPoints.length} cells = ${outputWidth}x${outputHeight})`);
|
|
1324
|
+
debug.log(`Tool dimensions: ${toolWidthMm.toFixed(2)}mm × ${toolHeightMm.toFixed(2)}mm (${toolWidthCells}×${toolHeightCells} cells)`);
|
|
1325
|
+
|
|
1326
|
+
// Create tiles with tool-size overlap (pass dimensions in grid cells)
|
|
1327
|
+
const { tiles, maxTileGridWidth, maxTileGridHeight } = createToolpathTiles(terrainBounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxSafeSize);
|
|
1328
|
+
debug.log(`Created ${tiles.length} tiles`);
|
|
1329
|
+
|
|
1330
|
+
// Pre-generate all tile terrain point arrays
|
|
1331
|
+
const pregenStartTime = performance.now();
|
|
1332
|
+
debug.log(`Pre-generating ${tiles.length} tile terrain arrays...`);
|
|
1333
|
+
const allTileTerrainPoints = [];
|
|
1334
|
+
|
|
1335
|
+
for (let i = 0; i < tiles.length; i++) {
|
|
1336
|
+
const tile = tiles[i];
|
|
1337
|
+
|
|
1338
|
+
// Extract terrain sub-grid for this tile (terrain is ALWAYS dense)
|
|
1339
|
+
const tileMinGridX = Math.floor((tile.bounds.min.x - terrainBounds.min.x) / gridStep);
|
|
1340
|
+
const tileMaxGridX = Math.ceil((tile.bounds.max.x - terrainBounds.min.x) / gridStep);
|
|
1341
|
+
const tileMinGridY = Math.floor((tile.bounds.min.y - terrainBounds.min.y) / gridStep);
|
|
1342
|
+
const tileMaxGridY = Math.ceil((tile.bounds.max.y - terrainBounds.min.y) / gridStep);
|
|
1343
|
+
|
|
1344
|
+
const tileWidth = tileMaxGridX - tileMinGridX + 1;
|
|
1345
|
+
const tileHeight = tileMaxGridY - tileMinGridY + 1;
|
|
1346
|
+
|
|
1347
|
+
// Pad to max dimensions for consistent buffer sizing
|
|
1348
|
+
const paddedTileTerrainPoints = new Float32Array(maxTileGridWidth * maxTileGridHeight);
|
|
1349
|
+
paddedTileTerrainPoints.fill(EMPTY_CELL);
|
|
1350
|
+
|
|
1351
|
+
// Copy relevant sub-grid from full terrain into top-left of padded array
|
|
1352
|
+
for (let ty = 0; ty < tileHeight; ty++) {
|
|
1353
|
+
const globalY = tileMinGridY + ty;
|
|
1354
|
+
if (globalY < 0 || globalY >= outputHeight) continue;
|
|
1355
|
+
|
|
1356
|
+
for (let tx = 0; tx < tileWidth; tx++) {
|
|
1357
|
+
const globalX = tileMinGridX + tx;
|
|
1358
|
+
if (globalX < 0 || globalX >= outputWidth) continue;
|
|
1359
|
+
|
|
1360
|
+
const globalIdx = globalY * outputWidth + globalX;
|
|
1361
|
+
const tileIdx = ty * maxTileGridWidth + tx; // Use maxTileGridWidth for stride
|
|
1362
|
+
paddedTileTerrainPoints[tileIdx] = terrainPoints[globalIdx];
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
allTileTerrainPoints.push({
|
|
1367
|
+
data: paddedTileTerrainPoints,
|
|
1368
|
+
actualWidth: tileWidth,
|
|
1369
|
+
actualHeight: tileHeight
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const pregenTime = performance.now() - pregenStartTime;
|
|
1374
|
+
debug.log(`Pre-generation complete in ${pregenTime.toFixed(1)}ms`);
|
|
1375
|
+
|
|
1376
|
+
// Create reusable GPU buffers (sized for maximum tile)
|
|
1377
|
+
if (!isInitialized) {
|
|
1378
|
+
const success = await initWebGPU();
|
|
1379
|
+
if (!success) {
|
|
1380
|
+
throw new Error('WebGPU not available');
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const sparseToolData = createSparseToolFromPoints(toolPoints);
|
|
1385
|
+
const reusableBuffers = createReusableToolpathBuffers(maxTileGridWidth, maxTileGridHeight, sparseToolData, xStep, yStep);
|
|
1386
|
+
debug.log(`Created reusable GPU buffers for ${maxTileGridWidth}x${maxTileGridHeight} tiles`);
|
|
1387
|
+
|
|
1388
|
+
// Process each tile with reusable buffers
|
|
1389
|
+
const tileResults = [];
|
|
1390
|
+
let totalTileTime = 0;
|
|
1391
|
+
for (let i = 0; i < tiles.length; i++) {
|
|
1392
|
+
const tile = tiles[i];
|
|
1393
|
+
const tileStartTime = performance.now();
|
|
1394
|
+
debug.log(`Processing tile ${i + 1}/${tiles.length}...`);
|
|
1395
|
+
|
|
1396
|
+
// Report progress
|
|
1397
|
+
const percent = Math.round(((i + 1) / tiles.length) * 100);
|
|
1398
|
+
self.postMessage({
|
|
1399
|
+
type: 'toolpath-progress',
|
|
1400
|
+
data: {
|
|
1401
|
+
percent,
|
|
1402
|
+
current: i + 1,
|
|
1403
|
+
total: tiles.length,
|
|
1404
|
+
layer: i + 1 // Using tile index as "layer" for consistency
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
debug.log(`Tile ${i+1} using pre-generated terrain: ${allTileTerrainPoints[i].actualWidth}x${allTileTerrainPoints[i].actualHeight} (padded to ${maxTileGridWidth}x${maxTileGridHeight})`);
|
|
1409
|
+
|
|
1410
|
+
// Generate toolpath for this tile using reusable buffers
|
|
1411
|
+
const tileToolpathResult = await runToolpathComputeWithBuffers(
|
|
1412
|
+
allTileTerrainPoints[i].data,
|
|
1413
|
+
maxTileGridWidth,
|
|
1414
|
+
maxTileGridHeight,
|
|
1415
|
+
xStep,
|
|
1416
|
+
yStep,
|
|
1417
|
+
oobZ,
|
|
1418
|
+
reusableBuffers,
|
|
1419
|
+
tileStartTime
|
|
1420
|
+
);
|
|
1421
|
+
|
|
1422
|
+
const tileTime = performance.now() - tileStartTime;
|
|
1423
|
+
totalTileTime += tileTime;
|
|
1424
|
+
|
|
1425
|
+
tileResults.push({
|
|
1426
|
+
pathData: tileToolpathResult.pathData,
|
|
1427
|
+
numScanlines: tileToolpathResult.numScanlines,
|
|
1428
|
+
pointsPerLine: tileToolpathResult.pointsPerLine,
|
|
1429
|
+
tile: tile
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
debug.log(`Tile ${i + 1}/${tiles.length} complete: ${tileToolpathResult.numScanlines}×${tileToolpathResult.pointsPerLine} in ${tileTime.toFixed(1)}ms`);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Cleanup reusable buffers
|
|
1436
|
+
destroyReusableToolpathBuffers(reusableBuffers);
|
|
1437
|
+
|
|
1438
|
+
debug.log(`All tiles processed in ${totalTileTime.toFixed(1)}ms (avg ${(totalTileTime/tiles.length).toFixed(1)}ms per tile)`);
|
|
1439
|
+
|
|
1440
|
+
// Stitch tiles together, dropping overlap regions
|
|
1441
|
+
const stitchStartTime = performance.now();
|
|
1442
|
+
const stitchedResult = stitchToolpathTiles(tileResults, terrainBounds, gridStep, xStep, yStep);
|
|
1443
|
+
const stitchTime = performance.now() - stitchStartTime;
|
|
1444
|
+
|
|
1445
|
+
const totalTime = performance.now() - tilingStartTime;
|
|
1446
|
+
debug.log(`Stitching took ${stitchTime.toFixed(1)}ms`);
|
|
1447
|
+
debug.log(`Tiled toolpath complete: ${stitchedResult.numScanlines}×${stitchedResult.pointsPerLine} in ${totalTime.toFixed(1)}ms total`);
|
|
1448
|
+
|
|
1449
|
+
// Update generation time to reflect total tiled time
|
|
1450
|
+
stitchedResult.generationTime = totalTime;
|
|
1451
|
+
|
|
1452
|
+
return stitchedResult;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// Create tiles for toolpath generation with overlap (using integer grid coordinates)
|
|
1456
|
+
// toolWidth and toolHeight are in grid cells (not mm)
|
|
1457
|
+
function createToolpathTiles(bounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxMemoryBytes) {
|
|
1458
|
+
// Calculate global grid dimensions
|
|
1459
|
+
const globalGridWidth = Math.ceil((bounds.max.x - bounds.min.x) / gridStep) + 1;
|
|
1460
|
+
const globalGridHeight = Math.ceil((bounds.max.y - bounds.min.y) / gridStep) + 1;
|
|
1461
|
+
|
|
1462
|
+
// Calculate tool overlap in grid cells (use radius = half diameter)
|
|
1463
|
+
// Tool centered at tile boundary needs terrain extending half tool width beyond boundary
|
|
1464
|
+
const toolOverlapX = Math.ceil(toolWidthCells / 2);
|
|
1465
|
+
const toolOverlapY = Math.ceil(toolHeightCells / 2);
|
|
1466
|
+
|
|
1467
|
+
// Binary search for optimal tile size in grid cells
|
|
1468
|
+
let low = Math.max(toolOverlapX, toolOverlapY) * 2; // At least 2x tool size
|
|
1469
|
+
let high = Math.max(globalGridWidth, globalGridHeight);
|
|
1470
|
+
let bestTileGridSize = high;
|
|
1471
|
+
|
|
1472
|
+
while (low <= high) {
|
|
1473
|
+
const mid = Math.floor((low + high) / 2);
|
|
1474
|
+
const outputW = Math.ceil(mid / xStep);
|
|
1475
|
+
const outputH = Math.ceil(mid / yStep);
|
|
1476
|
+
const memoryNeeded = outputW * outputH * 4;
|
|
1477
|
+
|
|
1478
|
+
if (memoryNeeded <= maxMemoryBytes) {
|
|
1479
|
+
bestTileGridSize = mid;
|
|
1480
|
+
low = mid + 1;
|
|
1481
|
+
} else {
|
|
1482
|
+
high = mid - 1;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const tilesX = Math.ceil(globalGridWidth / bestTileGridSize);
|
|
1487
|
+
const tilesY = Math.ceil(globalGridHeight / bestTileGridSize);
|
|
1488
|
+
const coreGridWidth = Math.ceil(globalGridWidth / tilesX);
|
|
1489
|
+
const coreGridHeight = Math.ceil(globalGridHeight / tilesY);
|
|
1490
|
+
|
|
1491
|
+
// Calculate maximum tile dimensions (for buffer sizing)
|
|
1492
|
+
const maxTileGridWidth = coreGridWidth + 2 * toolOverlapX;
|
|
1493
|
+
const maxTileGridHeight = coreGridHeight + 2 * toolOverlapY;
|
|
1494
|
+
|
|
1495
|
+
debug.log(`Creating ${tilesX}×${tilesY} tiles (${coreGridWidth}×${coreGridHeight} cells core + ${toolOverlapX}×${toolOverlapY} cells overlap)`);
|
|
1496
|
+
debug.log(`Max tile dimensions: ${maxTileGridWidth}×${maxTileGridHeight} cells (for buffer sizing)`);
|
|
1497
|
+
|
|
1498
|
+
const tiles = [];
|
|
1499
|
+
for (let ty = 0; ty < tilesY; ty++) {
|
|
1500
|
+
for (let tx = 0; tx < tilesX; tx++) {
|
|
1501
|
+
// Core tile in grid coordinates
|
|
1502
|
+
const coreGridStartX = tx * coreGridWidth;
|
|
1503
|
+
const coreGridStartY = ty * coreGridHeight;
|
|
1504
|
+
const coreGridEndX = Math.min((tx + 1) * coreGridWidth, globalGridWidth) - 1;
|
|
1505
|
+
const coreGridEndY = Math.min((ty + 1) * coreGridHeight, globalGridHeight) - 1;
|
|
1506
|
+
|
|
1507
|
+
// Extended tile with overlap in grid coordinates
|
|
1508
|
+
let extGridStartX = coreGridStartX;
|
|
1509
|
+
let extGridStartY = coreGridStartY;
|
|
1510
|
+
let extGridEndX = coreGridEndX;
|
|
1511
|
+
let extGridEndY = coreGridEndY;
|
|
1512
|
+
|
|
1513
|
+
// Add overlap on sides that aren't at global boundary
|
|
1514
|
+
if (tx > 0) extGridStartX -= toolOverlapX;
|
|
1515
|
+
if (ty > 0) extGridStartY -= toolOverlapY;
|
|
1516
|
+
if (tx < tilesX - 1) extGridEndX += toolOverlapX;
|
|
1517
|
+
if (ty < tilesY - 1) extGridEndY += toolOverlapY;
|
|
1518
|
+
|
|
1519
|
+
// Clamp to global bounds
|
|
1520
|
+
extGridStartX = Math.max(0, extGridStartX);
|
|
1521
|
+
extGridStartY = Math.max(0, extGridStartY);
|
|
1522
|
+
extGridEndX = Math.min(globalGridWidth - 1, extGridEndX);
|
|
1523
|
+
extGridEndY = Math.min(globalGridHeight - 1, extGridEndY);
|
|
1524
|
+
|
|
1525
|
+
// Calculate actual dimensions for this tile
|
|
1526
|
+
const tileGridWidth = extGridEndX - extGridStartX + 1;
|
|
1527
|
+
const tileGridHeight = extGridEndY - extGridStartY + 1;
|
|
1528
|
+
|
|
1529
|
+
// Convert grid coordinates to world coordinates
|
|
1530
|
+
const extMinX = bounds.min.x + extGridStartX * gridStep;
|
|
1531
|
+
const extMinY = bounds.min.y + extGridStartY * gridStep;
|
|
1532
|
+
const extMaxX = bounds.min.x + extGridEndX * gridStep;
|
|
1533
|
+
const extMaxY = bounds.min.y + extGridEndY * gridStep;
|
|
1534
|
+
|
|
1535
|
+
const coreMinX = bounds.min.x + coreGridStartX * gridStep;
|
|
1536
|
+
const coreMinY = bounds.min.y + coreGridStartY * gridStep;
|
|
1537
|
+
const coreMaxX = bounds.min.x + coreGridEndX * gridStep;
|
|
1538
|
+
const coreMaxY = bounds.min.y + coreGridEndY * gridStep;
|
|
1539
|
+
|
|
1540
|
+
tiles.push({
|
|
1541
|
+
id: `tile_${tx}_${ty}`,
|
|
1542
|
+
tx, ty,
|
|
1543
|
+
tilesX, tilesY,
|
|
1544
|
+
gridWidth: tileGridWidth,
|
|
1545
|
+
gridHeight: tileGridHeight,
|
|
1546
|
+
bounds: {
|
|
1547
|
+
min: { x: extMinX, y: extMinY, z: bounds.min.z },
|
|
1548
|
+
max: { x: extMaxX, y: extMaxY, z: bounds.max.z }
|
|
1549
|
+
},
|
|
1550
|
+
core: {
|
|
1551
|
+
gridStart: { x: coreGridStartX, y: coreGridStartY },
|
|
1552
|
+
gridEnd: { x: coreGridEndX, y: coreGridEndY },
|
|
1553
|
+
min: { x: coreMinX, y: coreMinY },
|
|
1554
|
+
max: { x: coreMaxX, y: coreMaxY }
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
return { tiles, maxTileGridWidth, maxTileGridHeight };
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Stitch toolpath tiles together, dropping overlap regions (using integer grid coordinates)
|
|
1564
|
+
function stitchToolpathTiles(tileResults, globalBounds, gridStep, xStep, yStep) {
|
|
1565
|
+
// Calculate global output dimensions
|
|
1566
|
+
const globalWidth = Math.ceil((globalBounds.max.x - globalBounds.min.x) / gridStep) + 1;
|
|
1567
|
+
const globalHeight = Math.ceil((globalBounds.max.y - globalBounds.min.y) / gridStep) + 1;
|
|
1568
|
+
const globalPointsPerLine = Math.ceil(globalWidth / xStep);
|
|
1569
|
+
const globalNumScanlines = Math.ceil(globalHeight / yStep);
|
|
1570
|
+
|
|
1571
|
+
debug.log(`Stitching toolpath: global grid ${globalWidth}x${globalHeight}, output ${globalPointsPerLine}x${globalNumScanlines}`);
|
|
1572
|
+
|
|
1573
|
+
const result = new Float32Array(globalPointsPerLine * globalNumScanlines);
|
|
1574
|
+
result.fill(NaN);
|
|
1575
|
+
|
|
1576
|
+
// Fast path for 1x1 stepping: use bulk row copying
|
|
1577
|
+
const use1x1FastPath = (xStep === 1 && yStep === 1);
|
|
1578
|
+
|
|
1579
|
+
for (const tileResult of tileResults) {
|
|
1580
|
+
const tile = tileResult.tile;
|
|
1581
|
+
const tileData = tileResult.pathData;
|
|
1582
|
+
|
|
1583
|
+
// Use the pre-calculated integer grid coordinates from tile.core
|
|
1584
|
+
const coreGridStartX = tile.core.gridStart.x;
|
|
1585
|
+
const coreGridStartY = tile.core.gridStart.y;
|
|
1586
|
+
const coreGridEndX = tile.core.gridEnd.x;
|
|
1587
|
+
const coreGridEndY = tile.core.gridEnd.y;
|
|
1588
|
+
|
|
1589
|
+
// Calculate tile's extended grid coordinates
|
|
1590
|
+
const extGridStartX = Math.round((tile.bounds.min.x - globalBounds.min.x) / gridStep);
|
|
1591
|
+
const extGridStartY = Math.round((tile.bounds.min.y - globalBounds.min.y) / gridStep);
|
|
1592
|
+
|
|
1593
|
+
let copiedCount = 0;
|
|
1594
|
+
|
|
1595
|
+
// Calculate output coordinate ranges for this tile's core
|
|
1596
|
+
// Core region in grid coordinates
|
|
1597
|
+
const coreGridWidth = coreGridEndX - coreGridStartX + 1;
|
|
1598
|
+
const coreGridHeight = coreGridEndY - coreGridStartY + 1;
|
|
1599
|
+
|
|
1600
|
+
// Core region in output coordinates (sampled by xStep/yStep)
|
|
1601
|
+
const coreOutStartX = Math.floor(coreGridStartX / xStep);
|
|
1602
|
+
const coreOutStartY = Math.floor(coreGridStartY / yStep);
|
|
1603
|
+
const coreOutEndX = Math.floor(coreGridEndX / xStep);
|
|
1604
|
+
const coreOutEndY = Math.floor(coreGridEndY / yStep);
|
|
1605
|
+
const coreOutWidth = coreOutEndX - coreOutStartX + 1;
|
|
1606
|
+
const coreOutHeight = coreOutEndY - coreOutStartY + 1;
|
|
1607
|
+
|
|
1608
|
+
// Tile's extended region start in grid coordinates
|
|
1609
|
+
const extOutStartX = Math.floor(extGridStartX / xStep);
|
|
1610
|
+
const extOutStartY = Math.floor(extGridStartY / yStep);
|
|
1611
|
+
|
|
1612
|
+
// Copy entire rows at once (works for all stepping values)
|
|
1613
|
+
for (let outY = 0; outY < coreOutHeight; outY++) {
|
|
1614
|
+
const globalOutY = coreOutStartY + outY;
|
|
1615
|
+
const tileOutY = globalOutY - extOutStartY;
|
|
1616
|
+
|
|
1617
|
+
if (globalOutY >= 0 && globalOutY < globalNumScanlines &&
|
|
1618
|
+
tileOutY >= 0 && tileOutY < tileResult.numScanlines) {
|
|
1619
|
+
|
|
1620
|
+
const globalRowStart = globalOutY * globalPointsPerLine + coreOutStartX;
|
|
1621
|
+
const tileRowStart = tileOutY * tileResult.pointsPerLine + (coreOutStartX - extOutStartX);
|
|
1622
|
+
|
|
1623
|
+
// Bulk copy entire row of output values
|
|
1624
|
+
result.set(tileData.subarray(tileRowStart, tileRowStart + coreOutWidth), globalRowStart);
|
|
1625
|
+
copiedCount += coreOutWidth;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
debug.log(` Tile ${tile.id}: copied ${copiedCount} values`);
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Count how many output values are still NaN (gaps)
|
|
1633
|
+
let nanCount = 0;
|
|
1634
|
+
for (let i = 0; i < result.length; i++) {
|
|
1635
|
+
if (isNaN(result[i])) nanCount++;
|
|
1636
|
+
}
|
|
1637
|
+
debug.log(`Stitching complete: ${result.length} total values, ${nanCount} still NaN`);
|
|
1638
|
+
|
|
1639
|
+
return {
|
|
1640
|
+
pathData: result,
|
|
1641
|
+
numScanlines: globalNumScanlines,
|
|
1642
|
+
pointsPerLine: globalPointsPerLine,
|
|
1643
|
+
generationTime: 0 // Sum from tiles if needed
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
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) {
|
|
2053
|
+
if (!device) {
|
|
2054
|
+
throw new Error('WebGPU not initialized');
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
const timings = {
|
|
2058
|
+
start: performance.now(),
|
|
2059
|
+
prep: 0,
|
|
2060
|
+
gpu: 0,
|
|
2061
|
+
stitch: 0
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
// Calculate grid dimensions based on BUCKET range (not model bounds)
|
|
2065
|
+
// Buckets may extend slightly beyond model bounds due to rounding
|
|
2066
|
+
const bucketMinX = bucketData.buckets[0].minX;
|
|
2067
|
+
const bucketMaxX = bucketData.buckets[bucketData.numBuckets - 1].maxX;
|
|
2068
|
+
const gridWidth = Math.ceil((bucketMaxX - bucketMinX) / resolution);
|
|
2069
|
+
const gridYHeight = Math.ceil(toolWidth / resolution);
|
|
2070
|
+
const bucketGridWidth = Math.ceil((bucketData.buckets[0].maxX - bucketData.buckets[0].minX) / resolution);
|
|
2071
|
+
|
|
2072
|
+
// Calculate workgroup load distribution for timeout analysis
|
|
2073
|
+
const bucketTriangleCounts = bucketData.buckets.map(b => b.count);
|
|
2074
|
+
const minTriangles = Math.min(...bucketTriangleCounts);
|
|
2075
|
+
const maxTriangles = Math.max(...bucketTriangleCounts);
|
|
2076
|
+
const avgTriangles = bucketTriangleCounts.reduce((a, b) => a + b, 0) / bucketTriangleCounts.length;
|
|
2077
|
+
const workPerWorkgroup = maxTriangles * numAngles * bucketGridWidth * gridYHeight;
|
|
2078
|
+
|
|
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)`);
|
|
2081
|
+
|
|
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();
|
|
2090
|
+
|
|
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
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
new Uint8Array(bucketInfoBuffer.getMappedRange()).set(new Uint8Array(bucketView));
|
|
2113
|
+
bucketInfoBuffer.unmap();
|
|
2114
|
+
|
|
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();
|
|
2123
|
+
|
|
2124
|
+
// Create output buffer (all angles, all buckets)
|
|
2125
|
+
const outputSize = numAngles * bucketData.numBuckets * bucketGridWidth * gridYHeight * 4;
|
|
2126
|
+
const outputBuffer = device.createBuffer({
|
|
2127
|
+
size: outputSize,
|
|
2128
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
// 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
|
+
const initData = new Float32Array(outputSize / 4);
|
|
2135
|
+
initData.fill(zFloor);
|
|
2136
|
+
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
|
+
});
|
|
2148
|
+
|
|
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
|
+
});
|
|
2179
|
+
|
|
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
|
+
});
|
|
2191
|
+
|
|
2192
|
+
console.time('RADIAL COMPUTE');
|
|
2193
|
+
// Dispatch
|
|
2194
|
+
const commandEncoder = device.createCommandEncoder();
|
|
2195
|
+
const passEncoder = commandEncoder.beginComputePass();
|
|
2196
|
+
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
|
+
|
|
2203
|
+
// Dispatch: (numAngles/8, gridYHeight/8, numBuckets)
|
|
2204
|
+
const dispatchX = Math.ceil(numAngles / 8);
|
|
2205
|
+
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
|
+
|
|
2209
|
+
passEncoder.dispatchWorkgroups(dispatchX, dispatchY, dispatchZ);
|
|
2210
|
+
passEncoder.end();
|
|
2211
|
+
|
|
2212
|
+
// Read back
|
|
2213
|
+
const stagingBuffer = device.createBuffer({
|
|
2214
|
+
size: outputSize,
|
|
2215
|
+
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize);
|
|
2219
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
2220
|
+
|
|
2221
|
+
// CRITICAL: Wait for GPU to finish before reading results
|
|
2222
|
+
await device.queue.onSubmittedWorkDone();
|
|
2223
|
+
console.timeEnd('RADIAL COMPUTE');
|
|
2224
|
+
|
|
2225
|
+
await stagingBuffer.mapAsync(GPUMapMode.READ);
|
|
2226
|
+
const outputData = new Float32Array(stagingBuffer.getMappedRange());
|
|
2227
|
+
const outputCopy = new Float32Array(outputData);
|
|
2228
|
+
stagingBuffer.unmap();
|
|
2229
|
+
|
|
2230
|
+
// Cleanup
|
|
2231
|
+
triangleBuffer.destroy();
|
|
2232
|
+
bucketInfoBuffer.destroy();
|
|
2233
|
+
triangleIndicesBuffer.destroy();
|
|
2234
|
+
outputBuffer.destroy();
|
|
2235
|
+
uniformBuffer.destroy();
|
|
2236
|
+
stagingBuffer.destroy();
|
|
2237
|
+
|
|
2238
|
+
timings.gpu = performance.now() - gpuStart;
|
|
2239
|
+
|
|
2240
|
+
// Stitch strips
|
|
2241
|
+
const stitchStart = performance.now();
|
|
2242
|
+
const strips = [];
|
|
2243
|
+
|
|
2244
|
+
for (let angleIdx = 0; angleIdx < numAngles; angleIdx++) {
|
|
2245
|
+
const stripData = new Float32Array(gridWidth * gridYHeight);
|
|
2246
|
+
stripData.fill(zFloor); // Initialize with zFloor, not zeros!
|
|
2247
|
+
|
|
2248
|
+
// Gather from each bucket
|
|
2249
|
+
for (let bucketIdx = 0; bucketIdx < bucketData.numBuckets; bucketIdx++) {
|
|
2250
|
+
const bucket = bucketData.buckets[bucketIdx];
|
|
2251
|
+
const bucketMinGridX = Math.floor((bucket.minX - bucketMinX) / resolution);
|
|
2252
|
+
|
|
2253
|
+
for (let localX = 0; localX < bucketGridWidth; localX++) {
|
|
2254
|
+
const gridX = bucketMinGridX + localX;
|
|
2255
|
+
if (gridX >= gridWidth) continue;
|
|
2256
|
+
|
|
2257
|
+
for (let gridY = 0; gridY < gridYHeight; gridY++) {
|
|
2258
|
+
const srcIdx = bucketIdx * numAngles * bucketGridWidth * gridYHeight
|
|
2259
|
+
+ angleIdx * bucketGridWidth * gridYHeight
|
|
2260
|
+
+ gridY * bucketGridWidth
|
|
2261
|
+
+ localX;
|
|
2262
|
+
const dstIdx = gridY * gridWidth + gridX;
|
|
2263
|
+
stripData[dstIdx] = outputCopy[srcIdx];
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// Keep as DENSE Z-only format (toolpath generator expects this!)
|
|
2269
|
+
// Count valid points
|
|
2270
|
+
let validCount = 0;
|
|
2271
|
+
for (let i = 0; i < stripData.length; i++) {
|
|
2272
|
+
if (stripData[i] !== zFloor) validCount++;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
strips.push({
|
|
2276
|
+
angle: startAngle + (angleIdx * angleStep),
|
|
2277
|
+
positions: stripData, // DENSE Z-only format!
|
|
2278
|
+
gridWidth,
|
|
2279
|
+
gridHeight: gridYHeight,
|
|
2280
|
+
pointCount: validCount, // Number of non-floor cells
|
|
2281
|
+
bounds: {
|
|
2282
|
+
min: { x: bucketMinX, y: 0, z: zFloor },
|
|
2283
|
+
max: { x: bucketMaxX, y: toolWidth, z: bounds.max.z }
|
|
2284
|
+
}
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
timings.stitch = performance.now() - stitchStart;
|
|
2289
|
+
const totalTime = performance.now() - timings.start;
|
|
2290
|
+
|
|
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)}%)`);
|
|
2295
|
+
|
|
2296
|
+
return { strips, timings };
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Handle messages from main thread
|
|
2300
|
+
self.onmessage = async function(e) {
|
|
2301
|
+
const { type, data } = e.data;
|
|
2302
|
+
|
|
2303
|
+
try {
|
|
2304
|
+
switch (type) {
|
|
2305
|
+
case 'init':
|
|
2306
|
+
// Store config
|
|
2307
|
+
config = data?.config || {
|
|
2308
|
+
maxGPUMemoryMB: 256,
|
|
2309
|
+
gpuMemorySafetyMargin: 0.8,
|
|
2310
|
+
tileOverlapMM: 10,
|
|
2311
|
+
autoTiling: true,
|
|
2312
|
+
minTileSize: 50
|
|
2313
|
+
};
|
|
2314
|
+
const success = await initWebGPU();
|
|
2315
|
+
self.postMessage({
|
|
2316
|
+
type: 'webgpu-ready',
|
|
2317
|
+
data: {
|
|
2318
|
+
success,
|
|
2319
|
+
capabilities: deviceCapabilities
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
break;
|
|
2323
|
+
|
|
2324
|
+
case 'update-config':
|
|
2325
|
+
config = data.config;
|
|
2326
|
+
debug.log('Config updated:', config);
|
|
2327
|
+
break;
|
|
2328
|
+
|
|
2329
|
+
case 'rasterize':
|
|
2330
|
+
const { triangles, stepSize, filterMode, boundsOverride } = data;
|
|
2331
|
+
const rasterOptions = boundsOverride || {};
|
|
2332
|
+
const rasterResult = await rasterizeMesh(triangles, stepSize, filterMode, rasterOptions);
|
|
2333
|
+
self.postMessage({
|
|
2334
|
+
type: 'rasterize-complete',
|
|
2335
|
+
data: rasterResult,
|
|
2336
|
+
}, [rasterResult.positions.buffer]);
|
|
2337
|
+
break;
|
|
2338
|
+
|
|
2339
|
+
case 'generate-toolpath':
|
|
2340
|
+
const { terrainPositions, toolPositions, xStep, yStep, zFloor, gridStep, terrainBounds, singleScanline } = data;
|
|
2341
|
+
const toolpathResult = await generateToolpath(
|
|
2342
|
+
terrainPositions, toolPositions, xStep, yStep, zFloor, gridStep, terrainBounds, singleScanline
|
|
2343
|
+
);
|
|
2344
|
+
self.postMessage({
|
|
2345
|
+
type: 'toolpath-complete',
|
|
2346
|
+
data: toolpathResult
|
|
2347
|
+
}, [toolpathResult.pathData.buffer]);
|
|
2348
|
+
break;
|
|
2349
|
+
|
|
2350
|
+
case 'radial-rasterize':
|
|
2351
|
+
const { triangles: radialTriangles, stepSize: radialStep, rotationStep: radialRotationStep, zFloor: radialZFloor = 0, boundsOverride: radialBounds, maxConcurrentTiles, trianglesPerTile, radialRotationOffset } = data;
|
|
2352
|
+
const radialResult = await radialRasterize(radialTriangles, radialStep, radialRotationStep, radialZFloor, radialBounds, { maxConcurrentTiles, trianglesPerTile, radialRotationOffset });
|
|
2353
|
+
self.postMessage({
|
|
2354
|
+
type: 'radial-rasterize-complete',
|
|
2355
|
+
data: radialResult
|
|
2356
|
+
}, [radialResult.positions.buffer]);
|
|
2357
|
+
break;
|
|
2358
|
+
|
|
2359
|
+
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
|
+
|
|
2496
|
+
self.postMessage({
|
|
2497
|
+
type: 'radial-toolpaths-complete',
|
|
2498
|
+
data: {
|
|
2499
|
+
strips: allStripToolpaths,
|
|
2500
|
+
totalPoints: totalToolpathPoints,
|
|
2501
|
+
numStrips: allStripToolpaths.length
|
|
2502
|
+
}
|
|
2503
|
+
}, toolpathTransferBuffers);
|
|
2504
|
+
break;
|
|
2505
|
+
|
|
2506
|
+
default:
|
|
2507
|
+
self.postMessage({
|
|
2508
|
+
type: 'error',
|
|
2509
|
+
message: 'Unknown message type: ' + type
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
} catch (error) {
|
|
2513
|
+
debug.error('Error:', error);
|
|
2514
|
+
self.postMessage({
|
|
2515
|
+
type: 'error',
|
|
2516
|
+
message: error.message,
|
|
2517
|
+
stack: error.stack
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
};
|