@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/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
+ }