@gridspace/raster-path 1.0.6 → 1.0.8

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.
@@ -45,7 +45,7 @@
45
45
  /**
46
46
  * Configuration options for RasterPath
47
47
  * @typedef {Object} RasterPathConfig
48
- * @property {'planar'|'radial'} mode - Rasterization mode (default: 'planar')
48
+ * @property {'planar'|'radial'|'tracing'} mode - Rasterization mode (default: 'planar')
49
49
  * @property {boolean} autoTiling - Automatically tile large datasets (default: true)
50
50
  * @property {number} gpuMemorySafetyMargin - Safety margin as percentage (default: 0.8 = 80%)
51
51
  * @property {number} maxGPUMemoryMB - Maximum GPU memory per tile (default: 256MB)
@@ -80,8 +80,8 @@ export class RasterPath {
80
80
 
81
81
  // Validate mode
82
82
  const mode = config.mode || 'planar';
83
- if (mode !== 'planar' && mode !== 'radial') {
84
- throw new Error(`Invalid mode: ${mode}. Must be 'planar' or 'radial'`);
83
+ if (mode !== 'planar' && mode !== 'radial' && mode !== 'tracing') {
84
+ throw new Error(`Invalid mode: ${mode}. Must be 'planar', 'radial', or 'tracing'`);
85
85
  }
86
86
 
87
87
  // Validate rotationStep for radial mode
@@ -111,6 +111,7 @@ export class RasterPath {
111
111
  gpuMemorySafetyMargin: config.gpuMemorySafetyMargin ?? 0.8,
112
112
  autoTiling: config.autoTiling ?? true,
113
113
  batchDivisor: config.batchDivisor ?? 1, // For testing batching overhead
114
+ radialV3: config.radialV3 ?? false, // Use radial V3 pipeline (rotate-filter-toolpath)
114
115
  debug: config.debug,
115
116
  quiet: config.quiet
116
117
  };
@@ -221,8 +222,8 @@ export class RasterPath {
221
222
  throw new Error('RasterPath not initialized. Call init() first.');
222
223
  }
223
224
 
224
- if (this.mode === 'planar') {
225
- // Planar: rasterize and return
225
+ if (this.mode === 'planar' || this.mode === 'tracing') {
226
+ // Planar/Tracing: rasterize and return (tracing reuses planar terrain rasterization)
226
227
  const terrainData = await this.#rasterizePlanar({ triangles, zFloor, boundsOverride, isForTool: false, onProgress });
227
228
  this.terrainData = terrainData;
228
229
  return terrainData;
@@ -269,7 +270,7 @@ export class RasterPath {
269
270
  * @param {function} params.onProgress - Optional progress callback (progress: number, info?: string) => void
270
271
  * @returns {Promise<object>} Planar: {pathData, width, height} | Radial: {strips[], numStrips, totalPoints}
271
272
  */
272
- async generateToolpaths({ xStep, yStep, zFloor, onProgress }) {
273
+ async generateToolpaths({ xStep, yStep, zFloor, onProgress, paths, step }) {
273
274
  if (!this.isInitialized) {
274
275
  throw new Error('RasterPath not initialized. Call init() first.');
275
276
  }
@@ -278,7 +279,7 @@ export class RasterPath {
278
279
  throw new Error('Tool not loaded. Call loadTool() first.');
279
280
  }
280
281
 
281
- debug.log('gen.paths', { xStep, yStep, zFloor });
282
+ debug.log('gen.paths', { xStep, yStep, zFloor, paths: paths?.length, step });
282
283
 
283
284
  if (this.mode === 'planar') {
284
285
  if (!this.terrainData) {
@@ -292,7 +293,7 @@ export class RasterPath {
292
293
  zFloor,
293
294
  onProgress
294
295
  });
295
- } else {
296
+ } else if (this.mode === 'radial') {
296
297
  // Radial mode: use stored triangles
297
298
  if (!this.terrainTriangles) {
298
299
  throw new Error('Terrain not loaded. Call loadTerrain() first.');
@@ -306,14 +307,83 @@ export class RasterPath {
306
307
  zFloor: zFloor ?? this.terrainZFloor,
307
308
  onProgress
308
309
  });
310
+ } else if (this.mode === 'tracing') {
311
+ // Tracing mode: follow input paths
312
+ if (!this.terrainData) {
313
+ throw new Error('Terrain not loaded. Call loadTerrain() first.');
314
+ }
315
+ if (!paths || paths.length === 0) {
316
+ throw new Error('Tracing mode requires paths parameter (array of Float32Array XY coordinates)');
317
+ }
318
+ if (!step || step <= 0) {
319
+ throw new Error('Tracing mode requires step parameter (sampling resolution in world units)');
320
+ }
321
+ return this.#generateToolpathsTracing({
322
+ paths,
323
+ step,
324
+ zFloor,
325
+ onProgress
326
+ });
309
327
  }
