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