@gridspace/raster-path 1.0.5 → 1.0.7

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
@@ -221,8 +221,8 @@ export class RasterPath {
221
221
  throw new Error('RasterPath not initialized. Call init() first.');
222
222
  }
223
223
 
224
- if (this.mode === 'planar') {
225
- // Planar: rasterize and return
224
+ if (this.mode === 'planar' || this.mode === 'tracing') {
225
+ // Planar/Tracing: rasterize and return (tracing reuses planar terrain rasterization)
226
226
  const terrainData = await this.#rasterizePlanar({ triangles, zFloor, boundsOverride, isForTool: false, onProgress });
227
227
  this.terrainData = terrainData;
228
228
  return terrainData;
@@ -269,7 +269,7 @@ export class RasterPath {
269
269
  * @param {function} params.onProgress - Optional progress callback (progress: number, info?: string) => void
270
270
  * @returns {Promise<object>} Planar: {pathData, width, height} | Radial: {strips[], numStrips, totalPoints}
271
271
  */
272
- async generateToolpaths({ xStep, yStep, zFloor, onProgress }) {
272
+ async generateToolpaths({ xStep, yStep, zFloor, onProgress, paths, step }) {
273
273
  if (!this.isInitialized) {
274
274
  throw new Error('RasterPath not initialized. Call init() first.');
275
275
  }
@@ -278,7 +278,7 @@ export class RasterPath {
278
278
  throw new Error('Tool not loaded. Call loadTool() first.');
279
279
  }
280
280
 
281
- debug.log('gen.paths', { xStep, yStep, zFloor });
281
+ debug.log('gen.paths', { xStep, yStep, zFloor, paths: paths?.length, step });
282
282
 
283
283
  if (this.mode === 'planar') {
284
284
  if (!this.terrainData) {
@@ -292,7 +292,7 @@ export class RasterPath {
292
292
  zFloor,
293
293
  onProgress
294
294
  });
295
- } else {
295
+ } else if (this.mode === 'radial') {
296
296
  // Radial mode: use stored triangles
297
297
  if (!this.terrainTriangles) {
298
298
  throw new Error('Terrain not loaded. Call loadTerrain() first.');
@@ -306,14 +306,83 @@ export class RasterPath {
306
306
  zFloor: zFloor ?? this.terrainZFloor,
307
307
  onProgress
308
308
  });
309
+ } else if (this.mode === 'tracing') {
310
+ // Tracing mode: follow input paths
311
+ if (!this.terrainData) {
312
+ throw new Error('Terrain not loaded. Call loadTerrain() first.');
313
+ }
314
+ if (!paths || paths.length === 0) {
315
+ throw new Error('Tracing mode requires paths parameter (array of Float32Array XY coordinates)');
316
+ }
317
+ if (!step || step <= 0) {
318
+ throw new Error('Tracing mode requires step parameter (sampling resolution in world units)');
319
+ }
320
+ return this.#generateToolpathsTracing({
321
+ paths,
322
+ step,
323
+ zFloor,
324
+ onProgress
325
+ });
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Create reusable GPU buffers for tracing mode (optimization for iterative tracing)
331
+ * Call this after loadTerrain() and loadTool() to cache buffers across multiple trace calls
332
+ * @returns {Promise<void>}
333
+ */
334
+ async createTracingBuffers() {
335
+ if (this.mode !== 'tracing') {
336
+ throw new Error('createTracingBuffers() only available in tracing mode');
309
337
  }
338
+ if (!this.terrainData || !this.toolData) {
339
+ throw new Error('Must call loadTerrain() and loadTool() before createTracingBuffers()');
340
+ }
341
+
342
+ return new Promise((resolve, reject) => {
343
+ const handler = () => resolve();
344
+ this.#sendMessage(
345
+ 'create-tracing-buffers',
346
+ {
347
+ terrainPositions: this.terrainData.positions,
348
+ toolPositions: this.toolData.positions
349
+ },
350
+ 'tracing-buffers-created',
351
+ handler
352
+ );
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Destroy reusable tracing buffers
358
+ * @returns {Promise<void>}
359
+ */
360
+ async destroyTracingBuffers() {
361
+ if (this.mode !== 'tracing') {
362
+ return; // No-op for non-tracing modes
363
+ }
364
+
365
+ return new Promise((resolve, reject) => {
366
+ const handler = () => resolve();
367
+ this.#sendMessage(
368
+ 'destroy-tracing-buffers',
369
+ {},
370
+ 'tracing-buffers-destroyed',
371
+ handler
372
+ );
373
+ });
310
374
  }
311
375
 
312
376
  /**
313
377
  * Terminate worker and cleanup resources
314
378
  */
315
- terminate() {
379
+ async terminate() {
316
380
  if (this.worker) {
381
+ // Cleanup tracing buffers if in tracing mode
382
+ if (this.mode === 'tracing') {
383
+ await this.destroyTracingBuffers();
384
+ }
385
+
317
386
  this.worker.terminate();
318
387
  this.worker = null;
319
388
  this.isInitialized = false;
@@ -388,6 +457,46 @@ export class RasterPath {
388
457
  });
389
458
  }
390
459
 
460
+ async #generateToolpathsTracing({ paths, step, zFloor, onProgress }) {
461
+ return new Promise((resolve, reject) => {
462
+ // Set up progress handler if callback provided
463
+ if (onProgress) {
464
+ const progressHandler = (data) => {
465
+ onProgress(data.percent, { current: data.current, total: data.total, pathIndex: data.pathIndex });
466
+ };
467
+ this.messageHandlers.set('tracing-progress', progressHandler);
468
+ }
469
+
470
+ const handler = (data) => {
471
+ // Clean up progress handler
472
+ if (onProgress) {
473
+ this.messageHandlers.delete('tracing-progress');
474
+ }
475
+ resolve(data);
476
+ };
477
+
478
+ this.#sendMessage(
479
+ 'tracing-generate-toolpaths',
480
+ {
481
+ paths,
482
+ terrainPositions: this.terrainData.positions,
483
+ terrainData: {
484
+ width: this.terrainData.gridWidth,
485
+ height: this.terrainData.gridHeight,
486
+ bounds: this.terrainData.bounds
487
+ },
488
+ toolPositions: this.toolData.positions,
489
+ step,
490
+ gridStep: this.resolution,
491
+ terrainBounds: this.terrainData.bounds,
492
+ zFloor: zFloor ?? 0
493
+ },
494
+ 'tracing-toolpaths-complete',
495
+ handler
496
+ );
497
+ });
498
+ }
499
+
391
500
  async #generateToolpathsRadial({ triangles, bounds, toolData, xStep, yStep, zFloor, onProgress }) {
392
501
  const maxRadius = this.#calculateMaxRadius(triangles);
393
502
 
@@ -54,6 +54,7 @@ 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 { generateTracingToolpaths, createReusableTracingBuffers, destroyReusableTracingBuffers } from './path-tracing.js';
57
58
  import { calibrateGPU } from './workload-calibrate.js';
58
59
 
59
60
  // Global error handler for uncaught errors in worker
@@ -127,6 +128,46 @@ self.onmessage = async function(e) {
127
128
  }, toolpathTransferBuffers);
128
129
  break;
129
130
 
131
+ case 'tracing-generate-toolpaths':
132
+ const tracingResult = await generateTracingToolpaths({
133
+ paths: data.paths,
134
+ terrainPositions: data.terrainPositions,
135
+ terrainData: data.terrainData,
136
+ toolPositions: data.toolPositions,
137
+ step: data.step,
138
+ gridStep: data.gridStep,
139
+ terrainBounds: data.terrainBounds,
140
+ zFloor: data.zFloor,
141
+ onProgress: (progressData) => {
142
+ self.postMessage({
143
+ type: 'tracing-progress',
144
+ data: progressData.data
145
+ });
146
+ }
147
+ });
148
+ const tracingTransferBuffers = tracingResult.paths.map(p => p.buffer);
149
+ self.postMessage({
150
+ type: 'tracing-toolpaths-complete',
151
+ data: tracingResult
152
+ }, tracingTransferBuffers);
153
+ break;
154
+
155
+ case 'create-tracing-buffers':
156
+ createReusableTracingBuffers(data.terrainPositions, data.toolPositions);
157
+ self.postMessage({
158
+ type: 'tracing-buffers-created',
159
+ data: { success: true }
160
+ });
161
+ break;
162
+
163
+ case 'destroy-tracing-buffers':
164
+ destroyReusableTracingBuffers();
165
+ self.postMessage({
166
+ type: 'tracing-buffers-destroyed',
167
+ data: { success: true }
168
+ });
169
+ break;
170
+
130
171
  case 'calibrate':
131
172
  const calibrationResult = await calibrateGPU(device, data?.options || {});
132
173
  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,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
+ }