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