@gridspace/raster-path 1.0.2
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/LICENSE +20 -0
- package/README.md +292 -0
- package/build/app.js +1254 -0
- package/build/index.html +92 -0
- package/build/parse-stl.js +114 -0
- package/build/raster-path.js +688 -0
- package/build/serve.json +12 -0
- package/build/style.css +158 -0
- package/build/webgpu-worker.js +3011 -0
- package/package.json +58 -0
- package/scripts/build-shaders.js +65 -0
- package/src/index.js +688 -0
- package/src/shaders/planar-rasterize.wgsl +213 -0
- package/src/shaders/planar-toolpath.wgsl +83 -0
- package/src/shaders/radial-raster-v2.wgsl +195 -0
- package/src/web/app.js +1254 -0
- package/src/web/parse-stl.js +114 -0
- package/src/web/webgpu-worker.js +2520 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// raster-path: Terrain and Tool Raster Path Finder using WebGPU
|
|
2
|
+
// Main ESM entry point
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* RasterPath API Overview
|
|
7
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
*
|
|
9
|
+
* Unified three-method API for GPU-accelerated toolpath generation.
|
|
10
|
+
* Works uniformly across both planar (XY grid) and radial (cylindrical) modes.
|
|
11
|
+
*
|
|
12
|
+
* USAGE PATTERN:
|
|
13
|
+
* ──────────────
|
|
14
|
+
* 1. Create instance: new RasterPath({ mode, resolution, rotationStep? })
|
|
15
|
+
* 2. Initialize GPU: await raster.init()
|
|
16
|
+
* 3. Load tool: await raster.loadTool({ triangles | sparseData })
|
|
17
|
+
* 4. Load terrain: await raster.loadTerrain({ triangles, zFloor?, ... })
|
|
18
|
+
* 5. Generate paths: await raster.generateToolpaths({ xStep, yStep, zFloor, ... })
|
|
19
|
+
* 6. Cleanup: raster.terminate()
|
|
20
|
+
*
|
|
21
|
+
* MODE DIFFERENCES:
|
|
22
|
+
* ─────────────────
|
|
23
|
+
* PLANAR MODE:
|
|
24
|
+
* - Traditional XY grid rasterization
|
|
25
|
+
* - loadTerrain() rasterizes immediately and returns data
|
|
26
|
+
* - Best for flat or gently curved surfaces
|
|
27
|
+
* - Output: Single 2D array of Z-heights in scanline order
|
|
28
|
+
*
|
|
29
|
+
* RADIAL MODE:
|
|
30
|
+
* - Cylindrical unwrap rasterization
|
|
31
|
+
* - loadTerrain() stores triangles, defers rasterization until generateToolpaths()
|
|
32
|
+
* - Best for cylindrical/rotational parts
|
|
33
|
+
* - Requires terrain centered in YZ plane (done automatically)
|
|
34
|
+
* - Output: Array of radial strips, one per rotation angle
|
|
35
|
+
*
|
|
36
|
+
* COORDINATE SYSTEMS:
|
|
37
|
+
* ───────────────────
|
|
38
|
+
* - Tool geometry: Z-axis is flipped during loadTool() for collision detection
|
|
39
|
+
* - Terrain (radial): Auto-centered in YZ plane before storage
|
|
40
|
+
* - All inputs use standard STL coordinates (right-handed, Z-up)
|
|
41
|
+
*
|
|
42
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Configuration options for RasterPath
|
|
47
|
+
* @typedef {Object} RasterPathConfig
|
|
48
|
+
* @property {'planar'|'radial'} mode - Rasterization mode (default: 'planar')
|
|
49
|
+
* @property {boolean} autoTiling - Automatically tile large datasets (default: true)
|
|
50
|
+
* @property {number} gpuMemorySafetyMargin - Safety margin as percentage (default: 0.8 = 80%)
|
|
51
|
+
* @property {number} maxConcurrentTiles - Max concurrent tiles for radial rasterization (default: 50)
|
|
52
|
+
* @property {number} maxGPUMemoryMB - Maximum GPU memory per tile (default: 256MB)
|
|
53
|
+
* @property {number} minTileSize - Minimum tile dimension (default: 50mm)
|
|
54
|
+
* @property {number} radialRotationOffset - Radial mode: rotation offset in degrees (default: 0, use 90 to start at Z-axis)
|
|
55
|
+
* @property {number} resolution - Grid step size in mm (required)
|
|
56
|
+
* @property {number} rotationStep - Radial mode only: degrees between rays (e.g., 1.0 = 360 rays)
|
|
57
|
+
* @property {number} trianglesPerTile - Target triangles per tile for radial rasterization (default: calculated)
|
|
58
|
+
* @property {boolean} debug - Enable debug logging (default: false)
|
|
59
|
+
* @property {boolean} quiet - Suppress log output (default: false)
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
const ZMAX = 10e6;
|
|
63
|
+
const EMPTY_CELL = -1e10;
|
|
64
|
+
const log_pre = '[Raster]';
|
|
65
|
+
|
|
66
|
+
const debug = {
|
|
67
|
+
error: function() { console.error(log_pre, ...arguments) },
|
|
68
|
+
warn: function() { console.warn(log_pre, ...arguments) },
|
|
69
|
+
log: function() { console.log(log_pre, ...arguments) },
|
|
70
|
+
ok: function() { console.log(log_pre, '✅', ...arguments) },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Main class for rasterizing geometry and generating toolpaths using WebGPU
|
|
75
|
+
* Supports both planar and radial (cylindrical) rasterization modes
|
|
76
|
+
*/
|
|
77
|
+
export class RasterPath {
|
|
78
|
+
constructor(config = {}) {
|
|
79
|
+
// Validate required parameters
|
|
80
|
+
if (!config.resolution) {
|
|
81
|
+
throw new Error('RasterPath requires resolution parameter');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Validate mode
|
|
85
|
+
const mode = config.mode || 'planar';
|
|
86
|
+
if (mode !== 'planar' && mode !== 'radial') {
|
|
87
|
+
throw new Error(`Invalid mode: ${mode}. Must be 'planar' or 'radial'`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Validate rotationStep for radial mode
|
|
91
|
+
if (mode === 'radial' && !config.rotationStep) {
|
|
92
|
+
throw new Error('Radial mode requires rotationStep parameter (degrees between rays)');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.mode = mode;
|
|
96
|
+
this.resolution = config.resolution;
|
|
97
|
+
this.rotationStep = config.rotationStep;
|
|
98
|
+
|
|
99
|
+
this.worker = null;
|
|
100
|
+
this.isInitialized = false;
|
|
101
|
+
this.messageHandlers = new Map();
|
|
102
|
+
this.messageId = 0;
|
|
103
|
+
this.deviceCapabilities = null;
|
|
104
|
+
|
|
105
|
+
// Configure debug output
|
|
106
|
+
let urlOpt = [];
|
|
107
|
+
if (config.quiet) {
|
|
108
|
+
debug.log = function() {};
|
|
109
|
+
urlOpt.push('quiet');
|
|
110
|
+
}
|
|
111
|
+
if (config.debug) {
|
|
112
|
+
urlOpt.push('debug');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Configuration with defaults
|
|
116
|
+
this.config = {
|
|
117
|
+
workerName: (config.workerName ?? "webgpu-worker.js") + (urlOpt.length ? "?"+urlOpt.join('&') : ""),
|
|
118
|
+
maxGPUMemoryMB: config.maxGPUMemoryMB ?? 256,
|
|
119
|
+
gpuMemorySafetyMargin: config.gpuMemorySafetyMargin ?? 0.8,
|
|
120
|
+
autoTiling: config.autoTiling ?? true,
|
|
121
|
+
minTileSize: config.minTileSize ?? 50,
|
|
122
|
+
maxConcurrentTiles: config.maxConcurrentTiles ?? 10,
|
|
123
|
+
trianglesPerTile: config.trianglesPerTile, // undefined = auto-calculate
|
|
124
|
+
radialRotationOffset: config.radialRotationOffset ?? 0, // degrees
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Initialize WebGPU worker
|
|
130
|
+
* Must be called before any processing operations
|
|
131
|
+
* @returns {Promise<boolean>} Success status
|
|
132
|
+
*/
|
|
133
|
+
async init() {
|
|
134
|
+
if (this.isInitialized) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
try {
|
|
140
|
+
// Create worker from the webgpu-worker.js file
|
|
141
|
+
const workerName = this.config.workerName;
|
|
142
|
+
const isBuildVersion = import.meta.url.includes('/build/') || import.meta.url.includes('raster-path.js');
|
|
143
|
+
const workerPath = workerName
|
|
144
|
+
? new URL(workerName, import.meta.url)
|
|
145
|
+
: isBuildVersion
|
|
146
|
+
? new URL(`./webgpu-worker.js`, import.meta.url)
|
|
147
|
+
: new URL(`./web/webgpu-worker.js`, import.meta.url);
|
|
148
|
+
this.worker = new Worker(workerPath, { type: 'module' });
|
|
149
|
+
|
|
150
|
+
// Set up message handler
|
|
151
|
+
this.worker.onmessage = (e) => this.#handleMessage(e);
|
|
152
|
+
this.worker.onerror = (error) => {
|
|
153
|
+
debug.error('[RasterPath] Worker error:', error);
|
|
154
|
+
reject(error);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Send init message with config
|
|
158
|
+
const handler = (data) => {
|
|
159
|
+
this.isInitialized = data.success;
|
|
160
|
+
if (data.success) {
|
|
161
|
+
this.deviceCapabilities = data.capabilities;
|
|
162
|
+
resolve(true);
|
|
163
|
+
} else {
|
|
164
|
+
reject(new Error('Failed to initialize WebGPU'));
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
this.#sendMessage('init', { config: this.config }, 'webgpu-ready', handler);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
reject(error);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Load tool - accepts either triangles (from STL) or sparse data (from Kiri:Moto)
|
|
177
|
+
* @param {object} params - Parameters
|
|
178
|
+
* @param {Float32Array} params.triangles - Optional: Unindexed triangle vertices
|
|
179
|
+
* @param {object} params.sparseData - Optional: Pre-computed sparse data {bounds, positions, pointCount}
|
|
180
|
+
* @returns {Promise<object>} Tool data (sparse format: {bounds, positions, pointCount})
|
|
181
|
+
*/
|
|
182
|
+
async loadTool({ triangles, sparseData }) {
|
|
183
|
+
if (!this.isInitialized) {
|
|
184
|
+
throw new Error('RasterPath not initialized. Call init() first.');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// If sparse data provided directly (from Kiri:Moto), use it
|
|
188
|
+
if (sparseData) {
|
|
189
|
+
this.toolData = sparseData;
|
|
190
|
+
return sparseData;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Otherwise rasterize from triangles
|
|
194
|
+
if (!triangles) {
|
|
195
|
+
throw new Error('loadTool() requires either triangles or sparseData');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const toolData = await this.#rasterizePlanar({ triangles, isForTool: true });
|
|
199
|
+
const { bounds, positions } = toolData;
|
|
200
|
+
|
|
201
|
+
// Transform tool coordinate system: flip Z-axis for collision detection
|
|
202
|
+
// Tool geometry is inverted so that tool-terrain collision can be computed
|
|
203
|
+
// as a simple subtraction (terrainZ - toolZ) instead of complex geometry tests
|
|
204
|
+
for (let i=0; i<positions.length; i += 3) {
|
|
205
|
+
positions[i+2] = -positions[i+2] - bounds.min.z;
|
|
206
|
+
}
|
|
207
|
+
let swapZ = bounds.min.z;
|
|
208
|
+
bounds.min.z = -bounds.max.z;
|
|
209
|
+
bounds.max.z = -swapZ;
|
|
210
|
+
|
|
211
|
+
this.toolData = toolData;
|
|
212
|
+
return toolData;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Load terrain - behavior depends on mode
|
|
217
|
+
* Planar mode: Rasterizes and returns terrain data
|
|
218
|
+
* Radial mode: Stores triangles for later use in generateToolpaths()
|
|
219
|
+
* @param {object} params - Parameters
|
|
220
|
+
* @param {Float32Array} params.triangles - Unindexed triangle vertices
|
|
221
|
+
* @param {number} params.zFloor - Z floor for out-of-bounds (optional)
|
|
222
|
+
* @param {object} params.boundsOverride - Optional bounding box {min: {x, y, z}, max: {x, y, z}}
|
|
223
|
+
* @param {function} params.onProgress - Optional progress callback (percent, info) => {}
|
|
224
|
+
* @returns {Promise<object|null>} Planar: terrain data {bounds, positions, pointCount}, Radial: null
|
|
225
|
+
*/
|
|
226
|
+
async loadTerrain({ triangles, zFloor, boundsOverride, onProgress }) {
|
|
227
|
+
if (!this.isInitialized) {
|
|
228
|
+
throw new Error('RasterPath not initialized. Call init() first.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.mode === 'planar') {
|
|
232
|
+
// Planar: rasterize and return
|
|
233
|
+
const terrainData = await this.#rasterizePlanar({ triangles, zFloor, boundsOverride, isForTool: false, onProgress });
|
|
234
|
+
this.terrainData = terrainData;
|
|
235
|
+
return terrainData;
|
|
236
|
+
} else {
|
|
237
|
+
// Radial: store triangles and metadata for generateToolpaths()
|
|
238
|
+
const originalBounds = boundsOverride || this.#calculateBounds(triangles);
|
|
239
|
+
|
|
240
|
+
// Center model in YZ plane (required for radial rasterization)
|
|
241
|
+
// Radial mode casts rays from origin, so terrain must be centered at (0,0) in YZ
|
|
242
|
+
// to ensure rays intersect the geometry symmetrically around the rotation axis
|
|
243
|
+
const centerY = (originalBounds.min.y + originalBounds.max.y) / 2;
|
|
244
|
+
const centerZ = (originalBounds.min.z + originalBounds.max.z) / 2;
|
|
245
|
+
|
|
246
|
+
let centeredTriangles = triangles;
|
|
247
|
+
let bounds = originalBounds;
|
|
248
|
+
|
|
249
|
+
if (Math.abs(centerY) > 0.001 || Math.abs(centerZ) > 0.001) {
|
|
250
|
+
debug.log(`Centering model in YZ: offset Y=${centerY.toFixed(3)}, Z=${centerZ.toFixed(3)}`);
|
|
251
|
+
centeredTriangles = new Float32Array(triangles.length);
|
|
252
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
253
|
+
centeredTriangles[i] = triangles[i]; // X unchanged
|
|
254
|
+
centeredTriangles[i + 1] = triangles[i + 1] - centerY; // Center Y
|
|
255
|
+
centeredTriangles[i + 2] = triangles[i + 2] - centerZ; // Center Z
|
|
256
|
+
}
|
|
257
|
+
bounds = this.#calculateBounds(centeredTriangles);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Store for generateToolpaths()
|
|
261
|
+
this.terrainTriangles = centeredTriangles;
|
|
262
|
+
this.terrainBounds = bounds;
|
|
263
|
+
this.terrainZFloor = zFloor ?? 0;
|
|
264
|
+
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Generate toolpaths from loaded terrain and tool
|
|
271
|
+
* Must call loadTool() and loadTerrain() first
|
|
272
|
+
* @param {object} params - Parameters
|
|
273
|
+
* @param {number} params.xStep - Sample every Nth point in X direction
|
|
274
|
+
* @param {number} params.yStep - Sample every Nth point in Y direction
|
|
275
|
+
* @param {number} params.zFloor - Z floor value for out-of-bounds areas
|
|
276
|
+
* @param {number} params.radiusOffset - (Radial mode only) Distance from terrain surface to tool tip in mm.
|
|
277
|
+
* Used to calculate radial collision offset. Default: 20mm
|
|
278
|
+
* @param {function} params.onProgress - Optional progress callback (progress: number, info?: string) => void
|
|
279
|
+
* @returns {Promise<object>} Planar: {pathData, width, height} | Radial: {strips[], numStrips, totalPoints}
|
|
280
|
+
*/
|
|
281
|
+
async generateToolpaths({ xStep, yStep, zFloor, radiusOffset = 20, onProgress }) {
|
|
282
|
+
if (!this.isInitialized) {
|
|
283
|
+
throw new Error('RasterPath not initialized. Call init() first.');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!this.toolData) {
|
|
287
|
+
throw new Error('Tool not loaded. Call loadTool() first.');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (this.mode === 'planar') {
|
|
291
|
+
if (!this.terrainData) {
|
|
292
|
+
throw new Error('Terrain not loaded. Call loadTerrain() first.');
|
|
293
|
+
}
|
|
294
|
+
return this.#generateToolpathsPlanar({
|
|
295
|
+
terrainData: this.terrainData,
|
|
296
|
+
toolData: this.toolData,
|
|
297
|
+
xStep,
|
|
298
|
+
yStep,
|
|
299
|
+
zFloor,
|
|
300
|
+
onProgress
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
// Radial mode: use stored triangles
|
|
304
|
+
if (!this.terrainTriangles) {
|
|
305
|
+
throw new Error('Terrain not loaded. Call loadTerrain() first.');
|
|
306
|
+
}
|
|
307
|
+
return this.#generateToolpathsRadial({
|
|
308
|
+
triangles: this.terrainTriangles,
|
|
309
|
+
bounds: this.terrainBounds,
|
|
310
|
+
toolData: this.toolData,
|
|
311
|
+
xStep,
|
|
312
|
+
yStep,
|
|
313
|
+
zFloor: zFloor ?? this.terrainZFloor,
|
|
314
|
+
onProgress
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Terminate worker and cleanup resources
|
|
321
|
+
*/
|
|
322
|
+
terminate() {
|
|
323
|
+
if (this.worker) {
|
|
324
|
+
this.worker.terminate();
|
|
325
|
+
this.worker = null;
|
|
326
|
+
this.isInitialized = false;
|
|
327
|
+
this.messageHandlers.clear();
|
|
328
|
+
this.deviceCapabilities = null;
|
|
329
|
+
// Clear loaded data
|
|
330
|
+
this.toolData = null;
|
|
331
|
+
this.terrainData = null;
|
|
332
|
+
this.terrainTriangles = null;
|
|
333
|
+
this.terrainBounds = null;
|
|
334
|
+
this.terrainZFloor = null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// Internal Methods (Planar)
|
|
340
|
+
// ============================================================================
|
|
341
|
+
|
|
342
|
+
async #rasterizePlanar({ triangles, zFloor, boundsOverride, isForTool }) {
|
|
343
|
+
const data = await new Promise((resolve, reject) => {
|
|
344
|
+
const handler = (data) => resolve(data);
|
|
345
|
+
|
|
346
|
+
this.#sendMessage(
|
|
347
|
+
'rasterize',
|
|
348
|
+
{
|
|
349
|
+
triangles,
|
|
350
|
+
stepSize: this.resolution,
|
|
351
|
+
filterMode: isForTool ? 1 : 0, // 0 = max Z (terrain), 1 = min Z (tool)
|
|
352
|
+
boundsOverride
|
|
353
|
+
},
|
|
354
|
+
'rasterize-complete',
|
|
355
|
+
handler
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return data;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async #generateToolpathsPlanar({ terrainData, toolData, xStep, yStep, zFloor, onProgress, singleScanline = false }) {
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
// Set up progress handler if callback provided
|
|
365
|
+
if (onProgress) {
|
|
366
|
+
const progressHandler = (data) => {
|
|
367
|
+
onProgress(data.percent, { current: data.current, total: data.total, layer: data.layer });
|
|
368
|
+
};
|
|
369
|
+
this.messageHandlers.set('toolpath-progress', progressHandler);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const handler = (data) => {
|
|
373
|
+
// Clean up progress handler
|
|
374
|
+
if (onProgress) {
|
|
375
|
+
this.messageHandlers.delete('toolpath-progress');
|
|
376
|
+
}
|
|
377
|
+
resolve(data);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
this.#sendMessage(
|
|
381
|
+
'generate-toolpath',
|
|
382
|
+
{
|
|
383
|
+
terrainPositions: terrainData.positions,
|
|
384
|
+
toolPositions: toolData.positions,
|
|
385
|
+
xStep,
|
|
386
|
+
yStep,
|
|
387
|
+
zFloor: zFloor ?? 0,
|
|
388
|
+
gridStep: this.resolution,
|
|
389
|
+
terrainBounds: terrainData.bounds,
|
|
390
|
+
singleScanline
|
|
391
|
+
},
|
|
392
|
+
'toolpath-complete',
|
|
393
|
+
handler
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async #generateToolpathsRadial({ triangles, bounds, toolData, xStep, yStep, zFloor, onProgress }) {
|
|
399
|
+
const maxRadius = this.#calculateMaxRadius(triangles);
|
|
400
|
+
|
|
401
|
+
// Calculate maximum tool extent in YZ plane (perpendicular to rotation axis)
|
|
402
|
+
// This determines the radial collision search radius for each ray cast
|
|
403
|
+
const toolWidth = Math.max(
|
|
404
|
+
Math.abs(toolData.bounds.max.y - toolData.bounds.min.y),
|
|
405
|
+
Math.abs(toolData.bounds.max.x - toolData.bounds.min.x)
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Build X-bucketing data for spatial partitioning along rotation axis
|
|
409
|
+
// Divides terrain into buckets along X-axis to reduce triangle intersection tests
|
|
410
|
+
const numAngles = Math.ceil(360 / this.rotationStep);
|
|
411
|
+
const bucketWidth = 1.0; // Bucket size in mm - smaller = better load balancing, more memory
|
|
412
|
+
const bucketData = this.#bucketTrianglesByX(triangles, bounds, bucketWidth);
|
|
413
|
+
|
|
414
|
+
return new Promise((resolve, reject) => {
|
|
415
|
+
// Setup progress handler
|
|
416
|
+
if (onProgress) {
|
|
417
|
+
const progressHandler = (data) => {
|
|
418
|
+
onProgress(data.current, data.total);
|
|
419
|
+
};
|
|
420
|
+
this.messageHandlers.set('toolpath-progress', progressHandler);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Setup completion handler
|
|
424
|
+
const completionHandler = (data) => {
|
|
425
|
+
// Clean up progress handler
|
|
426
|
+
if (onProgress) {
|
|
427
|
+
this.messageHandlers.delete('toolpath-progress');
|
|
428
|
+
}
|
|
429
|
+
resolve(data);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// Send entire pipeline to worker
|
|
433
|
+
this.#sendMessage(
|
|
434
|
+
'radial-generate-toolpaths',
|
|
435
|
+
{
|
|
436
|
+
triangles: triangles,
|
|
437
|
+
bucketData,
|
|
438
|
+
toolData,
|
|
439
|
+
resolution: this.resolution,
|
|
440
|
+
angleStep: this.rotationStep,
|
|
441
|
+
numAngles,
|
|
442
|
+
maxRadius: maxRadius * 1.01,
|
|
443
|
+
toolWidth,
|
|
444
|
+
zFloor: zFloor,
|
|
445
|
+
bounds,
|
|
446
|
+
xStep,
|
|
447
|
+
yStep,
|
|
448
|
+
gridStep: this.resolution
|
|
449
|
+
},
|
|
450
|
+
'radial-toolpaths-complete',
|
|
451
|
+
completionHandler
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ============================================================================
|
|
457
|
+
// Internal Utilities
|
|
458
|
+
// ============================================================================
|
|
459
|
+
|
|
460
|
+
#handleMessage(e) {
|
|
461
|
+
const { type, success, data } = e.data;
|
|
462
|
+
|
|
463
|
+
// Handle progress messages (don't delete handler)
|
|
464
|
+
if (type === 'rasterize-progress' || type === 'toolpath-progress') {
|
|
465
|
+
const handler = this.messageHandlers.get(type);
|
|
466
|
+
if (handler) {
|
|
467
|
+
handler(data);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Find handler for this message type (completion messages)
|
|
473
|
+
for (const [id, handler] of this.messageHandlers.entries()) {
|
|
474
|
+
if (handler.responseType === type) {
|
|
475
|
+
this.messageHandlers.delete(id);
|
|
476
|
+
handler.callback(data);
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#sendMessage(type, data, responseType, callback) {
|
|
483
|
+
const id = this.messageId++;
|
|
484
|
+
this.messageHandlers.set(id, { responseType, callback });
|
|
485
|
+
this.worker.postMessage({ type, data });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#calculateBounds(triangles) {
|
|
489
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
490
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
491
|
+
|
|
492
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
493
|
+
const x = triangles[i];
|
|
494
|
+
const y = triangles[i + 1];
|
|
495
|
+
const z = triangles[i + 2];
|
|
496
|
+
|
|
497
|
+
minX = Math.min(minX, x);
|
|
498
|
+
maxX = Math.max(maxX, x);
|
|
499
|
+
minY = Math.min(minY, y);
|
|
500
|
+
maxY = Math.max(maxY, y);
|
|
501
|
+
minZ = Math.min(minZ, z);
|
|
502
|
+
maxZ = Math.max(maxZ, z);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
min: { x: minX, y: minY, z: minZ },
|
|
507
|
+
max: { x: maxX, y: maxY, z: maxZ }
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#calculateMaxRadius(triangles) {
|
|
512
|
+
let maxRadius = 0;
|
|
513
|
+
|
|
514
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
515
|
+
const y = triangles[i + 1];
|
|
516
|
+
const z = triangles[i + 2];
|
|
517
|
+
const hypot = Math.sqrt(y * y + z * z);
|
|
518
|
+
maxRadius = Math.max(maxRadius, hypot);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return maxRadius;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Partition triangles into spatial buckets along X-axis for radial rasterization
|
|
526
|
+
* This optimization reduces triangle intersection tests by only checking triangles
|
|
527
|
+
* within relevant X-ranges during ray casting
|
|
528
|
+
*
|
|
529
|
+
* @returns {object} Bucket data structure with:
|
|
530
|
+
* - buckets: Array of {minX, maxX, startIndex, count}
|
|
531
|
+
* - triangleIndices: Uint32Array of triangle indices sorted by bucket
|
|
532
|
+
* - numBuckets: Total number of buckets
|
|
533
|
+
*/
|
|
534
|
+
#bucketTrianglesByX(triangles, bounds, bucketWidth) {
|
|
535
|
+
const numTriangles = triangles.length / 9;
|
|
536
|
+
const numBuckets = Math.ceil((bounds.max.x - bounds.min.x) / bucketWidth);
|
|
537
|
+
|
|
538
|
+
// Initialize buckets
|
|
539
|
+
const buckets = [];
|
|
540
|
+
for (let i = 0; i < numBuckets; i++) {
|
|
541
|
+
buckets.push({
|
|
542
|
+
minX: bounds.min.x + i * bucketWidth,
|
|
543
|
+
maxX: bounds.min.x + (i + 1) * bucketWidth,
|
|
544
|
+
triangleIndices: []
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Assign triangles to overlapping buckets
|
|
549
|
+
for (let triIdx = 0; triIdx < numTriangles; triIdx++) {
|
|
550
|
+
const baseIdx = triIdx * 9;
|
|
551
|
+
|
|
552
|
+
// Find triangle X range
|
|
553
|
+
const x0 = triangles[baseIdx];
|
|
554
|
+
const x1 = triangles[baseIdx + 3];
|
|
555
|
+
const x2 = triangles[baseIdx + 6];
|
|
556
|
+
|
|
557
|
+
const triMinX = Math.min(x0, x1, x2);
|
|
558
|
+
const triMaxX = Math.max(x0, x1, x2);
|
|
559
|
+
|
|
560
|
+
// Find overlapping buckets
|
|
561
|
+
const startBucket = Math.max(0, Math.floor((triMinX - bounds.min.x) / bucketWidth));
|
|
562
|
+
const endBucket = Math.min(numBuckets - 1, Math.floor((triMaxX - bounds.min.x) / bucketWidth));
|
|
563
|
+
|
|
564
|
+
for (let b = startBucket; b <= endBucket; b++) {
|
|
565
|
+
buckets[b].triangleIndices.push(triIdx);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Flatten triangle indices for GPU
|
|
570
|
+
const triangleIndices = [];
|
|
571
|
+
const bucketInfo = [];
|
|
572
|
+
|
|
573
|
+
for (let i = 0; i < buckets.length; i++) {
|
|
574
|
+
const bucket = buckets[i];
|
|
575
|
+
bucketInfo.push({
|
|
576
|
+
minX: bucket.minX,
|
|
577
|
+
maxX: bucket.maxX,
|
|
578
|
+
startIndex: triangleIndices.length,
|
|
579
|
+
count: bucket.triangleIndices.length
|
|
580
|
+
});
|
|
581
|
+
triangleIndices.push(...bucket.triangleIndices);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
buckets: bucketInfo,
|
|
586
|
+
triangleIndices: new Uint32Array(triangleIndices),
|
|
587
|
+
numBuckets
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ============================================================================
|
|
592
|
+
// Public Utilities
|
|
593
|
+
// ============================================================================
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Get device capabilities
|
|
597
|
+
* @returns {object|null} Device capabilities or null if not initialized
|
|
598
|
+
*/
|
|
599
|
+
getDeviceCapabilities() {
|
|
600
|
+
return this.deviceCapabilities;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Get current configuration
|
|
605
|
+
* @returns {object} Current configuration
|
|
606
|
+
*/
|
|
607
|
+
getConfig() {
|
|
608
|
+
return {
|
|
609
|
+
mode: this.mode,
|
|
610
|
+
resolution: this.resolution,
|
|
611
|
+
rotationStep: this.rotationStep,
|
|
612
|
+
...this.config
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Parse STL buffer to triangles
|
|
618
|
+
* @param {ArrayBuffer} buffer - Binary STL data
|
|
619
|
+
* @returns {Float32Array} Triangle vertices
|
|
620
|
+
*/
|
|
621
|
+
parseSTL(buffer) {
|
|
622
|
+
const view = new DataView(buffer);
|
|
623
|
+
const isASCII = this.#isASCIISTL(buffer);
|
|
624
|
+
|
|
625
|
+
if (isASCII) {
|
|
626
|
+
return this.#parseASCIISTL(buffer);
|
|
627
|
+
} else {
|
|
628
|
+
return this.#parseBinarySTL(view);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
#isASCIISTL(buffer) {
|
|
633
|
+
const text = new TextDecoder().decode(buffer.slice(0, 80));
|
|
634
|
+
return text.toLowerCase().startsWith('solid');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#parseASCIISTL(buffer) {
|
|
638
|
+
const text = new TextDecoder().decode(buffer);
|
|
639
|
+
const lines = text.split('\n');
|
|
640
|
+
const triangles = [];
|
|
641
|
+
let vertexCount = 0;
|
|
642
|
+
let vertices = [];
|
|
643
|
+
|
|
644
|
+
for (const line of lines) {
|
|
645
|
+
const trimmed = line.trim();
|
|
646
|
+
if (trimmed.startsWith('vertex')) {
|
|
647
|
+
const parts = trimmed.split(/\s+/);
|
|
648
|
+
vertices.push(
|
|
649
|
+
parseFloat(parts[1]),
|
|
650
|
+
parseFloat(parts[2]),
|
|
651
|
+
parseFloat(parts[3])
|
|
652
|
+
);
|
|
653
|
+
vertexCount++;
|
|
654
|
+
if (vertexCount === 3) {
|
|
655
|
+
triangles.push(...vertices);
|
|
656
|
+
vertices = [];
|
|
657
|
+
vertexCount = 0;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return new Float32Array(triangles);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
#parseBinarySTL(view) {
|
|
666
|
+
const numTriangles = view.getUint32(80, true);
|
|
667
|
+
const triangles = new Float32Array(numTriangles * 9); // 3 vertices * 3 components
|
|
668
|
+
|
|
669
|
+
let offset = 84; // Skip 80-byte header + 4-byte count
|
|
670
|
+
let floatIndex = 0;
|
|
671
|
+
|
|
672
|
+
for (let i = 0; i < numTriangles; i++) {
|
|
673
|
+
// Skip normal (12 bytes)
|
|
674
|
+
offset += 12;
|
|
675
|
+
|
|
676
|
+
// Read 3 vertices (9 floats)
|
|
677
|
+
for (let j = 0; j < 9; j++) {
|
|
678
|
+
triangles[floatIndex++] = view.getFloat32(offset, true);
|
|
679
|
+
offset += 4;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Skip attribute byte count (2 bytes)
|
|
683
|
+
offset += 2;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return triangles;
|
|
687
|
+
}
|
|
688
|
+
}
|