310
328
  }
311
329
 
330
+ /**
331
+ * Create reusable GPU buffers for tracing mode (optimization for iterative tracing)
332
+ * Call this after loadTerrain() and loadTool() to cache buffers across multiple trace calls
333
+ * @returns {Promise<void>}
334
+ */
335
+ async createTracingBuffers() {
336
+ if (this.mode !== 'tracing') {
337
+ throw new Error('createTracingBuffers() only available in tracing mode');
338
+ }
339
+ if (!this.terrainData || !this.toolData) {
340
+ throw new Error('Must call loadTerrain() and loadTool() before createTracingBuffers()');
341
+ }
342
+
343
+ return new Promise((resolve, reject) => {
344
+ const handler = () => resolve();
345
+ this.#sendMessage(
346
+ 'create-tracing-buffers',
347
+ {
348
+ terrainPositions: this.terrainData.positions,
349
+ toolPositions: this.toolData.positions
350
+ },
351
+ 'tracing-buffers-created',
352
+ handler
353
+ );
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Destroy reusable tracing buffers
359
+ * @returns {Promise<void>}
360
+ */
361
+ async destroyTracingBuffers() {
362
+ if (this.mode !== 'tracing') {
363
+ return; // No-op for non-tracing modes
364
+ }
365
+
366
+ return new Promise((resolve, reject) => {
367
+ const handler = () => resolve();
368
+ this.#sendMessage(
369
+ 'destroy-tracing-buffers',
370
+ {},
371
+ 'tracing-buffers-destroyed',
372
+ handler
373
+ );
374
+ });
375
+ }
376
+
312
377
  /**
313
378
  * Terminate worker and cleanup resources
314
379
  */
315
- terminate() {
380
+ async terminate() {
316
381
  if (this.worker) {
382
+ // Cleanup tracing buffers if in tracing mode
383
+ if (this.mode === 'tracing') {
384
+ await this.destroyTracingBuffers();
385
+ }
386
+
317
387
  this.worker.terminate();
318
388
  this.worker = null;
319
389
  this.isInitialized = false;
@@ -388,6 +458,46 @@ export class RasterPath {
388
458
  });
389
459
  }
390
460
 
461
+ async #generateToolpathsTracing({ paths, step, zFloor, onProgress }) {
462
+ return new Promise((resolve, reject) => {
463
+ // Set up progress handler if callback provided
464
+ if (onProgress) {
465
+ const progressHandler = (data) => {
466
+ onProgress(data.percent, { current: data.current, total: data.total, pathIndex: data.pathIndex });
467
+ };
468
+ this.messageHandlers.set('tracing-progress', progressHandler);
469
+ }
470
+
471
+ const handler = (data) => {
472
+ // Clean up progress handler
473
+ if (onProgress) {
474
+ this.messageHandlers.delete('tracing-progress');
475
+ }
476
+ resolve(data);
477
+ };
478
+
479
+ this.#sendMessage(
480
+ 'tracing-generate-toolpaths',
481
+ {
482
+ paths,
483
+ terrainPositions: this.terrainData.positions,
484
+ terrainData: {
485
+ width: this.terrainData.gridWidth,
486
+ height: this.terrainData.gridHeight,
487
+ bounds: this.terrainData.bounds
488
+ },
489
+ toolPositions: this.toolData.positions,
490
+ step,
491
+ gridStep: this.resolution,
492
+ terrainBounds: this.terrainData.bounds,
493
+ zFloor: zFloor ?? 0
494
+ },
495
+ 'tracing-toolpaths-complete',
496
+ handler
497
+ );
498
+ });
499
+ }
500
+
391
501
  async #generateToolpathsRadial({ triangles, bounds, toolData, xStep, yStep, zFloor, onProgress }) {
