@gridspace/raster-path 1.0.7 → 1.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gridspace/raster-path",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "private": false,
5
5
  "description": "Terrain and Tool Raster Path Finder using WebGPU",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "build:web": "mkdir -p build && cp src/web/*.html src/web/*.css src/web/*.js build/ && cp src/core/raster-path.js build/raster-path.js && cp src/etc/serve.json build/",
33
33
  "clean": "rm -rf build/",
34
34
  "dev": "npm run build && npm run serve",
35
- "serve": "npx serve build --config serve.json --listen 9090",
35
+ "serve": "npm run build && npx serve build --config serve.json --listen 9090",
36
36
  "publish:public": "npm publish --access public",
37
37
  "test": "npm run test:planar && npm run test:radial",
38
38
  "test:planar": "npm run build && npx electron src/test/planar-test.cjs",
@@ -45,6 +45,7 @@
45
45
  "test:lathe-cylinder-2-debug": "npm run build && npx electron src/test/lathe-cylinder-2-debug.cjs",
46
46
  "test:extreme-work": "npm run build && npx electron src/test/extreme-work-test.cjs",
47
47
  "test:radial-thread-limit": "npm run build && npx electron src/test/radial-thread-limit-test.cjs",
48
+ "test:radial-v3": "npm run build && npx electron src/test/radial-v3-benchmark.cjs",
48
49
  "test:tracing": "npm run build && npx electron src/test/tracing-test.cjs"
49
50
  },
