@gridspace/raster-path 1.0.3 → 1.0.5

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.
@@ -1,3011 +0,0 @@
1
- /**
2
- * ═══════════════════════════════════════════════════════════════════════════
3
- * WebGPU Worker - GPU Compute Operations
4
- * ═══════════════════════════════════════════════════════════════════════════
5
- *
6
- * Offloads all WebGPU compute operations to a worker thread to prevent UI blocking.
7
- * Handles both planar (XY grid) and radial (cylindrical) rasterization modes.
8
- *
9
- * MESSAGE PROTOCOL:
10
- * ─────────────────
11
- * Main Thread → Worker:
12
- * 'init' - Initialize WebGPU device
13
- * 'rasterize-planar' - Rasterize geometry to XY grid
14
- * 'generate-toolpath-planar' - Generate planar toolpath from rasters
15
- * 'radial-generate-toolpaths'- Generate radial toolpaths (does rasterization + toolpath)
16
- *
17
- * Worker → Main Thread:
18
- * 'webgpu-ready' - Initialization complete
19
- * 'rasterize-complete' - Planar rasterization complete
20
- * 'rasterize-progress' - Progress update (0-1)
21
- * 'toolpath-complete' - Planar toolpath complete
22
- * 'toolpath-progress' - Progress update (0-1)
23
- * 'radial-toolpaths-complete' - Radial toolpaths complete
24
- *
25
- * ARCHITECTURE:
26
- * ─────────────
27
- * 1. PLANAR MODE:
28
- * - Rasterize terrain: XY grid, keep max Z per cell
29
- * - Rasterize tool: XY grid, keep min Z per cell
30
- * - Generate toolpath: Scan tool over terrain, compute Z-heights
31
- *
32
- * 2. RADIAL MODE:
33
- * - Batched processing: 360 angles per batch
34
- * - X-bucketing: Spatial partitioning to reduce triangle tests
35
- * - For each angle:
36
- * * Cast ray from origin
37
- * * Rasterize terrain triangles along ray
38
- * * Calculate tool-terrain collision
39
- * * Output Z-heights along X-axis
40
- *
41
- * MEMORY MANAGEMENT:
42
- * ──────────────────
43
- * - All GPU buffers are preallocated to known maximum sizes
44
- * - Triangle data transferred once per operation
45
- * - Output buffers mapped asynchronously to avoid blocking
46
- * - Worker maintains pipeline cache to avoid recompilation
47
- *
48
- * ═══════════════════════════════════════════════════════════════════════════
49
- */
50
-
51
- let device = null;
52
- let isInitialized = false;
53
- let cachedRasterizePipeline = null;
54
- let cachedRasterizeShaderModule = null;
55
- let cachedToolpathPipeline = null;
56
- let cachedToolpathShaderModule = null;
57
- let config = null;
58
- let deviceCapabilities = null;
59
-
60
- const EMPTY_CELL = -1e10;
61
- const log_pre = '[Raster Worker]';
62
-
63
- // url params to control logging
64
- let { search } = self.location;
65
- let verbose = search.indexOf('debug') >= 0;
66
- let quiet = search.indexOf('quiet') >= 0;
67
-
68
- const debug = {
69
- error: function() { console.error(log_pre, ...arguments) },
70
- warn: function() { console.warn(log_pre, ...arguments) },
71
- log: function() { !quiet && console.log(log_pre, ...arguments) },
72
- ok: function() { console.log(log_pre, '✅', ...arguments) },
73
- };
74
-
75
- function round(v, d = 1) {
76
- return parseFloat(v.toFixed(d));
77
- }
78
-
79
- // Global error handler for uncaught errors in worker
80
- self.addEventListener('error', (event) => {
81
- debug.error('Uncaught error:', event.error || event.message);
82
- debug.error('Stack:', event.error?.stack);
83
- });
84
-
85
- self.addEventListener('unhandledrejection', (event) => {
86
- debug.error('Unhandled promise rejection:', event.reason);
87
- });
88
-
89
- // Initialize WebGPU device in worker context
90
- async function initWebGPU() {
91
- if (isInitialized) return true;
92
-
93
- if (!navigator.gpu) {
94
- debug.warn('WebGPU not supported');
95
- return false;
96
- }
97
-
98
- try {
99
- const adapter = await navigator.gpu.requestAdapter();
100
- if (!adapter) {
101
- debug.warn('WebGPU adapter not available');
102
- return false;
103
- }
104
-
105
- // Request device with higher limits for large meshes
106
- const adapterLimits = adapter.limits;
107
- debug.log('Adapter limits:', {
108
- maxStorageBufferBindingSize: adapterLimits.maxStorageBufferBindingSize,
109
- maxBufferSize: adapterLimits.maxBufferSize
110
- });
111
-
112
- device = await adapter.requestDevice({
113
- requiredLimits: {
114
- maxStorageBufferBindingSize: Math.min(
115
- adapterLimits.maxStorageBufferBindingSize,
116
- 1024 * 1024 * 1024 // Request up to 1GB
117
- ),
118
- maxBufferSize: Math.min(
119
- adapterLimits.maxBufferSize,
120
- 1024 * 1024 * 1024 // Request up to 1GB
121
- )
122
- }
123
- });
124
-
125
- // Pre-compile rasterize shader module (expensive operation)
126
- cachedRasterizeShaderModule = device.createShaderModule({ code: rasterizeShaderCode });
127
-
128
- // Pre-create rasterize pipeline (very expensive operation)
129
- cachedRasterizePipeline = device.createComputePipeline({
130
- layout: 'auto',
131
- compute: { module: cachedRasterizeShaderModule, entryPoint: 'main' },
132
- });
133
-
134
- // Pre-compile toolpath shader module
135
- cachedToolpathShaderModule = device.createShaderModule({ code: toolpathShaderCode });
136
-
137
- // Pre-create toolpath pipeline
138
- cachedToolpathPipeline = device.createComputePipeline({
139
- layout: 'auto',
140
- compute: { module: cachedToolpathShaderModule, entryPoint: 'main' },
141
- });
142
-
143
- // Store device capabilities
144
- deviceCapabilities = {
145
- maxStorageBufferBindingSize: device.limits.maxStorageBufferBindingSize,
146
- maxBufferSize: device.limits.maxBufferSize,
147
- maxComputeWorkgroupSizeX: device.limits.maxComputeWorkgroupSizeX,
148
- maxComputeWorkgroupSizeY: device.limits.maxComputeWorkgroupSizeY,
149
- };
150
-
151
- isInitialized = true;
152
- debug.log('Initialized (pipelines cached)');
153
- return true;
154
- } catch (error) {
155
- debug.error('Failed to initialize:', error);
156
- return false;
157
- }
158
- }
159
-
160
- // Planar rasterization with spatial partitioning
161
- const rasterizeShaderCode = `// Planar rasterization with spatial partitioning
162
- // Sentinel value for empty cells (far below any real geometry)
163
- const EMPTY_CELL: f32 = -1e10;
164
-
165
- struct Uniforms {
166
- bounds_min_x: f32,
167
- bounds_min_y: f32,
168
- bounds_min_z: f32,
169
- bounds_max_x: f32,
170
- bounds_max_y: f32,
171
- bounds_max_z: f32,
172
- step_size: f32,
173
- grid_width: u32,
174
- grid_height: u32,
175
- triangle_count: u32,
176
- filter_mode: u32, // 0 = UPWARD (terrain, keep highest), 1 = DOWNWARD (tool, keep lowest)
177
- spatial_grid_width: u32,
178
- spatial_grid_height: u32,
179
- spatial_cell_size: f32,
180
- }
181
-
182
- @group(0) @binding(0) var<storage, read> triangles: array<f32>;
183
- @group(0) @binding(1) var<storage, read_write> output_points: array<f32>;
184
- @group(0) @binding(2) var<storage, read_write> valid_mask: array<u32>;
185
- @group(0) @binding(3) var<uniform> uniforms: Uniforms;
186
- @group(0) @binding(4) var<storage, read> spatial_cell_offsets: array<u32>;
187
- @group(0) @binding(5) var<storage, read> spatial_triangle_indices: array<u32>;
188
-
189
- // Fast 2D bounding box check for XY plane
190
- fn ray_hits_triangle_bbox_2d(ray_x: f32, ray_y: f32, v0: vec3<f32>, v1: vec3<f32>, v2: vec3<f32>) -> bool {
191
- // Add small epsilon to catch near-misses (mesh vertex gaps, FP rounding)
192
- let epsilon = 0.001; // 1 micron tolerance
193
- let min_x = min(min(v0.x, v1.x), v2.x) - epsilon;
194
- let max_x = max(max(v0.x, v1.x), v2.x) + epsilon;
195
- let min_y = min(min(v0.y, v1.y), v2.y) - epsilon;
196
- let max_y = max(max(v0.y, v1.y), v2.y) + epsilon;
197
-
198
- return ray_x >= min_x && ray_x <= max_x && ray_y >= min_y && ray_y <= max_y;
199
- }
200
-
201
- // Ray-triangle intersection using Möller-Trumbore algorithm
202
- fn ray_triangle_intersect(
203
- ray_origin: vec3<f32>,
204
- ray_dir: vec3<f32>,
205
- v0: vec3<f32>,
206
- v1: vec3<f32>,
207
- v2: vec3<f32>
208
- ) -> vec2<f32> { // Returns (hit: 0.0 or 1.0, z: intersection_z)
209
- // Larger epsilon needed because near-parallel triangles (small 'a') amplify errors via f=1/a
210
- let EPSILON = 0.0001;
211
-
212
- // Early rejection using 2D bounding box (very cheap!)
213
- if (!ray_hits_triangle_bbox_2d(ray_origin.x, ray_origin.y, v0, v1, v2)) {
214
- return vec2<f32>(0.0, 0.0);
215
- }
216
-
217
- // Calculate edges
218
- let edge1 = v1 - v0;
219
- let edge2 = v2 - v0;
220
-
221
- // Cross product: ray_dir × edge2
222
- let h = cross(ray_dir, edge2);
223
-
224
- // Dot product: edge1 · h
225
- let a = dot(edge1, h);
226
-
227
- if (a > -EPSILON && a < EPSILON) {
228
- return vec2<f32>(0.0, 0.0); // Ray parallel to triangle
229
- }
230
-
231
- let f = 1.0 / a;
232
-
233
- // s = ray_origin - v0
234
- let s = ray_origin - v0;
235
-
236
- // u = f * (s · h)
237
- let u = f * dot(s, h);
238
-
239
- // Allow tolerance for edges/vertices to ensure watertight coverage
240
- if (u < -EPSILON || u > 1.0 + EPSILON) {
241
- return vec2<f32>(0.0, 0.0);
242
- }
243
-
244
- // Cross product: s × edge1
245
- let q = cross(s, edge1);
246
-
247
- // v = f * (ray_dir · q)
248
- let v = f * dot(ray_dir, q);
249
-
250
- // Allow tolerance for edges/vertices to ensure watertight coverage
251
- if (v < -EPSILON || u + v > 1.0 + EPSILON) {
252
- return vec2<f32>(0.0, 0.0);
253
- }
254
-
255
- // t = f * (edge2 · q)
256
- let t = f * dot(edge2, q);
257
-
258
- if (t > EPSILON) {
259
- // Intersection found - calculate Z coordinate
260
- let intersection_z = ray_origin.z + ray_dir.z * t;
261
- return vec2<f32>(1.0, intersection_z);
262
- }
263
-
264
- return vec2<f32>(0.0, 0.0);
265
- }
266
-
267
- @compute @workgroup_size(16, 16)
268
- fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
269
- let grid_x = global_id.x;
270
- let grid_y = global_id.y;
271
-
272
- if (grid_x >= uniforms.grid_width || grid_y >= uniforms.grid_height) {
273
- return;
274
- }
275
-
276
- // Calculate world position for this grid point (center of cell)
277
- let world_x = uniforms.bounds_min_x + f32(grid_x) * uniforms.step_size;
278
- let world_y = uniforms.bounds_min_y + f32(grid_y) * uniforms.step_size;
279
-
280
- // Initialize best_z based on filter mode
281
- var best_z: f32;
282
- if (uniforms.filter_mode == 0u) {
283
- best_z = -1e10; // Terrain: keep highest Z
284
- } else {
285
- best_z = 1e10; // Tool: keep lowest Z
286
- }
287
-
288
- var found = false;
289
-
290
- // Ray from below mesh pointing up (+Z direction)
291
- let ray_origin = vec3<f32>(world_x, world_y, uniforms.bounds_min_z - 1.0);
292
- let ray_dir = vec3<f32>(0.0, 0.0, 1.0);
293
-
294
- // Find which spatial grid cell this ray belongs to
295
- let spatial_cell_x = u32((world_x - uniforms.bounds_min_x) / uniforms.spatial_cell_size);
296
- let spatial_cell_y = u32((world_y - uniforms.bounds_min_y) / uniforms.spatial_cell_size);
297
-
298
- // Clamp to spatial grid bounds
299
- let clamped_cx = min(spatial_cell_x, uniforms.spatial_grid_width - 1u);
300
- let clamped_cy = min(spatial_cell_y, uniforms.spatial_grid_height - 1u);
301
-
302
- let spatial_cell_idx = clamped_cy * uniforms.spatial_grid_width + clamped_cx;
303
-
304
- // Get triangle range for this cell
305
- let start_idx = spatial_cell_offsets[spatial_cell_idx];
306
- let end_idx = spatial_cell_offsets[spatial_cell_idx + 1u];
307
-
308
- // Test only triangles in this spatial cell
309
- for (var idx = start_idx; idx < end_idx; idx++) {
310
- let tri_idx = spatial_triangle_indices[idx];
311
- let tri_base = tri_idx * 9u;
312
-
313
- // Read triangle vertices (already in local space and rotated if needed)
314
- let v0 = vec3<f32>(
315
- triangles[tri_base],
316
- triangles[tri_base + 1u],
317
- triangles[tri_base + 2u]
318
- );
319
- let v1 = vec3<f32>(
320
- triangles[tri_base + 3u],
321
- triangles[tri_base + 4u],
322
- triangles[tri_base + 5u]
323
- );
324
- let v2 = vec3<f32>(
325
- triangles[tri_base + 6u],
326
- triangles[tri_base + 7u],
327
- triangles[tri_base + 8u]
328
- );
329
-
330
- let result = ray_triangle_intersect(ray_origin, ray_dir, v0, v1, v2);
331
- let hit = result.x;
332
- let intersection_z = result.y;
333
-
334
- if (hit > 0.5) {
335
- if (uniforms.filter_mode == 0u) {
336
- // Terrain: keep highest
337
- if (intersection_z > best_z) {
338
- best_z = intersection_z;
339
- found = true;
340
- }
341
- } else {
342
- // Tool: keep lowest
343
- if (intersection_z < best_z) {
344
- best_z = intersection_z;
345
- found = true;
346
- }
347
- }
348
- }
349
- }
350
-
351
- // Write output based on filter mode
352
- let output_idx = grid_y * uniforms.grid_width + grid_x;
353
-
354
- if (uniforms.filter_mode == 0u) {
355
- // Terrain: Dense output (Z-only, sentinel value for empty cells)
356
- if (found) {
357
- output_points[output_idx] = best_z;
358
- } else {
359
- output_points[output_idx] = EMPTY_CELL;
360
- }
361
- } else {
362
- // Tool: Sparse output (X, Y, Z triplets with valid mask)
363
- output_points[output_idx * 3u] = f32(grid_x);
364
- output_points[output_idx * 3u + 1u] = f32(grid_y);
365
- output_points[output_idx * 3u + 2u] = best_z;
366
-
367
- if (found) {
368
- valid_mask[output_idx] = 1u;
369
- } else {
370
- valid_mask[output_idx] = 0u;
371
- }
372
- }
373
- }
374
- `;
375
-
376
- // Planar toolpath generation
377
- const toolpathShaderCode = `// Planar toolpath generation
378
- // Sentinel value for empty terrain cells (must match rasterize shader)
379
- const EMPTY_CELL: f32 = -1e10;
380
- const MAX_F32: f32 = 3.402823466e+38;
381
-
382
- struct SparseToolPoint {
383
- x_offset: i32,
384
- y_offset: i32,
385
- z_value: f32,
386
- padding: f32,
387
- }
388
-
389
- struct Uniforms {
390
- terrain_width: u32,
391
- terrain_height: u32,
392
- tool_count: u32,
393
- x_step: u32,
394
- y_step: u32,
395
- oob_z: f32,
396
- points_per_line: u32,
397
- num_scanlines: u32,
398
- y_offset: u32, // Offset to center Y position (for single-scanline radial mode)
399
- }
400
-
401
- @group(0) @binding(0) var<storage, read> terrain_map: array<f32>;
402
- @group(0) @binding(1) var<storage, read> sparse_tool: array<SparseToolPoint>;
403
- @group(0) @binding(2) var<storage, read_write> output_path: array<f32>;
404
- @group(0) @binding(3) var<uniform> uniforms: Uniforms;
405
-
406
- @compute @workgroup_size(16, 16)
407
- fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
408
- let scanline = global_id.y;
409
- let point_idx = global_id.x;
410
-
411
- if (scanline >= uniforms.num_scanlines || point_idx >= uniforms.points_per_line) {
412
- return;
413
- }
414
-
415
- let tool_center_x = i32(point_idx * uniforms.x_step);
416
- let tool_center_y = i32(scanline * uniforms.y_step) + i32(uniforms.y_offset);
417
-
418
- // var max_collision_z = -MAX_F32; // Track maximum collision height
419
- var max_collision_z = uniforms.oob_z; // Track maximum collision height
420
- var found_collision = false;
421
-
422
- for (var i = 0u; i < uniforms.tool_count; i++) {
423
- let tool_point = sparse_tool[i];
424
- let terrain_x = tool_center_x + tool_point.x_offset;
425
- let terrain_y = tool_center_y + tool_point.y_offset;
426
-
427
- // Bounds check: X must always be in bounds
428
- if (terrain_x < 0 || terrain_x >= i32(uniforms.terrain_width)) {
429
- continue;
430
- }
431
-
432
- // Bounds check: Y must be within terrain strip bounds
433
- // For single-scanline OUTPUT mode, the tool center is at Y=0 but the terrain
434
- // strip contains the full Y range (tool width), so tool offsets access different Y rows
435
- if (terrain_y < 0 || terrain_y >= i32(uniforms.terrain_height)) {
436
- continue;
437
- }
438
-
439
- let terrain_idx = u32(terrain_y) * uniforms.terrain_width + u32(terrain_x);
440
- let terrain_z = terrain_map[terrain_idx];
441
-
442
- // Check if terrain cell has geometry (not empty sentinel value)
443
- if (terrain_z > EMPTY_CELL + 1.0) {
444
- // Tool z_value is positive offset from tip (tip=0, shaft=+50)
445
- // Subtract from terrain to find where tool center needs to be
446
- let collision_z = terrain_z + tool_point.z_value;
447
- max_collision_z = max(max_collision_z, collision_z);
448
- found_collision = true;
449
- }
450
- }
451
-
452
- var output_z = uniforms.oob_z;
453
- if (found_collision) {
454
- output_z = max_collision_z;
455
- }
456
-
457
- let output_idx = scanline * uniforms.points_per_line + point_idx;
458
- output_path[output_idx] = output_z;
459
- }
460
- `;
461
-
462
- // Radial V2: Rasterization with rotating ray planes and X-bucketing
463
- const radialRasterizeV2ShaderCode = `// Radial V2 rasterization with X-bucketing and rotating ray planes
464
- // Sentinel value for empty cells (far below any real geometry)
465
- const EMPTY_CELL: f32 = -1e10;
466
- const PI: f32 = 3.14159265359;
467
-
468
- struct Uniforms {
469
- resolution: f32, // Grid step size (mm)
470
- angle_step: f32, // Radians between angles
471
- num_angles: u32, // Total number of angular strips
472
- max_radius: f32, // Ray origin distance from X-axis (maxHypot * 1.01)
473
- tool_width: f32, // Tool width in mm
474
- grid_y_height: u32, // Tool width in pixels (toolWidth / resolution)
475
- bucket_width: f32, // Width of each X-bucket (mm)
476
- bucket_grid_width: u32, // Bucket width in pixels
477
- global_min_x: f32, // Global minimum X coordinate
478
- z_floor: f32, // Z value for empty cells
479
- filter_mode: u32, // 0 = max Z (terrain), 1 = min Z (tool)
480
- num_buckets: u32, // Total number of X-buckets
481
- start_angle: f32, // Starting angle offset in radians (for batching)
482
- }
483
-
484
- struct BucketInfo {
485
- min_x: f32, // Bucket X range start (mm)
486
- max_x: f32, // Bucket X range end (mm)
487
- start_index: u32, // Index into triangle_indices array
488
- count: u32 // Number of triangles in this bucket
489
- }
490
-
491
- @group(0) @binding(0) var<storage, read> triangles: array<f32>;
492
- @group(0) @binding(1) var<storage, read_write> output: array<f32>;
493
- @group(0) @binding(2) var<uniform> uniforms: Uniforms;
494
- @group(0) @binding(3) var<storage, read> bucket_info: array<BucketInfo>;
495
- @group(0) @binding(4) var<storage, read> triangle_indices: array<u32>;
496
-
497
- // Note: AABB early rejection removed - X-bucketing already provides spatial filtering
498
- // A proper ray-AABB intersection test would be needed if we wanted bounding box culling,
499
- // but checking if ray_origin is inside AABB was incorrect and rejected valid triangles
500
-
501
- // Ray-triangle intersection using Möller-Trumbore algorithm
502
- fn ray_triangle_intersect(
503
- ray_origin: vec3<f32>,
504
- ray_dir: vec3<f32>,
505
- v0: vec3<f32>,
506
- v1: vec3<f32>,
507
- v2: vec3<f32>
508
- ) -> vec2<f32> { // Returns (hit: 0.0 or 1.0, z: intersection_z)
509
- let EPSILON = 0.0001;
510
-
511
- // Calculate edges
512
- let edge1 = v1 - v0;
513
- let edge2 = v2 - v0;
514
-
515
- // Cross product: ray_dir × edge2
516
- let h = cross(ray_dir, edge2);
517
-
518
- // Dot product: edge1 · h
519
- let a = dot(edge1, h);
520
-
521
- if (a > -EPSILON && a < EPSILON) {
522
- return vec2<f32>(0.0, 0.0); // Ray parallel to triangle
523
- }
524
-
525
- let f = 1.0 / a;
526
-
527
- // s = ray_origin - v0
528
- let s = ray_origin - v0;
529
-
530
- // u = f * (s · h)
531
- let u = f * dot(s, h);
532
-
533
- if (u < -EPSILON || u > 1.0 + EPSILON) {
534
- return vec2<f32>(0.0, 0.0);
535
- }
536
-
537
- // Cross product: s × edge1
538
- let q = cross(s, edge1);
539
-
540
- // v = f * (ray_dir · q)
541
- let v = f * dot(ray_dir, q);
542
-
543
- if (v < -EPSILON || u + v > 1.0 + EPSILON) {
544
- return vec2<f32>(0.0, 0.0);
545
- }
546
-
547
- // t = f * (edge2 · q)
548
- let t = f * dot(edge2, q);
549
-
550
- if (t > EPSILON) {
551
- // Intersection found - return distance along ray (t parameter)
552
- return vec2<f32>(1.0, t);
553
- }
554
-
555
- return vec2<f32>(0.0, 0.0);
556
- }
557
-
558
- @compute @workgroup_size(8, 8, 1)
559
- fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
560
- let bucket_idx = global_id.z;
561
- let grid_y = global_id.y;
562
- let angle_idx = global_id.x;
563
-
564
- // Bounds check
565
- if (angle_idx >= uniforms.num_angles ||
566
- grid_y >= uniforms.grid_y_height ||
567
- bucket_idx >= uniforms.num_buckets) {
568
- return;
569
- }
570
-
571
- let bucket = bucket_info[bucket_idx];
572
- let angle = uniforms.start_angle + (f32(angle_idx) * uniforms.angle_step);
573
-
574
- // Calculate bucket min grid X
575
- let bucket_min_grid_x = u32((bucket.min_x - uniforms.global_min_x) / uniforms.resolution);
576
-
577
- // Loop over X within this bucket
578
- for (var local_x = 0u; local_x < uniforms.bucket_grid_width; local_x++) {
579
- let grid_x = bucket_min_grid_x + local_x;
580
- let world_x = uniforms.global_min_x + f32(grid_x) * uniforms.resolution;
581
-
582
- // Rotating top-down scan: normal XY planar scan, rotated around X-axis
583
- // Step 1: Define scan position in planar frame (X, Y, Z_above)
584
- let scan_x = world_x;
585
- let scan_y = f32(grid_y) * uniforms.resolution - uniforms.tool_width / 2.0;
586
- let scan_z = uniforms.max_radius; // Start above the model
587
-
588
- // Step 2: Rotate position (scan_x, scan_y, scan_z) around X-axis by 'angle'
589
- // X stays the same, rotate YZ plane: y' = y*cos - z*sin, z' = y*sin + z*cos
590
- let ray_origin_x = scan_x;
591
- let ray_origin_y = scan_y * cos(angle) - scan_z * sin(angle);
592
- let ray_origin_z = scan_y * sin(angle) + scan_z * cos(angle);
593
- let ray_origin = vec3<f32>(ray_origin_x, ray_origin_y, ray_origin_z);
594
-
595
- // Step 3: Rotate ray direction (0, 0, -1) around X-axis by 'angle'
596
- // X component stays 0, rotate YZ: dy = 0*cos - (-1)*sin = sin, dz = 0*sin + (-1)*cos = -cos
597
- let ray_dir = vec3<f32>(0.0, sin(angle), -cos(angle));
598
-
599
- // Initialize best distance (closest hit)
600
- var best_t: f32 = 1e10; // Start with very large distance
601
- var found = false;
602
-
603
- // Ray-cast against triangles in this bucket
604
- for (var i = 0u; i < bucket.count; i++) {
605
- let tri_idx = triangle_indices[bucket.start_index + i];
606
- let tri_base = tri_idx * 9u;
607
-
608
- // Read triangle vertices
609
- let v0 = vec3<f32>(
610
- triangles[tri_base],
611
- triangles[tri_base + 1u],
612
- triangles[tri_base + 2u]
613
- );
614
- let v1 = vec3<f32>(
615
- triangles[tri_base + 3u],
616
- triangles[tri_base + 4u],
617
- triangles[tri_base + 5u]
618
- );
619
- let v2 = vec3<f32>(
620
- triangles[tri_base + 6u],
621
- triangles[tri_base + 7u],
622
- triangles[tri_base + 8u]
623
- );
624
-
625
- let result = ray_triangle_intersect(ray_origin, ray_dir, v0, v1, v2);
626
- let hit = result.x;
627
- let t = result.y; // Distance along ray
628
-
629
- if (hit > 0.5) {
630
- // Keep closest hit (minimum t)
631
- if (t < best_t) {
632
- best_t = t;
633
- found = true;
634
- }
635
- }
636
- }
637
-
638
- // Write output
639
- // Layout: bucket_idx * numAngles * bucketWidth * gridHeight
640
- // + angle_idx * bucketWidth * gridHeight
641
- // + grid_y * bucketWidth
642
- // + local_x
643
- let output_idx = bucket_idx * uniforms.num_angles * uniforms.bucket_grid_width * uniforms.grid_y_height
644
- + angle_idx * uniforms.bucket_grid_width * uniforms.grid_y_height
645
- + grid_y * uniforms.bucket_grid_width
646
- + local_x;
647
-
648
- if (found) {
649
- // Terrain height = distance from scan origin minus ray travel distance
650
- // Ray started at max_radius from X-axis, traveled best_t distance to hit
651
- let terrain_height = uniforms.max_radius - best_t;
652
- output[output_idx] = terrain_height;
653
- } else {
654
- output[output_idx] = uniforms.z_floor;
655
- }
656
- }
657
- }
658
- `;
659
-
660
- // Calculate bounding box from triangle vertices
661
- function calculateBounds(triangles) {
662
- let min_x = Infinity, min_y = Infinity, min_z = Infinity;
663
- let max_x = -Infinity, max_y = -Infinity, max_z = -Infinity;
664
-
665
- for (let i = 0; i < triangles.length; i += 3) {
666
- const x = triangles[i];
667
- const y = triangles[i + 1];
668
- const z = triangles[i + 2];
669
-
670
- if (x < min_x) min_x = x;
671
- if (y < min_y) min_y = y;
672
- if (z < min_z) min_z = z;
673
- if (x > max_x) max_x = x;
674
- if (y > max_y) max_y = y;
675
- if (z > max_z) max_z = z;
676
- }
677
-
678
- return {
679
- min: { x: min_x, y: min_y, z: min_z },
680
- max: { x: max_x, y: max_y, z: max_z }
681
- };
682
- }
683
-
684
- // Build spatial grid for efficient triangle culling
685
- function buildSpatialGrid(triangles, bounds, cellSize = 5.0) {
686
- const gridWidth = Math.max(1, Math.ceil((bounds.max.x - bounds.min.x) / cellSize));
687
- const gridHeight = Math.max(1, Math.ceil((bounds.max.y - bounds.min.y) / cellSize));
688
- const totalCells = gridWidth * gridHeight;
689
-
690
- // debug.log(`Building spatial grid ${gridWidth}x${gridHeight} (${cellSize}mm cells)`);
691
-
692
- const grid = new Array(totalCells);
693
- for (let i = 0; i < totalCells; i++) {
694
- grid[i] = [];
695
- }
696
-
697
- const triangleCount = triangles.length / 9;
698
- for (let t = 0; t < triangleCount; t++) {
699
- const base = t * 9;
700
-
701
- const v0x = triangles[base], v0y = triangles[base + 1];
702
- const v1x = triangles[base + 3], v1y = triangles[base + 4];
703
- const v2x = triangles[base + 6], v2y = triangles[base + 7];
704
-
705
- // Add small epsilon to catch triangles near cell boundaries
706
- const epsilon = cellSize * 0.01; // 1% of cell size
707
- const minX = Math.min(v0x, v1x, v2x) - epsilon;
708
- const maxX = Math.max(v0x, v1x, v2x) + epsilon;
709
- const minY = Math.min(v0y, v1y, v2y) - epsilon;
710
- const maxY = Math.max(v0y, v1y, v2y) + epsilon;
711
-
712
- let minCellX = Math.floor((minX - bounds.min.x) / cellSize);
713
- let maxCellX = Math.floor((maxX - bounds.min.x) / cellSize);
714
- let minCellY = Math.floor((minY - bounds.min.y) / cellSize);
715
- let maxCellY = Math.floor((maxY - bounds.min.y) / cellSize);
716
-
717
- minCellX = Math.max(0, Math.min(gridWidth - 1, minCellX));
718
- maxCellX = Math.max(0, Math.min(gridWidth - 1, maxCellX));
719
- minCellY = Math.max(0, Math.min(gridHeight - 1, minCellY));
720
- maxCellY = Math.max(0, Math.min(gridHeight - 1, maxCellY));
721
-
722
- for (let cy = minCellY; cy <= maxCellY; cy++) {
723
- for (let cx = minCellX; cx <= maxCellX; cx++) {
724
- const cellIdx = cy * gridWidth + cx;
725
- grid[cellIdx].push(t);
726
- }
727
- }
728
- }
729
-
730
- let totalTriangleRefs = 0;
731
- for (let i = 0; i < totalCells; i++) {
732
- totalTriangleRefs += grid[i].length;
733
- }
734
-
735
- const cellOffsets = new Uint32Array(totalCells + 1);
736
- const triangleIndices = new Uint32Array(totalTriangleRefs);
737
-
738
- let currentOffset = 0;
739
- for (let i = 0; i < totalCells; i++) {
740
- cellOffsets[i] = currentOffset;
741
- for (let j = 0; j < grid[i].length; j++) {
742
- triangleIndices[currentOffset++] = grid[i][j];
743
- }
744
- }
745
- cellOffsets[totalCells] = currentOffset;
746
-
747
- const avgPerCell = totalTriangleRefs / totalCells;
748
- // debug.log(`Spatial grid: ${totalTriangleRefs} refs (avg ${avgPerCell.toFixed(1)} per cell)`);
749
-
750
- return {
751
- gridWidth,
752
- gridHeight,
753
- cellSize,
754
- cellOffsets,
755
- triangleIndices,
756
- avgTrianglesPerCell: avgPerCell
757
- };
758
- }
759
-
760
- // Create reusable GPU buffers for multiple rasterization passes (e.g., radial rotations)
761
-
762
- // Rasterize mesh to point cloud
763
- // Internal function - rasterize without tiling (do not modify this function!)
764
- async function rasterizeMeshSingle(triangles, stepSize, filterMode, options = {}) {
765
- const startTime = performance.now();
766
-
767
- if (!isInitialized) {
768
- const initStart = performance.now();
769
- const success = await initWebGPU();
770
- if (!success) {
771
- throw new Error('WebGPU not available');
772
- }
773
- const initEnd = performance.now();
774
- debug.log(`First-time init: ${(initEnd - initStart).toFixed(1)}ms`);
775
- }
776
-
777
- // debug.log(`Rasterizing ${triangles.length / 9} triangles (step ${stepSize}mm, mode ${filterMode})...`);
778
-
779
- // Extract options
780
- // boundsOverride: Optional manual bounds to avoid recalculating from triangles
781
- // Useful when bounds are already known (e.g., from tiling operations)
782
- const boundsOverride = options.bounds || options.min ? options : null;
783
-
784
- // Use bounds override if provided, otherwise calculate from triangles
785
- const bounds = boundsOverride || calculateBounds(triangles);
786
-
787
- if (boundsOverride) {
788
- // debug.log(`Using bounds override: min(${bounds.min.x.toFixed(2)}, ${bounds.min.y.toFixed(2)}, ${bounds.min.z.toFixed(2)}) max(${bounds.max.x.toFixed(2)}, ${bounds.max.y.toFixed(2)}, ${bounds.max.z.toFixed(2)})`);
789
-
790
- // Validate bounds
791
- if (bounds.min.x >= bounds.max.x || bounds.min.y >= bounds.max.y || bounds.min.z >= bounds.max.z) {
792
- 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})`);
793
- }
794
- }
795
-
796
- const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
797
- const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
798
- const totalGridPoints = gridWidth * gridHeight;
799
-
800
- // debug.log(`Grid: ${gridWidth}x${gridHeight} = ${totalGridPoints.toLocaleString()} points`);
801
-
802
- // Calculate buffer size based on filter mode
803
- // filterMode 0 (terrain): Dense Z-only output (1 float per grid cell)
804
- // filterMode 1 (tool): Sparse X,Y,Z output (3 floats per grid cell)
805
- const floatsPerPoint = filterMode === 0 ? 1 : 3;
806
- const outputSize = totalGridPoints * floatsPerPoint * 4;
807
- const maxBufferSize = device.limits.maxBufferSize || 268435456; // 256MB default
808
- const modeStr = filterMode === 0 ? 'terrain (dense Z-only)' : 'tool (sparse XYZ)';
809
- // debug.log(`Output buffer size: ${(outputSize / 1024 / 1024).toFixed(2)} MB for ${modeStr} (max: ${(maxBufferSize / 1024 / 1024).toFixed(2)} MB)`);
810
-
811
- if (outputSize > maxBufferSize) {
812
- 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.`);
813
- }
814
-
815
- const spatialGrid = buildSpatialGrid(triangles, bounds);
816
-
817
- // Create buffers
818
- const triangleBuffer = device.createBuffer({
819
- size: triangles.byteLength,
820
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
821
- });
822
- device.queue.writeBuffer(triangleBuffer, 0, triangles);
823
-
824
- // Create and INITIALIZE output buffer (GPU buffers contain garbage by default!)
825
- const outputBuffer = device.createBuffer({
826
- size: outputSize,
827
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
828
- });
829
-
830
- // Initialize output buffer with sentinel value for terrain, zeros for tool
831
- if (filterMode === 0) {
832
- // Terrain: initialize with EMPTY_CELL sentinel value
833
- const initData = new Float32Array(totalGridPoints);
834
- initData.fill(EMPTY_CELL);
835
- device.queue.writeBuffer(outputBuffer, 0, initData);
836
- }
837
- // Tool mode: zeros are fine (will check valid mask)
838
-
839
- const validMaskBuffer = device.createBuffer({
840
- size: totalGridPoints * 4,
841
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
842
- });
843
-
844
- const spatialCellOffsetsBuffer = device.createBuffer({
845
- size: spatialGrid.cellOffsets.byteLength,
846
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
847
- });
848
- device.queue.writeBuffer(spatialCellOffsetsBuffer, 0, spatialGrid.cellOffsets);
849
-
850
- const spatialTriangleIndicesBuffer = device.createBuffer({
851
- size: spatialGrid.triangleIndices.byteLength,
852
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
853
- });
854
- device.queue.writeBuffer(spatialTriangleIndicesBuffer, 0, spatialGrid.triangleIndices);
855
-
856
- // Uniforms
857
- const uniformData = new Float32Array([
858
- bounds.min.x, bounds.min.y, bounds.min.z,
859
- bounds.max.x, bounds.max.y, bounds.max.z,
860
- stepSize,
861
- 0, 0, 0, 0, 0, 0, 0 // Padding for alignment
862
- ]);
863
- const uniformDataU32 = new Uint32Array(uniformData.buffer);
864
- uniformDataU32[7] = gridWidth;
865
- uniformDataU32[8] = gridHeight;
866
- uniformDataU32[9] = triangles.length / 9;
867
- uniformDataU32[10] = filterMode;
868
- uniformDataU32[11] = spatialGrid.gridWidth;
869
- uniformDataU32[12] = spatialGrid.gridHeight;
870
- const uniformDataF32 = new Float32Array(uniformData.buffer);
871
- uniformDataF32[13] = spatialGrid.cellSize;
872
-
873
- // Check for u32 overflow
874
- const maxU32 = 4294967295;
875
- if (gridWidth > maxU32 || gridHeight > maxU32) {
876
- throw new Error(`Grid dimensions exceed u32 max: ${gridWidth}x${gridHeight}`);
877
- }
878
-
879
- // debug.log(`Uniforms: gridWidth=${gridWidth}, gridHeight=${gridHeight}, triangles=${triangles.length / 9}`);
880
-
881
- const uniformBuffer = device.createBuffer({
882
- size: uniformData.byteLength,
883
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
884
- });
885
- device.queue.writeBuffer(uniformBuffer, 0, uniformData);
886
-
887
- // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
888
- await device.queue.onSubmittedWorkDone();
889
-
890
- // Use cached pipeline
891
- const bindGroup = device.createBindGroup({
892
- layout: cachedRasterizePipeline.getBindGroupLayout(0),
893
- entries: [
894
- { binding: 0, resource: { buffer: triangleBuffer } },
895
- { binding: 1, resource: { buffer: outputBuffer } },
896
- { binding: 2, resource: { buffer: validMaskBuffer } },
897
- { binding: 3, resource: { buffer: uniformBuffer } },
898
- { binding: 4, resource: { buffer: spatialCellOffsetsBuffer } },
899
- { binding: 5, resource: { buffer: spatialTriangleIndicesBuffer } },
900
- ],
901
- });
902
-
903
- // Dispatch compute shader
904
- const commandEncoder = device.createCommandEncoder();
905
- const passEncoder = commandEncoder.beginComputePass();
906
- passEncoder.setPipeline(cachedRasterizePipeline);
907
- passEncoder.setBindGroup(0, bindGroup);
908
-
909
- const workgroupsX = Math.ceil(gridWidth / 16);
910
- const workgroupsY = Math.ceil(gridHeight / 16);
911
-
912
- // Check dispatch limits
913
- const maxWorkgroupsPerDim = device.limits.maxComputeWorkgroupsPerDimension || 65535;
914
-
915
- if (workgroupsX > maxWorkgroupsPerDim || workgroupsY > maxWorkgroupsPerDim) {
916
- throw new Error(`Workgroup dispatch too large: ${workgroupsX}x${workgroupsY} exceeds limit of ${maxWorkgroupsPerDim}. Try a larger step size.`);
917
- }
918
-
919
- passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
920
- passEncoder.end();
921
-
922
- // Create staging buffers for readback
923
- const stagingOutputBuffer = device.createBuffer({
924
- size: outputSize,
925
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
926
- });
927
-
928
- const stagingValidMaskBuffer = device.createBuffer({
929
- size: totalGridPoints * 4,
930
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
931
- });
932
-
933
- commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingOutputBuffer, 0, outputSize);
934
- commandEncoder.copyBufferToBuffer(validMaskBuffer, 0, stagingValidMaskBuffer, 0, totalGridPoints * 4);
935
-
936
- device.queue.submit([commandEncoder.finish()]);
937
-
938
- // Wait for GPU to finish
939
- await device.queue.onSubmittedWorkDone();
940
-
941
- // Read back results
942
- await stagingOutputBuffer.mapAsync(GPUMapMode.READ);
943
- await stagingValidMaskBuffer.mapAsync(GPUMapMode.READ);
944
-
945
- const outputData = new Float32Array(stagingOutputBuffer.getMappedRange());
946
- const validMaskData = new Uint32Array(stagingValidMaskBuffer.getMappedRange());
947
-
948
- let result, pointCount;
949
-
950
- if (filterMode === 0) {
951
- // Terrain: Dense output (Z-only), no compaction needed
952
- // Copy the full array (already has NaN for empty cells)
953
- result = new Float32Array(outputData);
954
- pointCount = totalGridPoints;
955
-
956
- if (verbose) {
957
- // Count valid points for logging (sentinel value = -1e10)
958
- let zeroCount = 0;
959
- let validCount = 0;
960
- for (let i = 0; i < totalGridPoints; i++) {
961
- if (result[i] > EMPTY_CELL + 1) validCount++; // Any value significantly above sentinel
962
- if (result[i] === 0) zeroCount++;
963
- }
964
-
965
- let percentHit = validCount/totalGridPoints;
966
- if (zeroCount > 0 || percentHit < 0.5 ) {
967
- debug.log(totalGridPoints, 'cells,', round(percentHit*100), '% coverage,', zeroCount, 'zeros');
968
- }
969
- }
970
- } else {
971
- // Tool: Sparse output (X,Y,Z triplets), compact to remove invalid points
972
- const validPoints = [];
973
- for (let i = 0; i < totalGridPoints; i++) {
974
- if (validMaskData[i] === 1) {
975
- validPoints.push(
976
- outputData[i * 3],
977
- outputData[i * 3 + 1],
978
- outputData[i * 3 + 2]
979
- );
980
- }
981
- }
982
- result = new Float32Array(validPoints);
983
- pointCount = validPoints.length / 3;
984
- }
985
-
986
- stagingOutputBuffer.unmap();
987
- stagingValidMaskBuffer.unmap();
988
-
989
- // Cleanup
990
- triangleBuffer.destroy();
991
- outputBuffer.destroy();
992
- validMaskBuffer.destroy();
993
- uniformBuffer.destroy();
994
- spatialCellOffsetsBuffer.destroy();
995
- spatialTriangleIndicesBuffer.destroy();
996
- stagingOutputBuffer.destroy();
997
- stagingValidMaskBuffer.destroy();
998
-
999
- const endTime = performance.now();
1000
- const conversionTime = endTime - startTime;
1001
- // debug.log(`Rasterize complete: ${pointCount} points in ${conversionTime.toFixed(1)}ms`);
1002
- // debug.log(`Bounds: min(${bounds.min.x.toFixed(2)}, ${bounds.min.y.toFixed(2)}, ${bounds.min.z.toFixed(2)}) max(${bounds.max.x.toFixed(2)}, ${bounds.max.y.toFixed(2)}, ${bounds.max.z.toFixed(2)})`);
1003
-
1004
- // Verify result data integrity
1005
- if (filterMode === 0) {
1006
- // Terrain: Dense Z-only format
1007
- if (result.length > 0) {
1008
- const firstZ = result[0] <= EMPTY_CELL + 1 ? 'EMPTY' : result[0].toFixed(3);
1009
- const lastZ = result[result.length-1] <= EMPTY_CELL + 1 ? 'EMPTY' : result[result.length-1].toFixed(3);
1010
- // debug.log(`First Z: ${firstZ}, Last Z: ${lastZ}`);
1011
- }
1012
- } else {
1013
- // Tool: Sparse X,Y,Z format
1014
- if (result.length > 0) {
1015
- const firstPoint = `(${result[0].toFixed(3)}, ${result[1].toFixed(3)}, ${result[2].toFixed(3)})`;
1016
- const lastIdx = result.length - 3;
1017
- const lastPoint = `(${result[lastIdx].toFixed(3)}, ${result[lastIdx+1].toFixed(3)}, ${result[lastIdx+2].toFixed(3)})`;
1018
- // debug.log(`First point: ${firstPoint}, Last point: ${lastPoint}`);
1019
- }
1020
- }
1021
-
1022
- return {
1023
- positions: result,
1024
- pointCount: pointCount,
1025
- bounds: bounds,
1026
- conversionTime: conversionTime,
1027
- gridWidth: gridWidth,
1028
- gridHeight: gridHeight,
1029
- isDense: filterMode === 0 // True for terrain (dense), false for tool (sparse)
1030
- };
1031
- }
1032
-
1033
- // Create tiles for tiled rasterization
1034
- function createTiles(bounds, stepSize, maxMemoryBytes) {
1035
- const width = bounds.max.x - bounds.min.x;
1036
- const height = bounds.max.y - bounds.min.y;
1037
- const aspectRatio = width / height;
1038
-
1039
- // Calculate how many grid points we can fit in one tile
1040
- // Terrain uses dense Z-only format: (gridW * gridH * 1 * 4) for output
1041
- // This is 4x more efficient than the old sparse format (16 bytes → 4 bytes per point)
1042
- const bytesPerPoint = 1 * 4; // 4 bytes per grid point (Z-only)
1043
- const maxPointsPerTile = Math.floor(maxMemoryBytes / bytesPerPoint);
1044
- debug.log(`Dense terrain format: ${bytesPerPoint} bytes/point (was 16), can fit ${(maxPointsPerTile/1e6).toFixed(1)}M points per tile`);
1045
-
1046
- // Calculate optimal tile grid dimensions while respecting aspect ratio
1047
- // We want: tileGridW * tileGridH <= maxPointsPerTile
1048
- // And: tileGridW / tileGridH ≈ aspectRatio
1049
-
1050
- let tileGridW, tileGridH;
1051
- if (aspectRatio >= 1) {
1052
- // Width >= Height
1053
- tileGridH = Math.floor(Math.sqrt(maxPointsPerTile / aspectRatio));
1054
- tileGridW = Math.floor(tileGridH * aspectRatio);
1055
- } else {
1056
- // Height > Width
1057
- tileGridW = Math.floor(Math.sqrt(maxPointsPerTile * aspectRatio));
1058
- tileGridH = Math.floor(tileGridW / aspectRatio);
1059
- }
1060
-
1061
- // Ensure we don't exceed limits
1062
- while (tileGridW * tileGridH * bytesPerPoint > maxMemoryBytes) {
1063
- if (tileGridW > tileGridH) {
1064
- tileGridW--;
1065
- } else {
1066
- tileGridH--;
1067
- }
1068
- }
1069
-
1070
- // Convert grid dimensions to world dimensions
1071
- const tileWidth = tileGridW * stepSize;
1072
- const tileHeight = tileGridH * stepSize;
1073
-
1074
- // Calculate number of tiles needed
1075
- const tilesX = Math.ceil(width / tileWidth);
1076
- const tilesY = Math.ceil(height / tileHeight);
1077
-
1078
- // Calculate actual tile dimensions (distribute evenly)
1079
- const actualTileWidth = width / tilesX;
1080
- const actualTileHeight = height / tilesY;
1081
-
1082
- debug.log(`Creating ${tilesX}x${tilesY} = ${tilesX * tilesY} tiles (${actualTileWidth.toFixed(2)}mm × ${actualTileHeight.toFixed(2)}mm each)`);
1083
- debug.log(`Tile grid: ${Math.ceil(actualTileWidth / stepSize)}x${Math.ceil(actualTileHeight / stepSize)} points per tile`);
1084
-
1085
- const tiles = [];
1086
- const overlap = stepSize * 2; // Overlap by 2 grid cells to ensure no gaps
1087
-
1088
- for (let ty = 0; ty < tilesY; ty++) {
1089
- for (let tx = 0; tx < tilesX; tx++) {
1090
- // Calculate base tile bounds (no overlap)
1091
- let tileMinX = bounds.min.x + (tx * actualTileWidth);
1092
- let tileMinY = bounds.min.y + (ty * actualTileHeight);
1093
- let tileMaxX = Math.min(bounds.max.x, tileMinX + actualTileWidth);
1094
- let tileMaxY = Math.min(bounds.max.y, tileMinY + actualTileHeight);
1095
-
1096
- // Add overlap (except at outer edges) - but DON'T extend beyond global bounds
1097
- if (tx > 0) tileMinX = Math.max(bounds.min.x, tileMinX - overlap);
1098
- if (ty > 0) tileMinY = Math.max(bounds.min.y, tileMinY - overlap);
1099
- if (tx < tilesX - 1) tileMaxX = Math.min(bounds.max.x, tileMaxX + overlap);
1100
- if (ty < tilesY - 1) tileMaxY = Math.min(bounds.max.y, tileMaxY + overlap);
1101
-
1102
- tiles.push({
1103
- id: `tile_${tx}_${ty}`,
1104
- bounds: {
1105
- min: { x: tileMinX, y: tileMinY, z: bounds.min.z },
1106
- max: { x: tileMaxX, y: tileMaxY, z: bounds.max.z }
1107
- }
1108
- });
1109
- }
1110
- }
1111
-
1112
- return { tiles, tilesX, tilesY };
1113
- }
1114
-
1115
- // Stitch tiles from multiple rasterization passes
1116
- function stitchTiles(tileResults, fullBounds, stepSize) {
1117
- if (tileResults.length === 0) {
1118
- throw new Error('No tile results to stitch');
1119
- }
1120
-
1121
- // Check if results are dense (terrain) or sparse (tool)
1122
- const isDense = tileResults[0].isDense;
1123
-
1124
- if (isDense) {
1125
- // DENSE TERRAIN STITCHING: Simple array copying (Z-only format)
1126
- debug.log(`Stitching ${tileResults.length} dense terrain tiles...`);
1127
-
1128
- // Calculate global grid dimensions
1129
- const globalWidth = Math.ceil((fullBounds.max.x - fullBounds.min.x) / stepSize) + 1;
1130
- const globalHeight = Math.ceil((fullBounds.max.y - fullBounds.min.y) / stepSize) + 1;
1131
- const totalGridCells = globalWidth * globalHeight;
1132
-
1133
- // Allocate global dense grid (Z-only), initialize to sentinel value
1134
- const globalGrid = new Float32Array(totalGridCells);
1135
- globalGrid.fill(EMPTY_CELL);
1136
-
1137
- debug.log(`Global grid: ${globalWidth}x${globalHeight} = ${totalGridCells.toLocaleString()} cells`);
1138
-
1139
- // Copy each tile's Z-values to the correct position in global grid
1140
- for (const tile of tileResults) {
1141
- // Calculate tile's position in global grid
1142
- const tileOffsetX = Math.round((tile.tileBounds.min.x - fullBounds.min.x) / stepSize);
1143
- const tileOffsetY = Math.round((tile.tileBounds.min.y - fullBounds.min.y) / stepSize);
1144
-
1145
- const tileWidth = tile.gridWidth;
1146
- const tileHeight = tile.gridHeight;
1147
-
1148
- // Copy Z-values row by row
1149
- for (let ty = 0; ty < tileHeight; ty++) {
1150
- const globalY = tileOffsetY + ty;
1151
- if (globalY >= globalHeight) continue;
1152
-
1153
- for (let tx = 0; tx < tileWidth; tx++) {
1154
- const globalX = tileOffsetX + tx;
1155
- if (globalX >= globalWidth) continue;
1156
-
1157
- const tileIdx = ty * tileWidth + tx;
1158
- const globalIdx = globalY * globalWidth + globalX;
1159
- const tileZ = tile.positions[tileIdx];
1160
-
1161
- // For overlapping cells, keep max Z (terrain surface)
1162
- // Skip empty cells (sentinel value)
1163
- if (tileZ > EMPTY_CELL + 1) {
1164
- const existingZ = globalGrid[globalIdx];
1165
- if (existingZ <= EMPTY_CELL + 1 || tileZ > existingZ) {
1166
- globalGrid[globalIdx] = tileZ;
1167
- }
1168
- }
1169
- }
1170
- }
1171
- }
1172
-
1173
- // Count valid cells (above sentinel value)
1174
- let validCount = 0;
1175
- for (let i = 0; i < totalGridCells; i++) {
1176
- if (globalGrid[i] > EMPTY_CELL + 1) validCount++;
1177
- }
1178
-
1179
- debug.log(`Stitched: ${totalGridCells} total cells, ${validCount} with geometry (${(validCount/totalGridCells*100).toFixed(1)}% coverage)`);
1180
-
1181
- return {
1182
- positions: globalGrid,
1183
- pointCount: totalGridCells,
1184
- bounds: fullBounds,
1185
- gridWidth: globalWidth,
1186
- gridHeight: globalHeight,
1187
- isDense: true,
1188
- conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
1189
- tileCount: tileResults.length
1190
- };
1191
-
1192
- } else {
1193
- // SPARSE TOOL STITCHING: Keep existing deduplication logic (X,Y,Z triplets)
1194
- debug.log(`Stitching ${tileResults.length} sparse tool tiles...`);
1195
-
1196
- const pointMap = new Map();
1197
-
1198
- for (const result of tileResults) {
1199
- const positions = result.positions;
1200
-
1201
- // Calculate offset from tile origin to global origin (in grid cells)
1202
- const tileOffsetX = Math.round((result.tileBounds.min.x - fullBounds.min.x) / stepSize);
1203
- const tileOffsetY = Math.round((result.tileBounds.min.y - fullBounds.min.y) / stepSize);
1204
-
1205
- // Convert each point from tile-local to global grid coordinates
1206
- for (let i = 0; i < positions.length; i += 3) {
1207
- const localGridX = positions[i];
1208
- const localGridY = positions[i + 1];
1209
- const z = positions[i + 2];
1210
-
1211
- // Convert local grid indices to global grid indices
1212
- const globalGridX = localGridX + tileOffsetX;
1213
- const globalGridY = localGridY + tileOffsetY;
1214
-
1215
- const key = `${globalGridX},${globalGridY}`;
1216
- const existing = pointMap.get(key);
1217
-
1218
- // Keep lowest Z value (for tool)
1219
- if (!existing || z < existing.z) {
1220
- pointMap.set(key, { x: globalGridX, y: globalGridY, z });
1221
- }
1222
- }
1223
- }
1224
-
1225
- // Convert Map to flat array
1226
- const finalPointCount = pointMap.size;
1227
- const allPositions = new Float32Array(finalPointCount * 3);
1228
- let writeOffset = 0;
1229
-
1230
- for (const point of pointMap.values()) {
1231
- allPositions[writeOffset++] = point.x;
1232
- allPositions[writeOffset++] = point.y;
1233
- allPositions[writeOffset++] = point.z;
1234
- }
1235
-
1236
- debug.log(`Stitched: ${finalPointCount} unique sparse points`);
1237
-
1238
- return {
1239
- positions: allPositions,
1240
- pointCount: finalPointCount,
1241
- bounds: fullBounds,
1242
- isDense: false,
1243
- conversionTime: tileResults.reduce((sum, r) => sum + (r.conversionTime || 0), 0),
1244
- tileCount: tileResults.length
1245
- };
1246
- }
1247
- }
1248
-
1249
- // Check if tiling is needed (only called for terrain, which uses dense format)
1250
- function shouldUseTiling(bounds, stepSize) {
1251
- if (!config || !config.autoTiling) return false;
1252
- if (!deviceCapabilities) return false;
1253
-
1254
- const gridWidth = Math.ceil((bounds.max.x - bounds.min.x) / stepSize) + 1;
1255
- const gridHeight = Math.ceil((bounds.max.y - bounds.min.y) / stepSize) + 1;
1256
- const totalPoints = gridWidth * gridHeight;
1257
-
1258
- // Terrain uses dense Z-only format: 1 float (4 bytes) per grid cell
1259
- const gpuOutputBuffer = totalPoints * 1 * 4;
1260
- const totalGPUMemory = gpuOutputBuffer; // No mask needed for dense output
1261
-
1262
- // Use the smaller of configured limit or device capability
1263
- const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
1264
- const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
1265
- const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
1266
-
1267
- return totalGPUMemory > maxSafeSize;
1268
- }
1269
-
1270
- // Rasterize mesh - wrapper that handles automatic tiling if needed
1271
- async function rasterizeMesh(triangles, stepSize, filterMode, options = {}) {
1272
- const boundsOverride = options.bounds || options.min ? options : null; // Support old and new format
1273
- const bounds = boundsOverride || calculateBounds(triangles);
1274
-
1275
- // Check if tiling is needed
1276
- if (shouldUseTiling(bounds, stepSize)) {
1277
- debug.log('Tiling required - switching to tiled rasterization');
1278
-
1279
- // Calculate max safe size per tile
1280
- const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
1281
- const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
1282
- const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
1283
-
1284
- // Create tiles
1285
- const { tiles } = createTiles(bounds, stepSize, maxSafeSize);
1286
-
1287
- // Rasterize each tile
1288
- const tileResults = [];
1289
- for (let i = 0; i < tiles.length; i++) {
1290
- const tileStart = performance.now();
1291
- debug.log(`Processing tile ${i + 1}/${tiles.length}: ${tiles[i].id}`);
1292
- 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)})`);
1293
-
1294
- const tileResult = await rasterizeMeshSingle(triangles, stepSize, filterMode, {
1295
- ...tiles[i].bounds,
1296
- rotationAngleDeg: options.rotationAngleDeg
1297
- });
1298
-
1299
- const tileTime = performance.now() - tileStart;
1300
- debug.log(` Tile ${i + 1} complete: ${tileResult.pointCount} points in ${tileTime.toFixed(1)}ms`);
1301
-
1302
- // Store tile bounds with result for coordinate conversion during stitching
1303
- tileResult.tileBounds = tiles[i].bounds;
1304
- tileResults.push(tileResult);
1305
- }
1306
-
1307
- // Stitch tiles together (pass full bounds and step size for coordinate conversion)
1308
- return stitchTiles(tileResults, bounds, stepSize);
1309
- } else {
1310
- // Single-pass rasterization
1311
- return await rasterizeMeshSingle(triangles, stepSize, filterMode, options);
1312
- }
1313
- }
1314
-
1315
- // Helper: Create height map from dense terrain points (Z-only array)
1316
- // Terrain is ALWAYS dense (Z-only), never sparse
1317
- function createHeightMapFromPoints(points, gridStep, bounds = null) {
1318
- if (!points || points.length === 0) {
1319
- throw new Error('No points provided');
1320
- }
1321
-
1322
- // Calculate dimensions from bounds
1323
- if (!bounds) {
1324
- throw new Error('Bounds required for height map creation');
1325
- }
1326
-
1327
- const minX = bounds.min.x;
1328
- const minY = bounds.min.y;
1329
- const minZ = bounds.min.z;
1330
- const maxX = bounds.max.x;
1331
- const maxY = bounds.max.y;
1332
- const maxZ = bounds.max.z;
1333
- const width = Math.ceil((maxX - minX) / gridStep) + 1;
1334
- const height = Math.ceil((maxY - minY) / gridStep) + 1;
1335
-
1336
- // Terrain is ALWAYS dense (Z-only format from GPU rasterizer)
1337
- // debug.log(`Terrain dense format: ${width}x${height} = ${points.length} cells`);
1338
-
1339
- return {
1340
- grid: points, // Dense Z-only array
1341
- width,
1342
- height,
1343
- minX,
1344
- minY,
1345
- minZ,
1346
- maxX,
1347
- maxY,
1348
- maxZ
1349
- };
1350
- }
1351
-
1352
- // Helper: Create sparse tool representation
1353
- // Points come from GPU as [gridX, gridY, Z] - pure integer grid coordinates for X/Y
1354
- function createSparseToolFromPoints(points) {
1355
- if (!points || points.length === 0) {
1356
- throw new Error('No tool points provided');
1357
- }
1358
-
1359
- // Points are [gridX, gridY, Z] where gridX/gridY are grid indices (floats but integer values)
1360
- // Find bounds in grid space and tool tip Z
1361
- let minGridX = Infinity, minGridY = Infinity, minZ = Infinity;
1362
- let maxGridX = -Infinity, maxGridY = -Infinity;
1363
-
1364
- for (let i = 0; i < points.length; i += 3) {
1365
- const gridX = points[i]; // Already a grid index
1366
- const gridY = points[i + 1]; // Already a grid index
1367
- const z = points[i + 2];
1368
-
1369
- minGridX = Math.min(minGridX, gridX);
1370
- maxGridX = Math.max(maxGridX, gridX);
1371
- minGridY = Math.min(minGridY, gridY);
1372
- maxGridY = Math.max(maxGridY, gridY);
1373
- minZ = Math.min(minZ, z);
1374
- }
1375
-
1376
- // Calculate tool center in grid coordinates (pure integer)
1377
- const width = Math.floor(maxGridX - minGridX) + 1;
1378
- const height = Math.floor(maxGridY - minGridY) + 1;
1379
- const centerX = Math.floor(minGridX) + Math.floor(width / 2);
1380
- const centerY = Math.floor(minGridY) + Math.floor(height / 2);
1381
-
1382
- // Convert each point to offset from center (integer arithmetic only)
1383
- const xOffsets = [];
1384
- const yOffsets = [];
1385
- const zValues = [];
1386
-
1387
- for (let i = 0; i < points.length; i += 3) {
1388
- const gridX = Math.floor(points[i]); // Grid index (ensure integer)
1389
- const gridY = Math.floor(points[i + 1]); // Grid index (ensure integer)
1390
- const z = points[i + 2];
1391
-
1392
- // Calculate offset from tool center (pure integer arithmetic)
1393
- const xOffset = gridX - centerX;
1394
- const yOffset = gridY - centerY;
1395
- // Z relative to tool tip: tip=0, points above tip are positive
1396
- // minZ is the lowest Z (tip), so z - minZ gives positive offsets upward
1397
- const zValue = z;// - minZ;
1398
-
1399
- xOffsets.push(xOffset);
1400
- yOffsets.push(yOffset);
1401
- zValues.push(zValue);
1402
- }
1403
-
1404
- return {
1405
- count: xOffsets.length,
1406
- xOffsets: new Int32Array(xOffsets),
1407
- yOffsets: new Int32Array(yOffsets),
1408
- zValues: new Float32Array(zValues),
1409
- referenceZ: minZ
1410
- };
1411
- }
1412
-
1413
- // Generate toolpath with pre-created sparse tool (for batch operations)
1414
- async function generateToolpathWithSparseTools(terrainPoints, sparseToolData, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
1415
- const startTime = performance.now();
1416
-
1417
- try {
1418
- // Create height map from terrain points (use terrain bounds if provided)
1419
- const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
1420
-
1421
- // Run WebGPU compute with pre-created sparse tool
1422
- const result = await runToolpathCompute(
1423
- terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
1424
- );
1425
-
1426
- return result;
1427
- } catch (error) {
1428
- debug.error('Error generating toolpath:', error);
1429
- throw error;
1430
- }
1431
- }
1432
-
1433
- // Generate toolpath for a single region (internal)
1434
- async function generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null) {
1435
- const startTime = performance.now();
1436
- debug.log('Generating toolpath...');
1437
- debug.log(`Input: terrain ${terrainPoints.length/3} points, tool ${toolPoints.length/3} points, steps (${xStep}, ${yStep}), oobZ ${oobZ}, gridStep ${gridStep}`);
1438
-
1439
- if (terrainBounds) {
1440
- 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)})`);
1441
- }
1442
-
1443
- try {
1444
- // Create height map from terrain points (use terrain bounds if provided)
1445
- const terrainMapData = createHeightMapFromPoints(terrainPoints, gridStep, terrainBounds);
1446
- debug.log(`Created terrain map: ${terrainMapData.width}x${terrainMapData.height}`);
1447
-
1448
- // Create sparse tool representation
1449
- const sparseToolData = createSparseToolFromPoints(toolPoints);
1450
- debug.log(`Created sparse tool: ${sparseToolData.count} points`);
1451
-
1452
- // Run WebGPU compute
1453
- const result = await runToolpathCompute(
1454
- terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime
1455
- );
1456
-
1457
- return result;
1458
- } catch (error) {
1459
- debug.error('Error generating toolpath:', error);
1460
- throw error;
1461
- }
1462
- }
1463
-
1464
- async function runToolpathCompute(terrainMapData, sparseToolData, xStep, yStep, oobZ, startTime) {
1465
- if (!isInitialized) {
1466
- const success = await initWebGPU();
1467
- if (!success) {
1468
- throw new Error('WebGPU not available');
1469
- }
1470
- }
1471
-
1472
- // Use WASM-generated terrain grid
1473
- const terrainBuffer = device.createBuffer({
1474
- size: terrainMapData.grid.byteLength,
1475
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1476
- });
1477
- device.queue.writeBuffer(terrainBuffer, 0, terrainMapData.grid);
1478
-
1479
- // Use WASM-generated sparse tool
1480
- const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
1481
- const toolBufferI32 = new Int32Array(toolBufferData);
1482
- const toolBufferF32 = new Float32Array(toolBufferData);
1483
-
1484
- for (let i = 0; i < sparseToolData.count; i++) {
1485
- toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
1486
- toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
1487
- toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
1488
- toolBufferF32[i * 4 + 3] = 0;
1489
- }
1490
-
1491
- const toolBuffer = device.createBuffer({
1492
- size: toolBufferData.byteLength,
1493
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1494
- });
1495
- device.queue.writeBuffer(toolBuffer, 0, toolBufferData);
1496
-
1497
- // Calculate output dimensions
1498
- const pointsPerLine = Math.ceil(terrainMapData.width / xStep);
1499
- const numScanlines = Math.ceil(terrainMapData.height / yStep);
1500
- const outputSize = pointsPerLine * numScanlines;
1501
-
1502
- const outputBuffer = device.createBuffer({
1503
- size: outputSize * 4,
1504
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
1505
- });
1506
-
1507
- const uniformData = new Uint32Array([
1508
- terrainMapData.width,
1509
- terrainMapData.height,
1510
- sparseToolData.count,
1511
- xStep,
1512
- yStep,
1513
- 0,
1514
- pointsPerLine,
1515
- numScanlines,
1516
- 0, // y_offset (default 0 for planar mode)
1517
- ]);
1518
- const uniformDataFloat = new Float32Array(uniformData.buffer);
1519
- uniformDataFloat[5] = oobZ;
1520
-
1521
- const uniformBuffer = device.createBuffer({
1522
- size: uniformData.byteLength,
1523
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1524
- });
1525
- device.queue.writeBuffer(uniformBuffer, 0, uniformData);
1526
-
1527
- // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
1528
- await device.queue.onSubmittedWorkDone();
1529
-
1530
- // Use cached pipeline
1531
- const bindGroup = device.createBindGroup({
1532
- layout: cachedToolpathPipeline.getBindGroupLayout(0),
1533
- entries: [
1534
- { binding: 0, resource: { buffer: terrainBuffer } },
1535
- { binding: 1, resource: { buffer: toolBuffer } },
1536
- { binding: 2, resource: { buffer: outputBuffer } },
1537
- { binding: 3, resource: { buffer: uniformBuffer } },
1538
- ],
1539
- });
1540
-
1541
- const commandEncoder = device.createCommandEncoder();
1542
- const passEncoder = commandEncoder.beginComputePass();
1543
- passEncoder.setPipeline(cachedToolpathPipeline);
1544
- passEncoder.setBindGroup(0, bindGroup);
1545
-
1546
- const workgroupsX = Math.ceil(pointsPerLine / 16);
1547
- const workgroupsY = Math.ceil(numScanlines / 16);
1548
- passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
1549
- passEncoder.end();
1550
-
1551
- const stagingBuffer = device.createBuffer({
1552
- size: outputSize * 4,
1553
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1554
- });
1555
-
1556
- commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize * 4);
1557
-
1558
- device.queue.submit([commandEncoder.finish()]);
1559
-
1560
- // CRITICAL: Wait for GPU to finish before reading results
1561
- await device.queue.onSubmittedWorkDone();
1562
-
1563
- await stagingBuffer.mapAsync(GPUMapMode.READ);
1564
-
1565
- const outputData = new Float32Array(stagingBuffer.getMappedRange());
1566
- const result = new Float32Array(outputData);
1567
- stagingBuffer.unmap();
1568
-
1569
- terrainBuffer.destroy();
1570
- toolBuffer.destroy();
1571
- outputBuffer.destroy();
1572
- uniformBuffer.destroy();
1573
- stagingBuffer.destroy();
1574
-
1575
- const endTime = performance.now();
1576
-
1577
- return {
1578
- pathData: result,
1579
- numScanlines,
1580
- pointsPerLine,
1581
- generationTime: endTime - startTime
1582
- };
1583
- }
1584
-
1585
- // Create reusable GPU buffers for tiled toolpath generation
1586
- function createReusableToolpathBuffers(terrainWidth, terrainHeight, sparseToolData, xStep, yStep) {
1587
- const pointsPerLine = Math.ceil(terrainWidth / xStep);
1588
- const numScanlines = Math.ceil(terrainHeight / yStep);
1589
- const outputSize = pointsPerLine * numScanlines;
1590
-
1591
- // Create terrain buffer (will be updated for each tile)
1592
- const terrainBuffer = device.createBuffer({
1593
- size: terrainWidth * terrainHeight * 4,
1594
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1595
- });
1596
-
1597
- // Create tool buffer (STATIC - same for all tiles!)
1598
- const toolBufferData = new ArrayBuffer(sparseToolData.count * 16);
1599
- const toolBufferI32 = new Int32Array(toolBufferData);
1600
- const toolBufferF32 = new Float32Array(toolBufferData);
1601
-
1602
- for (let i = 0; i < sparseToolData.count; i++) {
1603
- toolBufferI32[i * 4 + 0] = sparseToolData.xOffsets[i];
1604
- toolBufferI32[i * 4 + 1] = sparseToolData.yOffsets[i];
1605
- toolBufferF32[i * 4 + 2] = sparseToolData.zValues[i];
1606
- toolBufferF32[i * 4 + 3] = 0;
1607
- }
1608
-
1609
- const toolBuffer = device.createBuffer({
1610
- size: toolBufferData.byteLength,
1611
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1612
- });
1613
- device.queue.writeBuffer(toolBuffer, 0, toolBufferData); // Write once!
1614
-
1615
- // Create output buffer (will be read for each tile)
1616
- const outputBuffer = device.createBuffer({
1617
- size: outputSize * 4,
1618
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
1619
- });
1620
-
1621
- // Create uniform buffer (will be updated for each tile)
1622
- const uniformBuffer = device.createBuffer({
1623
- size: 36, // 9 fields × 4 bytes (added y_offset field)
1624
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1625
- });
1626
-
1627
- // Create staging buffer (will be reused for readback)
1628
- const stagingBuffer = device.createBuffer({
1629
- size: outputSize * 4,
1630
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
1631
- });
1632
-
1633
- return {
1634
- terrainBuffer,
1635
- toolBuffer,
1636
- outputBuffer,
1637
- uniformBuffer,
1638
- stagingBuffer,
1639
- maxOutputSize: outputSize,
1640
- maxTerrainWidth: terrainWidth,
1641
- maxTerrainHeight: terrainHeight,
1642
- sparseToolData
1643
- };
1644
- }
1645
-
1646
- // Destroy reusable GPU buffers
1647
- function destroyReusableToolpathBuffers(buffers) {
1648
- buffers.terrainBuffer.destroy();
1649
- buffers.toolBuffer.destroy();
1650
- buffers.outputBuffer.destroy();
1651
- buffers.uniformBuffer.destroy();
1652
- buffers.stagingBuffer.destroy();
1653
- }
1654
-
1655
- // Run toolpath compute using pre-created reusable buffers
1656
- async function runToolpathComputeWithBuffers(terrainData, terrainWidth, terrainHeight, xStep, yStep, oobZ, buffers, startTime) {
1657
- // Update terrain buffer with new tile data
1658
- device.queue.writeBuffer(buffers.terrainBuffer, 0, terrainData);
1659
-
1660
- // Calculate output dimensions
1661
- const pointsPerLine = Math.ceil(terrainWidth / xStep);
1662
- const numScanlines = Math.ceil(terrainHeight / yStep);
1663
- const outputSize = pointsPerLine * numScanlines;
1664
-
1665
- // Calculate Y offset for single-scanline radial mode
1666
- // When numScanlines=1 and terrainHeight > 1, center the tool at the midline
1667
- const yOffset = (numScanlines === 1 && terrainHeight > 1) ? Math.floor(terrainHeight / 2) : 0;
1668
-
1669
- // Update uniforms for this tile
1670
- const uniformData = new Uint32Array([
1671
- terrainWidth,
1672
- terrainHeight,
1673
- buffers.sparseToolData.count,
1674
- xStep,
1675
- yStep,
1676
- 0,
1677
- pointsPerLine,
1678
- numScanlines,
1679
- yOffset, // y_offset for radial single-scanline mode
1680
- ]);
1681
- const uniformDataFloat = new Float32Array(uniformData.buffer);
1682
- uniformDataFloat[5] = oobZ;
1683
- device.queue.writeBuffer(buffers.uniformBuffer, 0, uniformData);
1684
-
1685
- // CRITICAL: Wait for all writeBuffer operations to complete before compute dispatch
1686
- // Without this, compute shader may read stale/incomplete buffer data
1687
- await device.queue.onSubmittedWorkDone();
1688
-
1689
- // Create bind group (reusing cached pipeline)
1690
- const bindGroup = device.createBindGroup({
1691
- layout: cachedToolpathPipeline.getBindGroupLayout(0),
1692
- entries: [
1693
- { binding: 0, resource: { buffer: buffers.terrainBuffer } },
1694
- { binding: 1, resource: { buffer: buffers.toolBuffer } },
1695
- { binding: 2, resource: { buffer: buffers.outputBuffer } },
1696
- { binding: 3, resource: { buffer: buffers.uniformBuffer } },
1697
- ],
1698
- });
1699
-
1700
- // Dispatch compute shader
1701
- const commandEncoder = device.createCommandEncoder();
1702
- const passEncoder = commandEncoder.beginComputePass();
1703
- passEncoder.setPipeline(cachedToolpathPipeline);
1704
- passEncoder.setBindGroup(0, bindGroup);
1705
-
1706
- const workgroupsX = Math.ceil(pointsPerLine / 16);
1707
- const workgroupsY = Math.ceil(numScanlines / 16);
1708
- passEncoder.dispatchWorkgroups(workgroupsX, workgroupsY);
1709
- passEncoder.end();
1710
-
1711
- // Copy to staging buffer
1712
- commandEncoder.copyBufferToBuffer(buffers.outputBuffer, 0, buffers.stagingBuffer, 0, outputSize * 4);
1713
-
1714
- device.queue.submit([commandEncoder.finish()]);
1715
-
1716
- // CRITICAL: Wait for GPU to finish before reading results
1717
- await device.queue.onSubmittedWorkDone();
1718
-
1719
- await buffers.stagingBuffer.mapAsync(GPUMapMode.READ);
1720
-
1721
- // Create a true copy using slice() - new Float32Array(typedArray) only creates a view!
1722
- const outputData = new Float32Array(buffers.stagingBuffer.getMappedRange(), 0, outputSize);
1723
- const result = outputData.slice(); // slice() creates a new ArrayBuffer with copied data
1724
- buffers.stagingBuffer.unmap();
1725
-
1726
- const endTime = performance.now();
1727
-
1728
- // Debug: Log first few Z values to detect non-determinism
1729
- if (result.length > 0) {
1730
- const samples = [];
1731
- for (let i = 0; i < Math.min(10, result.length); i++) {
1732
- samples.push(result[i].toFixed(3));
1733
- }
1734
- // debug.log(`[Toolpath] Output samples (${result.length} total): ${samples.join(', ')}`);
1735
- }
1736
-
1737
- return {
1738
- pathData: result,
1739
- numScanlines,
1740
- pointsPerLine,
1741
- generationTime: endTime - startTime
1742
- };
1743
- }
1744
-
1745
- // Generate toolpath with tiling support (public API)
1746
- async function generateToolpath(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds = null, singleScanline = false) {
1747
- // Calculate bounds if not provided
1748
- if (!terrainBounds) {
1749
- let minX = Infinity, minY = Infinity, minZ = Infinity;
1750
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
1751
- for (let i = 0; i < terrainPoints.length; i += 3) {
1752
- minX = Math.min(minX, terrainPoints[i]);
1753
- maxX = Math.max(maxX, terrainPoints[i]);
1754
- minY = Math.min(minY, terrainPoints[i + 1]);
1755
- maxY = Math.max(maxY, terrainPoints[i + 1]);
1756
- minZ = Math.min(minZ, terrainPoints[i + 2]);
1757
- maxZ = Math.max(maxZ, terrainPoints[i + 2]);
1758
- }
1759
- terrainBounds = {
1760
- min: { x: minX, y: minY, z: minZ },
1761
- max: { x: maxX, y: maxY, z: maxZ }
1762
- };
1763
- }
1764
-
1765
- // Note: singleScanline mode means OUTPUT only centerline, but terrain bounds stay full
1766
- // This ensures all terrain Y values contribute to tool interference at the centerline
1767
-
1768
- // Debug tool bounds and center
1769
- for (let i=0; i<toolPoints.length; i += 3) {
1770
- if (toolPoints[i] === 0 && toolPoints[i+1] === 0) {
1771
- debug.log('[WebGPU Worker]', { TOOL_CENTER: toolPoints[i+2] });
1772
- }
1773
- }
1774
- debug.log('[WebGPU Worker]',
1775
- 'toolZMin:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.min(a,b), Infinity)),
1776
- 'toolZMax:', ([...toolPoints].filter((_,i) => i % 3 === 2).reduce((a,b) => Math.max(a,b), -Infinity))
1777
- );
1778
-
1779
- // Calculate tool dimensions for overlap
1780
- // Tool points are [gridX, gridY, Z] where X/Y are grid indices (not mm)
1781
- let toolMinX = Infinity, toolMaxX = -Infinity;
1782
- let toolMinY = Infinity, toolMaxY = -Infinity;
1783
- for (let i = 0; i < toolPoints.length; i += 3) {
1784
- toolMinX = Math.min(toolMinX, toolPoints[i]);
1785
- toolMaxX = Math.max(toolMaxX, toolPoints[i]);
1786
- toolMinY = Math.min(toolMinY, toolPoints[i + 1]);
1787
- toolMaxY = Math.max(toolMaxY, toolPoints[i + 1]);
1788
- }
1789
- // Tool dimensions in grid cells
1790
- const toolWidthCells = toolMaxX - toolMinX;
1791
- const toolHeightCells = toolMaxY - toolMinY;
1792
- // Convert to mm for logging
1793
- const toolWidthMm = toolWidthCells * gridStep;
1794
- const toolHeightMm = toolHeightCells * gridStep;
1795
-
1796
- // Check if tiling is needed based on output grid size
1797
- const outputWidth = Math.ceil((terrainBounds.max.x - terrainBounds.min.x) / gridStep) + 1;
1798
- const outputHeight = Math.ceil((terrainBounds.max.y - terrainBounds.min.y) / gridStep) + 1;
1799
- const outputPoints = Math.ceil(outputWidth / xStep) * Math.ceil(outputHeight / yStep);
1800
- const outputMemory = outputPoints * 4; // 4 bytes per float
1801
-
1802
- const configuredLimit = config.maxGPUMemoryMB * 1024 * 1024;
1803
- const deviceLimit = deviceCapabilities.maxStorageBufferBindingSize;
1804
- const maxSafeSize = Math.min(configuredLimit, deviceLimit) * config.gpuMemorySafetyMargin;
1805
-
1806
- if (outputMemory <= maxSafeSize) {
1807
- // No tiling needed
1808
- return await generateToolpathSingle(terrainPoints, toolPoints, xStep, yStep, oobZ, gridStep, terrainBounds);
1809
- }
1810
-
1811
- // Tiling needed (terrain is ALWAYS dense)
1812
- const tilingStartTime = performance.now();
1813
- debug.log('Using tiled toolpath generation');
1814
- debug.log(`Terrain: DENSE (${terrainPoints.length} cells = ${outputWidth}x${outputHeight})`);
1815
- debug.log(`Tool dimensions: ${toolWidthMm.toFixed(2)}mm × ${toolHeightMm.toFixed(2)}mm (${toolWidthCells}×${toolHeightCells} cells)`);
1816
-
1817
- // Create tiles with tool-size overlap (pass dimensions in grid cells)
1818
- const { tiles, maxTileGridWidth, maxTileGridHeight } = createToolpathTiles(terrainBounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxSafeSize);
1819
- debug.log(`Created ${tiles.length} tiles`);
1820
-
1821
- // Pre-generate all tile terrain point arrays
1822
- const pregenStartTime = performance.now();
1823
- debug.log(`Pre-generating ${tiles.length} tile terrain arrays...`);
1824
- const allTileTerrainPoints = [];
1825
-
1826
- for (let i = 0; i < tiles.length; i++) {
1827
- const tile = tiles[i];
1828
-
1829
- // Extract terrain sub-grid for this tile (terrain is ALWAYS dense)
1830
- const tileMinGridX = Math.floor((tile.bounds.min.x - terrainBounds.min.x) / gridStep);
1831
- const tileMaxGridX = Math.ceil((tile.bounds.max.x - terrainBounds.min.x) / gridStep);
1832
- const tileMinGridY = Math.floor((tile.bounds.min.y - terrainBounds.min.y) / gridStep);
1833
- const tileMaxGridY = Math.ceil((tile.bounds.max.y - terrainBounds.min.y) / gridStep);
1834
-
1835
- const tileWidth = tileMaxGridX - tileMinGridX + 1;
1836
- const tileHeight = tileMaxGridY - tileMinGridY + 1;
1837
-
1838
- // Pad to max dimensions for consistent buffer sizing
1839
- const paddedTileTerrainPoints = new Float32Array(maxTileGridWidth * maxTileGridHeight);
1840
- paddedTileTerrainPoints.fill(EMPTY_CELL);
1841
-
1842
- // Copy relevant sub-grid from full terrain into top-left of padded array
1843
- for (let ty = 0; ty < tileHeight; ty++) {
1844
- const globalY = tileMinGridY + ty;
1845
- if (globalY < 0 || globalY >= outputHeight) continue;
1846
-
1847
- for (let tx = 0; tx < tileWidth; tx++) {
1848
- const globalX = tileMinGridX + tx;
1849
- if (globalX < 0 || globalX >= outputWidth) continue;
1850
-
1851
- const globalIdx = globalY * outputWidth + globalX;
1852
- const tileIdx = ty * maxTileGridWidth + tx; // Use maxTileGridWidth for stride
1853
- paddedTileTerrainPoints[tileIdx] = terrainPoints[globalIdx];
1854
- }
1855
- }
1856
-
1857
- allTileTerrainPoints.push({
1858
- data: paddedTileTerrainPoints,
1859
- actualWidth: tileWidth,
1860
- actualHeight: tileHeight
1861
- });
1862
- }
1863
-
1864
- const pregenTime = performance.now() - pregenStartTime;
1865
- debug.log(`Pre-generation complete in ${pregenTime.toFixed(1)}ms`);
1866
-
1867
- // Create reusable GPU buffers (sized for maximum tile)
1868
- if (!isInitialized) {
1869
- const success = await initWebGPU();
1870
- if (!success) {
1871
- throw new Error('WebGPU not available');
1872
- }
1873
- }
1874
-
1875
- const sparseToolData = createSparseToolFromPoints(toolPoints);
1876
- const reusableBuffers = createReusableToolpathBuffers(maxTileGridWidth, maxTileGridHeight, sparseToolData, xStep, yStep);
1877
- debug.log(`Created reusable GPU buffers for ${maxTileGridWidth}x${maxTileGridHeight} tiles`);
1878
-
1879
- // Process each tile with reusable buffers
1880
- const tileResults = [];
1881
- let totalTileTime = 0;
1882
- for (let i = 0; i < tiles.length; i++) {
1883
- const tile = tiles[i];
1884
- const tileStartTime = performance.now();
1885
- debug.log(`Processing tile ${i + 1}/${tiles.length}...`);
1886
-
1887
- // Report progress
1888
- const percent = Math.round(((i + 1) / tiles.length) * 100);
1889
- self.postMessage({
1890
- type: 'toolpath-progress',
1891
- data: {
1892
- percent,
1893
- current: i + 1,
1894
- total: tiles.length,
1895
- layer: i + 1 // Using tile index as "layer" for consistency
1896
- }
1897
- });
1898
-
1899
- debug.log(`Tile ${i+1} using pre-generated terrain: ${allTileTerrainPoints[i].actualWidth}x${allTileTerrainPoints[i].actualHeight} (padded to ${maxTileGridWidth}x${maxTileGridHeight})`);
1900
-
1901
- // Generate toolpath for this tile using reusable buffers
1902
- const tileToolpathResult = await runToolpathComputeWithBuffers(
1903
- allTileTerrainPoints[i].data,
1904
- maxTileGridWidth,
1905
- maxTileGridHeight,
1906
- xStep,
1907
- yStep,
1908
- oobZ,
1909
- reusableBuffers,
1910
- tileStartTime
1911
- );
1912
-
1913
- const tileTime = performance.now() - tileStartTime;
1914
- totalTileTime += tileTime;
1915
-
1916
- tileResults.push({
1917
- pathData: tileToolpathResult.pathData,
1918
- numScanlines: tileToolpathResult.numScanlines,
1919
- pointsPerLine: tileToolpathResult.pointsPerLine,
1920
- tile: tile
1921
- });
1922
-
1923
- debug.log(`Tile ${i + 1}/${tiles.length} complete: ${tileToolpathResult.numScanlines}×${tileToolpathResult.pointsPerLine} in ${tileTime.toFixed(1)}ms`);
1924
- }
1925
-
1926
- // Cleanup reusable buffers
1927
- destroyReusableToolpathBuffers(reusableBuffers);
1928
-
1929
- debug.log(`All tiles processed in ${totalTileTime.toFixed(1)}ms (avg ${(totalTileTime/tiles.length).toFixed(1)}ms per tile)`);
1930
-
1931
- // Stitch tiles together, dropping overlap regions
1932
- const stitchStartTime = performance.now();
1933
- const stitchedResult = stitchToolpathTiles(tileResults, terrainBounds, gridStep, xStep, yStep);
1934
- const stitchTime = performance.now() - stitchStartTime;
1935
-
1936
- const totalTime = performance.now() - tilingStartTime;
1937
- debug.log(`Stitching took ${stitchTime.toFixed(1)}ms`);
1938
- debug.log(`Tiled toolpath complete: ${stitchedResult.numScanlines}×${stitchedResult.pointsPerLine} in ${totalTime.toFixed(1)}ms total`);
1939
-
1940
- // Update generation time to reflect total tiled time
1941
- stitchedResult.generationTime = totalTime;
1942
-
1943
- return stitchedResult;
1944
- }
1945
-
1946
- // Create tiles for toolpath generation with overlap (using integer grid coordinates)
1947
- // toolWidth and toolHeight are in grid cells (not mm)
1948
- function createToolpathTiles(bounds, gridStep, xStep, yStep, toolWidthCells, toolHeightCells, maxMemoryBytes) {
1949
- // Calculate global grid dimensions
1950
- const globalGridWidth = Math.ceil((bounds.max.x - bounds.min.x) / gridStep) + 1;
1951
- const globalGridHeight = Math.ceil((bounds.max.y - bounds.min.y) / gridStep) + 1;
1952
-
1953
- // Calculate tool overlap in grid cells (use radius = half diameter)
1954
- // Tool centered at tile boundary needs terrain extending half tool width beyond boundary
1955
- const toolOverlapX = Math.ceil(toolWidthCells / 2);
1956
- const toolOverlapY = Math.ceil(toolHeightCells / 2);
1957
-
1958
- // Binary search for optimal tile size in grid cells
1959
- let low = Math.max(toolOverlapX, toolOverlapY) * 2; // At least 2x tool size
1960
- let high = Math.max(globalGridWidth, globalGridHeight);
1961
- let bestTileGridSize = high;
1962
-
1963
- while (low <= high) {
1964
- const mid = Math.floor((low + high) / 2);
1965
- const outputW = Math.ceil(mid / xStep);
1966
- const outputH = Math.ceil(mid / yStep);
1967
- const memoryNeeded = outputW * outputH * 4;
1968
-
1969
- if (memoryNeeded <= maxMemoryBytes) {
1970
- bestTileGridSize = mid;
1971
- low = mid + 1;
1972
- } else {
1973
- high = mid - 1;
1974
- }
1975
- }
1976
-
1977
- const tilesX = Math.ceil(globalGridWidth / bestTileGridSize);
1978
- const tilesY = Math.ceil(globalGridHeight / bestTileGridSize);
1979
- const coreGridWidth = Math.ceil(globalGridWidth / tilesX);
1980
- const coreGridHeight = Math.ceil(globalGridHeight / tilesY);
1981
-
1982
- // Calculate maximum tile dimensions (for buffer sizing)
1983
- const maxTileGridWidth = coreGridWidth + 2 * toolOverlapX;
1984
- const maxTileGridHeight = coreGridHeight + 2 * toolOverlapY;
1985
-
1986
- debug.log(`Creating ${tilesX}×${tilesY} tiles (${coreGridWidth}×${coreGridHeight} cells core + ${toolOverlapX}×${toolOverlapY} cells overlap)`);
1987
- debug.log(`Max tile dimensions: ${maxTileGridWidth}×${maxTileGridHeight} cells (for buffer sizing)`);
1988
-
1989
- const tiles = [];
1990
- for (let ty = 0; ty < tilesY; ty++) {
1991
- for (let tx = 0; tx < tilesX; tx++) {
1992
- // Core tile in grid coordinates
1993
- const coreGridStartX = tx * coreGridWidth;
1994
- const coreGridStartY = ty * coreGridHeight;
1995
- const coreGridEndX = Math.min((tx + 1) * coreGridWidth, globalGridWidth) - 1;
1996
- const coreGridEndY = Math.min((ty + 1) * coreGridHeight, globalGridHeight) - 1;
1997
-
1998
- // Extended tile with overlap in grid coordinates
1999
- let extGridStartX = coreGridStartX;
2000
- let extGridStartY = coreGridStartY;
2001
- let extGridEndX = coreGridEndX;
2002
- let extGridEndY = coreGridEndY;
2003
-
2004
- // Add overlap on sides that aren't at global boundary
2005
- if (tx > 0) extGridStartX -= toolOverlapX;
2006
- if (ty > 0) extGridStartY -= toolOverlapY;
2007
- if (tx < tilesX - 1) extGridEndX += toolOverlapX;
2008
- if (ty < tilesY - 1) extGridEndY += toolOverlapY;
2009
-
2010
- // Clamp to global bounds
2011
- extGridStartX = Math.max(0, extGridStartX);
2012
- extGridStartY = Math.max(0, extGridStartY);
2013
- extGridEndX = Math.min(globalGridWidth - 1, extGridEndX);
2014
- extGridEndY = Math.min(globalGridHeight - 1, extGridEndY);
2015
-
2016
- // Calculate actual dimensions for this tile
2017
- const tileGridWidth = extGridEndX - extGridStartX + 1;
2018
- const tileGridHeight = extGridEndY - extGridStartY + 1;
2019
-
2020
- // Convert grid coordinates to world coordinates
2021
- const extMinX = bounds.min.x + extGridStartX * gridStep;
2022
- const extMinY = bounds.min.y + extGridStartY * gridStep;
2023
- const extMaxX = bounds.min.x + extGridEndX * gridStep;
2024
- const extMaxY = bounds.min.y + extGridEndY * gridStep;
2025
-
2026
- const coreMinX = bounds.min.x + coreGridStartX * gridStep;
2027
- const coreMinY = bounds.min.y + coreGridStartY * gridStep;
2028
- const coreMaxX = bounds.min.x + coreGridEndX * gridStep;
2029
- const coreMaxY = bounds.min.y + coreGridEndY * gridStep;
2030
-
2031
- tiles.push({
2032
- id: `tile_${tx}_${ty}`,
2033
- tx, ty,
2034
- tilesX, tilesY,
2035
- gridWidth: tileGridWidth,
2036
- gridHeight: tileGridHeight,
2037
- bounds: {
2038
- min: { x: extMinX, y: extMinY, z: bounds.min.z },
2039
- max: { x: extMaxX, y: extMaxY, z: bounds.max.z }
2040
- },
2041
- core: {
2042
- gridStart: { x: coreGridStartX, y: coreGridStartY },
2043
- gridEnd: { x: coreGridEndX, y: coreGridEndY },
2044
- min: { x: coreMinX, y: coreMinY },
2045
- max: { x: coreMaxX, y: coreMaxY }
2046
- }
2047
- });
2048
- }
2049
- }
2050
-
2051
- return { tiles, maxTileGridWidth, maxTileGridHeight };
2052
- }
2053
-
2054
- // Stitch toolpath tiles together, dropping overlap regions (using integer grid coordinates)
2055
- function stitchToolpathTiles(tileResults, globalBounds, gridStep, xStep, yStep) {
2056
- // Calculate global output dimensions
2057
- const globalWidth = Math.ceil((globalBounds.max.x - globalBounds.min.x) / gridStep) + 1;
2058
- const globalHeight = Math.ceil((globalBounds.max.y - globalBounds.min.y) / gridStep) + 1;
2059
- const globalPointsPerLine = Math.ceil(globalWidth / xStep);
2060
- const globalNumScanlines = Math.ceil(globalHeight / yStep);
2061
-
2062
- debug.log(`Stitching toolpath: global grid ${globalWidth}x${globalHeight}, output ${globalPointsPerLine}x${globalNumScanlines}`);
2063
-
2064
- const result = new Float32Array(globalPointsPerLine * globalNumScanlines);
2065
- result.fill(NaN);
2066
-
2067
- // Fast path for 1x1 stepping: use bulk row copying
2068
- const use1x1FastPath = (xStep === 1 && yStep === 1);
2069
-
2070
- for (const tileResult of tileResults) {
2071
- const tile = tileResult.tile;
2072
- const tileData = tileResult.pathData;
2073
-
2074
- // Use the pre-calculated integer grid coordinates from tile.core
2075
- const coreGridStartX = tile.core.gridStart.x;
2076
- const coreGridStartY = tile.core.gridStart.y;
2077
- const coreGridEndX = tile.core.gridEnd.x;
2078
- const coreGridEndY = tile.core.gridEnd.y;
2079
-
2080
- // Calculate tile's extended grid coordinates
2081
- const extGridStartX = Math.round((tile.bounds.min.x - globalBounds.min.x) / gridStep);
2082
- const extGridStartY = Math.round((tile.bounds.min.y - globalBounds.min.y) / gridStep);
2083
-
2084
- let copiedCount = 0;
2085
-
2086
- // Calculate output coordinate ranges for this tile's core
2087
- // Core region in grid coordinates
2088
- const coreGridWidth = coreGridEndX - coreGridStartX + 1;
2089
- const coreGridHeight = coreGridEndY - coreGridStartY + 1;
2090
-
2091
- // Core region in output coordinates (sampled by xStep/yStep)
2092
- const coreOutStartX = Math.floor(coreGridStartX / xStep);
2093
- const coreOutStartY = Math.floor(coreGridStartY / yStep);
2094
- const coreOutEndX = Math.floor(coreGridEndX / xStep);
2095
- const coreOutEndY = Math.floor(coreGridEndY / yStep);
2096
- const coreOutWidth = coreOutEndX - coreOutStartX + 1;
2097
- const coreOutHeight = coreOutEndY - coreOutStartY + 1;
2098
-
2099
- // Tile's extended region start in grid coordinates
2100
- const extOutStartX = Math.floor(extGridStartX / xStep);
2101
- const extOutStartY = Math.floor(extGridStartY / yStep);
2102
-
2103
- // Copy entire rows at once (works for all stepping values)
2104
- for (let outY = 0; outY < coreOutHeight; outY++) {
2105
- const globalOutY = coreOutStartY + outY;
2106
- const tileOutY = globalOutY - extOutStartY;
2107
-
2108
- if (globalOutY >= 0 && globalOutY < globalNumScanlines &&
2109
- tileOutY >= 0 && tileOutY < tileResult.numScanlines) {
2110
-
2111
- const globalRowStart = globalOutY * globalPointsPerLine + coreOutStartX;
2112
- const tileRowStart = tileOutY * tileResult.pointsPerLine + (coreOutStartX - extOutStartX);
2113
-
2114
- // Bulk copy entire row of output values
2115
- result.set(tileData.subarray(tileRowStart, tileRowStart + coreOutWidth), globalRowStart);
2116
- copiedCount += coreOutWidth;
2117
- }
2118
- }
2119
-
2120
- debug.log(` Tile ${tile.id}: copied ${copiedCount} values`);
2121
- }
2122
-
2123
- // Count how many output values are still NaN (gaps)
2124
- let nanCount = 0;
2125
- for (let i = 0; i < result.length; i++) {
2126
- if (isNaN(result[i])) nanCount++;
2127
- }
2128
- debug.log(`Stitching complete: ${result.length} total values, ${nanCount} still NaN`);
2129
-
2130
- return {
2131
- pathData: result,
2132
- numScanlines: globalNumScanlines,
2133
- pointsPerLine: globalPointsPerLine,
2134
- generationTime: 0 // Sum from tiles if needed
2135
- };
2136
- }
2137
-
2138
- // Radial rasterization - two-pass tiled with bit-attention
2139
- // Workload budget: triangles × scanlines should stay under this to avoid timeouts
2140
- const MAX_WORKLOAD_PER_TILE = 10_000_000; // triangles × gridHeight budget per tile
2141
-
2142
- let cachedRadialCullPipeline = null;
2143
- let cachedRadialRasterizePipeline = null;
2144
-
2145
- async function radialRasterize(triangles, stepSize, rotationStepDegrees, zFloor = 0, boundsOverride = null, params = {}) {
2146
- const startTime = performance.now();
2147
-
2148
- if (!isInitialized) {
2149
- await initWebGPU();
2150
- }
2151
-
2152
- // Check if SharedArrayBuffer is available and triangle data is large (>1M triangles = 9M floats = 36MB)
2153
- const numTriangles = triangles.length / 9;
2154
- const useSharedBuffer = typeof SharedArrayBuffer !== 'undefined' &&
2155
- numTriangles > 1000000 &&
2156
- !(triangles.buffer instanceof SharedArrayBuffer);
2157
-
2158
- if (useSharedBuffer) {
2159
- debug.log(`Large dataset (${numTriangles.toLocaleString()} triangles), converting to SharedArrayBuffer`);
2160
- const sab = new SharedArrayBuffer(triangles.byteLength);
2161
- const sharedTriangles = new Float32Array(sab);
2162
- sharedTriangles.set(triangles);
2163
- triangles = sharedTriangles;
2164
- }
2165
-
2166
- const bounds = boundsOverride || calculateBounds(triangles);
2167
-
2168
- // Calculate max radius (distance from X-axis)
2169
- let maxRadius = 0;
2170
- for (let i = 0; i < triangles.length; i += 3) {
2171
- const y = triangles[i + 1];
2172
- const z = triangles[i + 2];
2173
- const radius = Math.sqrt(y * y + z * z);
2174
- maxRadius = Math.max(maxRadius, radius);
2175
- }
2176
-
2177
- // Add margin for ray origins to start outside mesh
2178
- maxRadius *= 1.2;
2179
-
2180
- const circumference = 2 * Math.PI * maxRadius;
2181
- const xRange = bounds.max.x - bounds.min.x;
2182
- const rotationStepRadians = rotationStepDegrees * (Math.PI / 180);
2183
-
2184
- // Calculate grid height (number of angular scanlines)
2185
- const gridHeight = Math.ceil(360 / rotationStepDegrees) + 1;
2186
-
2187
- // Calculate number of tiles based on user config or auto-calculation
2188
- let numTiles, trianglesPerTileTarget;
2189
-
2190
- if (params.trianglesPerTile) {
2191
- // User specified explicit triangles per tile
2192
- trianglesPerTileTarget = params.trianglesPerTile;
2193
- numTiles = Math.max(1, Math.ceil(numTriangles / trianglesPerTileTarget));
2194
- debug.log(`Radial: ${numTriangles} triangles, ${gridHeight} scanlines, ${numTiles} X-tiles (${trianglesPerTileTarget} target tri/tile)`);
2195
- } else {
2196
- // Auto-calculate based on workload budget
2197
- // Total work = numTriangles × gridHeight × culling_efficiency
2198
- // More tiles = smaller X-slices = better culling = less work per tile
2199
- const totalWorkload = numTriangles * gridHeight * 0.5; // 0.5 = expected culling efficiency
2200
- numTiles = Math.max(1, Math.ceil(totalWorkload / MAX_WORKLOAD_PER_TILE));
2201
- trianglesPerTileTarget = Math.ceil(numTriangles / numTiles);
2202
- debug.log(`Radial: ${numTriangles} triangles, ${gridHeight} scanlines, ${numTiles} X-tiles (${trianglesPerTileTarget} avg tri/tile, auto-calculated)`);
2203
- }
2204
-
2205
- const tileWidth = xRange / numTiles;
2206
-
2207
- // Create pipelines on first use
2208
- if (!cachedRadialCullPipeline) {
2209
- const cullShaderModule = device.createShaderModule({ code: radialCullShaderCode });
2210
- cachedRadialCullPipeline = device.createComputePipeline({
2211
- layout: 'auto',
2212
- compute: { module: cullShaderModule, entryPoint: 'main' }
2213
- });
2214
- debug.log('Created radial cull pipeline');
2215
- }
2216
-
2217
- if (!cachedRadialRasterizePipeline) {
2218
- const rasterShaderModule = device.createShaderModule({ code: radialRasterizeShaderCode });
2219
- cachedRadialRasterizePipeline = device.createComputePipeline({
2220
- layout: 'auto',
2221
- compute: { module: rasterShaderModule, entryPoint: 'main' }
2222
- });
2223
- debug.log('Created radial rasterize pipeline');
2224
- }
2225
-
2226
- // Create shared triangle buffer
2227
- const triangleBuffer = device.createBuffer({
2228
- size: triangles.byteLength,
2229
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2230
- });
2231
- device.queue.writeBuffer(triangleBuffer, 0, triangles);
2232
-
2233
- // Calculate attention bit array size
2234
- const numWords = Math.ceil(numTriangles / 32);
2235
- debug.log(`Attention array: ${numWords} words (${numWords * 4} bytes)`);
2236
-
2237
- // Helper function to process a single tile
2238
- async function processTile(tileIdx) {
2239
- const prefix = `Tile ${tileIdx + 1}/${numTiles}:`;
2240
- try {
2241
- const tile_min_x = bounds.min.x + tileIdx * tileWidth;
2242
- const tile_max_x = bounds.min.x + (tileIdx + 1) * tileWidth;
2243
-
2244
- debug.log(`${prefix} X=[${tile_min_x.toFixed(2)}, ${tile_max_x.toFixed(2)}]`);
2245
-
2246
- // Pass 1: Cull triangles for this X-tile
2247
- const tileStartTime = performance.now();
2248
-
2249
- const attentionBuffer = device.createBuffer({
2250
- size: numWords * 4,
2251
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
2252
- });
2253
-
2254
- // Clear attention buffer
2255
- const zeros = new Uint32Array(numWords);
2256
- device.queue.writeBuffer(attentionBuffer, 0, zeros);
2257
-
2258
- const cullUniformData = new Float32Array(4);
2259
- cullUniformData[0] = tile_min_x;
2260
- cullUniformData[1] = tile_max_x;
2261
- const cullUniformU32 = new Uint32Array(cullUniformData.buffer);
2262
- cullUniformU32[2] = numTriangles;
2263
-
2264
- const cullUniformBuffer = device.createBuffer({
2265
- size: 16,
2266
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2267
- });
2268
- device.queue.writeBuffer(cullUniformBuffer, 0, cullUniformData);
2269
-
2270
- // CRITICAL: Wait for writeBuffer operations before compute dispatch
2271
- await device.queue.onSubmittedWorkDone();
2272
-
2273
- const cullBindGroup = device.createBindGroup({
2274
- layout: cachedRadialCullPipeline.getBindGroupLayout(0),
2275
- entries: [
2276
- { binding: 0, resource: { buffer: triangleBuffer } },
2277
- { binding: 1, resource: { buffer: attentionBuffer } },
2278
- { binding: 2, resource: { buffer: cullUniformBuffer } },
2279
- ],
2280
- });
2281
-
2282
- const cullEncoder = device.createCommandEncoder();
2283
- const cullPass = cullEncoder.beginComputePass();
2284
- cullPass.setPipeline(cachedRadialCullPipeline);
2285
- cullPass.setBindGroup(0, cullBindGroup);
2286
- cullPass.dispatchWorkgroups(Math.ceil(numTriangles / 256));
2287
- cullPass.end();
2288
- device.queue.submit([cullEncoder.finish()]);
2289
- await device.queue.onSubmittedWorkDone();
2290
-
2291
- const cullTime = performance.now() - tileStartTime;
2292
- // debug.log(` Culling: ${cullTime.toFixed(1)}ms`);
2293
-
2294
- // Pass 1.5: Read back attention bits and compact to triangle index list
2295
- const compactStartTime = performance.now();
2296
-
2297
- // Create staging buffer to read attention bits
2298
- const attentionStagingBuffer = device.createBuffer({
2299
- size: numWords * 4,
2300
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
2301
- });
2302
-
2303
- const copyEncoder = device.createCommandEncoder();
2304
- copyEncoder.copyBufferToBuffer(attentionBuffer, 0, attentionStagingBuffer, 0, numWords * 4);
2305
- device.queue.submit([copyEncoder.finish()]);
2306
- await device.queue.onSubmittedWorkDone();
2307
-
2308
- await attentionStagingBuffer.mapAsync(GPUMapMode.READ);
2309
- const attentionBits = new Uint32Array(attentionStagingBuffer.getMappedRange());
2310
-
2311
- // CPU compaction: build list of marked triangle indices
2312
- const compactIndices = [];
2313
- for (let triIdx = 0; triIdx < numTriangles; triIdx++) {
2314
- const wordIdx = Math.floor(triIdx / 32);
2315
- const bitIdx = triIdx % 32;
2316
- const isMarked = (attentionBits[wordIdx] & (1 << bitIdx)) !== 0;
2317
- if (isMarked) {
2318
- compactIndices.push(triIdx);
2319
- }
2320
- }
2321
-
2322
- attentionStagingBuffer.unmap();
2323
- attentionStagingBuffer.destroy();
2324
-
2325
- const compactTime = performance.now() - compactStartTime;
2326
- const cullingEfficiency = ((numTriangles - compactIndices.length) / numTriangles * 100).toFixed(1);
2327
- debug.log(`${prefix} Compacted ${numTriangles} → ${compactIndices.length} triangles (${cullingEfficiency}% culled) in ${compactTime.toFixed(1)}ms`);
2328
-
2329
- // Create compact triangle index buffer
2330
- const compactIndexBuffer = device.createBuffer({
2331
- size: Math.max(4, compactIndices.length * 4), // At least 4 bytes
2332
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2333
- });
2334
- if (compactIndices.length > 0) {
2335
- device.queue.writeBuffer(compactIndexBuffer, 0, new Uint32Array(compactIndices));
2336
- }
2337
-
2338
- // Pass 2: Rasterize this X-tile
2339
- const rasterStartTime = performance.now();
2340
-
2341
- const gridWidth = Math.ceil(tileWidth / stepSize) + 1;
2342
- const gridHeight = Math.ceil(360 / rotationStepDegrees) + 1; // Number of angular samples
2343
- const totalCells = gridWidth * gridHeight;
2344
-
2345
- debug.log(`${prefix} Grid: ${gridWidth}×${gridHeight} = ${totalCells} cells (rotationStep=${rotationStepDegrees}°)`);
2346
-
2347
- const outputBuffer = device.createBuffer({
2348
- size: totalCells * 4,
2349
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
2350
- });
2351
-
2352
- // Initialize with EMPTY_CELL
2353
- const initData = new Float32Array(totalCells);
2354
- initData.fill(EMPTY_CELL);
2355
- device.queue.writeBuffer(outputBuffer, 0, initData);
2356
-
2357
- const rotationOffsetRadians = (params.radialRotationOffset ?? 0) * (Math.PI / 180);
2358
-
2359
- const rasterUniformData = new Float32Array(16);
2360
- rasterUniformData[0] = tile_min_x;
2361
- rasterUniformData[1] = tile_max_x;
2362
- rasterUniformData[2] = maxRadius;
2363
- rasterUniformData[3] = rotationStepRadians;
2364
- rasterUniformData[4] = stepSize;
2365
- rasterUniformData[5] = zFloor;
2366
- rasterUniformData[6] = rotationOffsetRadians;
2367
- rasterUniformData[7] = 0; // padding
2368
- const rasterUniformU32 = new Uint32Array(rasterUniformData.buffer);
2369
- rasterUniformU32[8] = gridWidth;
2370
- rasterUniformU32[9] = gridHeight;
2371
- rasterUniformU32[10] = compactIndices.length; // Use compact count instead of numTriangles
2372
- rasterUniformU32[11] = 0; // No longer using attention words
2373
-
2374
- const rasterUniformBuffer = device.createBuffer({
2375
- size: 64,
2376
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2377
- });
2378
- device.queue.writeBuffer(rasterUniformBuffer, 0, rasterUniformData);
2379
-
2380
- // CRITICAL: Wait for writeBuffer operations before compute dispatch
2381
- await device.queue.onSubmittedWorkDone();
2382
-
2383
- const rasterBindGroup = device.createBindGroup({
2384
- layout: cachedRadialRasterizePipeline.getBindGroupLayout(0),
2385
- entries: [
2386
- { binding: 0, resource: { buffer: triangleBuffer } },
2387
- { binding: 1, resource: { buffer: compactIndexBuffer } }, // Use compact indices instead of attention bits
2388
- { binding: 2, resource: { buffer: outputBuffer } },
2389
- { binding: 3, resource: { buffer: rasterUniformBuffer } },
2390
- ],
2391
- });
2392
-
2393
- const rasterEncoder = device.createCommandEncoder();
2394
- const rasterPass = rasterEncoder.beginComputePass();
2395
- rasterPass.setPipeline(cachedRadialRasterizePipeline);
2396
- rasterPass.setBindGroup(0, rasterBindGroup);
2397
- const workgroupsX = Math.ceil(gridWidth / 16);
2398
- const workgroupsY = Math.ceil(gridHeight / 16);
2399
- rasterPass.dispatchWorkgroups(workgroupsX, workgroupsY);
2400
- rasterPass.end();
2401
-
2402
- const stagingBuffer = device.createBuffer({
2403
- size: totalCells * 4,
2404
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
2405
- });
2406
-
2407
- rasterEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, totalCells * 4);
2408
- device.queue.submit([rasterEncoder.finish()]);
2409
- await device.queue.onSubmittedWorkDone();
2410
-
2411
- await stagingBuffer.mapAsync(GPUMapMode.READ);
2412
- const outputData = new Float32Array(stagingBuffer.getMappedRange());
2413
- const tileResult = new Float32Array(outputData);
2414
- stagingBuffer.unmap();
2415
-
2416
- const rasterTime = performance.now() - rasterStartTime;
2417
- debug.log(`${prefix} Rasterization: ${rasterTime.toFixed(1)}ms`);
2418
-
2419
- // Cleanup tile buffers
2420
- attentionBuffer.destroy();
2421
- cullUniformBuffer.destroy();
2422
- compactIndexBuffer.destroy();
2423
- outputBuffer.destroy();
2424
- rasterUniformBuffer.destroy();
2425
- stagingBuffer.destroy();
2426
-
2427
- return {
2428
- tileIdx,
2429
- data: tileResult,
2430
- gridWidth,
2431
- gridHeight,
2432
- minX: tile_min_x,
2433
- maxX: tile_max_x
2434
- };
2435
- } catch (error) {
2436
- debug.error(`Error processing tile ${tileIdx + 1}/${numTiles}:`, error);
2437
- throw new Error(`Tile ${tileIdx + 1} failed: ${error.message}`);
2438
- }
2439
- }
2440
-
2441
- // Process tiles with rolling window to maintain constant concurrency
2442
- // This keeps GPU busy while preventing browser from allocating too many buffers at once
2443
- const maxConcurrentTiles = params.maxConcurrentTiles ?? 50;
2444
- debug.log(`Processing ${numTiles} tiles (max ${maxConcurrentTiles} concurrent)...`);
2445
-
2446
- let completedTiles = 0;
2447
- let nextTileIdx = 0;
2448
- const activeTiles = new Map(); // promise -> tileIdx
2449
- const tileResults = new Array(numTiles);
2450
-
2451
- // Helper to start a tile and track it
2452
- const startTile = (tileIdx) => {
2453
- const promise = processTile(tileIdx).then(result => {
2454
- completedTiles++;
2455
- const percent = Math.round((completedTiles / numTiles) * 100);
2456
-
2457
- // Report progress
2458
- self.postMessage({
2459
- type: 'rasterize-progress',
2460
- data: {
2461
- percent,
2462
- current: completedTiles,
2463
- total: numTiles
2464
- }
2465
- });
2466
-
2467
- tileResults[tileIdx] = result;
2468
- activeTiles.delete(promise);
2469
- return result;
2470
- });
2471
- activeTiles.set(promise, tileIdx);
2472
- return promise;
2473
- };
2474
-
2475
- // Start initial window of tiles
2476
- while (nextTileIdx < numTiles && activeTiles.size < maxConcurrentTiles) {
2477
- startTile(nextTileIdx++);
2478
- }
2479
-
2480
- // As tiles complete, start new ones to maintain window size
2481
- while (activeTiles.size > 0) {
2482
- // Wait for at least one tile to complete
2483
- await Promise.race(activeTiles.keys());
2484
-
2485
- // Start as many new tiles as needed to fill window
2486
- while (nextTileIdx < numTiles && activeTiles.size < maxConcurrentTiles) {
2487
- startTile(nextTileIdx++);
2488
- }
2489
- }
2490
-
2491
- triangleBuffer.destroy();
2492
-
2493
- const totalTime = performance.now() - startTime;
2494
- debug.log(`Radial complete in ${totalTime.toFixed(1)}ms`);
2495
-
2496
- // Stitch tiles together into a single dense array
2497
- const fullGridHeight = Math.ceil(360 / rotationStepDegrees) + 1; // Number of angular samples
2498
- const fullGridWidth = Math.ceil(xRange / stepSize) + 1;
2499
- const stitchedData = new Float32Array(fullGridWidth * fullGridHeight);
2500
- stitchedData.fill(EMPTY_CELL);
2501
-
2502
- for (const tile of tileResults) {
2503
- const tileXOffset = Math.round((tile.minX - bounds.min.x) / stepSize);
2504
-
2505
- for (let ty = 0; ty < tile.gridHeight; ty++) {
2506
- for (let tx = 0; tx < tile.gridWidth; tx++) {
2507
- const tileIdx = ty * tile.gridWidth + tx;
2508
- const fullX = tileXOffset + tx;
2509
- const fullY = ty;
2510
-
2511
- if (fullX >= 0 && fullX < fullGridWidth && fullY >= 0 && fullY < fullGridHeight) {
2512
- const fullIdx = fullY * fullGridWidth + fullX;
2513
- stitchedData[fullIdx] = tile.data[tileIdx];
2514
- }
2515
- }
2516
- }
2517
- }
2518
-
2519
- // For toolpath generation, bounds.max.y must match the actual grid dimensions
2520
- // so that createHeightMapFromPoints calculates the correct height:
2521
- // height = ceil((max.y - min.y) / stepSize) + 1 = gridHeight
2522
- // Therefore: max.y = (gridHeight - 1) * stepSize
2523
- const boundsMaxY = (fullGridHeight - 1) * stepSize;
2524
-
2525
- return {
2526
- positions: stitchedData,
2527
- pointCount: stitchedData.length,
2528
- bounds: {
2529
- min: { x: bounds.min.x, y: 0, z: 0 },
2530
- max: { x: bounds.max.x, y: boundsMaxY, z: maxRadius }
2531
- },
2532
- conversionTime: totalTime,
2533
- gridWidth: fullGridWidth,
2534
- gridHeight: fullGridHeight,
2535
- isDense: true,
2536
- maxRadius,
2537
- circumference,
2538
- rotationStepDegrees // NEW: needed for wrapping and toolpath generation
2539
- };
2540
- }
2541
-
2542
- // Radial V2: Rasterize model with rotating ray planes and X-bucketing
2543
- async function radialRasterizeV2(triangles, bucketData, resolution, angleStep, numAngles, maxRadius, toolWidth, zFloor, bounds, startAngle = 0) {
2544
- if (!device) {
2545
- throw new Error('WebGPU not initialized');
2546
- }
2547
-
2548
- const timings = {
2549
- start: performance.now(),
2550
- prep: 0,
2551
- gpu: 0,
2552
- stitch: 0
2553
- };
2554
-
2555
- // Calculate grid dimensions based on BUCKET range (not model bounds)
2556
- // Buckets may extend slightly beyond model bounds due to rounding
2557
- const bucketMinX = bucketData.buckets[0].minX;
2558
- const bucketMaxX = bucketData.buckets[bucketData.numBuckets - 1].maxX;
2559
- const gridWidth = Math.ceil((bucketMaxX - bucketMinX) / resolution);
2560
- const gridYHeight = Math.ceil(toolWidth / resolution);
2561
- const bucketGridWidth = Math.ceil((bucketData.buckets[0].maxX - bucketData.buckets[0].minX) / resolution);
2562
-
2563
- // Calculate workgroup load distribution for timeout analysis
2564
- const bucketTriangleCounts = bucketData.buckets.map(b => b.count);
2565
- const minTriangles = Math.min(...bucketTriangleCounts);
2566
- const maxTriangles = Math.max(...bucketTriangleCounts);
2567
- const avgTriangles = bucketTriangleCounts.reduce((a, b) => a + b, 0) / bucketTriangleCounts.length;
2568
- const workPerWorkgroup = maxTriangles * numAngles * bucketGridWidth * gridYHeight;
2569
-
2570
- debug.log(`[Worker] Radial V2: ${gridWidth}x${gridYHeight} grid, ${numAngles} angles, ${bucketData.buckets.length} buckets`);
2571
- debug.log(`[Worker] Load: min=${minTriangles} max=${maxTriangles} avg=${avgTriangles.toFixed(0)} (${(maxTriangles/avgTriangles).toFixed(2)}x imbalance, worst=${(workPerWorkgroup/1e6).toFixed(1)}M tests)`);
2572
-
2573
- // Create GPU buffers
2574
- const triangleBuffer = device.createBuffer({
2575
- size: triangles.byteLength,
2576
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2577
- mappedAtCreation: true
2578
- });
2579
- new Float32Array(triangleBuffer.getMappedRange()).set(triangles);
2580
- triangleBuffer.unmap();
2581
-
2582
- // Create bucket info buffer (f32, f32, u32, u32 per bucket)
2583
- const bucketInfoSize = bucketData.buckets.length * 16; // 4 fields * 4 bytes
2584
- const bucketInfoBuffer = device.createBuffer({
2585
- size: bucketInfoSize,
2586
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2587
- mappedAtCreation: true
2588
- });
2589
-
2590
- const bucketView = new ArrayBuffer(bucketInfoSize);
2591
- const bucketFloatView = new Float32Array(bucketView);
2592
- const bucketUintView = new Uint32Array(bucketView);
2593
-
2594
- for (let i = 0; i < bucketData.buckets.length; i++) {
2595
- const bucket = bucketData.buckets[i];
2596
- const offset = i * 4;
2597
- bucketFloatView[offset] = bucket.minX; // f32
2598
- bucketFloatView[offset + 1] = bucket.maxX; // f32
2599
- bucketUintView[offset + 2] = bucket.startIndex; // u32
2600
- bucketUintView[offset + 3] = bucket.count; // u32
2601
- }
2602
-
2603
- new Uint8Array(bucketInfoBuffer.getMappedRange()).set(new Uint8Array(bucketView));
2604
- bucketInfoBuffer.unmap();
2605
-
2606
- // Create triangle indices buffer
2607
- const triangleIndicesBuffer = device.createBuffer({
2608
- size: bucketData.triangleIndices.byteLength,
2609
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
2610
- mappedAtCreation: true
2611
- });
2612
- new Uint32Array(triangleIndicesBuffer.getMappedRange()).set(bucketData.triangleIndices);
2613
- triangleIndicesBuffer.unmap();
2614
-
2615
- // Create output buffer (all angles, all buckets)
2616
- const outputSize = numAngles * bucketData.numBuckets * bucketGridWidth * gridYHeight * 4;
2617
- const outputBuffer = device.createBuffer({
2618
- size: outputSize,
2619
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST
2620
- });
2621
-
2622
- // CRITICAL: Initialize output buffer with zFloor to avoid reading garbage data
2623
- // Use mappedAtCreation for deterministic initialization (not writeBuffer!)
2624
- const initEncoder = device.createCommandEncoder();
2625
- const initData = new Float32Array(outputSize / 4);
2626
- initData.fill(zFloor);
2627
- device.queue.writeBuffer(outputBuffer, 0, initData);
2628
- // Force initialization to complete
2629
- device.queue.submit([initEncoder.finish()]);
2630
- await device.queue.onSubmittedWorkDone();
2631
-
2632
- // Create uniforms with proper alignment (f32 and u32 mixed)
2633
- // Struct layout: f32, f32, u32, f32, f32, u32, f32, u32, f32, f32, u32, u32, f32
2634
- const uniformBuffer = device.createBuffer({
2635
- size: 52, // 13 fields * 4 bytes
2636
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
2637
- mappedAtCreation: true
2638
- });
2639
-
2640
- const uniformView = new ArrayBuffer(52);
2641
- const floatView = new Float32Array(uniformView);
2642
- const uintView = new Uint32Array(uniformView);
2643
-
2644
- floatView[0] = resolution; // f32
2645
- floatView[1] = angleStep * (Math.PI / 180); // f32
2646
- uintView[2] = numAngles; // u32
2647
- floatView[3] = maxRadius; // f32
2648
- floatView[4] = toolWidth; // f32
2649
- uintView[5] = gridYHeight; // u32
2650
- floatView[6] = bucketData.buckets[0].maxX - bucketData.buckets[0].minX; // f32 bucketWidth
2651
- uintView[7] = bucketGridWidth; // u32
2652
- floatView[8] = bucketMinX; // f32 global_min_x (use bucket range)
2653
- floatView[9] = zFloor; // f32
2654
- uintView[10] = 0; // u32 filterMode
2655
- uintView[11] = bucketData.numBuckets; // u32
2656
- floatView[12] = startAngle * (Math.PI / 180); // f32 start_angle (radians)
2657
-
2658
- new Uint8Array(uniformBuffer.getMappedRange()).set(new Uint8Array(uniformView));
2659
- uniformBuffer.unmap();
2660
-
2661
- // Create shader and pipeline
2662
- const shaderModule = device.createShaderModule({ code: radialRasterizeV2ShaderCode });
2663
- const pipeline = device.createComputePipeline({
2664
- layout: 'auto',
2665
- compute: {
2666
- module: shaderModule,
2667
- entryPoint: 'main'
2668
- }
2669
- });
2670
-
2671
- // Create bind group
2672
- const bindGroup = device.createBindGroup({
2673
- layout: pipeline.getBindGroupLayout(0),
2674
- entries: [
2675
- { binding: 0, resource: { buffer: triangleBuffer } },
2676
- { binding: 1, resource: { buffer: outputBuffer } },
2677
- { binding: 2, resource: { buffer: uniformBuffer } },
2678
- { binding: 3, resource: { buffer: bucketInfoBuffer } },
2679
- { binding: 4, resource: { buffer: triangleIndicesBuffer } }
2680
- ]
2681
- });
2682
-
2683
- console.time('RADIAL COMPUTE');
2684
- // Dispatch
2685
- const commandEncoder = device.createCommandEncoder();
2686
- const passEncoder = commandEncoder.beginComputePass();
2687
- passEncoder.setPipeline(pipeline);
2688
- passEncoder.setBindGroup(0, bindGroup);
2689
-
2690
- // Prep complete, GPU starting
2691
- timings.prep = performance.now() - timings.start;
2692
- const gpuStart = performance.now();
2693
-
2694
- // Dispatch: (numAngles/8, gridYHeight/8, numBuckets)
2695
- const dispatchX = Math.ceil(numAngles / 8);
2696
- const dispatchY = Math.ceil(gridYHeight / 8);
2697
- const dispatchZ = bucketData.numBuckets;
2698
- debug.log(`[Worker] Dispatch: (${dispatchX}, ${dispatchY}, ${dispatchZ}) = ${dispatchX * 8} angles, ${dispatchY * 8} Y cells, ${dispatchZ} buckets`);
2699
-
2700
- passEncoder.dispatchWorkgroups(dispatchX, dispatchY, dispatchZ);
2701
- passEncoder.end();
2702
-
2703
- // Read back
2704
- const stagingBuffer = device.createBuffer({
2705
- size: outputSize,
2706
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
2707
- });
2708
-
2709
- commandEncoder.copyBufferToBuffer(outputBuffer, 0, stagingBuffer, 0, outputSize);
2710
- device.queue.submit([commandEncoder.finish()]);
2711
-
2712
- // CRITICAL: Wait for GPU to finish before reading results
2713
- await device.queue.onSubmittedWorkDone();
2714
- console.timeEnd('RADIAL COMPUTE');
2715
-
2716
- await stagingBuffer.mapAsync(GPUMapMode.READ);
2717
- const outputData = new Float32Array(stagingBuffer.getMappedRange());
2718
- const outputCopy = new Float32Array(outputData);
2719
- stagingBuffer.unmap();
2720
-
2721
- // Cleanup
2722
- triangleBuffer.destroy();
2723
- bucketInfoBuffer.destroy();
2724
- triangleIndicesBuffer.destroy();
2725
- outputBuffer.destroy();
2726
- uniformBuffer.destroy();
2727
- stagingBuffer.destroy();
2728
-
2729
- timings.gpu = performance.now() - gpuStart;
2730
-
2731
- // Stitch strips
2732
- const stitchStart = performance.now();
2733
- const strips = [];
2734
-
2735
- for (let angleIdx = 0; angleIdx < numAngles; angleIdx++) {
2736
- const stripData = new Float32Array(gridWidth * gridYHeight);
2737
- stripData.fill(zFloor); // Initialize with zFloor, not zeros!
2738
-
2739
- // Gather from each bucket
2740
- for (let bucketIdx = 0; bucketIdx < bucketData.numBuckets; bucketIdx++) {
2741
- const bucket = bucketData.buckets[bucketIdx];
2742
- const bucketMinGridX = Math.floor((bucket.minX - bucketMinX) / resolution);
2743
-
2744
- for (let localX = 0; localX < bucketGridWidth; localX++) {
2745
- const gridX = bucketMinGridX + localX;
2746
- if (gridX >= gridWidth) continue;
2747
-
2748
- for (let gridY = 0; gridY < gridYHeight; gridY++) {
2749
- const srcIdx = bucketIdx * numAngles * bucketGridWidth * gridYHeight
2750
- + angleIdx * bucketGridWidth * gridYHeight
2751
- + gridY * bucketGridWidth
2752
- + localX;
2753
- const dstIdx = gridY * gridWidth + gridX;
2754
- stripData[dstIdx] = outputCopy[srcIdx];
2755
- }
2756
- }
2757
- }
2758
-
2759
- // Keep as DENSE Z-only format (toolpath generator expects this!)
2760
- // Count valid points
2761
- let validCount = 0;
2762
- for (let i = 0; i < stripData.length; i++) {
2763
- if (stripData[i] !== zFloor) validCount++;
2764
- }
2765
-
2766
- strips.push({
2767
- angle: startAngle + (angleIdx * angleStep),
2768
- positions: stripData, // DENSE Z-only format!
2769
- gridWidth,
2770
- gridHeight: gridYHeight,
2771
- pointCount: validCount, // Number of non-floor cells
2772
- bounds: {
2773
- min: { x: bucketMinX, y: 0, z: zFloor },
2774
- max: { x: bucketMaxX, y: toolWidth, z: bounds.max.z }
2775
- }
2776
- });
2777
- }
2778
-
2779
- timings.stitch = performance.now() - stitchStart;
2780
- const totalTime = performance.now() - timings.start;
2781
-
2782
- debug.log(`[Worker] Radial V2 complete: ${totalTime.toFixed(0)}ms`);
2783
- debug.log(`[Worker] Prep: ${timings.prep.toFixed(0)}ms (${(timings.prep/totalTime*100).toFixed(0)}%)`);
2784
- debug.log(`[Worker] GPU: ${timings.gpu.toFixed(0)}ms (${(timings.gpu/totalTime*100).toFixed(0)}%)`);
2785
- debug.log(`[Worker] Stitch: ${timings.stitch.toFixed(0)}ms (${(timings.stitch/totalTime*100).toFixed(0)}%)`);
2786
-
2787
- return { strips, timings };
2788
- }
2789
-
2790
- // Handle messages from main thread
2791
- self.onmessage = async function(e) {
2792
- const { type, data } = e.data;
2793
-
2794
- try {
2795
- switch (type) {
2796
- case 'init':
2797
- // Store config
2798
- config = data?.config || {
2799
- maxGPUMemoryMB: 256,
2800
- gpuMemorySafetyMargin: 0.8,
2801
- tileOverlapMM: 10,
2802
- autoTiling: true,
2803
- minTileSize: 50
2804
- };
2805
- const success = await initWebGPU();
2806
- self.postMessage({
2807
- type: 'webgpu-ready',
2808
- data: {
2809
- success,
2810
- capabilities: deviceCapabilities
2811
- }
2812
- });
2813
- break;
2814
-
2815
- case 'update-config':
2816
- config = data.config;
2817
- debug.log('Config updated:', config);
2818
- break;
2819
-
2820
- case 'rasterize':
2821
- const { triangles, stepSize, filterMode, boundsOverride } = data;
2822
- const rasterOptions = boundsOverride || {};
2823
- const rasterResult = await rasterizeMesh(triangles, stepSize, filterMode, rasterOptions);
2824
- self.postMessage({
2825
- type: 'rasterize-complete',
2826
- data: rasterResult,
2827
- }, [rasterResult.positions.buffer]);
2828
- break;
2829
-
2830
- case 'generate-toolpath':
2831
- const { terrainPositions, toolPositions, xStep, yStep, zFloor, gridStep, terrainBounds, singleScanline } = data;
2832
- const toolpathResult = await generateToolpath(
2833
- terrainPositions, toolPositions, xStep, yStep, zFloor, gridStep, terrainBounds, singleScanline
2834
- );
2835
- self.postMessage({
2836
- type: 'toolpath-complete',
2837
- data: toolpathResult
2838
- }, [toolpathResult.pathData.buffer]);
2839
- break;
2840
-
2841
- case 'radial-rasterize':
2842
- const { triangles: radialTriangles, stepSize: radialStep, rotationStep: radialRotationStep, zFloor: radialZFloor = 0, boundsOverride: radialBounds, maxConcurrentTiles, trianglesPerTile, radialRotationOffset } = data;
2843
- const radialResult = await radialRasterize(radialTriangles, radialStep, radialRotationStep, radialZFloor, radialBounds, { maxConcurrentTiles, trianglesPerTile, radialRotationOffset });
2844
- self.postMessage({
2845
- type: 'radial-rasterize-complete',
2846
- data: radialResult
2847
- }, [radialResult.positions.buffer]);
2848
- break;
2849
-
2850
- case 'radial-generate-toolpaths':
2851
- // Complete radial pipeline: rasterize model + generate toolpaths for all strips
2852
- const {
2853
- triangles: radialModelTriangles,
2854
- bucketData: radialBucketData,
2855
- toolData: radialToolData,
2856
- resolution: radialResolution,
2857
- angleStep: radialAngleStep,
2858
- numAngles: radialNumAngles,
2859
- maxRadius: radialMaxRadius,
2860
- toolWidth: radialToolWidth,
2861
- zFloor: radialToolpathZFloor,
2862
- bounds: radialToolpathBounds,
2863
- xStep: radialXStep,
2864
- yStep: radialYStep,
2865
- gridStep: radialGridStep
2866
- } = data;
2867
-
2868
- debug.log('[Worker] Starting complete radial toolpath pipeline...');
2869
-
2870
- // Batch processing: rasterize angle ranges to avoid memory allocation failure
2871
- const ANGLES_PER_BATCH = 360; // Process 360 angles at a time
2872
- const numBatches = Math.ceil(radialNumAngles / ANGLES_PER_BATCH);
2873
-
2874
- debug.log(`[Worker] Processing ${radialNumAngles} angles in ${numBatches} batch(es) of up to ${ANGLES_PER_BATCH} angles`);
2875
-
2876
- const allStripToolpaths = [];
2877
- let totalToolpathPoints = 0;
2878
- const pipelineStartTime = performance.now();
2879
-
2880
- // Prepare sparse tool once
2881
- const sparseToolData = createSparseToolFromPoints(radialToolData.positions);
2882
- debug.log(`[Worker] Created sparse tool: ${sparseToolData.count} points (reusing for all strips)`);
2883
-
2884
- for (let batchIdx = 0; batchIdx < numBatches; batchIdx++) {
2885
- const startAngleIdx = batchIdx * ANGLES_PER_BATCH;
2886
- const endAngleIdx = Math.min(startAngleIdx + ANGLES_PER_BATCH, radialNumAngles);
2887
- const batchNumAngles = endAngleIdx - startAngleIdx;
2888
- const batchStartAngle = startAngleIdx * radialAngleStep;
2889
-
2890
- debug.log(`[Worker] Batch ${batchIdx + 1}/${numBatches}: angles ${startAngleIdx}-${endAngleIdx - 1} (${batchNumAngles} angles), startAngle=${batchStartAngle.toFixed(1)}°`);
2891
-
2892
- // Rasterize this batch of strips
2893
- const batchModelResult = await radialRasterizeV2(
2894
- radialModelTriangles,
2895
- radialBucketData,
2896
- radialResolution,
2897
- radialAngleStep,
2898
- batchNumAngles,
2899
- radialMaxRadius,
2900
- radialToolWidth,
2901
- radialToolpathZFloor,
2902
- radialToolpathBounds,
2903
- batchStartAngle // Start angle for this batch
2904
- );
2905
-
2906
- debug.log(`[Worker] Batch ${batchIdx + 1}: Rasterized ${batchModelResult.strips.length} strips, first angle=${batchModelResult.strips[0]?.angle.toFixed(1)}°, last angle=${batchModelResult.strips[batchModelResult.strips.length - 1]?.angle.toFixed(1)}°`);
2907
-
2908
- // Find max dimensions for this batch
2909
- let maxStripWidth = 0;
2910
- let maxStripHeight = 0;
2911
- for (const strip of batchModelResult.strips) {
2912
- maxStripWidth = Math.max(maxStripWidth, strip.gridWidth);
2913
- maxStripHeight = Math.max(maxStripHeight, strip.gridHeight);
2914
- }
2915
-
2916
- // Create reusable buffers for this batch
2917
- const reusableBuffers = createReusableToolpathBuffers(maxStripWidth, maxStripHeight, sparseToolData, radialXStep, maxStripHeight);
2918
-
2919
- // Generate toolpaths for this batch
2920
- debug.log(`[Worker] Batch ${batchIdx + 1}: Generating toolpaths for ${batchModelResult.strips.length} strips...`);
2921
- for (let i = 0; i < batchModelResult.strips.length; i++) {
2922
- const strip = batchModelResult.strips[i];
2923
- const globalStripIdx = startAngleIdx + i;
2924
-
2925
- if (globalStripIdx % 10 === 0 || globalStripIdx === radialNumAngles - 1) {
2926
- self.postMessage({
2927
- type: 'toolpath-progress',
2928
- data: {
2929
- percent: Math.round(((globalStripIdx + 1) / radialNumAngles) * 100),
2930
- current: globalStripIdx + 1,
2931
- total: radialNumAngles,
2932
- layer: globalStripIdx + 1
2933
- }
2934
- });
2935
- }
2936
-
2937
- if (!strip.positions || strip.positions.length === 0) continue;
2938
-
2939
- // DEBUG: Diagnostic logging (BUILD_ID gets injected during build)
2940
- // Used to trace data flow through radial toolpath pipeline
2941
- if (globalStripIdx === 0 || globalStripIdx === 360) {
2942
- debug.log(`[Worker] 9P5WQ2V3 | Strip ${globalStripIdx} (${strip.angle.toFixed(1)}°) INPUT terrain first 5 Z values: ${strip.positions.slice(0, 5).map(v => v.toFixed(3)).join(',')}`);
2943
- }
2944
-
2945
- const stripToolpathResult = await runToolpathComputeWithBuffers(
2946
- strip.positions,
2947
- strip.gridWidth,
2948
- strip.gridHeight,
2949
- radialXStep,
2950
- strip.gridHeight,
2951
- radialToolpathZFloor,
2952
- reusableBuffers,
2953
- pipelineStartTime
2954
- );
2955
-
2956
- // DEBUG: Verify toolpath generation output
2957
- if (globalStripIdx === 0 || globalStripIdx === 360) {
2958
- debug.log(`[Worker] 9P5WQ2V3 | Strip ${globalStripIdx} (${strip.angle.toFixed(1)}°) OUTPUT toolpath first 5 Z values: ${stripToolpathResult.pathData.slice(0, 5).map(v => v.toFixed(3)).join(',')}`);
2959
- }
2960
-
2961
- allStripToolpaths.push({
2962
- angle: strip.angle,
2963
- pathData: stripToolpathResult.pathData,
2964
- numScanlines: stripToolpathResult.numScanlines,
2965
- pointsPerLine: stripToolpathResult.pointsPerLine,
2966
- terrainBounds: strip.bounds // Include terrain bounds for display
2967
- });
2968
-
2969
- totalToolpathPoints += stripToolpathResult.pathData.length;
2970
- }
2971
-
2972
- destroyReusableToolpathBuffers(reusableBuffers);
2973
-
2974
- debug.log(`[Worker] Batch ${batchIdx + 1}: Completed, allStripToolpaths now has ${allStripToolpaths.length} strips total`);
2975
-
2976
- // Free batch terrain data
2977
- for (const strip of batchModelResult.strips) {
2978
- strip.positions = null;
2979
- }
2980
- }
2981
-
2982
- const pipelineTotalTime = performance.now() - pipelineStartTime;
2983
- debug.log(`[Worker] Complete radial toolpath: ${allStripToolpaths.length} strips, ${totalToolpathPoints} total points in ${pipelineTotalTime.toFixed(0)}ms`);
2984
-
2985
- const toolpathTransferBuffers = allStripToolpaths.map(strip => strip.pathData.buffer);
2986
-
2987
- self.postMessage({
2988
- type: 'radial-toolpaths-complete',
2989
- data: {
2990
- strips: allStripToolpaths,
2991
- totalPoints: totalToolpathPoints,
2992
- numStrips: allStripToolpaths.length
2993
- }
2994
- }, toolpathTransferBuffers);
2995
- break;
2996
-
2997
- default:
2998
- self.postMessage({
2999
- type: 'error',
3000
- message: 'Unknown message type: ' + type
3001
- });
3002
- }
3003
- } catch (error) {
3004
- debug.error('Error:', error);
3005
- self.postMessage({
3006
- type: 'error',
3007
- message: error.message,
3008
- stack: error.stack
3009
- });
3010
- }
3011
- };