392
502
  const maxRadius = this.#calculateMaxRadius(triangles);
393
503
 
@@ -422,9 +532,13 @@ export class RasterPath {
422
532
  resolve(data);
423
533
  };
424
534
 
425
- // Send entire pipeline to worker
535
+ // Send entire pipeline to worker (use V3 if configured)
536
+ const messageType = this.config.radialV3
537
+ ? 'radial-generate-toolpaths-v3'
538
+ : 'radial-generate-toolpaths';
539
+
426
540
  this.#sendMessage(
427
- 'radial-generate-toolpaths',
541
+ messageType,
428
542
  {
429
543
  triangles: triangles,
430
544
  bucketData,
@@ -54,6 +54,8 @@ import { initWebGPU, setConfig, updateConfig, deviceCapabilities, debug, device
54
54
  import { rasterizeMesh } from './raster-planar.js';
55
55
  import { generateToolpath } from './path-planar.js';
56
56
  import { generateRadialToolpaths } from './path-radial.js';
57
+ import { generateRadialToolpathsV3 } from './path-radial-v3.js';
58
+ import { generateTracingToolpaths, createReusableTracingBuffers, destroyReusableTracingBuffers } from './path-tracing.js';
57
59
  import { calibrateGPU } from './workload-calibrate.js';
58
60
 
59
61
  // Global error handler for uncaught errors in worker
@@ -127,6 +129,55 @@ self.onmessage = async function(e) {
127
129
  }, toolpathTransferBuffers);
128
130
  break;
129
131
 
132
+ case 'radial-generate-toolpaths-v3':
133
+ const radialV3ToolpathResult = await generateRadialToolpathsV3(data);
134
+ const v3ToolpathTransferBuffers = radialV3ToolpathResult.strips.map(strip => strip.pathData.buffer);
135
+ self.postMessage({
136
+ type: 'radial-toolpaths-complete',
137
+ data: radialV3ToolpathResult
138
+ }, v3ToolpathTransferBuffers);
139
+ break;
140
+
141
+ case 'tracing-generate-toolpaths':
142
+ const tracingResult = await generateTracingToolpaths({
143
+ paths: data.paths,
144
+ terrainPositions: data.terrainPositions,
145
+ terrainData: data.terrainData,
146
+ toolPositions: data.toolPositions,
147
+ step: data.step,
148
+ gridStep: data.gridStep,
149
+ terrainBounds: data.terrainBounds,
150
+ zFloor: data.zFloor,
151
+ onProgress: (progressData) => {
152
+ self.postMessage({
153
+ type: 'tracing-progress',
154
+ data: progressData.data
155
+ });
156
+ }
157
+ });
158
+ const tracingTransferBuffers = tracingResult.paths.map(p => p.buffer);
159
+ self.postMessage({
160
+ type: 'tracing-toolpaths-complete',
161
+ data: tracingResult
162
+ }, tracingTransferBuffers);
163
+ break;
164
+
165
+ case 'create-tracing-buffers':
166
+ createReusableTracingBuffers(data.terrainPositions, data.toolPositions);
167
+ self.postMessage({
168
+ type: 'tracing-buffers-created',
169
+ data: { success: true }
170
+ });
171
+ break;
172
+
173
+ case 'destroy-tracing-buffers':
174
+ destroyReusableTracingBuffers();
175
+ self.postMessage({
176
+ type: 'tracing-buffers-destroyed',
177
+ data: { success: true }
178
+ });
179
+ break;
180
+
130
181
  case 'calibrate':
131
182
  const calibrationResult = await calibrateGPU(device, data?.options || {});
132
183
  self.postMessage({
@@ -114,18 +114,72 @@ async function testWorkloadDispatch(device, pipeline, workgroupSize, triangleTes
114
114
  passEncoder.dispatchWorkgroups(dispatchX, dispatchY, 1);
115
115
  passEncoder.end();
116
116
 
117
- // Readback
117
+ // Submit the compute work
118
+ device.queue.submit([commandEncoder.finish()]);
119
+
120
+ // TEST: Queue progress checkpoints while GPU works
121
+ const numCheckpoints = 5;
122
+ const checkpointInterval = 100; // ms
123
+ const progressSnapshots = [];
124
+
125
+ for (let i = 0; i < numCheckpoints; i++) {
126
+ await new Promise(resolve => setTimeout(resolve, checkpointInterval));
127
+
128
+ const checkpointStart = performance.now();
129
+
130
+ // Queue a copy to read progress
131
+ const checkpointEncoder = device.createCommandEncoder();
132
+ const checkpointStaging = device.createBuffer({
133
+ size: totalThreads * 4,
134
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
135
+ });
136
+
137
+ checkpointEncoder.copyBufferToBuffer(completionBuffer, 0, checkpointStaging, 0, totalThreads * 4);
138
+ device.queue.submit([checkpointEncoder.finish()]);
139
+
140
+ // Wait for this checkpoint copy to complete
141
+ await checkpointStaging.mapAsync(GPUMapMode.READ);
142
+ const checkpointElapsed = performance.now() - checkpointStart;
143
+
144
+ // Count completed threads
145
+ const checkpointData = new Uint32Array(checkpointStaging.getMappedRange());
146
+ let completedThreads = 0;
147
+ for (let j = 0; j < totalThreads; j++) {
148
+ if (checkpointData[j] === 1) completedThreads++;
149
+ }
150
+
151
+ progressSnapshots.push({
152
+ checkpoint: i + 1,
153
+ timeMs: Math.round(performance.now() - startTime),
154
+ completedThreads,
155
+ totalThreads,
156
+ percentComplete: Math.round((completedThreads / totalThreads) * 100),
157
+ checkpointLatencyMs: checkpointElapsed.toFixed(2)
158
+ });
159
+
160
+ checkpointStaging.unmap();
161
+ checkpointStaging.destroy();
162
+ }
163
+
164
+ // Final readback
118
165
  const stagingBuffer = device.createBuffer({
119
166
  size: totalThreads * 4,
120
167
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
121
168
  });
122
169
 
123
- commandEncoder.copyBufferToBuffer(completionBuffer, 0, stagingBuffer, 0, totalThreads * 4);
124
- device.queue.submit([commandEncoder.finish()]);
170
+ const finalEncoder = device.createCommandEncoder();
171
+ finalEncoder.copyBufferToBuffer(completionBuffer, 0, stagingBuffer, 0, totalThreads * 4);
172
+ device.queue.submit([finalEncoder.finish()]);
125
173
 
126
174
  await device.queue.onSubmittedWorkDone();
127
175
  const elapsed = performance.now() - startTime;
128
176
 
177
+ // Log progress snapshots
178
+ if (progressSnapshots.length > 0) {
179
+ console.log('\n📊 Progress Checkpoints:');
180
+ console.table(progressSnapshots);
181
+ }
182
+
129
183
  await stagingBuffer.mapAsync(GPUMapMode.READ);
130
184
  const completionData = new Uint32Array(stagingBuffer.getMappedRange());
131
185
  const completionCopy = new Uint32Array(completionData);
@@ -0,0 +1,164 @@
1
+ // Radial V3 batched bucket rasterization
2
+ // Processes ALL buckets in one dispatch - GPU threads find their bucket
3
+
4
+ const EPSILON: f32 = 0.0001;
5
+
6
+ struct Uniforms {
7
+ resolution: f32, // Grid step size (mm)
8
+ tool_radius: f32, // Tool radius for Y-filtering
9
+ full_grid_width: u32, // Full grid width (all buckets)
10
+ grid_height: u32, // Number of Y cells
11
+ global_min_x: f32, // Global minimum X coordinate
12
+ bucket_min_y: f32, // Y-axis start (typically -tool_width/2)
13
+ z_floor: f32, // Z value for empty cells
14
+ num_buckets: u32, // Number of buckets
15
+ }
16
+
17
+ struct BucketInfo {
18
+ min_x: f32, // Bucket X range start
19
+ max_x: f32, // Bucket X range end
20
+ start_index: u32, // Index into triangle_indices array
21
+ count: u32, // Number of triangles in this bucket
22
+ }
23
+
24
+ @group(0) @binding(0) var<storage, read> rotated_triangles: array<f32>; // ALL rotated triangles + bounds
25
+ @group(0) @binding(1) var<storage, read_write> output: array<f32>; // Full-width output grid
26
+ @group(0) @binding(2) var<uniform> uniforms: Uniforms;
27
+ @group(0) @binding(3) var<storage, read> all_buckets: array<BucketInfo>; // All bucket descriptors
28
+ @group(0) @binding(4) var<storage, read> triangle_indices: array<u32>; // All triangle indices
29
+
30
+ // Simplified ray-triangle intersection for downward rays
31
+ fn ray_triangle_intersect_downward(
32
+ ray_origin: vec3<f32>,
33
+ v0: vec3<f32>,
34
+ v1: vec3<f32>,
35
+ v2: vec3<f32>
36
+ ) -> vec2<f32> { // Returns (hit: 0.0 or 1.0, t: distance along ray)
37
+ let ray_dir = vec3<f32>(0.0, 0.0, -1.0);
38
+
39
+ let edge1 = v1 - v0;
40
+ let edge2 = v2 - v0;
41
+ let h = cross(ray_dir, edge2);
42
+ let a = dot(edge1, h);
43
+
44
+ if (a > -EPSILON && a < EPSILON) {
45
+ return vec2<f32>(0.0, 0.0);
46
+ }
47
+
48
+ let f = 1.0 / a;
49
+ let s = ray_origin - v0;
50
+ let u = f * dot(s, h);
51
+
52
+ if (u < -EPSILON || u > 1.0 + EPSILON) {
53
+ return vec2<f32>(0.0, 0.0);
54
+ }
55
+
56
+ let q = cross(s, edge1);
57
+ let v = f * dot(ray_dir, q);
58
+
59
+ if (v < -EPSILON || u + v > 1.0 + EPSILON) {
60
+ return vec2<f32>(0.0, 0.0);
61
+ }
62
+
63
+ let t = f * dot(edge2, q);
64
+
65
+ if (t > EPSILON) {
66
+ return vec2<f32>(1.0, t);
67
+ }
68
+
69
+ return vec2<f32>(0.0, 0.0);
70
+ }
71
+
72
+ @compute @workgroup_size(8, 8, 1)
73
+ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
74
+ let grid_x = global_id.x;
75
+ let grid_y = global_id.y;
76
+
77
+ // Bounds check
78
+ if (grid_x >= uniforms.full_grid_width || grid_y >= uniforms.grid_height) {
79
+ return;
80
+ }
81
+
82
+ // Calculate world position
83
+ let world_x = uniforms.global_min_x + f32(grid_x) * uniforms.resolution;
84
+ let world_y = uniforms.bucket_min_y + f32(grid_y) * uniforms.resolution;
85
+
86
+ // FIND WHICH BUCKET THIS X POSITION BELONGS TO
87
+ // Simple linear search (could be binary search for many buckets)
88
+ var bucket_idx = 0u;
89
+ var found_bucket = false;
90
+ for (var i = 0u; i < uniforms.num_buckets; i++) {
91
+ if (world_x >= all_buckets[i].min_x && world_x < all_buckets[i].max_x) {
92
+ bucket_idx = i;
93
+ found_bucket = true;
94
+ break;
95
+ }
96
+ }
97
+
98
+ // If not in any bucket, write floor and return
99
+ if (!found_bucket) {
100
+ let output_idx = grid_y * uniforms.full_grid_width + grid_x;
101
+ output[output_idx] = uniforms.z_floor;
102
+ return;
103
+ }
104
+
105
+ let bucket = all_buckets[bucket_idx];
106
+
107
+ // Fixed downward ray from high above
108
+ let ray_origin = vec3<f32>(world_x, world_y, 1000.0);
109
+
110
+ // Track best (closest) hit
111
+ var best_z = uniforms.z_floor;
112
+
113
+ // Test triangles in this bucket with Y-bounds filtering
114
+ for (var i = 0u; i < bucket.count; i++) {
115
+ // Get triangle index from bucket's index array
116
+ let tri_idx = triangle_indices[bucket.start_index + i];
117
+
118
+ // Read Y-bounds first (cheaper than reading all vertices)
119
+ let base = tri_idx * 11u;
120
+ let y_min = rotated_triangles[base + 9u];
121
+ let y_max = rotated_triangles[base + 10u];
122
+
123
+ // Y-bounds check: skip triangles that don't overlap this ray's Y position
124
+ if (y_max < world_y - uniforms.tool_radius ||
125
+ y_min > world_y + uniforms.tool_radius) {
126
+ continue;
127
+ }
128
+
129
+ // Read rotated vertices
130
+ let v0 = vec3<f32>(
131
+ rotated_triangles[base],
132
+ rotated_triangles[base + 1u],
133
+ rotated_triangles[base + 2u]
134
+ );
135
+ let v1 = vec3<f32>(
136
+ rotated_triangles[base + 3u],
137
+ rotated_triangles[base + 4u],
138
+ rotated_triangles[base + 5u]
139
+ );
140
+ let v2 = vec3<f32>(
141
+ rotated_triangles[base + 6u],
142
+ rotated_triangles[base + 7u],
143
+ rotated_triangles[base + 8u]
144
+ );
145
+
146
+ let result = ray_triangle_intersect_downward(ray_origin, v0, v1, v2);
147
+ let hit = result.x;
148
+ let t = result.y;
149
+
150
+ if (hit > 0.5) {
151
+ // Calculate Z position of intersection
152
+ let hit_z = ray_origin.z - t;
153
+
154
+ // Keep highest (max Z) hit
155
+ if (hit_z > best_z) {
156
+ best_z = hit_z;
157
+ }
158
+ }
159
+ }
160
+
161
+ // Write to FULL-WIDTH output (no stitching needed!)
162
+ let output_idx = grid_y * uniforms.full_grid_width + grid_x;
163
+ output[output_idx] = best_z;
164
+ }
@@ -0,0 +1,70 @@
1
+ // Triangle rotation shader for radial rasterization V3
2
+ // Rotates all triangles in a bucket by a single angle and computes Y-bounds
3
+
4
+ struct Uniforms {
5
+ angle: f32, // Rotation angle in radians
6
+ num_triangles: u32, // Number of triangles to rotate
7
+ }
8
+
9
+ struct RotatedTriangle {
10
+ v0: vec3<f32>, // Rotated vertex 0
11
+ v1: vec3<f32>, // Rotated vertex 1
12
+ v2: vec3<f32>, // Rotated vertex 2
13
+ y_min: f32, // Minimum Y coordinate (for filtering)
14
+ y_max: f32, // Maximum Y coordinate (for filtering)
15
+ }
16
+
17
+ @group(0) @binding(0) var<storage, read> triangles: array<f32>; // Input: original triangles (flat array)
18
+ @group(0) @binding(1) var<storage, read_write> rotated: array<f32>; // Output: rotated triangles + bounds
19
+ @group(0) @binding(2) var<uniform> uniforms: Uniforms;
20
+
21
+ // Rotate a point around X-axis
22
+ fn rotate_around_x(p: vec3<f32>, angle: f32) -> vec3<f32> {
23
+ let cos_a = cos(angle);
24
+ let sin_a = sin(angle);
25
+
26
+ return vec3<f32>(
27
+ p.x,
28
+ p.y * cos_a - p.z * sin_a,
29
+ p.y * sin_a + p.z * cos_a
30
+ );
31
+ }
32
+
33
+ @compute @workgroup_size(64, 1, 1)
34
+ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
35
+ let tri_idx = global_id.x;
36
+
37
+ if (tri_idx >= uniforms.num_triangles) {
38
+ return;
39
+ }
40
+
41
+ // Read original triangle vertices
42
+ let base = tri_idx * 9u;
43
+ let v0 = vec3<f32>(triangles[base], triangles[base + 1u], triangles[base + 2u]);
44
+ let v1 = vec3<f32>(triangles[base + 3u], triangles[base + 4u], triangles[base + 5u]);
45
+ let v2 = vec3<f32>(triangles[base + 6u], triangles[base + 7u], triangles[base + 8u]);
46
+
47
+ // Rotate vertices around X-axis
48
+ let v0_rot = rotate_around_x(v0, uniforms.angle);
49
+ let v1_rot = rotate_around_x(v1, uniforms.angle);
50
+ let v2_rot = rotate_around_x(v2, uniforms.angle);
51
+
52
+ // Compute Y bounds for fast filtering during rasterization
53
+ let y_min = min(v0_rot.y, min(v1_rot.y, v2_rot.y));
54
+ let y_max = max(v0_rot.y, max(v1_rot.y, v2_rot.y));
55
+
56
+ // Write rotated triangle + bounds
57
+ // Layout: 11 floats per triangle: v0(3), v1(3), v2(3), y_min(1), y_max(1)
58
+ let out_base = tri_idx * 11u;
59
+ rotated[out_base] = v0_rot.x;
60
+ rotated[out_base + 1u] = v0_rot.y;
61
+ rotated[out_base + 2u] = v0_rot.z;
62
+ rotated[out_base + 3u] = v1_rot.x;
63
+ rotated[out_base + 4u] = v1_rot.y;
64
+ rotated[out_base + 5u] = v1_rot.z;
65
+ rotated[out_base + 6u] = v2_rot.x;
66
+ rotated[out_base + 7u] = v2_rot.y;
67
+ rotated[out_base + 8u] = v2_rot.z;
68
+ rotated[out_base + 9u] = y_min;
69
+ rotated[out_base + 10u] = y_max;
70
+ }
@@ -0,0 +1,95 @@
1
+ // Tracing toolpath generation
2
+ // Follows input polylines and calculates Z-depth at each sampled point
3
+ // Sentinel value for empty terrain cells (must match rasterize shader)
4
+ const EMPTY_CELL: f32 = -1e10;
5
+ const MAX_F32: f32 = 3.402823466e+38;
6
+
7
+ struct SparseToolPoint {
8
+ x_offset: i32,
9
+ y_offset: i32,
10
+ z_value: f32,
11
+ padding: f32,
12
+ }
13
+
14
+ struct Uniforms {
15
+ terrain_width: u32,
16
+ terrain_height: u32,
17
+ tool_count: u32,
18
+ point_count: u32, // Number of sampled points to process
19
+ path_index: u32, // Index of current path being processed
20
+ terrain_min_x: f32, // Terrain bounding box (world coordinates)
21
+ terrain_min_y: f32,
22
+ grid_step: f32, // Resolution of terrain rasterization
23
+ oob_z: f32, // Z value for out-of-bounds points (zFloor)
24
+ }
25
+
26
+ @group(0) @binding(0) var<storage, read> terrain_map: array<f32>;
27
+ @group(0) @binding(1) var<storage, read> sparse_tool: array<SparseToolPoint>;
28
+ @group(0) @binding(2) var<storage, read> input_points: array<f32>; // XY pairs
29
+ @group(0) @binding(3) var<storage, read_write> output_depths: array<f32>; // Z values
30
+ @group(0) @binding(4) var<storage, read_write> max_z_buffer: array<atomic<i32>>; // Max Z per path (as bits)
31
+ @group(0) @binding(5) var<uniform> uniforms: Uniforms;
32
+
33
+ @compute @workgroup_size(64, 1, 1)
34
+ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
35
+ let point_idx = global_id.x;
36
+
37
+ if (point_idx >= uniforms.point_count) {
38
+ return;
39
+ }
40
+
41
+ // Read input X,Y world coordinates
42
+ let world_x = input_points[point_idx * 2u + 0u];
43
+ let world_y = input_points[point_idx * 2u + 1u];
44
+
45
+ // Convert world coordinates to grid coordinates
46
+ let grid_x_f32 = (world_x - uniforms.terrain_min_x) / uniforms.grid_step;
47
+ let grid_y_f32 = (world_y - uniforms.terrain_min_y) / uniforms.grid_step;
48
+ let tool_center_x = i32(grid_x_f32);
49
+ let tool_center_y = i32(grid_y_f32);
50
+
51
+ // Check if tool center is outside terrain bounds
52
+ let center_oob = tool_center_x < 0 || tool_center_x >= i32(uniforms.terrain_width) ||
53
+ tool_center_y < 0 || tool_center_y >= i32(uniforms.terrain_height);
54
+
55
+ var max_collision_z = uniforms.oob_z;
56
+ var found_collision = false;
57
+
58
+ // Test each tool point for collision with terrain
59
+ for (var i = 0u; i < uniforms.tool_count; i++) {
60
+ let tool_point = sparse_tool[i];
61
+ let terrain_x = tool_center_x + tool_point.x_offset;
62
+ let terrain_y = tool_center_y + tool_point.y_offset;
63
+
64
+ // Bounds check: terrain sample must be within terrain grid
65
+ if (terrain_x < 0 || terrain_x >= i32(uniforms.terrain_width) ||
66
+ terrain_y < 0 || terrain_y >= i32(uniforms.terrain_height)) {
67
+ continue;
68
+ }
69
+
70
+ let terrain_idx = u32(terrain_y) * uniforms.terrain_width + u32(terrain_x);
71
+ let terrain_z = terrain_map[terrain_idx];
72
+
73
+ // Check if terrain cell has geometry (not empty sentinel value)
74
+ if (terrain_z > EMPTY_CELL + 1.0) {
75
+ // Tool z_value is positive offset from tip (tip=0, shaft=+50)
76
+ // Add to terrain height to find where tool center needs to be
77
+ let collision_z = terrain_z + tool_point.z_value;
78
+ max_collision_z = max(max_collision_z, collision_z);
79
+ found_collision = true;
80
+ }
81
+ }
82
+
83
+ // If no collision found and center was in-bounds, use oob_z
84
+ var output_z = uniforms.oob_z;
85
+ if (found_collision) {
86
+ output_z = max_collision_z;
87
+ }
88
+
89
+ output_depths[point_idx] = output_z;
90
+
91
+ // Update max Z for this path using atomic operation
92
+ // Convert float to int bits for atomic comparison
93
+ let z_bits = bitcast<i32>(output_z);
94
+ atomicMax(&max_z_buffer[uniforms.path_index], z_bits);
95
+ }