@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.
@@ -0,0 +1,2450 @@
1
+ // src/core/raster-config.js
2
+ var config = {};
3
+ var device = null;
4
+ var deviceCapabilities = null;
5
+ var isInitialized = false;
6
+ var cachedRasterizePipeline = null;
7
+ var cachedRasterizeShaderModule = null;
8
+ var cachedToolpathPipeline = null;
9
+ var cachedToolpathShaderModule = null;
10
+ var cachedRadialBatchPipeline = null;
11
+ var cachedRadialBatchShaderModule = null;
12
+ var EMPTY_CELL = -1e10;
13
+ var log_pre = "[Worker]";
14
+ var diagnostic = false;
15
+ var lastlog;
16
+ var debug = {
17
+ error: function() {
18
+ console.error(log_pre, ...arguments);
19
+ },
20
+ warn: function() {
21
+ console.warn(log_pre, ...arguments);
22
+ },
23
+ log: function() {
24
+ if (!config.quiet) {
25
+ let now = performance.now();
26
+ let since = (now - (lastlog ?? now) | 0).toString().padStart(4, " ");
27
+ console.log(log_pre, `[${since}]`, ...arguments);
28
+ lastlog = now;
29
+ }
30
+ },
31
+ ok: function() {
32
+ console.log(log_pre, "\u2705", ...arguments);
33
+ }
34
+ };
35
+ function round(v, d = 1) {
36
+ return parseFloat(v.toFixed(d));
37
+ }
38
+ var rasterizeShaderCode = `// Planar rasterization with spatial partitioning
39
+ // Sentinel value for empty cells (far below any real geometry)
40
+ const EMPTY_CELL: f32 = -1e10;
41
+
42
+ struct Uniforms {
43
+ bounds_min_x: f32,
44
+ bounds_min_y: f32,
45
+ bounds_min_z: f32,
46
+ bounds_max_x: f32,
47
+ bounds_max_y: f32,
48
+ bounds_max_z: f32,
49
+ step_size: f32,
50
+ grid_width: u32,
51
+ grid_height: u32,
52
+ triangle_count: u32,
53
+ filter_mode: u32, // 0 = UPWARD (terrain, keep highest), 1 = DOWNWARD (tool, keep lowest)
54
+ spatial_grid_width: u32,
55
+ spatial_grid_height: u32,
56
+ spatial_cell_size: f32,
57
+ }
58
+
59
+ @group(0) @binding(0) var<storage, read> triangles: array<f32>;
60
+ @group(0) @binding(1) var<storage, read_write> output_points: array<f32>;
61
+ @group(0) @binding(2) var<storage, read_write> valid_mask: array<u32>;
62
+ @group(0) @binding(3) var<uniform> uniforms: Uniforms;
63
+ @group(0) @binding(4) var<storage, read> spatial_cell_offsets: array<u32>;
64
+ @group(0) @binding(5) var<storage, read> spatial_triangle_indices: array<u32>;
65
+
66
+ // Fast 2D bounding box check for XY plane
67
+ fn ray_hits_triangle_bbox_2d(ray_x: f32, ray_y: f32, v0: vec3<f32>, v1: vec3<f32>, v2: vec3<f32>) -> bool {
68
+ // Add small epsilon to catch near-misses (mesh vertex gaps, FP rounding)
69
+ let epsilon = 0.001; // 1 micron tolerance
70
+ let min_x = min(min(v0.x, v1.x), v2.x) - epsilon;
71
+ let max_x = max(max(v0.x, v1.x), v2.x) + epsilon;
72
+ let min_y = min(min(v0.y, v1.y), v2.y) - epsilon;
73
+ let max_y = max(max(v0.y, v1.y), v2.y) + epsilon;
74
+
75
+ return ray_x >= min_x && ray_x <= max_x && ray_y >= min_y && ray_y <= max_y;
76
+ }
77
+
78
+ // Ray-triangle intersection using Möller-Trumbore algorithm
79
+ fn ray_triangle_intersect(
80
+ ray_origin: vec3<f32>,
81
+ ray_dir: vec3<f32>,
82
+ v0: vec3<f32>,
83
+ v1: vec3<f32>,
84
+ v2: vec3<f32>
85
+ ) -> vec2<f32> { // Returns (hit: 0.0 or 1.0, z: intersection_z)
86
+ // Larger epsilon needed because near-parallel triangles (small 'a') amplify errors via f=1/a
87
+ let EPSILON = 0.0001;
88
+
89
+ // Early rejection using 2D bounding box (very cheap!)
90
+ if (!ray_hits_triangle_bbox_2d(ray_origin.x, ray_origin.y, v0, v1, v2)) {
91
+ return vec2<f32>(0.0, 0.0);
92
+ }
93
+
94
+ // Calculate edges
95
+ let edge1 = v1 - v0;
96
+ let edge2 = v2 - v0;
97
+
98
+ // Cross product: ray_dir × edge2
99
+ let h = cross(ray_dir, edge2);
100
+
101
+ // Dot product: edge1 · h
102
+ let a = dot(edge1, h);
103
+
104
+ if (a > -EPSILON && a < EPSILON) {
105
+ return vec2<f32>(0.0, 0.0); // Ray parallel to triangle
106
+ }
107
+
108
+ let f = 1.0 / a;
109
+
110
+ // s = ray_origin - v0
111
+ let s = ray_origin - v0;
112
+
113
+ // u = f * (s · h)
114
+ let u = f * dot(s, h);
115
+
116
+ // Allow tolerance for edges/vertices to ensure watertight coverage
117
+ if (u < -EPSILON || u > 1.0 + EPSILON) {
118
+ return vec2<f32>(0.0, 0.0);
119
+ }
120
+
121
+ // Cross product: s × edge1
122
+ let q = cross(s, edge1);
123
+
124
+ // v = f * (ray_dir · q)
125
+ let v = f * dot(ray_dir, q);
126
+
127
+ // Allow tolerance for edges/vertices to ensure watertight coverage
128
+ if (v < -EPSILON || u + v > 1.0 + EPSILON) {
129
+ return vec2<f32>(0.0, 0.0);
130
+ }
131
+
132
+ // t = f * (edge2 · q)
133
+ let t = f * dot(edge2, q);
134
+
135
+ if (t > EPSILON) {
136
+ // Intersection found - calculate Z coordinate
137
+ let intersection_z = ray_origin.z + ray_dir.z * t;
138
+ return vec2<f32>(1.0, intersection_z);
139
+ }
140
+
141
+ return vec2<f32>(0.0, 0.0);
142
+ }
143
+
144
+ @compute @workgroup_size(16, 16)
145
+ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
146
+ let grid_x = global_id.x;
147
+ let grid_y = global_id.y;
148
+
149
+ if (grid_x >= uniforms.grid_width || grid_y >= uniforms.grid_height) {
150
+ return;
151
+ }
152
+
153
+ // Calculate world position for this grid point (center of cell)
154
+ let world_x = uniforms.bounds_min_x + f32(grid_x) * uniforms.step_size;
155
+ let world_y = uniforms.bounds_min_y + f32(grid_y) * uniforms.step_size;
156
+
157
+ // Initialize best_z based on filter mode
158
+ var best_z: f32;
159
+ if (uniforms.filter_mode == 0u) {
160
+ best_z = -1e10; // Terrain: keep highest Z
161
+ } else {
162
+ best_z = 1e10; // Tool: keep lowest Z
163
+ }
164
+
165
+ var found = false;
166
+
167
+ // Ray from below mesh pointing up (+Z direction)
168
+ let ray_origin = vec3<f32>(world_x, world_y, uniforms.bounds_min_z - 1.0);
169
+ let ray_dir = vec3<f32>(0.0, 0.0, 1.0);
170
+
171
+ // Find which spatial grid cell this ray belongs to
172
+ let spatial_cell_x = u32((world_x - uniforms.bounds_min_x) / uniforms.spatial_cell_size);
173
+ let spatial_cell_y = u32((world_y - uniforms.bounds_min_y) / uniforms.spatial_cell_size);
174
+
175
+ // Clamp to spatial grid bounds
176
+ let clamped_cx = min(spatial_cell_x, uniforms.spatial_grid_width - 1u);
177
+ let clamped_cy = min(spatial_cell_y, uniforms.spatial_grid_height - 1u);
178
+
179
+ let spatial_cell_idx = clamped_cy * uniforms.spatial_grid_width + clamped_cx;
180
+
181
+ // Get triangle range for this cell
182
+ let start_idx = spatial_cell_offsets[spatial_cell_idx];
183
+ let end_idx = spatial_cell_offsets[spatial_cell_idx + 1u];
184
+
185
+ // Test only triangles in this spatial cell
186
+ for (var idx = start_idx; idx < end_idx; idx++) {
187
+ let tri_idx = spatial_triangle_indices[idx];
188
+ let tri_base = tri_idx * 9u;
189
+
190
+ // Read triangle vertices (already in local space and rotated if needed)
191
+ let v0 = vec3<f32>(
192
+ triangles[tri_base],
193
+ triangles[tri_base + 1u],
194
+ triangles[tri_base + 2u]
195
+ );
196
+ let v1 = vec3<f32>(
197
+ triangles[tri_base + 3u],
198
+ triangles[tri_base + 4u],
199
+ triangles[tri_base + 5u]
200
+ );
201
+ let v2 = vec3<f32>(
202
+ triangles[tri_base + 6u],
203
+ triangles[tri_base + 7u],
204
+ triangles[tri_base + 8u]
205
+ );
206
+
207
+ let result = ray_triangle_intersect(ray_origin, ray_dir, v0, v1, v2);
208
+ let hit = result.x;
209
+ let intersection_z = result.y;
210
+
211
+ if (hit > 0.5) {
212
+ if (uniforms.filter_mode == 0u) {
213
+ // Terrain: keep highest
214
+ if (intersection_z > best_z) {
215
+ best_z = intersection_z;
216
+ found = true;
217
+ }
218
+ } else {
219
+ // Tool: keep lowest
220
+ if (intersection_z < best_z) {
221
+ best_z = intersection_z;
222
+ found = true;
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ // Write output based on filter mode
229
+ let output_idx = grid_y * uniforms.grid_width + grid_x;
230
+
231
+ if (uniforms.filter_mode == 0u) {
232
+ // Terrain: Dense output (Z-only, sentinel value for empty cells)
233
+ if (found) {
234
+ output_points[output_idx] = best_z;
235
+ } else {
236
+ output_points[output_idx] = EMPTY_CELL;
237
+ }
238
+ } else {
239
+ // Tool: Sparse output (X, Y, Z triplets with valid mask)
240
+ output_points[output_idx * 3u] = f32(grid_x);
241
+ output_points[output_idx * 3u + 1u] = f32(grid_y);
242
+ output_points[output_idx * 3u + 2u] = best_z;
243
+
244
+ if (found) {
245
+ valid_mask[output_idx] = 1u;
246
+ } else {
247
+ valid_mask[output_idx] = 0u;
248
+ }
249
+ }
250
+ }
251
+ `;
252
+ var toolpathShaderCode = `// Planar toolpath generation
253
+ // Sentinel value for empty terrain cells (must match rasterize shader)
254
+ const EMPTY_CELL: f32 = -1e10;
255
+ const MAX_F32: f32 = 3.402823466e+38;
256
+
257
+ struct SparseToolPoint {
258
+ x_offset: i32,
259
+ y_offset: i32,
260
+ z_value: f32,
261
+ padding: f32,
262
+ }
263
+
264
+ struct Uniforms {
265
+ terrain_width: u32,
266
+ terrain_height: u32,
267
+ tool_count: u32,
268
+ x_step: u32,
269
+ y_step: u32,
270
+ oob_z: f32,
271
+ points_per_line: u32,
272
+ num_scanlines: u32,
273
+ y_offset: u32, // Offset to center Y position (for single-scanline radial mode)
274
+ }
275
+
276
+ @group(0) @binding(0) var<storage, read> terrain_map: array<f32>;
277
+ @group(0) @binding(1) var<storage, read> sparse_tool: array<SparseToolPoint>;
278
+ @group(0) @binding(2) var<storage, read_write> output_path: array<f32>;
279
+ @group(0) @binding(3) var<uniform> uniforms: Uniforms;
280
+
281
+ @compute @workgroup_size(16, 16)
282
+ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
283
+ let scanline = global_id.y;
284
+ let point_idx = global_id.x;
285
+
286
+ if (scanline >= uniforms.num_scanlines || point_idx >= uniforms.points_per_line) {
287
+ return;
288
+ }
289
+
290
+ let tool_center_x = i32(point_idx * uniforms.x_step);
291
+ let tool_center_y = i32(scanline * uniforms.y_step) + i32(uniforms.y_offset);
292
+
293
+ // var max_collision_z = -MAX_F32; // Track maximum collision height
294
+ var max_collision_z = uniforms.oob_z; // Track maximum collision height
295
+ var found_collision = false;
296
+
297
+ for (var i = 0u; i < uniforms.tool_count; i++) {
298
+ let tool_point = sparse_tool[i];
299
+ let terrain_x = tool_center_x + tool_point.x_offset;
300
+ let terrain_y = tool_center_y + tool_point.y_offset;
301
+
302
+ // Bounds check: X must always be in bounds
303
+ if (terrain_x < 0 || terrain_x >= i32(uniforms.terrain_width)) {
304
+ continue;
305
+ }
306
+
307
+ // Bounds check: Y must be within terrain strip bounds
308
+ // For single-scanline OUTPUT mode, the tool center is at Y=0 but the terrain
309
+ // strip contains the full Y range (tool width), so tool offsets access different Y rows
310
+ if (terrain_y < 0 || terrain_y >= i32(uniforms.terrain_height)) {
311
+ continue;
312
+ }
313
+
314
+ let terrain_idx = u32(terrain_y) * uniforms.terrain_width + u32(terrain_x);
315
+ let terrain_z = terrain_map[terrain_idx];
316
+
317
+ // Check if terrain cell has geometry (not empty sentinel value)
318
+ if (terrain_z > EMPTY_CELL + 1.0) {
319
+ // Tool z_value is positive offset from tip (tip=0, shaft=+50)
320
+ // Subtract from terrain to find where tool center needs to be
321
+ let collision_z = terrain_z + tool_point.z_value;
322
+ max_collision_z = max(max_collision_z, collision_z);
323
+ found_collision = true;
324
+ }
325
+ }
326
+
327
+ var output_z = uniforms.oob_z;
328
+ if (found_collision) {
329
+ output_z = max_collision_z;
330
+ }
331
+
332
+ let output_idx = scanline * uniforms.points_per_line + point_idx;
333
+ output_path[output_idx] = output_z;
334
+ }
335
+ `;
336
+ var radialRasterizeShaderCode = `// Radial V2 rasterization with X-bucketing and rotating ray planes
337
+ // Sentinel value for empty cells (far below any real geometry)
338
+ const EMPTY_CELL: f32 = -1e10;
339
+ const PI: f32 = 3.14159265359;
340
+
341
+ struct Uniforms {
342
+ resolution: f32, // Grid step size (mm)
343
+ angle_step: f32, // Radians between angles
344
+ num_angles: u32, // Total number of angular strips
345
+ max_radius: f32, // Ray origin distance from X-axis (maxHypot * 1.01)
346
+ tool_width: f32, // Tool width in mm
347
+ grid_y_height: u32, // Tool width in pixels (toolWidth / resolution)
348
+ bucket_width: f32, // Width of each X-bucket (mm)
349
+ bucket_grid_width: u32, // Bucket width in pixels
350
+ global_min_x: f32, // Global minimum X coordinate
351
+ z_floor: f32, // Z value for empty cells
352
+ filter_mode: u32, // 0 = max Z (terrain), 1 = min Z (tool)
353
+ num_buckets: u32, // Total number of X-buckets
354
+ start_angle: f32, // Starting angle offset in radians (for batching)
355
+ bucket_offset: u32, // Offset for bucket batching (bucket_idx in batch writes to bucket_offset + bucket_idx in output)
356
+ }
357
+
358
+ struct BucketInfo {
359
+ min_x: f32, // Bucket X range start (mm)
360
+ max_x: f32, // Bucket X range end (mm)
361
+ start_index: u32, // Index into triangle_indices array
362
+ count: u32 // Number of triangles in this bucket
363
+ }
364
+
365
+ @group(0) @binding(0) var<storage, read> triangles: array<f32>;
366
+ @group(0) @binding(1) var<storage, read_write> output: array<f32>;
367
+ @group(0) @binding(2) var<uniform> uniforms: Uniforms;
368
+ @group(0) @binding(3) var<storage, read> bucket_info: array<BucketInfo>;
369
+ @group(0) @binding(4) var<storage, read> triangle_indices: array<u32>;
370
+
371
+ // Note: AABB early rejection removed - X-bucketing already provides spatial filtering
372
+ // A proper ray-AABB intersection test would be needed if we wanted bounding box culling,
373
+ // but checking if ray_origin is inside AABB was incorrect and rejected valid triangles
374
+
375
+ // Ray-triangle intersection using Möller-Trumbore algorithm
376
+ fn ray_triangle_intersect(
377
+ ray_origin: vec3<f32>,
378
+ ray_dir: vec3<f32>,
379
+ v0: vec3<f32>,
380
+ v1: vec3<f32>,
381
+ v2: vec3<f32>
382
+ ) -> vec2<f32> { // Returns (hit: 0.0 or 1.0, z: intersection_z)
383
+ let EPSILON = 0.0001;
384
+
385
+ // Calculate edges
386
+ let edge1 = v1 - v0;
387
+ let edge2 = v2 - v0;
388
+
389
+ // Cross product: ray_dir × edge2
390
+ let h = cross(ray_dir, edge2);
391
+
392
+ // Dot product: edge1 · h
393
+ let a = dot(edge1, h);
394
+
395
+ if (a > -EPSILON && a < EPSILON) {
396
+ return vec2<f32>(0.0, 0.0); // Ray parallel to triangle
397
+ }
398
+
399
+ let f = 1.0 / a;
400
+
401
+ // s = ray_origin - v0
402
+ let s = ray_origin - v0;
403
+
404
+ // u = f * (s · h)
405
+ let u = f * dot(s, h);
406
+
407
+ if (u < -EPSILON || u > 1.0 + EPSILON) {
408
+ return vec2<f32>(0.0, 0.0);
409
+ }
410
+
411
+ // Cross product: s × edge1
412
+ let q = cross(s, edge1);
413
+
414
+ // v = f * (ray_dir · q)
415
+ let v = f * dot(ray_dir, q);
416
+
417
+ if (v < -EPSILON || u + v > 1.0 + EPSILON) {
418
+ return vec2<f32>(0.0, 0.0);
419
+ }
420
+
421
+ // t = f * (edge2 · q)
422
+ let t = f * dot(edge2, q);
423
+
424
+ if (t > EPSILON) {
425
+ // Intersection found - return distance along ray (t parameter)
426
+ return vec2<f32>(1.0, t);
427
+ }
428
+
429
+ return vec2<f32>(0.0, 0.0);
430
+ }
431
+
432
+ @compute @workgroup_size(8, 8, 1)
433
+ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
434
+ let bucket_idx = global_id.z;
435
+ let grid_y = global_id.y;
436
+ let angle_idx = global_id.x;
437
+
438
+ // Bounds check
439
+ if (angle_idx >= uniforms.num_angles ||
440
+ grid_y >= uniforms.grid_y_height ||
441
+ bucket_idx >= uniforms.num_buckets) {
442
+ return;
443
+ }
444
+
445
+ let bucket = bucket_info[bucket_idx];
446
+ let angle = uniforms.start_angle + (f32(angle_idx) * uniforms.angle_step);
447
+
448
+ // Calculate bucket min grid X
449
+ let bucket_min_grid_x = u32((bucket.min_x - uniforms.global_min_x) / uniforms.resolution);
450
+
451
+ // Loop over X within this bucket
452
+ for (var local_x = 0u; local_x < uniforms.bucket_grid_width; local_x++) {
453
+ let grid_x = bucket_min_grid_x + local_x;
454
+ let world_x = uniforms.global_min_x + f32(grid_x) * uniforms.resolution;
455
+
456
+ // Rotating top-down scan: normal XY planar scan, rotated around X-axis
457
+ // Step 1: Define scan position in planar frame (X, Y, Z_above)
458
+ let scan_x = world_x;
459
+ let scan_y = f32(grid_y) * uniforms.resolution - uniforms.tool_width / 2.0;
460
+ let scan_z = uniforms.max_radius; // Start above the model
461
+
462
+ // Step 2: Rotate position (scan_x, scan_y, scan_z) around X-axis by 'angle'
463
+ // X stays the same, rotate YZ plane: y' = y*cos - z*sin, z' = y*sin + z*cos
464
+ // NOTE: This uses right-handed rotation (positive angle rotates +Y towards +Z)
465
+ // To reverse rotation direction (left-handed or opposite), flip signs:
466
+ // y' = y*cos + z*sin (flip sign on z term)
467
+ // z' = -y*sin + z*cos (flip sign on y term)
468
+ let ray_origin_x = scan_x;
469
+ let ray_origin_y = scan_y * cos(angle) - scan_z * sin(angle);
470
+ let ray_origin_z = scan_y * sin(angle) + scan_z * cos(angle);
471
+ let ray_origin = vec3<f32>(ray_origin_x, ray_origin_y, ray_origin_z);
472
+
473
+ // Step 3: Rotate ray direction (0, 0, -1) around X-axis by 'angle'
474
+ // X component stays 0, rotate YZ: dy = 0*cos - (-1)*sin = sin, dz = 0*sin + (-1)*cos = -cos
475
+ // NOTE: For reversed rotation, use: vec3<f32>(0.0, -sin(angle), -cos(angle))
476
+ let ray_dir = vec3<f32>(0.0, sin(angle), -cos(angle));
477
+
478
+ // Initialize best distance (closest hit)
479
+ var best_t: f32 = 1e10; // Start with very large distance
480
+ var found = false;
481
+
482
+ // Ray-cast against triangles in this bucket
483
+ for (var i = 0u; i < bucket.count; i++) {
484
+ let tri_idx = triangle_indices[bucket.start_index + i];
485
+ let tri_base = tri_idx * 9u;
486
+
487
+ // Read triangle vertices
488
+ let v0 = vec3<f32>(
489
+ triangles[tri_base],
490
+ triangles[tri_base + 1u],
491
+ triangles[tri_base + 2u]
492
+ );
493
+ let v1 = vec3<f32>(
494
+ triangles[tri_base + 3u],
495
+ triangles[tri_base + 4u],
496
+ triangles[tri_base + 5u]
497
+ );
498
+ let v2 = vec3<f32>(
499
+ triangles[tri_base + 6u],
500
+ triangles[tri_base + 7u],
501
+ triangles[tri_base + 8u]
502
+ );
503
+
504
+ let result = ray_triangle_intersect(ray_origin, ray_dir, v0, v1, v2);
505
+ let hit = result.x;
506
+ let t = result.y; // Distance along ray
507
+
508
+ if (hit > 0.5) {
509
+ // Keep closest hit (minimum t)
510
+ if (t < best_t) {
511
+ best_t = t;
512
+ found = true;
513
+ }
514
+ }
515
+ }
516
+
517
+ // Write output
518
+ // Layout: (bucket_offset + bucket_idx) * numAngles * bucketWidth * gridHeight
519
+ // + angle_idx * bucketWidth * gridHeight
520
+ // + grid_y * bucketWidth
521
+ // + local_x
522
+ let output_idx = (uniforms.bucket_offset + bucket_idx) * uniforms.num_angles * uniforms.bucket_grid_width * uniforms.grid_y_height
523
+ + angle_idx * uniforms.bucket_grid_width * uniforms.grid_y_height
524
+ + grid_y * uniforms.bucket_grid_width
525
+ + local_x;
526
+
527
+ if (found) {
528
+ // Terrain height = distance from scan origin minus ray travel distance
529
+ // Ray started at max_radius from X-axis, traveled best_t distance to hit
530
+ let terrain_height = uniforms.max_radius - best_t;
531
+ output[output_idx] = terrain_height;
532
+ } else {
533
+ output[output_idx] = uniforms.z_floor;
534
+ }
535
+ }
536
+ }
537
+ `;
538
+ async function initWebGPU() {
539
+ if (isInitialized)
540
+ return true;
541
+ if (!navigator.gpu) {
542
+ debug.warn("WebGPU not supported");
543
+ return false;
544
+ }
545
+ try {
546
+ const adapter = await navigator.gpu.requestAdapter();
547
+ if (!adapter) {
548
+ debug.warn("WebGPU adapter not available");
549
+ return false;
550
+ }
551
+ const adapterLimits = adapter.limits;
552
+ debug.log("Adapter limits:", {
553
+ maxStorageBufferBindingSize: adapterLimits.maxStorageBufferBindingSize,
554
+ maxBufferSize: adapterLimits.maxBufferSize
555
+ });
556
+ device = await adapter.requestDevice({
557
+ requiredLimits: {
558
+ maxStorageBufferBindingSize: Math.min(
559
+ adapterLimits.maxStorageBufferBindingSize,
560
+ 1024 * 1024 * 1024
561
+ // Request up to 1GB
562
+ ),
563
+ maxBufferSize: Math.min(
564
+ adapterLimits.maxBufferSize,
565
+ 1024 * 1024 * 1024
566
+ // Request up to 1GB
567
+ )
568
+ }
569
+ });
570
+ cachedRasterizeShaderModule = device.createShaderModule({ code: rasterizeShaderCode });
571
+ cachedRasterizePipeline = device.createComputePipeline({
572
+ layout: "auto",
573
+ compute: { module: cachedRasterizeShaderModule, entryPoint: "main" }
574
+ });
575
+ cachedToolpathShaderModule = device.createShaderModule({ code: toolpathShaderCode });
576
+ cachedToolpathPipeline = device.createComputePipeline({
577
+ layout: "auto",
578
+ compute: { module: cachedToolpathShaderModule, entryPoint: "main" }
579
+ });
580
+ cachedRadialBatchShaderModule = device.createShaderModule({ code: radialRasterizeShaderCode });
581
+ cachedRadialBatchPipeline = device.createComputePipeline({
582
+ layout: "auto",
583
+ compute: { module: cachedRadialBatchShaderModule, entryPoint: "main" }
584
+ });
585
+ deviceCapabilities = {
586
+ maxStorageBufferBindingSize: device.limits.maxStorageBufferBindingSize,
587
+ maxBufferSize: device.limits.maxBufferSize,
588
+ maxComputeWorkgroupSizeX: device.limits.maxComputeWorkgroupSizeX,
589
+ maxComputeWorkgroupSizeY: device.limits.maxComputeWorkgroupSizeY
590
+ };
591
+ isInitialized = true;
592
+ debug.log("Initialized (pipelines cached)");
593
+ return true;
594
+ } catch (error) {
595
+ debug.error("Failed to initialize:", error);
596
+ return false;
597
+ }
598
+ }
599
+ function setConfig(newConfig) {
600
+ config = newConfig;
601
+ }
602
+ function updateConfig(updates) {
603
+ Object.assign(config, updates);
604
+ }
605
+
606
+ // src/core/raster-planar.js
607
+ function calculateBounds(triangles) {
608
+ let min_x = Infinity, min_y = Infinity, min_z = Infinity;
609
+ let max_x = -Infinity, max_y = -Infinity, max_z = -Infinity;
610
+ for (let i = 0; i < triangles.length; i += 3) {
611
+ const x = triangles[i];
612
+ const y = triangles[i + 1];
613
+ const z = triangles[i + 2];
614
+ if (x < min_x)
615
+ min_x = x;
616
+ if (y < min_y)
617
+ min_y = y;
618
+ if (z < min_z)
619
+ min_z = z;
620
+ if (x > max_x)
621
+ max_x = x;
622
+ if (y > max_y)
623
+ max_y = y;
624
+ if (z > max_z)
625
+ max_z = z;
626
+ }
627
+ return {
628
+ min: { x: min_x, y: min_y, z: min_z },
629
+ max: { x: max_x, y: max_y, z: max_z }
630
+ };
631
+ }
632
+ function buildSpatialGrid(triangles, bounds, cellSize = 5) {
633
+ const gridWidth = Math.max(1, Math.ceil((bounds.max.x - bounds.min.x) / cellSize));
634
+ const gridHeight = Math.max(1, Math.ceil((bounds.max.y - bounds.min.y) / cellSize));
635
+ const totalCells = gridWidth * gridHeight;
636
+ const grid = new Array(totalCells);
637
+ for (let i = 0; i < totalCells; i++) {
638
+ grid[i] = [];
639
+ }
640
+ const triangleCount = triangles.length / 9;
641
+ for (let t = 0; t < triangleCount; t++) {
642
+ const base = t * 9;
643
+ const v0x = triangles[base], v0y = triangles[base + 1];
644
+ const v1x = triangles[base + 3], v1y = triangles[base + 4];
645
+ const v2x = triangles[base + 6], v2y = triangles[base + 7];
646
+ const epsilon = cellSize * 0.01;
647
+ const minX = Math.min(v0x, v1x, v2x) - epsilon;
648
+ const maxX = Math.max(v0x, v1x, v2x) + epsilon;
649
+ const minY = Math.min(v0y, v1y, v2y) - epsilon;
650
+ const maxY = Math.max(v0y, v1y, v2y) + epsilon;
651
+ let minCellX = Math.floor((minX - bounds.min.x) / cellSize);
652
+ let maxCellX = Math.floor((maxX - bounds.min.x) / cellSize);
653
+ let minCellY = Math.floor((minY - bounds.min.y) / cellSize);
654
+ let maxCellY = Math.floor((maxY - bounds.min.y) / cellSize);
655
+ minCellX = Math.max(0, Math.min(gridWidth - 1, minCellX));
656
+ maxCellX = Math.max(0, Math.min(gridWidth - 1, maxCellX));
657
+ minCellY = Math.max(0, Math.min(gridHeight - 1, minCellY));
658
+ maxCellY = Math.max(0, Math.min(gridHeight - 1, maxCellY));
659
+ for (let cy = minCellY; cy <= maxCellY; cy++) {
660
+ for (let cx = minCellX; cx <= maxCellX; cx++) {
661
+ const cellIdx = cy * gridWidth + cx;
662
+ grid[cellIdx].push(t);
663
+ }
664
+ }
665
+ }
666
+ let totalTriangleRefs = 0;
667
+ for (let i = 0; i < totalCells; i++) {
668
+ totalTriangleRefs += grid[i].length;
669
+ }
670
+ const cellOffsets = new Uint32Array(totalCells + 1);
671
+ const triangleIndices = new Uint32Array(totalTriangleRefs);
672
+ let currentOffset = 0;
673
+ for (let i = 0; i < totalCells; i++) {
674
+ cellOffsets[i] = currentOffset;
675
+ for (let j = 0; j < grid[i].length; j++) {
676
+ triangleIndices[currentOffset++] = grid[i][j];
677
+ }
678
+ }
679
+ cellOffsets[totalCells] = currentOffset;
680
+ const avgPerCell = totalTriangleRefs / totalCells;
681
+ const toolWidth = bounds.max.x - bounds.min.x;
682
+ const toolHeight = bounds.max.y - bounds.min.y;
683
+ const toolDiameter = Math.max(toolWidth, toolHeight);
684
+ debug.log(`Spatial grid: ${gridWidth}x${gridHeight} ${totalTriangleRefs} tri-refs ~${avgPerCell.toFixed(0)}/${cellSize}mm cell (tool: ${toolDiameter.toFixed(2)}mm)`);
685
+ return {
686
+ gridWidth,
687
+ gridHeight,
688
+ cellSize,
689
+ cellOffsets,
690
+ triangleIndices,
691
+ avgTrianglesPerCell: avgPerCell
692
+ };
693
+ }
694
+ async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}) {
695
+ const startTime = performance.now();
696
+ if (!isInitialized) {
697
+ const initStart = performance.now();
698
+ const success = await initWebGPU();
699
+ if (!success) {
700
+ throw new Error("WebGPU not available");
701
+ }
702
+ const initEnd = performance.now();
703
+ debug.log(`First-time init: ${(initEnd - initStart).toFixed(1)}ms`);
704
+ }
705
+ const boundsOverride = options.bounds || options.min ? options : null;
706
+ const bounds = boundsOverride || calculateBounds(triangles);
707
+ if (boundsOverride) {
708
+ if (bounds.min.x >= bounds.max.x || bounds.min.y >= bounds.max.y || bounds.min.z >= bounds.max.z) {
709
+ 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})`);
710
+ }
711
+ }
712
+ const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
713
+ const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
714
+ const totalGridPoints = gridWidth * gridHeight;
715
+ const floatsPerPoint = filterMode === 0 ? 1 : 3;
716
+ const outputSize = totalGridPoints * floatsPerPoint * 4;
717
+ const maxBufferSize = device.limits.maxBufferSize || 268435456;
718
+ if (outputSize > maxBufferSize) {
719
+ 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.`);
720
+ }
721
+ console.time(`${log_pre} Build Spatial Grid`);
722
+ const spatialGrid = buildSpatialGrid(triangles, bounds);
723
+ console.timeEnd(`${log_pre} Build Spatial Grid`);
724
+ const triangleBuffer = device.createBuffer({
725
+ size: triangles.byteLength,
726
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
727
+ });
728
+ device.queue.writeBuffer(triangleBuffer, 0, triangles);
729
+ const outputBuffer = device.createBuffer({
730
+ size: outputSize,
731
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
732
+ });
733
+ if (filterMode === 0) {
734
+ const initData = new Float32Array(totalGridPoints);
735
+ initData.fill(EMPTY_CELL);
736
+ device.queue.writeBuffer(outputBuffer, 0, initData);
737
+ }
738
+ const validMaskBuffer = device.createBuffer({
739
+ size: totalGridPoints * 4,
740
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
741
+ });
742
+ const spatialCellOffsetsBuffer = device.createBuffer({
743
+ size: spatialGrid.cellOffsets.byteLength,
744
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
745
+ });
746
+ device.queue.writeBuffer(spatialCellOffsetsBuffer, 0, spatialGrid.cellOffsets);
747
+ const spatialTriangleIndicesBuffer = device.createBuffer({
748
+ size: spatialGrid.triangleIndices.byteLength,
749
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
750
+ });
751
+ device.queue.writeBuffer(spatialTriangleIndicesBuffer, 0, spatialGrid.triangleIndices);
752
+ const uniformData = new Float32Array([
753
+ bounds.min.x,
754
+ bounds.min.y,
755
+ bounds.min.z,
756
+ bounds.max.x,
757
+ bounds.max.y,
758
+ bounds.max.z,
759
+ stepSize,
760
+ 0,
761
+ 0,
762
+ 0,
763
+ 0,
764
+ 0,
765
+ 0,
766
+ 0
767
+ // Padding for alignment
768
+ ]);
769
+ const uniformDataU32 = new Uint32Array(uniformData.buffer);
770
+ uniformDataU32[7] = gridWidth;
771
+ uniformDataU32[8] = gridHeight;
772
+ uniformDataU32[9] = triangles.length / 9;
773
+ uniformDataU32[10] = filterMode;
774
+ uniformDataU32[11] = spatialGrid.gridWidth;
775
+ uniformDataU32[12] = spatialGrid.gridHeight;
776
+ const uniformDataF32 = new Float32Array(uniformData.buffer);
777
+ uniformDataF32[13] = spatialGrid.cellSize;
778
+ const maxU32 = 4294967295;
779
+ if (gridWidth > maxU32 || gridHeight > maxU32) {
780
+ throw new Error(`Grid dimensions exceed u32 max: ${gridWidth}x${gridHeight}`);
781
+ }
782
+ const uniformBuffer = device.createBuffer({
783
+ size: uniformData.byteLength,
784
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
785
+ });
786
+ device.queue.writeBuffer(uniformBuffer, 0, uniformData);
787
+ await device.queue.onSubmittedWorkDone();
788
+ const bindGroup = device.createBindGroup({
789
+ layout: cachedRasterizePipeline.getBindGroupLayout(0),
790
+ entries: [
791
+ { binding: 0, resource: { buffer: triangleBuffer } },
792
+ { binding: 1, resource: { buffer: outputBuffer } },
793
+ { binding: 2, resource: { buffer: validMaskBuffer } },
794
+ { binding: 3, resource: { buffer: uniformBuffer } },
795
+ { binding: 4, resource: { buffer: spatialCellOffsetsBuffer } },
796
+ { binding: 5, resource: { buffer: spatialTriangleIndicesBuffer } }
797
+ ]
798
+ });
799
+ const commandEncoder = device.createCommandEncoder();
800
+ const passEncoder = commandEncoder.beginComputePass();
801
+ passEncoder.setPipeline(cachedRasterizePipeline);
802
+ passEncoder.setBindGroup(0, bindGroup);
803
+ const workgroupsX = Math.ceil(gridWidth / 16);
804
+ const workgroupsY = Math.ceil(gridHeight / 16);
805
+ const maxWorkgroupsPerDim = device.limits.maxComputeWorkgroupsPerDimension || 65535;
806
+ if (workgroupsX > maxWorkgroupsPerDim || workgroupsY > maxWorkgroupsPerDim) {
807
+ throw new Error(`Workgroup dispatch too large: ${workgroupsX}x${workgroupsY} exceeds limit of ${maxWorkgroupsPerDim}. Try a larger step size.`);
808
+ }
809
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
810
+ passEncoder.end();
811
+ const stagingOutputBuffer = device.createBuffer({
812
+ size: outputSize,
813
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
814
+ });
815
+ const stagingValidMaskBuffer = device.createBuffer({
816
+ size: totalGridPoints * 4,
817
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
818
+ });
819
+ commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingOutputBuffer, 0, outputSize);
820
+ commandEncoder.copyBufferToBuffer(validMaskBuffer, 0, stagingValidMaskBuffer, 0, totalGridPoints * 4);
821
+ device.queue.submit([commandEncoder.finish()]);
822
+ await device.queue.onSubmittedWorkDone();
823
+ await stagingOutputBuffer.mapAsync(GPUMapMode.READ);
824
+ await stagingValidMaskBuffer.mapAsync(GPUMapMode.READ);
825
+ const outputData = new Float32Array(stagingOutputBuffer.getMappedRange());
826
+ const validMaskData = new Uint32Array(stagingValidMaskBuffer.getMappedRange());
827
+ let result, pointCount;
828
+ if (filterMode === 0) {
829
+ result = new Float32Array(outputData);
830
+ pointCount = totalGridPoints;
831
+ if (config.debug) {
832
+ let zeroCount = 0;
833
+ let validCount = 0;
834
+ for (let i = 0; i < totalGridPoints; i++) {
835
+ if (result[i] > EMPTY_CELL + 1)
836
+ validCount++;
837
+ if (result[i] === 0)
838
+ zeroCount++;
839
+ }
840
+ let percentHit = validCount / totalGridPoints;
841
+ if (zeroCount > 0 || percentHit < 0.5) {
842
+ debug.log(totalGridPoints, "cells,", round(percentHit * 100), "% coverage,", zeroCount, "zeros");
843
+ }
844
+ }
845
+ } else {
846
+ const validPoints = [];
847
+ for (let i = 0; i < totalGridPoints; i++) {
848
+ if (validMaskData[i] === 1) {
849
+ validPoints.push(
850
+ outputData[i * 3],
851
+ outputData[i * 3 + 1],
852
+ outputData[i * 3 + 2]
853
+ );
854
+ }
855
+ }
856
+ result = new Float32Array(validPoints);
857
+ pointCount = validPoints.length / 3;
858
+ }
859
+ stagingOutputBuffer.unmap();
860
+ stagingValidMaskBuffer.unmap();
861
+ triangleBuffer.destroy();
862
+ outputBuffer.destroy();
863
+ validMaskBuffer.destroy();
864
+ uniformBuffer.destroy();
865
+ spatialCellOffsetsBuffer.destroy();
866
+ spatialTriangleIndicesBuffer.destroy();
867
+ stagingOutputBuffer.destroy();
868
+ stagingValidMaskBuffer.destroy();
869
+ const endTime = performance.now();
870
+ const conversionTime = endTime - startTime;
871
+ if (filterMode === 0) {
872
+ if (result.length > 0) {
873
+ const firstZ = result[0] <= EMPTY_CELL + 1 ? "EMPTY" : result[0].toFixed(3);
874
+ const lastZ = result[result.length - 1] <= EMPTY_CELL + 1 ? "EMPTY" : result[result.length - 1].toFixed(3);
875
+ }
876
+ } else {
877
+ if (result.length > 0) {
878
+ const firstPoint = `(${result[0].toFixed(3)}, ${result[1].toFixed(3)}, ${result[2].toFixed(3)})`;
879
+ const lastIdx = result.length - 3;
880
+ const lastPoint = `(${result[lastIdx].toFixed(3)}, ${result[lastIdx + 1].toFixed(3)}, ${result[lastIdx + 2].toFixed(3)})`;
881
+ }
882
+ }
883
+ return {
884
+ positions: result,
885
+ pointCount,
886
+ bounds,
887
+ conversionTime,
888
+ gridWidth,
889
+ gridHeight,
890
+ isDense: filterMode === 0
891
+ // True for terrain (dense), false for tool (sparse)
892
+ };
893
+ }
894
+ function createTiles(bounds, stepSize, maxMemoryBytes) {
895
+ const width = bounds.max.x - bounds.min.x;
896
+ const height = bounds.max.y - bounds.min.y;
897
+ const aspectRatio = width / height;
898
+ const bytesPerPoint = 1 * 4;
899
+ const maxPointsPerTile = Math.floor(maxMemoryBytes / bytesPerPoint);
900
+ debug.log(`Dense terrain format: ${bytesPerPoint} bytes/point (was 16), can fit ${(maxPointsPerTile / 1e6).toFixed(1)}M points per tile`);
901
+ let tileGridW, tileGridH;
902
+ if (aspectRatio >= 1) {
903
+ tileGridH = Math.floor(Math.sqrt(maxPointsPerTile / aspectRatio));
904
+ tileGridW = Math.floor(tileGridH * aspectRatio);
905
+ } else {
906
+ tileGridW = Math.floor(Math.sqrt(maxPointsPerTile * aspectRatio));
907
+ tileGridH = Math.floor(tileGridW / aspectRatio);
908
+ }
909
+ while (tileGridW * tileGridH * bytesPerPoint > maxMemoryBytes) {
910
+ if (tileGridW > tileGridH) {
911
+ tileGridW--;
912
+ } else {
913
+ tileGridH--;
914
+ }
915
+ }
916
+ const tileWidth = tileGridW * stepSize;
917
+ const tileHeight = tileGridH * stepSize;
918
+ const tilesX = Math.ceil(width / tileWidth);
919
+ const tilesY = Math.ceil(height / tileHeight);
920
+ const actualTileWidth = width / tilesX;
921
+ const actualTileHeight = height / tilesY;
922
+ debug.log(`Creating ${tilesX}x${tilesY} = ${tilesX * tilesY} tiles (${actualTileWidth.toFixed(2)}mm \xD7 ${actualTileHeight.toFixed(2)}mm each)`);
923
+ debug.log(`Tile grid: ${Math.ceil(actualTileWidth / stepSize)}x${Math.ceil(actualTileHeight / stepSize)} points per tile`);
924
+ const tiles = [];
925
+ const overlap = stepSize * 2;
926
+ for (let ty = 0; ty < tilesY; ty++) {
927
+ for (let tx = 0; tx < tilesX; tx++) {
928
+ let tileMinX = bounds.min.x + tx * actualTileWidth;
929
+ let tileMinY = bounds.min.y + ty * actualTileHeight;
930
+ let tileMaxX = Math.min(bounds.max.x, tileMinX + actualTileWidth);
931
+ let tileMaxY = Math.min(bounds.max.y, tileMinY + actualTileHeight);
932
+ if (tx > 0)
933
+ tileMinX = Math.max(bounds.min.x, tileMinX - overlap);
934
+ if (ty > 0)
935
+ tileMinY = Math.max(bounds.min.y, tileMinY - overlap);
936
+ if (tx < tilesX - 1)
937
+ tileMaxX = Math.min(bounds.max.x, tileMaxX + overlap);
938
+ if (ty < tilesY - 1)
939
+ tileMaxY = Math.min(bounds.max.y, tileMaxY + overlap);
940
+ tiles.push({
941
+ id: `tile_${tx}_${ty}`,
942
+ bounds: {
943
+ min: { x: tileMinX, y: tileMinY, z: bounds.min.z },
944
+ max: { x: tileMaxX, y: tileMaxY, z: bounds.max.z }
945
+ }
946
+ });
947
+ }
948
+ }
949
+ return { tiles, tilesX, tilesY };
950
+ }
951
+ function stitchTiles(tileResults, fullBounds, stepSize) {
952
+ if (tileResults.length === 0) {
953
+ throw new Error("No tile results to stitch");
954
+ }
955
+ const isDense = tileResults[0].isDense;
956
+ if (isDense) {
957
+ debug.log(`Stitching ${tileResults.length} dense terrain tiles...`);
958
+ const globalWidth = Math.ceil((fullBounds.max.x - fullBounds.min.x) / stepSize) + 1;
959
+ const globalHeight = Math.ceil((fullBounds.max.y - fullBounds.min.y) / stepSize) + 1;
960
+ const totalGridCells = globalWidth * globalHeight;
961
+ const globalGrid = new Float32Array(totalGridCells);
962
+ globalGrid.fill(EMPTY_CELL);
963
+ debug.log(`Global grid: ${globalWidth}x${globalHeight} = ${totalGridCells.toLocaleString()} cells`);
964
+ for (const tile of tileResults) {
965
+ const tileOffsetX = Math.round((tile.tileBounds.min.x - fullBounds.min.x) / stepSize);
966
+ const tileOffsetY = Math.round((tile.tileBounds.min.y - fullBounds.min.y) / stepSize);
967
+ const tileWidth = tile.gridWidth;
968
+ const tileHeight = tile.gridHeight;
969
+ for (let ty = 0; ty < tileHeight; ty++) {
970
+ const globalY = tileOffsetY + ty;
971
+ if (globalY >= globalHeight)
972
+ continue;
973
+ for (let tx = 0; tx < tileWidth; tx++) {
974
+ const globalX = tileOffsetX + tx;
975
+ if (globalX >= globalWidth)
976
+ continue;
977
+ const tileIdx = ty * tileWidth + tx;
978
+ const globalIdx = globalY * globalWidth + globalX;
979
+ const tileZ = tile.positions[tileIdx];
980
+ if (tileZ > EMPTY_CELL + 1) {
981
+ const existingZ = globalGrid[globalIdx];
982
+ if (existingZ <= EMPTY_CELL + 1 || tileZ > existingZ) {
983
+ globalGrid[globalIdx] = tileZ;
984
+ }
985
+ }
986
+ }
987
+ }
988
+ }
989
+ let validCount = 0;
990
+ for (let i = 0; i < totalGridCells; i++) {
991
+ if (globalGrid[i] > EMPTY_CELL + 1)
992
+ validCount++;
993
+ }
994
+ debug.log(`Stitched: ${totalGridCells} total cells, ${validCount} with geometry (${(validCount / totalGridCells * 100).toFixed(1)}% coverage)`);
995
+ return {
996
+ positions: globalGrid,
997
+ pointCount: totalGridCells,
998
+ bounds: fullBounds,
999
+ gridWidth: globalWidth,
1000
+ gridHeight: globalHeight,
1001
+ isDense: true,
1002
+ conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
1003
+ tileCount: tileResults.length
1004
+ };
1005
+ } else {
1006
+ debug.log(`Stitching ${tileResults.length} sparse tool tiles...`);
1007
+ const pointMap = /* @__PURE__ */ new Map();
1008
+ for (const result of tileResults) {
1009
+ const positions = result.positions;
1010
+ const tileOffsetX = Math.round((result.tileBounds.min.x - fullBounds.min.x) / stepSize);
1011
+ const tileOffsetY = Math.round((result.tileBounds.min.y - fullBounds.min.y) / stepSize);
1012
+ for (let i = 0; i < positions.length; i += 3) {
1013
+ const localGridX = positions[i];
1014
+ const localGridY = positions[i + 1];
1015
+ const z = positions[i + 2];
1016
+ const globalGridX = localGridX + tileOffsetX;
1017
+ const globalGridY = localGridY + tileOffsetY;
1018
+ const key = `${globalGridX},${globalGridY}`;
1019
+ const existing = pointMap.get(key);
1020
+ if (!existing || z < existing.z) {
1021
+ pointMap.set(key, { x: globalGridX, y: globalGridY, z });
1022
+ }
1023
+ }
1024
+ }
1025
+ const finalPointCount = pointMap.size;
1026
+ const allPositions = new Float32Array(finalPointCount * 3);
1027
+ let writeOffset = 0;
1028
+ for (const point of pointMap.values()) {
1029
+ allPositions[writeOffset++] = point.x;
1030
+ allPositions[writeOffset++] = point.y;
1031
+ allPositions[writeOffset++] = point.z;
1032
+ }
1033
+ debug.log(`Stitched: ${finalPointCount} unique sparse points`);
1034
+ return {
1035
+ positions: allPositions,
1036
+ pointCount: finalPointCount,
1037
+ bounds: fullBounds,
1038
+ isDense: false,
1039
+ conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
1040
+ tileCount: tileResults.length
1041
+ };
1042
+ }
1043
+ }
1044
+ function shouldUseTiling(bounds, stepSize) {
1045
+ if (!config || !config.autoTiling)
1046
+ return false;
1047
+ if (!deviceCapabilities)
1048
+ return false;
1049
+ const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
1050
+ const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
1051
+ const totalPoints = gridWidth * gridHeight;
1052
+ const gpuOutputBuffer = totalPoints * 1 * 4;
1053
+ const totalGPUMemory = gpuOutputBuffer;
1054
+ const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
1055
+ const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
1056
+ const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
1057
+ return totalGPUMemory > maxSafeSize;
1058
+ }
1059
+ async function rasterizeMesh(triangles, stepSize, filterMode, options = {}) {
1060
+ const boundsOverride = options.bounds || options.min ? options : null;
1061
+ const bounds = boundsOverride || calculateBounds(triangles);
1062
+ if (shouldUseTiling(bounds, stepSize)) {
1063
+ debug.log("Tiling required - switching to tiled rasterization");
1064
+ const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
1065
+ const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
1066
+ const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
1067
+ const { tiles } = createTiles(bounds, stepSize, maxSafeSize);
1068
+ const tileResults = [];
1069
+ for (let i = 0; i < tiles.length; i++) {
1070
+ const tileStart = performance.now();
1071
+ debug.log(`Processing tile ${i + 1}/${tiles.length}: ${tiles[i].id}`);
1072
+ 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)})`);
1073
+ const tileResult = await rasterizeMeshSingle(triangles, stepSize, filterMode, {
1074
+ ...tiles[i].bounds
1075
+ });
1076
+ const tileTime = performance.now() - tileStart;
1077
+ debug.log(` Tile ${i + 1} complete: ${tileResult.pointCount} points in ${tileTime.toFixed(1)}ms`);
1078
+ tileResult.tileBounds = tiles[i].bounds;
1079
+ tileResults.push(tileResult);
1080
+ }
1081
+ return stitchTiles(tileResults, bounds, stepSize);
1082
+ } else {
1083
+ return await rasterizeMeshSingle(triangles, stepSize, filterMode, options);
1084
+ }
1085
+ }
1086
+ function createHeightMapFromPoints(points, gridStep, bounds = null) {
1087
+ if (!points || points.length === 0) {
1088
+ throw new Error("No points provided");
1089
+ }
1090
+ if (!bounds) {
1091
+ throw new Error("Bounds required for height map creation");
1092
+ }
1093
+ const minX = bounds.min.x;
1094
+ const minY = bounds.min.y;
1095
+ const minZ = bounds.min.z;
1096
+ const maxX = bounds.max.x;
1097
+ const maxY = bounds.max.y;
1098
+ const maxZ = bounds.max.z;
1099
+ const width = Math.ceil((maxX - minX) / gridStep) + 1;
1100
+ const height = Math.ceil((maxY - minY) / gridStep) + 1;
1101
+ return {
1102
+ grid: points,
1103
+ // Dense Z-only array
1104
+ width,
1105
+ height,
1106
+ minX,
1107
+ minY,
1108
+ minZ,
1109
+ maxX,
1110
+ maxY,
1111
+ maxZ
1112
+ };
1113
+ }
1114
+
1115
+ // src/core/raster-tool.js
1116
+ function createSparseToolFromPoints(points) {
1117
+ if (!points || points.length === 0) {
1118
+ throw new Error("No tool points provided");
1119
+ }
1120
+ let minGridX = Infinity, minGridY = Infinity, minZ = Infinity;
1121
+ let maxGridX = -Infinity, maxGridY = -Infinity;
1122
+ for (let i = 0; i < points.length; i += 3) {
1123
+ const gridX = points[i];
1124
+ const gridY = points[i + 1];
1125
+ const z = points[i + 2];
1126
+ minGridX = Math.min(minGridX, gridX);
1127
+ maxGridX = Math.max(maxGridX, gridX);
1128
+ minGridY = Math.min(minGridY, gridY);
1129
+ maxGridY = Math.max(maxGridY, gridY);
1130
+ minZ = Math.min(minZ, z);
1131
+ }
1132
+ const width = Math.floor(maxGridX - minGridX) + 1;
1133
+ const height = Math.floor(maxGridY - minGridY) + 1;
1134
+ const centerX = Math.floor(minGridX) + Math.floor(width / 2);
1135
+ const centerY = Math.floor(minGridY) + Math.floor(height / 2);
1136
+ const xOffsets = [];
1137
+ const yOffsets = [];
1138
+ const zValues = [];
1139
+ for (let i = 0; i < points.length; i += 3) {
1140
+ const gridX = Math.floor(points[i]);
1141
+ const gridY = Math.floor(points[i + 1]);
1142
+ const z = points[i + 2];
1143
+ const xOffset = gridX - centerX;
1144
+ const yOffset = gridY - centerY;
1145
+ const zValue = z;
1146
+ xOffsets.push(xOffset);
1147
+ yOffsets.push(yOffset);
1148
+ zValues.push(zValue);
1149
+ }
1150
+ return {
1151
+ count: xOffsets.length,
1152
+ xOffsets: new Int32Array(xOffsets),
1153
+ yOffsets: new Int32Array(yOffsets),
1154
+ zValues: new Float32Array(zValues),
1155
+ referenceZ: minZ
1156
+ };
1157
+ }
1158
+
1159
+ // src/core/path-planar.js
1160
+ async function generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null) {
1161
+ const startTime = performance.now();
1162
+ debug.log("Generating toolpath...");
1163
+ debug.log(`Input: terrain ${terrainPoints.length / 3} points, tool ${toolPoints.length / 3} points, steps (${xStep}, ${yStep}), oobZ ${oobZ}, gridStep ${gridStep}`);
1164
+ if (terrainBounds) {
1165
+ 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)})`);
1166
+ }
1167
+ try {
1168
+ const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
1169
+ debug.log(`Created terrain map: ${terrainMapData.width}x${terrainMapData.height}`);
1170
+ const sparseToolData = createSparseToolFromPoints(toolPoints);
1171
+ debug.log(`Created sparse tool: ${sparseToolData.count} points`);
1172
+ const result = await runToolpathCompute(
1173
+ terrainMapData,
1174
+ sparseToolData,
1175
+ xStep,
1176
+ yStep,
1177
+ oobZ,
1178
+ startTime
1179
+ );
1180
+ return result;
1181
+ } catch (error) {
1182
+ debug.error("Error generating toolpath:", error);
1183
+ throw error;
1184
+ }
1185
+ }
1186
+ async function runToolpathCompute(terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime) {
1187
+ if (!isInitialized) {
1188
+ const success = await initWebGPU();
1189
+ if (!success) {
1190
+ throw new Error("WebGPU not available");
1191
+ }
1192
+ }
1193
+ const terrainBuffer = device.createBuffer({
1194
+ size: terrainMapData.grid.byteLength,
1195
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
1196
+ });
1197
+ device.queue.writeBuffer(terrainBuffer, 0, terrainMapData.grid);
1198
+ const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
1199
+ const toolBufferI32 = new Int32Array(toolBufferData);
1200
+ const toolBufferF32 = new Float32Array(toolBufferData);
1201
+ for (let i = 0; i < sparseToolData.count; i++) {
1202
+ toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
1203
+ toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
1204
+ toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
1205
+ toolBufferF32[i * 4 + 3] = 0;
1206
+ }
1207
+ const toolBuffer = device.createBuffer({
1208
+ size: toolBufferData.byteLength,
1209
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
1210
+ });
1211
+ device.queue.writeBuffer(toolBuffer, 0, toolBufferData);
1212
+ const pointsPerLine = Math.ceil(terrainMapData.width / xStep);
1213
+ const numScanlines = Math.ceil(terrainMapData.height / yStep);
1214
+ const outputSize = pointsPerLine * numScanlines;
1215
+ const outputBuffer = device.createBuffer({
1216
+ size: outputSize * 4,
1217
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
1218
+ });
1219
+ const uniformData = new Uint32Array([
1220
+ terrainMapData.width,
1221
+ terrainMapData.height,
1222
+ sparseToolData.count,
1223
+ xStep,
1224
+ yStep,
1225
+ 0,
1226
+ pointsPerLine,
1227
+ numScanlines,
1228
+ 0
1229
+ // y_offset (default 0 for planar mode)
1230
+ ]);
1231
+ const uniformDataFloat = new Float32Array(uniformData.buffer);
1232
+ uniformDataFloat[5] = oobZ;
1233
+ const uniformBuffer = device.createBuffer({
1234
+ size: uniformData.byteLength,
1235
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1236
+ });
1237
+ device.queue.writeBuffer(uniformBuffer, 0, uniformData);
1238
+ await device.queue.onSubmittedWorkDone();
1239
+ const bindGroup = device.createBindGroup({
1240
+ layout: cachedToolpathPipeline.getBindGroupLayout(0),
1241
+ entries: [
1242
+ { binding: 0, resource: { buffer: terrainBuffer } },
1243
+ { binding: 1, resource: { buffer: toolBuffer } },
1244
+ { binding: 2, resource: { buffer: outputBuffer } },
1245
+ { binding: 3, resource: { buffer: uniformBuffer } }
1246
+ ]
1247
+ });
1248
+ const commandEncoder = device.createCommandEncoder();
1249
+ const passEncoder = commandEncoder.beginComputePass();
1250
+ passEncoder.setPipeline(cachedToolpathPipeline);
1251
+ passEncoder.setBindGroup(0, bindGroup);
1252
+ const workgroupsX = Math.ceil(pointsPerLine / 16);
1253
+ const workgroupsY = Math.ceil(numScanlines / 16);
1254
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
1255
+ passEncoder.end();
1256
+ const stagingBuffer = device.createBuffer({
1257
+ size: outputSize * 4,
1258
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
1259
+ });
1260
+ commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize * 4);
1261
+ device.queue.submit([commandEncoder.finish()]);
1262
+ await device.queue.onSubmittedWorkDone();
1263
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
1264
+ const outputData = new Float32Array(stagingBuffer.getMappedRange());
1265
+ const result = new Float32Array(outputData);
1266
+ stagingBuffer.unmap();
1267
+ terrainBuffer.destroy();
1268
+ toolBuffer.destroy();
1269
+ outputBuffer.destroy();
1270
+ uniformBuffer.destroy();
1271
+ stagingBuffer.destroy();
1272
+ const endTime = performance.now();
1273
+ return {
1274
+ pathData: result,
1275
+ numScanlines,
1276
+ pointsPerLine,
1277
+ generationTime: endTime - startTime
1278
+ };
1279
+ }
1280
+ function createReusableToolpathBuffers(terrainWidth, terrainHeight, sparseToolData, xStep, yStep) {
1281
+ const pointsPerLine = Math.ceil(terrainWidth / xStep);
1282
+ const numScanlines = Math.ceil(terrainHeight / yStep);
1283
+ const outputSize = pointsPerLine * numScanlines;
1284
+ const terrainBuffer = device.createBuffer({
1285
+ size: terrainWidth * terrainHeight * 4,
1286
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
1287
+ });
1288
+ const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
1289
+ const toolBufferI32 = new Int32Array(toolBufferData);
1290
+ const toolBufferF32 = new Float32Array(toolBufferData);
1291
+ for (let i = 0; i < sparseToolData.count; i++) {
1292
+ toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
1293
+ toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
1294
+ toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
1295
+ toolBufferF32[i * 4 + 3] = 0;
1296
+ }
1297
+ const toolBuffer = device.createBuffer({
1298
+ size: toolBufferData.byteLength,
1299
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
1300
+ });
1301
+ device.queue.writeBuffer(toolBuffer, 0, toolBufferData);
1302
+ const outputBuffer = device.createBuffer({
1303
+ size: outputSize * 4,
1304
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
1305
+ });
1306
+ const uniformBuffer = device.createBuffer({
1307
+ size: 36,
1308
+ // 9 fields × 4 bytes (added y_offset field)
1309
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1310
+ });
1311
+ const stagingBuffer = device.createBuffer({
1312
+ size: outputSize * 4,
1313
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
1314
+ });
1315
+ return {
1316
+ terrainBuffer,
1317
+ toolBuffer,
1318
+ outputBuffer,
1319
+ uniformBuffer,
1320
+ stagingBuffer,
1321
+ maxOutputSize: outputSize,
1322
+ maxTerrainWidth: terrainWidth,
1323
+ maxTerrainHeight: terrainHeight,
1324
+ sparseToolData
1325
+ };
1326
+ }
1327
+ function destroyReusableToolpathBuffers(buffers) {
1328
+ buffers.terrainBuffer.destroy();
1329
+ buffers.toolBuffer.destroy();
1330
+ buffers.outputBuffer.destroy();
1331
+ buffers.uniformBuffer.destroy();
1332
+ buffers.stagingBuffer.destroy();
1333
+ }
1334
+ async function runToolpathComputeWithBuffers(terrainData, terrainWidth, terrainHeight, xStep, yStep, oobZ, buffers, startTime) {
1335
+ device.queue.writeBuffer(buffers.terrainBuffer, 0, terrainData);
1336
+ const pointsPerLine = Math.ceil(terrainWidth / xStep);
1337
+ const numScanlines = Math.ceil(terrainHeight / yStep);
1338
+ const outputSize = pointsPerLine * numScanlines;
1339
+ const yOffset = numScanlines === 1 && terrainHeight > 1 ? Math.floor(terrainHeight / 2) : 0;
1340
+ const uniformData = new Uint32Array([
1341
+ terrainWidth,
1342
+ terrainHeight,
1343
+ buffers.sparseToolData.count,
1344
+ xStep,
1345
+ yStep,
1346
+ 0,
1347
+ pointsPerLine,
1348
+ numScanlines,
1349
+ yOffset
1350
+ // y_offset for radial single-scanline mode
1351
+ ]);
1352
+ const uniformDataFloat = new Float32Array(uniformData.buffer);
1353
+ uniformDataFloat[5] = oobZ;
1354
+ device.queue.writeBuffer(buffers.uniformBuffer, 0, uniformData);
1355
+ await device.queue.onSubmittedWorkDone();
1356
+ const bindGroup = device.createBindGroup({
1357
+ layout: cachedToolpathPipeline.getBindGroupLayout(0),
1358
+ entries: [
1359
+ { binding: 0, resource: { buffer: buffers.terrainBuffer } },
1360
+ { binding: 1, resource: { buffer: buffers.toolBuffer } },
1361
+ { binding: 2, resource: { buffer: buffers.outputBuffer } },
1362
+ { binding: 3, resource: { buffer: buffers.uniformBuffer } }
1363
+ ]
1364
+ });
1365
+ const commandEncoder = device.createCommandEncoder();
1366
+ const passEncoder = commandEncoder.beginComputePass();
1367
+ passEncoder.setPipeline(cachedToolpathPipeline);
1368
+ passEncoder.setBindGroup(0, bindGroup);
1369
+ const workgroupsX = Math.ceil(pointsPerLine / 16);
1370
+ const workgroupsY = Math.ceil(numScanlines / 16);
1371
+ passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
1372
+ passEncoder.end();
1373
+ commandEncoder.copyBufferToBuffer(buffers.outputBuffer, 0, buffers.stagingBuffer, 0, outputSize * 4);
1374
+ device.queue.submit([commandEncoder.finish()]);
1375
+ await device.queue.onSubmittedWorkDone();
1376
+ await buffers.stagingBuffer.mapAsync(GPUMapMode.READ);
1377
+ const outputData = new Float32Array(buffers.stagingBuffer.getMappedRange(), 0, outputSize);
1378
+ const result = outputData.slice();
1379
+ buffers.stagingBuffer.unmap();
1380
+ const endTime = performance.now();
1381
+ if (result.length > 0) {
1382
+ const samples = [];
1383
+ for (let i = 0; i < Math.min(10, result.length); i++) {
1384
+ samples.push(result[i].toFixed(3));
1385
+ }
1386
+ }
1387
+ return {
1388
+ pathData: result,
1389
+ numScanlines,
1390
+ pointsPerLine,
1391
+ generationTime: endTime - startTime
1392
+ };
1393
+ }
1394
+ async function generateToolpath(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
1395
+ if (!terrainBounds) {
1396
+ let minX = Infinity, minY = Infinity, minZ = Infinity;
1397
+ let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
1398
+ for (let i = 0; i < terrainPoints.length; i += 3) {
1399
+ minX = Math.min(minX, terrainPoints[i]);
1400
+ maxX = Math.max(maxX, terrainPoints[i]);
1401
+ minY = Math.min(minY, terrainPoints[i + 1]);
1402
+ maxY = Math.max(maxY, terrainPoints[i + 1]);
1403
+ minZ = Math.min(minZ, terrainPoints[i + 2]);
1404
+ maxZ = Math.max(maxZ, terrainPoints[i + 2]);
1405
+ }
1406
+ terrainBounds = {
1407
+ min: { x: minX, y: minY, z: minZ },
1408
+ max: { x: maxX, y: maxY, z: maxZ }
1409
+ };
1410
+ }
1411
+ for (let i = 0; i < toolPoints.length; i += 3) {
1412
+ if (toolPoints[i] === 0 && toolPoints[i + 1] === 0) {
1413
+ debug.log("[WebGPU Worker]", { TOOL_CENTER: toolPoints[i + 2] });
1414
+ }
1415
+ }
1416
+ debug.log(
1417
+ "[WebGPU Worker]",
1418
+ "toolZMin:",
1419
+ [...toolPoints].filter((_, i) => i % 3 === 2).reduce((a, b) => Math.min(a, b), Infinity),
1420
+ "toolZMax:",
1421
+ [...toolPoints].filter((_, i) => i % 3 === 2).reduce((a, b) => Math.max(a, b), -Infinity)
1422
+ );
1423
+ let toolMinX = Infinity, toolMaxX = -Infinity;
1424
+ let toolMinY = Infinity, toolMaxY = -Infinity;
1425
+ for (let i = 0; i < toolPoints.length; i += 3) {
1426
+ toolMinX = Math.min(toolMinX, toolPoints[i]);
1427
+ toolMaxX = Math.max(toolMaxX, toolPoints[i]);
1428
+ toolMinY = Math.min(toolMinY, toolPoints[i + 1]);
1429
+ toolMaxY = Math.max(toolMaxY, toolPoints[i + 1]);
1430
+ }
1431
+ const toolWidthCells = toolMaxX - toolMinX;
1432
+ const toolHeightCells = toolMaxY - toolMinY;
1433
+ const toolWidthMm = toolWidthCells * gridStep;
1434
+ const toolHeightMm = toolHeightCells * gridStep;
1435
+ const outputWidth = Math.ceil((terrainBounds.max.x - terrainBounds.min.x) / gridStep) + 1;
1436
+ const outputHeight = Math.ceil((terrainBounds.max.y - terrainBounds.min.y) / gridStep) + 1;
1437
+ const outputPoints = Math.ceil(outputWidth / xStep) * Math.ceil(outputHeight / yStep);
1438
+ const outputMemory = outputPoints * 4;
1439
+ const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
1440
+ const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
1441
+ const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
1442
+ if (outputMemory <= maxSafeSize) {
1443
+ return await generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds);
1444
+ }
1445
+ const tilingStartTime = performance.now();
1446
+ debug.log("Using tiled toolpath generation");
1447
+ debug.log(`Terrain: DENSE (${terrainPoints.length} cells = ${outputWidth}x${outputHeight})`);
1448
+ debug.log(`Tool dimensions: ${toolWidthMm.toFixed(2)}mm \xD7 ${toolHeightMm.toFixed(2)}mm (${toolWidthCells}\xD7${toolHeightCells} cells)`);
1449
+ const { tiles, maxTileGridWidth, maxTileGridHeight } = createToolpathTiles(terrainBounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxSafeSize);
1450
+ debug.log(`Created ${tiles.length} tiles`);
1451
+ const pregenStartTime = performance.now();
1452
+ debug.log(`Pre-generating ${tiles.length} tile terrain arrays...`);
1453
+ const allTileTerrainPoints = [];
1454
+ for (let i = 0; i < tiles.length; i++) {
1455
+ const tile = tiles[i];
1456
+ const tileMinGridX = Math.floor((tile.bounds.min.x - terrainBounds.min.x) / gridStep);
1457
+ const tileMaxGridX = Math.ceil((tile.bounds.max.x - terrainBounds.min.x) / gridStep);
1458
+ const tileMinGridY = Math.floor((tile.bounds.min.y - terrainBounds.min.y) / gridStep);
1459
+ const tileMaxGridY = Math.ceil((tile.bounds.max.y - terrainBounds.min.y) / gridStep);
1460
+ const tileWidth = tileMaxGridX - tileMinGridX + 1;
1461
+ const tileHeight = tileMaxGridY - tileMinGridY + 1;
1462
+ const paddedTileTerrainPoints = new Float32Array(maxTileGridWidth * maxTileGridHeight);
1463
+ paddedTileTerrainPoints.fill(EMPTY_CELL);
1464
+ for (let ty = 0; ty < tileHeight; ty++) {
1465
+ const globalY = tileMinGridY + ty;
1466
+ if (globalY < 0 || globalY >= outputHeight)
1467
+ continue;
1468
+ for (let tx = 0; tx < tileWidth; tx++) {
1469
+ const globalX = tileMinGridX + tx;
1470
+ if (globalX < 0 || globalX >= outputWidth)
1471
+ continue;
1472
+ const globalIdx = globalY * outputWidth + globalX;
1473
+ const tileIdx = ty * maxTileGridWidth + tx;
1474
+ paddedTileTerrainPoints[tileIdx] = terrainPoints[globalIdx];
1475
+ }
1476
+ }
1477
+ allTileTerrainPoints.push({
1478
+ data: paddedTileTerrainPoints,
1479
+ actualWidth: tileWidth,
1480
+ actualHeight: tileHeight
1481
+ });
1482
+ }
1483
+ const pregenTime = performance.now() - pregenStartTime;
1484
+ debug.log(`Pre-generation complete in ${pregenTime.toFixed(1)}ms`);
1485
+ if (!isInitialized) {
1486
+ const success = await initWebGPU();
1487
+ if (!success) {
1488
+ throw new Error("WebGPU not available");
1489
+ }
1490
+ }
1491
+ const sparseToolData = createSparseToolFromPoints(toolPoints);
1492
+ const reusableBuffers = createReusableToolpathBuffers(maxTileGridWidth, maxTileGridHeight, sparseToolData, xStep, yStep);
1493
+ debug.log(`Created reusable GPU buffers for ${maxTileGridWidth}x${maxTileGridHeight} tiles`);
1494
+ const tileResults = [];
1495
+ let totalTileTime = 0;
1496
+ for (let i = 0; i < tiles.length; i++) {
1497
+ const tile = tiles[i];
1498
+ const tileStartTime = performance.now();
1499
+ debug.log(`Processing tile ${i + 1}/${tiles.length}...`);
1500
+ const percent = Math.round((i + 1) / tiles.length * 100);
1501
+ self.postMessage({
1502
+ type: "toolpath-progress",
1503
+ data: {
1504
+ percent,
1505
+ current: i + 1,
1506
+ total: tiles.length,
1507
+ layer: i + 1
1508
+ // Using tile index as "layer" for consistency
1509
+ }
1510
+ });
1511
+ debug.log(`Tile ${i + 1} using pre-generated terrain: ${allTileTerrainPoints[i].actualWidth}x${allTileTerrainPoints[i].actualHeight} (padded to ${maxTileGridWidth}x${maxTileGridHeight})`);
1512
+ const tileToolpathResult = await runToolpathComputeWithBuffers(
1513
+ allTileTerrainPoints[i].data,
1514
+ maxTileGridWidth,
1515
+ maxTileGridHeight,
1516
+ xStep,
1517
+ yStep,
1518
+ oobZ,
1519
+ reusableBuffers,
1520
+ tileStartTime
1521
+ );
1522
+ const tileTime = performance.now() - tileStartTime;
1523
+ totalTileTime += tileTime;
1524
+ tileResults.push({
1525
+ pathData: tileToolpathResult.pathData,
1526
+ numScanlines: tileToolpathResult.numScanlines,
1527
+ pointsPerLine: tileToolpathResult.pointsPerLine,
1528
+ tile
1529
+ });
1530
+ debug.log(`Tile ${i + 1}/${tiles.length} complete: ${tileToolpathResult.numScanlines}\xD7${tileToolpathResult.pointsPerLine} in ${tileTime.toFixed(1)}ms`);
1531
+ }
1532
+ destroyReusableToolpathBuffers(reusableBuffers);
1533
+ debug.log(`All tiles processed in ${totalTileTime.toFixed(1)}ms (avg ${(totalTileTime / tiles.length).toFixed(1)}ms per tile)`);
1534
+ const stitchStartTime = performance.now();
1535
+ const stitchedResult = stitchToolpathTiles(tileResults, terrainBounds, gridStep, xStep, yStep);
1536
+ const stitchTime = performance.now() - stitchStartTime;
1537
+ const totalTime = performance.now() - tilingStartTime;
1538
+ debug.log(`Stitching took ${stitchTime.toFixed(1)}ms`);
1539
+ debug.log(`Tiled toolpath complete: ${stitchedResult.numScanlines}\xD7${stitchedResult.pointsPerLine} in ${totalTime.toFixed(1)}ms total`);
1540
+ stitchedResult.generationTime = totalTime;
1541
+ return stitchedResult;
1542
+ }
1543
+ function createToolpathTiles(bounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxMemoryBytes) {
1544
+ const globalGridWidth = Math.ceil((bounds.max.x - bounds.min.x) / gridStep) + 1;
1545
+ const globalGridHeight = Math.ceil((bounds.max.y - bounds.min.y) / gridStep) + 1;
1546
+ const toolOverlapX = Math.ceil(toolWidthCells / 2);
1547
+ const toolOverlapY = Math.ceil(toolHeightCells / 2);
1548
+ let low = Math.max(toolOverlapX, toolOverlapY) * 2;
1549
+ let high = Math.max(globalGridWidth, globalGridHeight);
1550
+ let bestTileGridSize = high;
1551
+ while (low <= high) {
1552
+ const mid = Math.floor((low + high) / 2);
1553
+ const outputW = Math.ceil(mid / xStep);
1554
+ const outputH = Math.ceil(mid / yStep);
1555
+ const memoryNeeded = outputW * outputH * 4;
1556
+ if (memoryNeeded <= maxMemoryBytes) {
1557
+ bestTileGridSize = mid;
1558
+ low = mid + 1;
1559
+ } else {
1560
+ high = mid - 1;
1561
+ }
1562
+ }
1563
+ const tilesX = Math.ceil(globalGridWidth / bestTileGridSize);
1564
+ const tilesY = Math.ceil(globalGridHeight / bestTileGridSize);
1565
+ const coreGridWidth = Math.ceil(globalGridWidth / tilesX);
1566
+ const coreGridHeight = Math.ceil(globalGridHeight / tilesY);
1567
+ const maxTileGridWidth = coreGridWidth + 2 * toolOverlapX;
1568
+ const maxTileGridHeight = coreGridHeight + 2 * toolOverlapY;
1569
+ debug.log(`Creating ${tilesX}\xD7${tilesY} tiles (${coreGridWidth}\xD7${coreGridHeight} cells core + ${toolOverlapX}\xD7${toolOverlapY} cells overlap)`);
1570
+ debug.log(`Max tile dimensions: ${maxTileGridWidth}\xD7${maxTileGridHeight} cells (for buffer sizing)`);
1571
+ const tiles = [];
1572
+ for (let ty = 0; ty < tilesY; ty++) {
1573
+ for (let tx = 0; tx < tilesX; tx++) {
1574
+ const coreGridStartX = tx * coreGridWidth;
1575
+ const coreGridStartY = ty * coreGridHeight;
1576
+ const coreGridEndX = Math.min((tx + 1) * coreGridWidth, globalGridWidth) - 1;
1577
+ const coreGridEndY = Math.min((ty + 1) * coreGridHeight, globalGridHeight) - 1;
1578
+ let extGridStartX = coreGridStartX;
1579
+ let extGridStartY = coreGridStartY;
1580
+ let extGridEndX = coreGridEndX;
1581
+ let extGridEndY = coreGridEndY;
1582
+ if (tx > 0)
1583
+ extGridStartX -= toolOverlapX;
1584
+ if (ty > 0)
1585
+ extGridStartY -= toolOverlapY;
1586
+ if (tx < tilesX - 1)
1587
+ extGridEndX += toolOverlapX;
1588
+ if (ty < tilesY - 1)
1589
+ extGridEndY += toolOverlapY;
1590
+ extGridStartX = Math.max(0, extGridStartX);
1591
+ extGridStartY = Math.max(0, extGridStartY);
1592
+ extGridEndX = Math.min(globalGridWidth - 1, extGridEndX);
1593
+ extGridEndY = Math.min(globalGridHeight - 1, extGridEndY);
1594
+ const tileGridWidth = extGridEndX - extGridStartX + 1;
1595
+ const tileGridHeight = extGridEndY - extGridStartY + 1;
1596
+ const extMinX = bounds.min.x + extGridStartX * gridStep;
1597
+ const extMinY = bounds.min.y + extGridStartY * gridStep;
1598
+ const extMaxX = bounds.min.x + extGridEndX * gridStep;
1599
+ const extMaxY = bounds.min.y + extGridEndY * gridStep;
1600
+ const coreMinX = bounds.min.x + coreGridStartX * gridStep;
1601
+ const coreMinY = bounds.min.y + coreGridStartY * gridStep;
1602
+ const coreMaxX = bounds.min.x + coreGridEndX * gridStep;
1603
+ const coreMaxY = bounds.min.y + coreGridEndY * gridStep;
1604
+ tiles.push({
1605
+ id: `tile_${tx}_${ty}`,
1606
+ tx,
1607
+ ty,
1608
+ tilesX,
1609
+ tilesY,
1610
+ gridWidth: tileGridWidth,
1611
+ gridHeight: tileGridHeight,
1612
+ bounds: {
1613
+ min: { x: extMinX, y: extMinY, z: bounds.min.z },
1614
+ max: { x: extMaxX, y: extMaxY, z: bounds.max.z }
1615
+ },
1616
+ core: {
1617
+ gridStart: { x: coreGridStartX, y: coreGridStartY },
1618
+ gridEnd: { x: coreGridEndX, y: coreGridEndY },
1619
+ min: { x: coreMinX, y: coreMinY },
1620
+ max: { x: coreMaxX, y: coreMaxY }
1621
+ }
1622
+ });
1623
+ }
1624
+ }
1625
+ return { tiles, maxTileGridWidth, maxTileGridHeight };
1626
+ }
1627
+ function stitchToolpathTiles(tileResults, globalBounds, gridStep, xStep, yStep) {
1628
+ const globalWidth = Math.ceil((globalBounds.max.x - globalBounds.min.x) / gridStep) + 1;
1629
+ const globalHeight = Math.ceil((globalBounds.max.y - globalBounds.min.y) / gridStep) + 1;
1630
+ const globalPointsPerLine = Math.ceil(globalWidth / xStep);
1631
+ const globalNumScanlines = Math.ceil(globalHeight / yStep);
1632
+ debug.log(`Stitching toolpath: global grid ${globalWidth}x${globalHeight}, output ${globalPointsPerLine}x${globalNumScanlines}`);
1633
+ const result = new Float32Array(globalPointsPerLine * globalNumScanlines);
1634
+ result.fill(NaN);
1635
+ const use1x1FastPath = xStep === 1 && yStep === 1;
1636
+ for (const tileResult of tileResults) {
1637
+ const tile = tileResult.tile;
1638
+ const tileData = tileResult.pathData;
1639
+ const coreGridStartX = tile.core.gridStart.x;
1640
+ const coreGridStartY = tile.core.gridStart.y;
1641
+ const coreGridEndX = tile.core.gridEnd.x;
1642
+ const coreGridEndY = tile.core.gridEnd.y;
1643
+ const extGridStartX = Math.round((tile.bounds.min.x - globalBounds.min.x) / gridStep);
1644
+ const extGridStartY = Math.round((tile.bounds.min.y - globalBounds.min.y) / gridStep);
1645
+ let copiedCount = 0;
1646
+ const coreGridWidth = coreGridEndX - coreGridStartX + 1;
1647
+ const coreGridHeight = coreGridEndY - coreGridStartY + 1;
1648
+ const coreOutStartX = Math.floor(coreGridStartX / xStep);
1649
+ const coreOutStartY = Math.floor(coreGridStartY / yStep);
1650
+ const coreOutEndX = Math.floor(coreGridEndX / xStep);
1651
+ const coreOutEndY = Math.floor(coreGridEndY / yStep);
1652
+ const coreOutWidth = coreOutEndX - coreOutStartX + 1;
1653
+ const coreOutHeight = coreOutEndY - coreOutStartY + 1;
1654
+ const extOutStartX = Math.floor(extGridStartX / xStep);
1655
+ const extOutStartY = Math.floor(extGridStartY / yStep);
1656
+ for (let outY = 0; outY < coreOutHeight; outY++) {
1657
+ const globalOutY = coreOutStartY + outY;
1658
+ const tileOutY = globalOutY - extOutStartY;
1659
+ if (globalOutY >= 0 && globalOutY < globalNumScanlines && tileOutY >= 0 && tileOutY < tileResult.numScanlines) {
1660
+ const globalRowStart = globalOutY * globalPointsPerLine + coreOutStartX;
1661
+ const tileRowStart = tileOutY * tileResult.pointsPerLine + (coreOutStartX - extOutStartX);
1662
+ result.set(tileData.subarray(tileRowStart, tileRowStart + coreOutWidth), globalRowStart);
1663
+ copiedCount += coreOutWidth;
1664
+ }
1665
+ }
1666
+ debug.log(` Tile ${tile.id}: copied ${copiedCount} values`);
1667
+ }
1668
+ let nanCount = 0;
1669
+ for (let i = 0; i < result.length; i++) {
1670
+ if (isNaN(result[i]))
1671
+ nanCount++;
1672
+ }
1673
+ debug.log(`Stitching complete: ${result.length} total values, ${nanCount} still NaN`);
1674
+ return {
1675
+ pathData: result,
1676
+ numScanlines: globalNumScanlines,
1677
+ pointsPerLine: globalPointsPerLine,
1678
+ generationTime: 0
1679
+ // Sum from tiles if needed
1680
+ };
1681
+ }
1682
+
1683
+ // src/core/path-radial.js
1684
+ async function radialRasterize({
1685
+ triangles,
1686
+ bucketData,
1687
+ resolution,
1688
+ angleStep,
1689
+ numAngles,
1690
+ maxRadius,
1691
+ toolWidth,
1692
+ zFloor,
1693
+ bounds,
1694
+ startAngle = 0,
1695
+ reusableBuffers = null,
1696
+ returnBuffersForReuse = false,
1697
+ batchInfo = {}
1698
+ }) {
1699
+ if (!device) {
1700
+ throw new Error("WebGPU not initialized");
1701
+ }
1702
+ const timings = {
1703
+ start: performance.now(),
1704
+ prep: 0,
1705
+ gpu: 0,
1706
+ stitch: 0
1707
+ };
1708
+ const bucketMinX = bucketData.buckets[0].minX;
1709
+ const bucketMaxX = bucketData.buckets[bucketData.numBuckets - 1].maxX;
1710
+ const gridWidth = Math.ceil((bucketMaxX - bucketMinX) / resolution);
1711
+ const gridYHeight = Math.ceil(toolWidth / resolution);
1712
+ const bucketGridWidth = Math.ceil((bucketData.buckets[0].maxX - bucketData.buckets[0].minX) / resolution);
1713
+ const bucketTriangleCounts = bucketData.buckets.map((b) => b.count);
1714
+ const minTriangles = Math.min(...bucketTriangleCounts);
1715
+ const maxTriangles = Math.max(...bucketTriangleCounts);
1716
+ const avgTriangles = bucketTriangleCounts.reduce((a, b) => a + b, 0) / bucketTriangleCounts.length;
1717
+ const workPerWorkgroup = maxTriangles * numAngles * bucketGridWidth * gridYHeight;
1718
+ const maxWorkPerBatch = 1e10;
1719
+ const estimatedWorkPerBucket = avgTriangles * numAngles * bucketGridWidth * gridYHeight;
1720
+ const THREADS_PER_WORKGROUP = 64;
1721
+ const maxConcurrentThreads = config.maxConcurrentThreads || 32768;
1722
+ const dispatchX = Math.ceil(numAngles / 8);
1723
+ const dispatchY = Math.ceil(gridYHeight / 8);
1724
+ const threadsPerBucket = dispatchX * dispatchY * THREADS_PER_WORKGROUP;
1725
+ const threadLimitBuckets = Math.max(1, Math.floor(maxConcurrentThreads / threadsPerBucket));
1726
+ let maxBucketsPerBatch;
1727
+ if (estimatedWorkPerBucket === 0) {
1728
+ maxBucketsPerBatch = Math.min(threadLimitBuckets, bucketData.numBuckets);
1729
+ } else {
1730
+ const workBasedLimit = Math.floor(maxWorkPerBatch / estimatedWorkPerBucket);
1731
+ const idealBucketsPerBatch = Math.min(workBasedLimit, threadLimitBuckets);
1732
+ const minBucketsPerBatch = Math.min(4, bucketData.numBuckets, threadLimitBuckets);
1733
+ maxBucketsPerBatch = Math.max(minBucketsPerBatch, idealBucketsPerBatch);
1734
+ maxBucketsPerBatch = Math.min(maxBucketsPerBatch, bucketData.numBuckets);
1735
+ }
1736
+ const numBucketBatches = Math.ceil(bucketData.numBuckets / maxBucketsPerBatch);
1737
+ if (diagnostic) {
1738
+ debug.log(`Radial: ${gridWidth}x${gridYHeight} grid, ${numAngles} angles, ${bucketData.buckets.length} buckets`);
1739
+ debug.log(`Load: min=${minTriangles} max=${maxTriangles} avg=${avgTriangles.toFixed(0)} (${(maxTriangles / avgTriangles).toFixed(2)}x imbalance, worst=${(workPerWorkgroup / 1e6).toFixed(1)}M tests)`);
1740
+ debug.log(`Thread limits: ${threadsPerBucket} threads/bucket, max ${threadLimitBuckets} buckets/dispatch (${maxConcurrentThreads} thread limit)`);
1741
+ debug.log(`Estimated work/bucket: ${(estimatedWorkPerBucket / 1e6).toFixed(1)}M tests`);
1742
+ debug.log(`Bucket batching: ${numBucketBatches} batches of ${maxBucketsPerBatch} buckets (work limit: ${Math.floor(maxWorkPerBatch / estimatedWorkPerBucket)}, thread limit: ${threadLimitBuckets})`);
1743
+ }
1744
+ let triangleBuffer, triangleIndicesBuffer;
1745
+ let shouldCleanupBuffers = false;
1746
+ if (reusableBuffers) {
1747
+ triangleBuffer = reusableBuffers.triangleBuffer;
1748
+ triangleIndicesBuffer = reusableBuffers.triangleIndicesBuffer;
1749
+ } else {
1750
+ shouldCleanupBuffers = true;
1751
+ triangleBuffer = device.createBuffer({
1752
+ size: triangles.byteLength,
1753
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1754
+ mappedAtCreation: true
1755
+ });
1756
+ new Float32Array(triangleBuffer.getMappedRange()).set(triangles);
1757
+ triangleBuffer.unmap();
1758
+ triangleIndicesBuffer = device.createBuffer({
1759
+ size: bucketData.triangleIndices.byteLength,
1760
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1761
+ mappedAtCreation: true
1762
+ });
1763
+ new Uint32Array(triangleIndicesBuffer.getMappedRange()).set(bucketData.triangleIndices);
1764
+ triangleIndicesBuffer.unmap();
1765
+ }
1766
+ const outputSize = numAngles * bucketData.numBuckets * bucketGridWidth * gridYHeight * 4;
1767
+ const outputBuffer = device.createBuffer({
1768
+ size: outputSize,
1769
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
1770
+ });
1771
+ const initData = new Float32Array(outputSize / 4);
1772
+ initData.fill(zFloor);
1773
+ device.queue.writeBuffer(outputBuffer, 0, initData);
1774
+ timings.prep = performance.now() - timings.start;
1775
+ const gpuStart = performance.now();
1776
+ const pipeline = cachedRadialBatchPipeline;
1777
+ const commandEncoder = device.createCommandEncoder();
1778
+ const passEncoder = commandEncoder.beginComputePass();
1779
+ passEncoder.setPipeline(pipeline);
1780
+ const batchBuffersToDestroy = [];
1781
+ for (let batchIdx = 0; batchIdx < numBucketBatches; batchIdx++) {
1782
+ const startBucket = batchIdx * maxBucketsPerBatch;
1783
+ const endBucket = Math.min(startBucket + maxBucketsPerBatch, bucketData.numBuckets);
1784
+ const bucketsInBatch = endBucket - startBucket;
1785
+ const bucketInfoSize = bucketsInBatch * 16;
1786
+ const bucketInfoBuffer = device.createBuffer({
1787
+ size: bucketInfoSize,
1788
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1789
+ mappedAtCreation: true
1790
+ });
1791
+ const bucketView = new ArrayBuffer(bucketInfoSize);
1792
+ const bucketFloatView = new Float32Array(bucketView);
1793
+ const bucketUintView = new Uint32Array(bucketView);
1794
+ for (let i = 0; i < bucketsInBatch; i++) {
1795
+ const bucket = bucketData.buckets[startBucket + i];
1796
+ const offset = i * 4;
1797
+ bucketFloatView[offset] = bucket.minX;
1798
+ bucketFloatView[offset + 1] = bucket.maxX;
1799
+ bucketUintView[offset + 2] = bucket.startIndex;
1800
+ bucketUintView[offset + 3] = bucket.count;
1801
+ }
1802
+ new Uint8Array(bucketInfoBuffer.getMappedRange()).set(new Uint8Array(bucketView));
1803
+ bucketInfoBuffer.unmap();
1804
+ const uniformBuffer = device.createBuffer({
1805
+ size: 56,
1806
+ // 14 fields * 4 bytes
1807
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1808
+ mappedAtCreation: true
1809
+ });
1810
+ const uniformView = new ArrayBuffer(56);
1811
+ const floatView = new Float32Array(uniformView);
1812
+ const uintView = new Uint32Array(uniformView);
1813
+ floatView[0] = resolution;
1814
+ floatView[1] = angleStep * (Math.PI / 180);
1815
+ uintView[2] = numAngles;
1816
+ floatView[3] = maxRadius;
1817
+ floatView[4] = toolWidth;
1818
+ uintView[5] = gridYHeight;
1819
+ floatView[6] = bucketData.buckets[0].maxX - bucketData.buckets[0].minX;
1820
+ uintView[7] = bucketGridWidth;
1821
+ floatView[8] = bucketMinX;
1822
+ floatView[9] = zFloor;
1823
+ uintView[10] = 0;
1824
+ uintView[11] = bucketData.numBuckets;
1825
+ floatView[12] = startAngle * (Math.PI / 180);
1826
+ uintView[13] = startBucket;
1827
+ new Uint8Array(uniformBuffer.getMappedRange()).set(new Uint8Array(uniformView));
1828
+ uniformBuffer.unmap();
1829
+ const bindGroup = device.createBindGroup({
1830
+ layout: pipeline.getBindGroupLayout(0),
1831
+ entries: [
1832
+ { binding: 0, resource: { buffer: triangleBuffer } },
1833
+ { binding: 1, resource: { buffer: outputBuffer } },
1834
+ { binding: 2, resource: { buffer: uniformBuffer } },
1835
+ { binding: 3, resource: { buffer: bucketInfoBuffer } },
1836
+ { binding: 4, resource: { buffer: triangleIndicesBuffer } }
1837
+ ]
1838
+ });
1839
+ passEncoder.setBindGroup(0, bindGroup);
1840
+ passEncoder.dispatchWorkgroups(dispatchX, dispatchY, bucketsInBatch);
1841
+ if (diagnostic) {
1842
+ const totalThreads = dispatchX * dispatchY * bucketsInBatch * THREADS_PER_WORKGROUP;
1843
+ debug.log(` Batch ${batchIdx + 1}/${numBucketBatches}: (${dispatchX}, ${dispatchY}, ${bucketsInBatch}) = ${totalThreads} threads, buckets ${startBucket}-${endBucket - 1}`);
1844
+ }
1845
+ batchBuffersToDestroy.push(uniformBuffer, bucketInfoBuffer);
1846
+ }
1847
+ passEncoder.end();
1848
+ const stagingBuffer = device.createBuffer({
1849
+ size: outputSize,
1850
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
1851
+ });
1852
+ commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize);
1853
+ device.queue.submit([commandEncoder.finish()]);
1854
+ await device.queue.onSubmittedWorkDone();
1855
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
1856
+ const outputCopy = new Float32Array(stagingBuffer.getMappedRange().slice());
1857
+ stagingBuffer.unmap();
1858
+ for (const buffer of batchBuffersToDestroy) {
1859
+ buffer.destroy();
1860
+ }
1861
+ outputBuffer.destroy();
1862
+ stagingBuffer.destroy();
1863
+ timings.gpu = performance.now() - gpuStart;
1864
+ const stitchStart = performance.now();
1865
+ const strips = [];
1866
+ for (let angleIdx = 0; angleIdx < numAngles; angleIdx++) {
1867
+ const stripData = new Float32Array(gridWidth * gridYHeight);
1868
+ stripData.fill(zFloor);
1869
+ for (let bucketIdx = 0; bucketIdx < bucketData.numBuckets; bucketIdx++) {
1870
+ const bucket = bucketData.buckets[bucketIdx];
1871
+ const bucketMinGridX = Math.floor((bucket.minX - bucketMinX) / resolution);
1872
+ for (let localX = 0; localX < bucketGridWidth; localX++) {
1873
+ const gridX = bucketMinGridX + localX;
1874
+ if (gridX >= gridWidth)
1875
+ continue;
1876
+ for (let gridY = 0; gridY < gridYHeight; gridY++) {
1877
+ const srcIdx = bucketIdx * numAngles * bucketGridWidth * gridYHeight + angleIdx * bucketGridWidth * gridYHeight + gridY * bucketGridWidth + localX;
1878
+ const dstIdx = gridY * gridWidth + gridX;
1879
+ stripData[dstIdx] = outputCopy[srcIdx];
1880
+ }
1881
+ }
1882
+ }
1883
+ let validCount = 0;
1884
+ for (let i = 0; i < stripData.length; i++) {
1885
+ if (stripData[i] !== zFloor)
1886
+ validCount++;
1887
+ }
1888
+ strips.push({
1889
+ angle: startAngle + angleIdx * angleStep,
1890
+ positions: stripData,
1891
+ // DENSE Z-only format!
1892
+ gridWidth,
1893
+ gridHeight: gridYHeight,
1894
+ pointCount: validCount,
1895
+ // Number of non-floor cells
1896
+ bounds: {
1897
+ min: { x: bucketMinX, y: 0, z: zFloor },
1898
+ max: { x: bucketMaxX, y: toolWidth, z: bounds.max.z }
1899
+ }
1900
+ });
1901
+ }
1902
+ timings.stitch = performance.now() - stitchStart;
1903
+ const totalTime = performance.now() - timings.start;
1904
+ Object.assign(batchInfo, {
1905
+ "prep": timings.prep | 0,
1906
+ "raster": timings.gpu | 0,
1907
+ "stitch": timings.stitch | 0
1908
+ });
1909
+ const result = { strips, timings };
1910
+ if (returnBuffersForReuse && shouldCleanupBuffers) {
1911
+ result.reusableBuffers = {
1912
+ triangleBuffer,
1913
+ triangleIndicesBuffer
1914
+ };
1915
+ } else if (shouldCleanupBuffers) {
1916
+ triangleBuffer.destroy();
1917
+ triangleIndicesBuffer.destroy();
1918
+ }
1919
+ return result;
1920
+ }
1921
+ async function generateRadialToolpaths({
1922
+ triangles,
1923
+ bucketData,
1924
+ toolData,
1925
+ resolution,
1926
+ angleStep,
1927
+ numAngles,
1928
+ maxRadius,
1929
+ toolWidth,
1930
+ zFloor,
1931
+ bounds,
1932
+ xStep,
1933
+ yStep
1934
+ }) {
1935
+ debug.log("radial-generate-toolpaths", { triangles: triangles.length, numAngles, resolution });
1936
+ const MAX_BUFFER_SIZE_MB = 1800;
1937
+ const bytesPerCell = 4;
1938
+ const xSize = bounds.max.x - bounds.min.x;
1939
+ const ySize = bounds.max.y - bounds.min.y;
1940
+ const gridXSize = Math.ceil(xSize / resolution);
1941
+ const gridYHeight = Math.ceil(ySize / resolution);
1942
+ const cellsPerAngle = gridXSize * gridYHeight;
1943
+ const bytesPerAngle = cellsPerAngle * bytesPerCell;
1944
+ const totalMemoryMB = numAngles * bytesPerAngle / (1024 * 1024);
1945
+ const batchDivisor = config?.batchDivisor || 1;
1946
+ let ANGLES_PER_BATCH, numBatches;
1947
+ if (totalMemoryMB > MAX_BUFFER_SIZE_MB) {
1948
+ const maxAnglesPerBatch = Math.floor(MAX_BUFFER_SIZE_MB * 1024 * 1024 / bytesPerAngle);
1949
+ const adjustedMaxAngles = Math.floor(maxAnglesPerBatch / batchDivisor);
1950
+ ANGLES_PER_BATCH = Math.max(1, Math.min(adjustedMaxAngles, numAngles));
1951
+ numBatches = Math.ceil(numAngles / ANGLES_PER_BATCH);
1952
+ const batchSizeMB = (ANGLES_PER_BATCH * bytesPerAngle / 1024 / 1024).toFixed(1);
1953
+ debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
1954
+ debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB exceeds limit, batching required`);
1955
+ if (batchDivisor > 1) {
1956
+ debug.log(`batchDivisor: ${batchDivisor}x (testing overhead: ${maxAnglesPerBatch} \u2192 ${adjustedMaxAngles} angles/batch)`);
1957
+ }
1958
+ debug.log(`Batch size: ${ANGLES_PER_BATCH} angles (~${batchSizeMB}MB per batch)`);
1959
+ debug.log(`Processing ${numAngles} angles in ${numBatches} batch(es)`);
1960
+ } else {
1961
+ if (batchDivisor > 1) {
1962
+ ANGLES_PER_BATCH = Math.max(10, Math.floor(numAngles / batchDivisor));
1963
+ numBatches = Math.ceil(numAngles / ANGLES_PER_BATCH);
1964
+ debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
1965
+ debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB (fits in buffer normally)`);
1966
+ debug.log(`batchDivisor: ${batchDivisor}x (artificially creating ${numBatches} batches for overhead testing)`);
1967
+ } else {
1968
+ ANGLES_PER_BATCH = numAngles;
1969
+ numBatches = 1;
1970
+ debug.log(`Grid: ${gridXSize} x ${gridYHeight}, ${cellsPerAngle.toLocaleString()} cells/angle`);
1971
+ debug.log(`Total memory: ${totalMemoryMB.toFixed(1)}MB fits in buffer, processing all ${numAngles} angles in single batch`);
1972
+ }
1973
+ }
1974
+ const allStripToolpaths = [];
1975
+ let totalToolpathPoints = 0;
1976
+ const pipelineStartTime = performance.now();
1977
+ const sparseToolData = createSparseToolFromPoints(toolData.positions);
1978
+ debug.log(`Created sparse tool: ${sparseToolData.count} points (reusing for all strips)`);
1979
+ let batchReuseBuffers = null;
1980
+ let batchTracking = [];
1981
+ for (let batchIdx = 0; batchIdx < numBatches; batchIdx++) {
1982
+ const batchStartTime = performance.now();
1983
+ const startAngleIdx = batchIdx * ANGLES_PER_BATCH;
1984
+ const endAngleIdx = Math.min(startAngleIdx + ANGLES_PER_BATCH, numAngles);
1985
+ const batchNumAngles = endAngleIdx - startAngleIdx;
1986
+ const batchStartAngle = startAngleIdx * angleStep;
1987
+ const batchInfo = {
1988
+ from: startAngleIdx,
1989
+ to: endAngleIdx
1990
+ };
1991
+ batchTracking.push(batchInfo);
1992
+ debug.log(`Batch ${batchIdx + 1}/${numBatches}: angles ${startAngleIdx}-${endAngleIdx - 1} (${batchNumAngles} angles), startAngle=${batchStartAngle.toFixed(1)}\xB0`);
1993
+ const rasterStartTime = performance.now();
1994
+ const shouldReturnBuffers = batchIdx === 0 && numBatches > 1;
1995
+ const batchModelResult = await radialRasterize({
1996
+ triangles,
1997
+ bucketData,
1998
+ resolution,
1999
+ angleStep,
2000
+ numAngles: batchNumAngles,
2001
+ maxRadius,
2002
+ toolWidth,
2003
+ zFloor,
2004
+ bounds,
2005
+ startAngle: batchStartAngle,
2006
+ reusableBuffers: batchReuseBuffers,
2007
+ returnBuffersForReuse: shouldReturnBuffers,
2008
+ batchInfo
2009
+ });
2010
+ const rasterTime = performance.now() - rasterStartTime;
2011
+ if (batchIdx === 0 && batchModelResult.reusableBuffers) {
2012
+ batchReuseBuffers = batchModelResult.reusableBuffers;
2013
+ }
2014
+ let maxStripWidth = 0;
2015
+ let maxStripHeight = 0;
2016
+ for (const strip of batchModelResult.strips) {
2017
+ maxStripWidth = Math.max(maxStripWidth, strip.gridWidth);
2018
+ maxStripHeight = Math.max(maxStripHeight, strip.gridHeight);
2019
+ }
2020
+ const reusableBuffers = createReusableToolpathBuffers(maxStripWidth, maxStripHeight, sparseToolData, xStep, maxStripHeight);
2021
+ const toolpathStartTime = performance.now();
2022
+ for (let i = 0; i < batchModelResult.strips.length; i++) {
2023
+ const strip = batchModelResult.strips[i];
2024
+ const globalStripIdx = startAngleIdx + i;
2025
+ if (globalStripIdx % 10 === 0 || globalStripIdx === numAngles - 1) {
2026
+ const stripProgress = (globalStripIdx + 1) / numAngles * 98;
2027
+ self.postMessage({
2028
+ type: "toolpath-progress",
2029
+ data: {
2030
+ percent: Math.round(stripProgress),
2031
+ current: globalStripIdx + 1,
2032
+ total: numAngles,
2033
+ layer: globalStripIdx + 1
2034
+ }
2035
+ });
2036
+ }
2037
+ if (!strip.positions || strip.positions.length === 0)
2038
+ continue;
2039
+ if (diagnostic && (globalStripIdx === 0 || globalStripIdx === 360)) {
2040
+ debug.log(`GLY1HF51 | Strip ${globalStripIdx} (${strip.angle.toFixed(1)}\xB0) INPUT terrain first 5 Z values: ${strip.positions.slice(0, 5).map((v) => v.toFixed(3)).join(",")}`);
2041
+ }
2042
+ const stripToolpathResult = await runToolpathComputeWithBuffers(
2043
+ strip.positions,
2044
+ strip.gridWidth,
2045
+ strip.gridHeight,
2046
+ xStep,
2047
+ strip.gridHeight,
2048
+ zFloor,
2049
+ reusableBuffers,
2050
+ pipelineStartTime
2051
+ );
2052
+ if (diagnostic && (globalStripIdx === 0 || globalStripIdx === 360)) {
2053
+ debug.log(`GLY1HF51 | Strip ${globalStripIdx} (${strip.angle.toFixed(1)}\xB0) OUTPUT toolpath first 5 Z values: ${stripToolpathResult.pathData.slice(0, 5).map((v) => v.toFixed(3)).join(",")}`);
2054
+ }
2055
+ allStripToolpaths.push({
2056
+ angle: strip.angle,
2057
+ pathData: stripToolpathResult.pathData,
2058
+ numScanlines: stripToolpathResult.numScanlines,
2059
+ pointsPerLine: stripToolpathResult.pointsPerLine,
2060
+ terrainBounds: strip.bounds
2061
+ });
2062
+ totalToolpathPoints += stripToolpathResult.pathData.length;
2063
+ }
2064
+ const toolpathTime = performance.now() - toolpathStartTime;
2065
+ for (const strip of batchModelResult.strips) {
2066
+ strip.positions = null;
2067
+ }
2068
+ destroyReusableToolpathBuffers(reusableBuffers);
2069
+ const batchTotalTime = performance.now() - batchStartTime;
2070
+ Object.assign(batchInfo, {
2071
+ "prep": batchInfo.prep || 0,
2072
+ "gpu": batchInfo.gpu || 0,
2073
+ "stitch": batchInfo.stitch || 0,
2074
+ "raster": batchInfo.raster || 0,
2075
+ "paths": toolpathTime | 0,
2076
+ "strips": allStripToolpaths.length,
2077
+ "total": batchTotalTime | 0
2078
+ });
2079
+ }
2080
+ console.table(batchTracking);
2081
+ if (batchReuseBuffers) {
2082
+ batchReuseBuffers.triangleBuffer.destroy();
2083
+ batchReuseBuffers.triangleIndicesBuffer.destroy();
2084
+ debug.log(`Destroyed cached GPU buffers after all batches`);
2085
+ }
2086
+ const pipelineTotalTime = performance.now() - pipelineStartTime;
2087
+ debug.log(`Complete radial toolpath: ${allStripToolpaths.length} strips, ${totalToolpathPoints} total points in ${pipelineTotalTime.toFixed(0)}ms`);
2088
+ self.postMessage({
2089
+ type: "toolpath-progress",
2090
+ data: {
2091
+ percent: 100,
2092
+ current: numAngles,
2093
+ total: numAngles,
2094
+ layer: numAngles
2095
+ }
2096
+ });
2097
+ return {
2098
+ strips: allStripToolpaths,
2099
+ totalPoints: totalToolpathPoints,
2100
+ numStrips: allStripToolpaths.length
2101
+ };
2102
+ }
2103
+
2104
+ // src/core/workload-calibrate.js
2105
+ var calibrateShaderCode = `// Workload Calibration Shader
2106
+ // Tests GPU watchdog limits by doing configurable amount of work per thread
2107
+
2108
+ struct Uniforms {
2109
+ workgroup_size_x: u32,
2110
+ workgroup_size_y: u32,
2111
+ workgroup_size_z: u32,
2112
+ triangle_tests: u32, // How many intersection tests to run
2113
+ }
2114
+
2115
+ @group(0) @binding(0) var<storage, read_write> completion_flags: array<u32>;
2116
+ @group(0) @binding(1) var<uniform> uniforms: Uniforms;
2117
+
2118
+ // Ray-triangle intersection using Möller-Trumbore algorithm
2119
+ // This is the actual production code - same ALU/cache characteristics
2120
+ fn ray_triangle_intersect(
2121
+ ray_origin: vec3<f32>,
2122
+ ray_dir: vec3<f32>,
2123
+ v0: vec3<f32>,
2124
+ v1: vec3<f32>,
2125
+ v2: vec3<f32>
2126
+ ) -> vec2<f32> { // Returns (hit: 0.0 or 1.0, z: intersection_z)
2127
+ let EPSILON = 0.0001;
2128
+
2129
+ // Calculate edges
2130
+ let edge1 = v1 - v0;
2131
+ let edge2 = v2 - v0;
2132
+
2133
+ // Cross product: ray_dir × edge2
2134
+ let h = cross(ray_dir, edge2);
2135
+
2136
+ // Dot product: edge1 · h
2137
+ let a = dot(edge1, h);
2138
+
2139
+ // Check if ray is parallel to triangle
2140
+ if (abs(a) < EPSILON) {
2141
+ return vec2<f32>(0.0, 0.0);
2142
+ }
2143
+
2144
+ let f = 1.0 / a;
2145
+ let s = ray_origin - v0;
2146
+ let u = f * dot(s, h);
2147
+
2148
+ // Check if intersection is outside triangle (u parameter)
2149
+ if (u < 0.0 || u > 1.0) {
2150
+ return vec2<f32>(0.0, 0.0);
2151
+ }
2152
+
2153
+ let q = cross(s, edge1);
2154
+ let v = f * dot(ray_dir, q);
2155
+
2156
+ // Check if intersection is outside triangle (v parameter)
2157
+ if (v < 0.0 || u + v > 1.0) {
2158
+ return vec2<f32>(0.0, 0.0);
2159
+ }
2160
+
2161
+ // Calculate intersection point along ray
2162
+ let t = f * dot(edge2, q);
2163
+
2164
+ if (t > EPSILON) {
2165
+ // Ray hit triangle
2166
+ let intersection_z = ray_origin.z + t * ray_dir.z;
2167
+ return vec2<f32>(1.0, intersection_z);
2168
+ }
2169
+
2170
+ return vec2<f32>(0.0, 0.0);
2171
+ }
2172
+
2173
+ @compute @workgroup_size(16, 16, 1)
2174
+ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
2175
+ let thread_index = global_id.z * (uniforms.workgroup_size_x * uniforms.workgroup_size_y) +
2176
+ global_id.y * uniforms.workgroup_size_x +
2177
+ global_id.x;
2178
+
2179
+ // Synthetic triangle vertices (deterministic, no memory reads needed)
2180
+ let v0 = vec3<f32>(0.0, 0.0, 0.0);
2181
+ let v1 = vec3<f32>(1.0, 0.0, 0.0);
2182
+ let v2 = vec3<f32>(0.5, 1.0, 0.0);
2183
+
2184
+ // Ray parameters based on thread ID (deterministic)
2185
+ let ray_origin = vec3<f32>(
2186
+ f32(global_id.x) * 0.1,
2187
+ f32(global_id.y) * 0.1,
2188
+ 10.0
2189
+ );
2190
+ let ray_dir = vec3<f32>(0.0, 0.0, -1.0);
2191
+
2192
+ // Perform N intersection tests (configurable workload)
2193
+ var hit_count = 0u;
2194
+ for (var i = 0u; i < uniforms.triangle_tests; i++) {
2195
+ // Slightly vary triangle vertices to prevent compiler optimization
2196
+ let offset = f32(i) * 0.001;
2197
+ let v0_offset = v0 + vec3<f32>(offset, 0.0, 0.0);
2198
+ let v1_offset = v1 + vec3<f32>(0.0, offset, 0.0);
2199
+ let v2_offset = v2 + vec3<f32>(offset, offset, 0.0);
2200
+
2201
+ let result = ray_triangle_intersect(ray_origin, ray_dir, v0_offset, v1_offset, v2_offset);
2202
+ if (result.x > 0.5) {
2203
+ hit_count += 1u;
2204
+ }
2205
+ }
2206
+
2207
+ // Write completion flag (1 = thread completed all work)
2208
+ // If this thread was killed by watchdog, this write never happens (stays 0)
2209
+ completion_flags[thread_index] = 1u;
2210
+ }
2211
+ `;
2212
+ async function testWorkload(device2, pipeline, workgroupSize, triangleTests) {
2213
+ const [x, y, z] = workgroupSize;
2214
+ const totalThreads = x * y * z;
2215
+ const completionBuffer = device2.createBuffer({
2216
+ size: totalThreads * 4,
2217
+ // u32 per thread
2218
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
2219
+ });
2220
+ const zeroData = new Uint32Array(totalThreads);
2221
+ device2.queue.writeBuffer(completionBuffer, 0, zeroData);
2222
+ const uniformData = new Uint32Array([x, y, z, triangleTests]);
2223
+ const uniformBuffer = device2.createBuffer({
2224
+ size: uniformData.byteLength,
2225
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
2226
+ });
2227
+ device2.queue.writeBuffer(uniformBuffer, 0, uniformData);
2228
+ await device2.queue.onSubmittedWorkDone();
2229
+ const bindGroup = device2.createBindGroup({
2230
+ layout: pipeline.getBindGroupLayout(0),
2231
+ entries: [
2232
+ { binding: 0, resource: { buffer: completionBuffer } },
2233
+ { binding: 1, resource: { buffer: uniformBuffer } }
2234
+ ]
2235
+ });
2236
+ const startTime = performance.now();
2237
+ const commandEncoder = device2.createCommandEncoder();
2238
+ const passEncoder = commandEncoder.beginComputePass();
2239
+ passEncoder.setPipeline(pipeline);
2240
+ passEncoder.setBindGroup(0, bindGroup);
2241
+ passEncoder.dispatchWorkgroups(1, 1, 1);
2242
+ passEncoder.end();
2243
+ const stagingBuffer = device2.createBuffer({
2244
+ size: totalThreads * 4,
2245
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
2246
+ });
2247
+ commandEncoder.copyBufferToBuffer(completionBuffer, 0, stagingBuffer, 0, totalThreads * 4);
2248
+ device2.queue.submit([commandEncoder.finish()]);
2249
+ await device2.queue.onSubmittedWorkDone();
2250
+ const elapsed = performance.now() - startTime;
2251
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
2252
+ const completionData = new Uint32Array(stagingBuffer.getMappedRange());
2253
+ const completionCopy = new Uint32Array(completionData);
2254
+ stagingBuffer.unmap();
2255
+ let failedThreads = 0;
2256
+ for (let i = 0; i < totalThreads; i++) {
2257
+ if (completionCopy[i] === 0) {
2258
+ failedThreads++;
2259
+ }
2260
+ }
2261
+ completionBuffer.destroy();
2262
+ uniformBuffer.destroy();
2263
+ stagingBuffer.destroy();
2264
+ return {
2265
+ success: failedThreads === 0,
2266
+ failedThreads,
2267
+ totalThreads,
2268
+ elapsed
2269
+ };
2270
+ }
2271
+ async function findMaxWork(device2, pipeline, workgroupSize, minWork, maxWork) {
2272
+ let low = minWork;
2273
+ let high = maxWork;
2274
+ let lastSuccess = minWork;
2275
+ while (low <= high) {
2276
+ const mid = Math.floor((low + high) / 2);
2277
+ const result = await testWorkload(device2, pipeline, workgroupSize, mid);
2278
+ if (result.success) {
2279
+ lastSuccess = mid;
2280
+ low = mid + 1;
2281
+ } else {
2282
+ high = mid - 1;
2283
+ }
2284
+ }
2285
+ return lastSuccess;
2286
+ }
2287
+ async function calibrateGPU(device2, options = {}) {
2288
+ const {
2289
+ workgroupSizes = [
2290
+ [8, 8, 1],
2291
+ [16, 16, 1],
2292
+ [32, 32, 1],
2293
+ [64, 64, 1]
2294
+ ],
2295
+ minWork = 1e3,
2296
+ maxWork = 1e5,
2297
+ verbose = true
2298
+ } = options;
2299
+ const shaderModule = device2.createShaderModule({ code: calibrateShaderCode });
2300
+ const pipeline = device2.createComputePipeline({
2301
+ layout: "auto",
2302
+ compute: { module: shaderModule, entryPoint: "main" }
2303
+ });
2304
+ const results = [];
2305
+ if (verbose) {
2306
+ console.log("[Calibrate] Starting GPU calibration...");
2307
+ console.log("[Calibrate] Testing workgroup sizes:", workgroupSizes);
2308
+ }
2309
+ for (const size of workgroupSizes) {
2310
+ const [x, y, z] = size;
2311
+ const totalThreads = x * y * z;
2312
+ if (verbose) {
2313
+ console.log(`[Calibrate] Testing ${x}x${y}x${z} (${totalThreads} threads)...`);
2314
+ }
2315
+ const minTest = await testWorkload(device2, pipeline, size, minWork);
2316
+ if (!minTest.success) {
2317
+ if (verbose) {
2318
+ console.log(`[Calibrate] \u274C Failed even at minimum work (${minWork} tests)`);
2319
+ }
2320
+ break;
2321
+ }
2322
+ const maxWorkFound = await findMaxWork(device2, pipeline, size, minWork, maxWork);
2323
+ const finalTest = await testWorkload(device2, pipeline, size, maxWorkFound);
2324
+ results.push({
2325
+ workgroupSize: size,
2326
+ totalThreads,
2327
+ maxWork: maxWorkFound,
2328
+ timingMs: finalTest.elapsed,
2329
+ msPerThread: finalTest.elapsed / totalThreads,
2330
+ testsPerSecond: maxWorkFound * totalThreads / (finalTest.elapsed / 1e3)
2331
+ });
2332
+ if (verbose) {
2333
+ console.log(`[Calibrate] \u2713 Max work: ${maxWorkFound} tests (${finalTest.elapsed.toFixed(1)}ms)`);
2334
+ console.log(`[Calibrate] ${(maxWorkFound * totalThreads).toLocaleString()} total ray-triangle tests`);
2335
+ }
2336
+ }
2337
+ const maxWorkgroupResult = results[results.length - 1];
2338
+ const minWorkPerThread = Math.min(...results.map((r) => r.maxWork));
2339
+ const calibration = {
2340
+ maxWorkgroupSize: maxWorkgroupResult.workgroupSize,
2341
+ maxWorkPerThread: minWorkPerThread,
2342
+ // Conservative: min across all sizes
2343
+ safeWorkloadMatrix: results,
2344
+ deviceInfo: {
2345
+ maxComputeWorkgroupSizeX: device2.limits.maxComputeWorkgroupSizeX,
2346
+ maxComputeWorkgroupSizeY: device2.limits.maxComputeWorkgroupSizeY,
2347
+ maxComputeWorkgroupSizeZ: device2.limits.maxComputeWorkgroupSizeZ,
2348
+ maxComputeWorkgroupsPerDimension: device2.limits.maxComputeWorkgroupsPerDimension
2349
+ }
2350
+ };
2351
+ if (verbose) {
2352
+ console.log("[Calibrate] Calibration complete:");
2353
+ console.log(`[Calibrate] Max safe workgroup: ${maxWorkgroupResult.workgroupSize.join("x")}`);
2354
+ console.log(`[Calibrate] Max work per thread: ${minWorkPerThread.toLocaleString()}`);
2355
+ }
2356
+ return calibration;
2357
+ }
2358
+
2359
+ // src/core/raster-worker.js
2360
+ self.addEventListener("error", (event) => {
2361
+ debug.error("Uncaught error:", event.error || event.message);
2362
+ debug.error("Stack:", event.error?.stack);
2363
+ });
2364
+ self.addEventListener("unhandledrejection", (event) => {
2365
+ debug.error("Unhandled promise rejection:", event.reason);
2366
+ });
2367
+ self.onmessage = async function(e) {
2368
+ const { type, data } = e.data;
2369
+ try {
2370
+ switch (type) {
2371
+ case "init":
2372
+ setConfig(data?.config || {
2373
+ maxGPUMemoryMB: 256,
2374
+ gpuMemorySafetyMargin: 0.8,
2375
+ tileOverlapMM: 10,
2376
+ autoTiling: true,
2377
+ batchDivisor: 1,
2378
+ // For testing batching overhead: 1=optimal, 2=2x batches, 4=4x batches, etc.
2379
+ maxConcurrentThreads: 32768
2380
+ // GPU watchdog limit: max threads across all workgroups in a dispatch
2381
+ });
2382
+ const success = await initWebGPU();
2383
+ self.postMessage({
2384
+ type: "webgpu-ready",
2385
+ data: {
2386
+ success,
2387
+ capabilities: deviceCapabilities
2388
+ }
2389
+ });
2390
+ break;
2391
+ case "update-config":
2392
+ updateConfig(data.config);
2393
+ debug.log("Config updated");
2394
+ break;
2395
+ case "rasterize":
2396
+ const { triangles, stepSize, filterMode, boundsOverride } = data;
2397
+ const rasterOptions = boundsOverride || {};
2398
+ const rasterResult = await rasterizeMesh(triangles, stepSize, filterMode, rasterOptions);
2399
+ self.postMessage({
2400
+ type: "rasterize-complete",
2401
+ data: rasterResult
2402
+ }, [rasterResult.positions.buffer]);
2403
+ break;
2404
+ case "generate-toolpath":
2405
+ const { terrainPositions, toolPositions, xStep, yStep, zFloor, gridStep, terrainBounds, singleScanline } = data;
2406
+ const toolpathResult = await generateToolpath(
2407
+ terrainPositions,
2408
+ toolPositions,
2409
+ xStep,
2410
+ yStep,
2411
+ zFloor,
2412
+ gridStep,
2413
+ terrainBounds,
2414
+ singleScanline
2415
+ );
2416
+ self.postMessage({
2417
+ type: "toolpath-complete",
2418
+ data: toolpathResult
2419
+ }, [toolpathResult.pathData.buffer]);
2420
+ break;
2421
+ case "radial-generate-toolpaths":
2422
+ const radialToolpathResult = await generateRadialToolpaths(data);
2423
+ const toolpathTransferBuffers = radialToolpathResult.strips.map((strip) => strip.pathData.buffer);
2424
+ self.postMessage({
2425
+ type: "radial-toolpaths-complete",
2426
+ data: radialToolpathResult
2427
+ }, toolpathTransferBuffers);
2428
+ break;
2429
+ case "calibrate":
2430
+ const calibrationResult = await calibrateGPU(device, data?.options || {});
2431
+ self.postMessage({
2432
+ type: "calibrate-complete",
2433
+ data: calibrationResult
2434
+ });
2435
+ break;
2436
+ default:
2437
+ self.postMessage({
2438
+ type: "error",
2439
+ message: "Unknown message type: " + type
2440
+ });
2441
+ }
2442
+ } catch (error) {
2443
+ debug.error("Error:", error);
2444
+ self.postMessage({
2445
+ type: "error",
2446
+ message: error.message,
2447
+ stack: error.stack
2448
+ });
2449
+ }
2450
+ };