50
51
  "keywords": [
@@ -0,0 +1,405 @@
1
+ /**
2
+ * ═══════════════════════════════════════════════════════════════════════════
3
+ * Path Radial V3 - Bucket-Angle Pipeline with Y-Filtering
4
+ * ═══════════════════════════════════════════════════════════════════════════
5
+ *
6
+ * ALGORITHM CHANGES FROM V2:
7
+ * ──────────────────────────
8
+ * V2 (current): Process all angles simultaneously
9
+ * - Rotate rays on the fly
10
+ * - Test all triangles in bucket (no Y-filtering)
11
+ * - Large memory footprint: numAngles × gridWidth × gridHeight
12
+ *
13
+ * V3 (this file): Process bucket-by-angle pipeline
14
+ * For each bucket:
15
+ * For each angle:
16
+ * 1. Rotate triangles (parallel) → rotated tris + Y-bounds
17
+ * 2. Rasterize with Y-filter (parallel) → dense terrain strip
18
+ * 3. Toolpath generation (parallel) → sparse toolpath
19
+ *
20
+ * BENEFITS:
21
+ * ─────────
22
+ * - Lower memory: Only one angle's data in GPU at a time
23
+ * - Y-axis filtering: Skip triangles outside tool radius
24
+ * - Immediate toolpath generation: No need to store all strips
25
+ * - Better cache locality: Process bucket completely before moving on
26
+ *
27
+ * TODO: Memory Safety
28
+ * ───────────────────
29
+ * V3 currently does NOT have memory safety checks like V2 does. V2 batches angles
30
+ * if total memory (numAngles × gridWidth × gridHeight × 4) exceeds 1800MB.
31
+ *
32
+ * V3 processes one angle at a time (inherently lower memory), but doesn't check if:
33
+ * - Triangle input buffer (triangles.byteLength) exceeds GPU limits
34
+ * - Rotated triangles buffer (numTriangles × 11 × 4) exceeds GPU limits
35
+ * - Output raster buffer (fullGridWidth × gridHeight × 4) exceeds GPU limits
36
+ *
37
+ * This could cause crashes on extremely large models with millions of triangles.
38
+ * Consider adding checks and batching for triangle buffers if needed.
39
+ *
40
+ * ═══════════════════════════════════════════════════════════════════════════
41
+ */
42
+
43
+ import {
44
+ device, config, debug, diagnostic,
45
+ cachedRadialV3RotatePipeline,
46
+ cachedRadialV3BatchedRasterizePipeline
47
+ } from './raster-config.js';
48
+ import {
49
+ createReusableToolpathBuffers,
50
+ destroyReusableToolpathBuffers,
51
+ runToolpathComputeWithBuffers
52
+ } from './path-planar.js';
53
+ import { createSparseToolFromPoints } from './raster-tool.js';
54
+
55
+ /**
56
+ * Rotate all triangles in a bucket by a single angle
57
+ */
58
+ async function rotateTriangles({
59
+ triangleBuffer, // GPU buffer with original triangles
60
+ numTriangles,
61
+ angle // Radians
62
+ }) {
63
+ const rotatePipeline = cachedRadialV3RotatePipeline;
64
+ if (!rotatePipeline) {
65
+ throw new Error('Radial V3 pipelines not initialized');
66
+ }
67
+
68
+ // Create output buffer for rotated triangles + bounds
69
+ // Layout: 11 floats per triangle (v0, v1, v2, y_min, y_max)
70
+ const outputSize = numTriangles * 11 * 4;
71
+ const rotatedBuffer = device.createBuffer({
72
+ size: outputSize,
73
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
74
+ });
75
+
76
+ // Create uniforms
77
+ const uniformBuffer = device.createBuffer({
78
+ size: 8, // f32 angle + u32 num_triangles
79
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
80
+ mappedAtCreation: true
81
+ });
82
+
83
+ const uniformView = new ArrayBuffer(8);
84
+ const floatView = new Float32Array(uniformView);
85
+ const uintView = new Uint32Array(uniformView);
86
+ floatView[0] = angle;
87
+ uintView[1] = numTriangles;
88
+
89
+ new Uint8Array(uniformBuffer.getMappedRange()).set(new Uint8Array(uniformView));
90
+ uniformBuffer.unmap();
91
+
92
+ // Create bind group
93
+ const bindGroup = device.createBindGroup({
94
+ layout: rotatePipeline.getBindGroupLayout(0),
95
+ entries: [
96
+ { binding: 0, resource: { buffer: triangleBuffer } },
97
+ { binding: 1, resource: { buffer: rotatedBuffer } },
98
+ { binding: 2, resource: { buffer: uniformBuffer } }
99
+ ]
100
+ });
101
+
102
+ // Dispatch
103
+ const commandEncoder = device.createCommandEncoder();
104
+ const passEncoder = commandEncoder.beginComputePass();
105
+ passEncoder.setPipeline(rotatePipeline);
106
+ passEncoder.setBindGroup(0, bindGroup);
107
+ passEncoder.dispatchWorkgroups(Math.ceil(numTriangles / 64));
108
+ passEncoder.end();
109
+
110
+ device.queue.submit([commandEncoder.finish()]);
111
+
112
+ // Cleanup
113
+ uniformBuffer.destroy();
114
+
115
+ return rotatedBuffer;
116
+ }
117
+
118
+ /**
119
+ * Rasterize ALL buckets in one dispatch (batched GPU processing)
120
+ */
121
+ async function rasterizeAllBuckets({
122
+ rotatedTrianglesBuffer,
123
+ buckets,
124
+ triangleIndices,
125
+ resolution,
126
+ toolRadius,
127
+ fullGridWidth,
128
+ gridHeight,
129
+ globalMinX,
130
+ bucketMinY,
131
+ zFloor
132
+ }) {
133
+ const rasterizePipeline = cachedRadialV3BatchedRasterizePipeline;
134
+ if (!rasterizePipeline) {
135
+ throw new Error('Radial V3 batched pipeline not initialized');
136
+ }
137
+
138
+ // Create bucket info buffer (all buckets)
139
+ const bucketInfoSize = buckets.length * 16; // 4 fields × 4 bytes per bucket
140
+ const bucketInfoBuffer = device.createBuffer({
141
+ size: bucketInfoSize,
142
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
143
+ mappedAtCreation: true
144
+ });
145
+
146
+ const bucketView = new ArrayBuffer(bucketInfoSize);
147
+ const bucketFloatView = new Float32Array(bucketView);
148
+ const bucketUintView = new Uint32Array(bucketView);
149
+
150
+ for (let i = 0; i < buckets.length; i++) {
151
+ const bucket = buckets[i];
152
+ const offset = i * 4;
153
+ bucketFloatView[offset] = bucket.minX;
154
+ bucketFloatView[offset + 1] = bucket.maxX;
155
+ bucketUintView[offset + 2] = bucket.startIndex;
156
+ bucketUintView[offset + 3] = bucket.count;
157
+ }
158
+
159
+ new Uint8Array(bucketInfoBuffer.getMappedRange()).set(new Uint8Array(bucketView));
160
+ bucketInfoBuffer.unmap();
161
+
162
+ // Create triangle indices buffer
163
+ const indicesBuffer = device.createBuffer({
164
+ size: triangleIndices.byteLength,
165
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
166
+ mappedAtCreation: true
167
+ });
168
+ new Uint32Array(indicesBuffer.getMappedRange()).set(triangleIndices);
169
+ indicesBuffer.unmap();
170
+
171
+ // Create output buffer for full terrain strip
172
+ const outputSize = fullGridWidth * gridHeight * 4;
173
+ const outputBuffer = device.createBuffer({
174
+ size: outputSize,
175
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
176
+ });
177
+
178
+ // Initialize with zFloor
179
+ const initData = new Float32Array(fullGridWidth * gridHeight);
180
+ initData.fill(zFloor);
181
+ device.queue.writeBuffer(outputBuffer, 0, initData);
182
+
183
+ // Create uniforms
184
+ const uniformBuffer = device.createBuffer({
185
+ size: 32, // 8 fields × 4 bytes
186
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
187
+ mappedAtCreation: true
188
+ });
189
+
190
+ const uniformView = new ArrayBuffer(32);
191
+ const floatView = new Float32Array(uniformView);
192
+ const uintView = new Uint32Array(uniformView);
193
+
194
+ floatView[0] = resolution;
195
+ floatView[1] = toolRadius;
196
+ uintView[2] = fullGridWidth;
197
+ uintView[3] = gridHeight;
198
+ floatView[4] = globalMinX;
199
+ floatView[5] = bucketMinY;
200
+ floatView[6] = zFloor;
201
+ uintView[7] = buckets.length;
202
+
203
+ new Uint8Array(uniformBuffer.getMappedRange()).set(new Uint8Array(uniformView));
204
+ uniformBuffer.unmap();
205
+
206
+ // Create bind group
207
+ const bindGroup = device.createBindGroup({
208
+ layout: rasterizePipeline.getBindGroupLayout(0),
209
+ entries: [
210
+ { binding: 0, resource: { buffer: rotatedTrianglesBuffer } },
211
+ { binding: 1, resource: { buffer: outputBuffer } },
212
+ { binding: 2, resource: { buffer: uniformBuffer } },
213
+ { binding: 3, resource: { buffer: bucketInfoBuffer } },
214
+ { binding: 4, resource: { buffer: indicesBuffer } }
215
+ ]
216
+ });
217
+
218
+ // Dispatch - covers full grid width (all buckets)
219
+ const commandEncoder = device.createCommandEncoder();
220
+ const passEncoder = commandEncoder.beginComputePass();
221
+ passEncoder.setPipeline(rasterizePipeline);
222
+ passEncoder.setBindGroup(0, bindGroup);
223
+
224
+ const dispatchX = Math.ceil(fullGridWidth / 8);
225
+ const dispatchY = Math.ceil(gridHeight / 8);
226
+ passEncoder.dispatchWorkgroups(dispatchX, dispatchY);
227
+ passEncoder.end();
228
+
229
+ // Read back results
230
+ const stagingBuffer = device.createBuffer({
231
+ size: outputSize,
232
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
233
+ });
234
+
235
+ commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize);
236
+ device.queue.submit([commandEncoder.finish()]);
237
+
238
+ await device.queue.onSubmittedWorkDone();
239
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
240
+ const terrainData = new Float32Array(stagingBuffer.getMappedRange().slice());
241
+ stagingBuffer.unmap();
242
+
243
+ // Cleanup
244
+ outputBuffer.destroy();
245
+ stagingBuffer.destroy();
246
+ uniformBuffer.destroy();
247
+ bucketInfoBuffer.destroy();
248
+ indicesBuffer.destroy();
249
+
250
+ return terrainData;
251
+ }
252
+ /**
253
+ * Generate radial toolpaths using bucket-angle pipeline
254
+ */
255
+ export async function generateRadialToolpathsV3({
256
+ triangles,
257
+ bucketData,
258
+ toolData,
259
+ resolution,
260
+ angleStep,
261
+ numAngles,
262
+ maxRadius,
263
+ toolWidth,
264
+ zFloor,
265
+ bounds,
266
+ xStep,
267
+ yStep
268
+ }) {
269
+ debug.log('radial-v3-generate-toolpaths', { triangles: triangles.length / 9, numAngles, resolution });
270
+
271
+ const pipelineStartTime = performance.now();
272
+ const allStripToolpaths = [];
273
+ let totalToolpathPoints = 0;
274
+
275
+ // Prepare sparse tool once
276
+ const sparseToolData = createSparseToolFromPoints(toolData.positions);
277
+ debug.log(`Created sparse tool: ${sparseToolData.count} points (reusing for all strips)`);
278
+
279
+ const toolRadius = toolWidth / 2;
280
+
281
+ // Calculate full grid dimensions (all buckets)
282
+ const bucketMinX = bucketData.buckets[0].minX;
283
+ const bucketMaxX = bucketData.buckets[bucketData.numBuckets - 1].maxX;
284
+ const fullWidth = bucketMaxX - bucketMinX;
285
+ const fullGridWidth = Math.ceil(fullWidth / resolution);
286
+ const gridHeight = Math.ceil(toolWidth / resolution);
287
+
288
+ // OPTIMIZATION: Upload all triangles to GPU ONCE (reused across all angles)
289
+ debug.log(`Uploading ${triangles.length / 9} triangles to GPU (reused across all angles)...`);
290
+ const allTrianglesBuffer = device.createBuffer({
291
+ size: triangles.byteLength,
292
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
293
+ mappedAtCreation: true
294
+ });
295
+ new Float32Array(allTrianglesBuffer.getMappedRange()).set(triangles);
296
+ allTrianglesBuffer.unmap();
297
+
298
+ // Process angle-by-angle (outer loop)
299
+ for (let angleIdx = 0; angleIdx < numAngles; angleIdx++) {
300
+ const angle = -(angleIdx * angleStep * (Math.PI / 180)); // Convert to radians (negative: rotating terrain vs tool)
301
+ const angleDegrees = angleIdx * angleStep;
302
+
303
+ if (diagnostic) {
304
+ debug.log(`Angle ${angleIdx + 1}/${numAngles}: ${angleDegrees.toFixed(1)}°`);
305
+ }
306
+
307
+ // Report progress
308
+ if (angleIdx % 10 === 0 || angleIdx === numAngles - 1) {
309
+ const stripProgress = ((angleIdx + 1) / numAngles) * 98;
310
+ self.postMessage({
311
+ type: 'toolpath-progress',
312
+ data: {
313
+ percent: Math.round(stripProgress),
314
+ current: angleIdx + 1,
315
+ total: numAngles,
316
+ layer: angleIdx + 1
317
+ }
318
+ });
319
+ }
320
+
321
+ // OPTIMIZATION: Rotate ALL triangles once per angle (batch rotation)
322
+ const numTotalTriangles = triangles.length / 9;
323
+ const allRotatedTrianglesBuffer = await rotateTriangles({
324
+ triangleBuffer: allTrianglesBuffer,
325
+ numTriangles: numTotalTriangles,
326
+ angle
327
+ });
328
+
329
+ // OPTIMIZATION: Rasterize ALL buckets in ONE dispatch (no CPU loop!)
330
+ const fullTerrainStrip = await rasterizeAllBuckets({
331
+ rotatedTrianglesBuffer: allRotatedTrianglesBuffer,
332
+ buckets: bucketData.buckets,
333
+ triangleIndices: bucketData.triangleIndices,
334
+ resolution,
335
+ toolRadius,
336
+ fullGridWidth,
337
+ gridHeight,
338
+ globalMinX: bucketMinX,
339
+ bucketMinY: -toolWidth / 2,
340
+ zFloor
341
+ });
342
+
343
+ // Cleanup rotated buffer (created per angle)
344
+ allRotatedTrianglesBuffer.destroy();
345
+
346
+ // Step 3: Generate toolpath for this complete angle strip
347
+ const reusableToolpathBuffers = createReusableToolpathBuffers(
348
+ fullGridWidth,
349
+ gridHeight,
350
+ sparseToolData,
351
+ xStep,
352
+ gridHeight
353
+ );
354
+
355
+ const stripToolpathResult = await runToolpathComputeWithBuffers(
356
+ fullTerrainStrip,
357
+ fullGridWidth,
358
+ gridHeight,
359
+ xStep,
360
+ gridHeight,
361
+ zFloor,
362
+ reusableToolpathBuffers,
363
+ pipelineStartTime
364
+ );
365
+
366
+ destroyReusableToolpathBuffers(reusableToolpathBuffers);
367
+
368
+ allStripToolpaths.push({
369
+ angle: angleDegrees,
370
+ pathData: stripToolpathResult.pathData,
371
+ numScanlines: stripToolpathResult.numScanlines,
372
+ pointsPerLine: stripToolpathResult.pointsPerLine,
373
+ terrainBounds: {
374
+ min: { x: bucketMinX, y: -toolWidth / 2, z: zFloor },
375
+ max: { x: bucketMaxX, y: toolWidth / 2, z: bounds.max.z }
376
+ }
377
+ });
378
+
379
+ totalToolpathPoints += stripToolpathResult.pathData.length;
380
+ }
381
+
382
+ // Cleanup triangles buffer (reused across all angles)
383
+ allTrianglesBuffer.destroy();
384
+ debug.log(`Destroyed reusable triangle buffer`);
385
+
386
+ const pipelineTotalTime = performance.now() - pipelineStartTime;
387
+ debug.log(`Complete radial V3 toolpath: ${allStripToolpaths.length} strips, ${totalToolpathPoints} total points in ${pipelineTotalTime.toFixed(0)}ms`);
388
+
389
+ // Send final 100% progress
390
+ self.postMessage({
391
+ type: 'toolpath-progress',
392
+ data: {
393
+ percent: 100,
394
+ current: bucketData.numBuckets * numAngles,
395
+ total: bucketData.numBuckets * numAngles,
396
+ layer: numAngles
397
+ }
398
+ });
399
+
400
+ return {
401
+ strips: allStripToolpaths,
402
+ totalPoints: totalToolpathPoints,
403
+ numStrips: allStripToolpaths.length
404
+ };
405
+ }