@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/build/app.js
ADDED
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
3
|
+
import { RasterPath } from './raster-path.js';
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// State
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
let mode = 'planar';
|
|
10
|
+
let resolution = 0.1;
|
|
11
|
+
let zFloor = -100;
|
|
12
|
+
let xStep = 5;
|
|
13
|
+
let yStep = 5;
|
|
14
|
+
let angleStep = 1.0; // degrees
|
|
15
|
+
|
|
16
|
+
let modelSTL = null; // ArrayBuffer
|
|
17
|
+
let toolSTL = null; // ArrayBuffer
|
|
18
|
+
|
|
19
|
+
let modelTriangles = null; // Float32Array
|
|
20
|
+
let toolTriangles = null; // Float32Array
|
|
21
|
+
|
|
22
|
+
let modelRasterData = null;
|
|
23
|
+
let toolRasterData = null;
|
|
24
|
+
let toolpathData = null;
|
|
25
|
+
|
|
26
|
+
let modelMaxZ = 0; // Track max Z for tool offset
|
|
27
|
+
let rasterPath = null; // RasterPath instance
|
|
28
|
+
|
|
29
|
+
// Three.js objects
|
|
30
|
+
let scene, camera, renderer, controls;
|
|
31
|
+
let rotatedGroup = null; // Group for 90-degree rotation
|
|
32
|
+
let modelMesh = null;
|
|
33
|
+
let toolMesh = null;
|
|
34
|
+
let modelRasterPoints = null;
|
|
35
|
+
let toolRasterPoints = null;
|
|
36
|
+
let toolpathPoints = null;
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Parameter Persistence
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
function saveParameters() {
|
|
43
|
+
localStorage.setItem('raster-mode', mode);
|
|
44
|
+
localStorage.setItem('raster-resolution', resolution);
|
|
45
|
+
localStorage.setItem('raster-zFloor', zFloor);
|
|
46
|
+
localStorage.setItem('raster-xStep', xStep);
|
|
47
|
+
localStorage.setItem('raster-yStep', yStep);
|
|
48
|
+
localStorage.setItem('raster-angleStep', angleStep);
|
|
49
|
+
|
|
50
|
+
// Save view checkboxes
|
|
51
|
+
const showWrappedCheckbox = document.getElementById('show-wrapped');
|
|
52
|
+
if (showWrappedCheckbox) {
|
|
53
|
+
localStorage.setItem('raster-showWrapped', showWrappedCheckbox.checked);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadParameters() {
|
|
58
|
+
const savedMode = localStorage.getItem('raster-mode');
|
|
59
|
+
if (savedMode !== null) {
|
|
60
|
+
mode = savedMode;
|
|
61
|
+
const modeRadio = document.querySelector(`input[name="mode"][value="${mode}"]`);
|
|
62
|
+
if (modeRadio) {
|
|
63
|
+
modeRadio.checked = true;
|
|
64
|
+
// Trigger mode change to update UI visibility
|
|
65
|
+
updateModeUI();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const savedResolution = localStorage.getItem('raster-resolution');
|
|
70
|
+
if (savedResolution !== null) {
|
|
71
|
+
resolution = parseFloat(savedResolution);
|
|
72
|
+
const resolutionSelect = document.getElementById('resolution');
|
|
73
|
+
|
|
74
|
+
// Check if the saved value exists as an option
|
|
75
|
+
const optionExists = Array.from(resolutionSelect.options).some(opt => parseFloat(opt.value) === resolution);
|
|
76
|
+
|
|
77
|
+
if (!optionExists) {
|
|
78
|
+
// Add the custom resolution as an option
|
|
79
|
+
const newOption = document.createElement('option');
|
|
80
|
+
newOption.value = resolution.toFixed(3);
|
|
81
|
+
newOption.textContent = `${resolution.toFixed(3)}mm`;
|
|
82
|
+
resolutionSelect.appendChild(newOption);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Set the value as a string to match the option values
|
|
86
|
+
resolutionSelect.value = resolution.toFixed(3);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const savedZFloor = localStorage.getItem('raster-zFloor');
|
|
90
|
+
if (savedZFloor !== null) {
|
|
91
|
+
zFloor = parseFloat(savedZFloor);
|
|
92
|
+
document.getElementById('z-floor').value = zFloor;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const savedXStep = localStorage.getItem('raster-xStep');
|
|
96
|
+
if (savedXStep !== null) {
|
|
97
|
+
xStep = parseInt(savedXStep);
|
|
98
|
+
document.getElementById('x-step').value = xStep;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const savedYStep = localStorage.getItem('raster-yStep');
|
|
102
|
+
if (savedYStep !== null) {
|
|
103
|
+
yStep = parseInt(savedYStep);
|
|
104
|
+
document.getElementById('y-step').value = yStep;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const savedAngleStep = localStorage.getItem('raster-angleStep');
|
|
108
|
+
if (savedAngleStep !== null) {
|
|
109
|
+
angleStep = parseFloat(savedAngleStep);
|
|
110
|
+
document.getElementById('angle-step').value = angleStep;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Restore view checkboxes
|
|
114
|
+
const savedShowWrapped = localStorage.getItem('raster-showWrapped');
|
|
115
|
+
if (savedShowWrapped !== null) {
|
|
116
|
+
const showWrappedCheckbox = document.getElementById('show-wrapped');
|
|
117
|
+
if (showWrappedCheckbox) {
|
|
118
|
+
showWrappedCheckbox.checked = savedShowWrapped === 'true';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// IndexedDB for STL Caching
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
const DB_NAME = 'raster-path-cache';
|
|
128
|
+
const DB_VERSION = 1;
|
|
129
|
+
const STORE_NAME = 'stl-files';
|
|
130
|
+
|
|
131
|
+
async function openDB() {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
134
|
+
|
|
135
|
+
request.onerror = () => reject(request.error);
|
|
136
|
+
request.onsuccess = () => resolve(request.result);
|
|
137
|
+
|
|
138
|
+
request.onupgradeneeded = (event) => {
|
|
139
|
+
const db = event.target.result;
|
|
140
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
141
|
+
db.createObjectStore(STORE_NAME);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function cacheSTL(key, arrayBuffer, name) {
|
|
148
|
+
const db = await openDB();
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
|
151
|
+
const store = transaction.objectStore(STORE_NAME);
|
|
152
|
+
const data = { arrayBuffer, name };
|
|
153
|
+
const request = store.put(data, key);
|
|
154
|
+
request.onsuccess = () => resolve();
|
|
155
|
+
request.onerror = () => reject(request.error);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function getCachedSTL(key) {
|
|
160
|
+
const db = await openDB();
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const transaction = db.transaction(STORE_NAME, 'readonly');
|
|
163
|
+
const store = transaction.objectStore(STORE_NAME);
|
|
164
|
+
const request = store.get(key);
|
|
165
|
+
request.onsuccess = () => resolve(request.result);
|
|
166
|
+
request.onerror = () => reject(request.error);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// STL Parsing
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
function calculateTriangleBounds(triangles) {
|
|
175
|
+
const bounds = {
|
|
176
|
+
min: { x: Infinity, y: Infinity, z: Infinity },
|
|
177
|
+
max: { x: -Infinity, y: -Infinity, z: -Infinity }
|
|
178
|
+
};
|
|
179
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
180
|
+
bounds.min.x = Math.min(bounds.min.x, triangles[i]);
|
|
181
|
+
bounds.max.x = Math.max(bounds.max.x, triangles[i]);
|
|
182
|
+
bounds.min.y = Math.min(bounds.min.y, triangles[i + 1]);
|
|
183
|
+
bounds.max.y = Math.max(bounds.max.y, triangles[i + 1]);
|
|
184
|
+
bounds.min.z = Math.min(bounds.min.z, triangles[i + 2]);
|
|
185
|
+
bounds.max.z = Math.max(bounds.max.z, triangles[i + 2]);
|
|
186
|
+
}
|
|
187
|
+
return bounds;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseSTL(arrayBuffer) {
|
|
191
|
+
const view = new DataView(arrayBuffer);
|
|
192
|
+
|
|
193
|
+
// Check if ASCII (starts with "solid")
|
|
194
|
+
const text = new TextDecoder().decode(arrayBuffer.slice(0, 80));
|
|
195
|
+
if (text.toLowerCase().startsWith('solid')) {
|
|
196
|
+
return parseASCIISTL(arrayBuffer);
|
|
197
|
+
} else {
|
|
198
|
+
return parseBinarySTL(view);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseBinarySTL(view) {
|
|
203
|
+
const numTriangles = view.getUint32(80, true);
|
|
204
|
+
const triangles = new Float32Array(numTriangles * 9);
|
|
205
|
+
|
|
206
|
+
let offset = 84;
|
|
207
|
+
let floatIndex = 0;
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < numTriangles; i++) {
|
|
210
|
+
offset += 12; // Skip normal
|
|
211
|
+
|
|
212
|
+
for (let j = 0; j < 9; j++) {
|
|
213
|
+
triangles[floatIndex++] = view.getFloat32(offset, true);
|
|
214
|
+
offset += 4;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
offset += 2; // Skip attribute byte count
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return triangles;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseASCIISTL(arrayBuffer) {
|
|
224
|
+
const text = new TextDecoder().decode(arrayBuffer);
|
|
225
|
+
const lines = text.split('\n');
|
|
226
|
+
const triangles = [];
|
|
227
|
+
let vertices = [];
|
|
228
|
+
|
|
229
|
+
for (const line of lines) {
|
|
230
|
+
const trimmed = line.trim();
|
|
231
|
+
if (trimmed.startsWith('vertex')) {
|
|
232
|
+
const parts = trimmed.split(/\s+/);
|
|
233
|
+
vertices.push(
|
|
234
|
+
parseFloat(parts[1]),
|
|
235
|
+
parseFloat(parts[2]),
|
|
236
|
+
parseFloat(parts[3])
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (vertices.length === 9) {
|
|
240
|
+
triangles.push(...vertices);
|
|
241
|
+
vertices = [];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return new Float32Array(triangles);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ============================================================================
|
|
250
|
+
// File Loading
|
|
251
|
+
// ============================================================================
|
|
252
|
+
|
|
253
|
+
async function loadSTLFile(isModel) {
|
|
254
|
+
const input = document.createElement('input');
|
|
255
|
+
input.type = 'file';
|
|
256
|
+
input.accept = '.stl';
|
|
257
|
+
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
input.onchange = async (e) => {
|
|
260
|
+
const file = e.target.files[0];
|
|
261
|
+
if (!file) return resolve(null);
|
|
262
|
+
|
|
263
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
264
|
+
const triangles = parseSTL(arrayBuffer);
|
|
265
|
+
|
|
266
|
+
// Cache in IndexedDB with filename
|
|
267
|
+
const cacheKey = isModel ? 'model-stl' : 'tool-stl';
|
|
268
|
+
await cacheSTL(cacheKey, arrayBuffer, file.name);
|
|
269
|
+
|
|
270
|
+
updateInfo(`Loaded ${file.name}: ${(triangles.length / 9).toLocaleString()} triangles`);
|
|
271
|
+
resolve({ arrayBuffer, triangles, name: file.name });
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
input.click();
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ============================================================================
|
|
279
|
+
// RasterPath Integration
|
|
280
|
+
// ============================================================================
|
|
281
|
+
|
|
282
|
+
async function initRasterPath() {
|
|
283
|
+
if (rasterPath) {
|
|
284
|
+
rasterPath.terminate();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
rasterPath = new RasterPath({
|
|
288
|
+
mode: mode,
|
|
289
|
+
resolution: resolution,
|
|
290
|
+
rotationStep: mode === 'radial' ? angleStep : undefined,
|
|
291
|
+
// trianglesPerTile: 15000,
|
|
292
|
+
debug: true // Enable debug logging
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await rasterPath.init();
|
|
296
|
+
updateInfo(`RasterPath initialized: ${mode} mode, ${resolution}mm resolution`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function rasterizeAll() {
|
|
300
|
+
if (!modelTriangles && !toolTriangles) {
|
|
301
|
+
updateInfo('No STL files loaded');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
// Ensure RasterPath is initialized with current settings
|
|
307
|
+
await initRasterPath();
|
|
308
|
+
|
|
309
|
+
// Load tool (works for both modes)
|
|
310
|
+
if (toolTriangles) {
|
|
311
|
+
updateInfo('Loading tool...');
|
|
312
|
+
const t0 = performance.now();
|
|
313
|
+
toolRasterData = await rasterPath.loadTool({
|
|
314
|
+
triangles: toolTriangles
|
|
315
|
+
});
|
|
316
|
+
const t1 = performance.now();
|
|
317
|
+
updateInfo(`Tool loaded in ${(t1 - t0).toFixed(0)}ms`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (mode === 'planar') {
|
|
321
|
+
// Planar mode: rasterize terrain immediately
|
|
322
|
+
if (modelTriangles) {
|
|
323
|
+
updateInfo('Rasterizing terrain...');
|
|
324
|
+
const t0 = performance.now();
|
|
325
|
+
modelRasterData = await rasterPath.loadTerrain({
|
|
326
|
+
triangles: modelTriangles,
|
|
327
|
+
zFloor: zFloor
|
|
328
|
+
});
|
|
329
|
+
const t1 = performance.now();
|
|
330
|
+
updateInfo(`Terrain rasterized in ${(t1 - t0).toFixed(0)}ms`);
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Radial mode: MUST load tool FIRST
|
|
334
|
+
if (!toolTriangles) {
|
|
335
|
+
updateInfo('Error: Radial mode requires tool to be loaded first');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Load terrain (stores triangles for later, doesn't rasterize yet)
|
|
340
|
+
if (modelTriangles) {
|
|
341
|
+
updateInfo('Loading terrain...');
|
|
342
|
+
const t0 = performance.now();
|
|
343
|
+
await rasterPath.loadTerrain({
|
|
344
|
+
triangles: modelTriangles,
|
|
345
|
+
zFloor: zFloor
|
|
346
|
+
});
|
|
347
|
+
const t1 = performance.now();
|
|
348
|
+
updateInfo(`Terrain loaded in ${(t1 - t0).toFixed(0)}ms (will rasterize during toolpath generation)`);
|
|
349
|
+
} else {
|
|
350
|
+
updateInfo('Tool loaded. Load model and click "Generate Toolpath" to continue.');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Update button states for radial mode
|
|
354
|
+
updateButtonStates();
|
|
355
|
+
return; // Don't rasterize model here - do it in toolpath generation
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
updateInfo('Rasterization complete');
|
|
359
|
+
|
|
360
|
+
// Auto-enable raster view
|
|
361
|
+
document.getElementById('show-raster').checked = true;
|
|
362
|
+
|
|
363
|
+
updateVisualization();
|
|
364
|
+
updateButtonStates();
|
|
365
|
+
|
|
366
|
+
} catch (error) {
|
|
367
|
+
console.error('Rasterization error:', error);
|
|
368
|
+
updateInfo(`Error: ${error.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function generateToolpath() {
|
|
373
|
+
// Check if tool and terrain are loaded (unified check for both modes)
|
|
374
|
+
if (!toolRasterData) {
|
|
375
|
+
updateInfo('Tool must be loaded first');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (mode === 'planar') {
|
|
380
|
+
if (!modelRasterData) {
|
|
381
|
+
updateInfo('Model must be rasterized first');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
// Radial mode: terrain must be loaded (stored internally)
|
|
386
|
+
if (!modelTriangles) {
|
|
387
|
+
updateInfo('Model STL must be loaded');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const t0 = performance.now();
|
|
394
|
+
updateInfo('Generating toolpath...');
|
|
395
|
+
|
|
396
|
+
// Unified API - works for both modes!
|
|
397
|
+
toolpathData = await rasterPath.generateToolpaths({
|
|
398
|
+
xStep: xStep,
|
|
399
|
+
yStep: yStep,
|
|
400
|
+
zFloor: zFloor
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const t1 = performance.now();
|
|
404
|
+
|
|
405
|
+
if (mode === 'planar') {
|
|
406
|
+
const numPoints = toolpathData.pathData.length;
|
|
407
|
+
updateInfo(`Toolpath generated: ${numPoints.toLocaleString()} points in ${(t1 - t0).toFixed(0)}ms`);
|
|
408
|
+
} else {
|
|
409
|
+
console.log('Radial toolpaths generated:', toolpathData);
|
|
410
|
+
console.log(`[Radial] Received ${toolpathData.strips.length} strips from worker, numStrips=${toolpathData.numStrips}`);
|
|
411
|
+
updateInfo(`Toolpath generated: ${toolpathData.numStrips} strips, ${toolpathData.totalPoints.toLocaleString()} points in ${(t1 - t0).toFixed(0)}ms`);
|
|
412
|
+
|
|
413
|
+
// Store terrain strips for visualization
|
|
414
|
+
if (toolpathData.terrainStrips) {
|
|
415
|
+
modelRasterData = toolpathData.terrainStrips;
|
|
416
|
+
console.log('[Radial] Terrain strips available:', modelRasterData.length, 'strips');
|
|
417
|
+
|
|
418
|
+
// DEBUG: Check X range in strips
|
|
419
|
+
if (modelRasterData.length > 0) {
|
|
420
|
+
const strip0 = modelRasterData[0];
|
|
421
|
+
console.log('[Radial] Strip 0 structure:', {
|
|
422
|
+
angle: strip0.angle,
|
|
423
|
+
pointCount: strip0.pointCount,
|
|
424
|
+
positionsLength: strip0.positions?.length,
|
|
425
|
+
gridWidth: strip0.gridWidth,
|
|
426
|
+
gridHeight: strip0.gridHeight,
|
|
427
|
+
bounds: strip0.bounds
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Find actual X range in positions
|
|
431
|
+
if (strip0.positions && strip0.positions.length > 0) {
|
|
432
|
+
let minX = Infinity, maxX = -Infinity;
|
|
433
|
+
for (let i = 0; i < strip0.positions.length; i += 3) {
|
|
434
|
+
const x = strip0.positions[i];
|
|
435
|
+
minX = Math.min(minX, x);
|
|
436
|
+
maxX = Math.max(maxX, x);
|
|
437
|
+
}
|
|
438
|
+
console.log('[Radial] Strip 0 actual X range in positions:', minX.toFixed(2), 'to', maxX.toFixed(2));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} else if (toolpathData.strips && toolpathData.strips[0]?.terrainBounds) {
|
|
442
|
+
// Batched mode: create synthetic terrain strips with bounds only
|
|
443
|
+
modelRasterData = toolpathData.strips.map(strip => ({
|
|
444
|
+
angle: strip.angle,
|
|
445
|
+
bounds: strip.terrainBounds
|
|
446
|
+
}));
|
|
447
|
+
console.log('[Radial] Batched mode: created synthetic terrain strips from bounds:', modelRasterData.length, 'strips');
|
|
448
|
+
}
|
|
449
|
+
// This is fine - we only need toolpathData for visualization
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Auto-enable toolpath view
|
|
453
|
+
document.getElementById('show-paths').checked = true;
|
|
454
|
+
|
|
455
|
+
updateVisualization();
|
|
456
|
+
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error('Toolpath generation error:', error);
|
|
459
|
+
updateInfo(`Error: ${error.message}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ============================================================================
|
|
464
|
+
// Three.js Visualization
|
|
465
|
+
// ============================================================================
|
|
466
|
+
|
|
467
|
+
function initThreeJS() {
|
|
468
|
+
// Scene
|
|
469
|
+
scene = new THREE.Scene();
|
|
470
|
+
scene.background = new THREE.Color(0x1a1a1a);
|
|
471
|
+
|
|
472
|
+
// Camera
|
|
473
|
+
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000);
|
|
474
|
+
camera.position.set(100, 100, 100);
|
|
475
|
+
camera.lookAt(0, 0, 0);
|
|
476
|
+
|
|
477
|
+
// Renderer
|
|
478
|
+
const canvas = document.getElementById('canvas');
|
|
479
|
+
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
480
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
481
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
482
|
+
|
|
483
|
+
// Controls
|
|
484
|
+
controls = new OrbitControls(camera, canvas);
|
|
485
|
+
|
|
486
|
+
// Lights
|
|
487
|
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
488
|
+
scene.add(ambientLight);
|
|
489
|
+
|
|
490
|
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
491
|
+
directionalLight.position.set(100, 100, 100);
|
|
492
|
+
scene.add(directionalLight);
|
|
493
|
+
|
|
494
|
+
// Grid
|
|
495
|
+
const gridHelper = new THREE.GridHelper(200, 20, 0x444444, 0x222222);
|
|
496
|
+
scene.add(gridHelper);
|
|
497
|
+
|
|
498
|
+
// Create rotated group for all visualizations (-90deg around X)
|
|
499
|
+
rotatedGroup = new THREE.Group();
|
|
500
|
+
rotatedGroup.rotation.x = -Math.PI / 2;
|
|
501
|
+
scene.add(rotatedGroup);
|
|
502
|
+
|
|
503
|
+
// Axes
|
|
504
|
+
const axesHelper = new THREE.AxesHelper(50);
|
|
505
|
+
scene.add(axesHelper);
|
|
506
|
+
|
|
507
|
+
// Axis labels
|
|
508
|
+
function makeTextSprite(message, color) {
|
|
509
|
+
const canvas = document.createElement('canvas');
|
|
510
|
+
const context = canvas.getContext('2d');
|
|
511
|
+
canvas.width = 256;
|
|
512
|
+
canvas.height = 128;
|
|
513
|
+
context.font = 'Bold 48px Arial';
|
|
514
|
+
context.fillStyle = color;
|
|
515
|
+
context.textAlign = 'center';
|
|
516
|
+
context.fillText(message, 128, 64);
|
|
517
|
+
|
|
518
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
519
|
+
const spriteMaterial = new THREE.SpriteMaterial({ map: texture });
|
|
520
|
+
const sprite = new THREE.Sprite(spriteMaterial);
|
|
521
|
+
sprite.scale.set(10, 5, 1);
|
|
522
|
+
return sprite;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const xLabel = makeTextSprite('X', '#ff0000');
|
|
526
|
+
xLabel.position.set(60, 0, 0);
|
|
527
|
+
scene.add(xLabel);
|
|
528
|
+
|
|
529
|
+
const yLabel = makeTextSprite('Y', '#00ff00');
|
|
530
|
+
yLabel.position.set(0, 0, -60);
|
|
531
|
+
scene.add(yLabel);
|
|
532
|
+
|
|
533
|
+
const zLabel = makeTextSprite('Z', '#0000ff');
|
|
534
|
+
zLabel.position.set(0, 60, 6);
|
|
535
|
+
scene.add(zLabel);
|
|
536
|
+
|
|
537
|
+
// Window resize
|
|
538
|
+
window.addEventListener('resize', () => {
|
|
539
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
540
|
+
camera.updateProjectionMatrix();
|
|
541
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Animation loop
|
|
545
|
+
function animate() {
|
|
546
|
+
requestAnimationFrame(animate);
|
|
547
|
+
controls.update();
|
|
548
|
+
renderer.render(scene, camera);
|
|
549
|
+
}
|
|
550
|
+
animate();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function updateVisualization() {
|
|
554
|
+
const showModel = document.getElementById('show-model').checked;
|
|
555
|
+
const showRaster = document.getElementById('show-raster').checked;
|
|
556
|
+
const showPaths = document.getElementById('show-paths').checked;
|
|
557
|
+
const showWrapped = document.getElementById('show-wrapped').checked;
|
|
558
|
+
|
|
559
|
+
// Model mesh
|
|
560
|
+
if (modelMesh) {
|
|
561
|
+
modelMesh.visible = showModel;
|
|
562
|
+
} else if (showModel && modelTriangles) {
|
|
563
|
+
displayModelMesh();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Tool mesh
|
|
567
|
+
if (toolMesh) {
|
|
568
|
+
toolMesh.visible = showModel;
|
|
569
|
+
} else if (showModel && toolTriangles) {
|
|
570
|
+
displayToolMesh();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Model raster points
|
|
574
|
+
if (modelRasterPoints) {
|
|
575
|
+
rotatedGroup.remove(modelRasterPoints);
|
|
576
|
+
modelRasterPoints.geometry.dispose();
|
|
577
|
+
modelRasterPoints.material.dispose();
|
|
578
|
+
modelRasterPoints = null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (showRaster && modelRasterData) {
|
|
582
|
+
displayModelRaster(showWrapped);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Tool raster points
|
|
586
|
+
if (toolRasterPoints) {
|
|
587
|
+
rotatedGroup.remove(toolRasterPoints);
|
|
588
|
+
toolRasterPoints.geometry.dispose();
|
|
589
|
+
toolRasterPoints.material.dispose();
|
|
590
|
+
toolRasterPoints = null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (showRaster && toolRasterData) {
|
|
594
|
+
displayToolRaster();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Toolpath points
|
|
598
|
+
if (toolpathPoints) {
|
|
599
|
+
rotatedGroup.remove(toolpathPoints);
|
|
600
|
+
toolpathPoints.geometry.dispose();
|
|
601
|
+
toolpathPoints.material.dispose();
|
|
602
|
+
toolpathPoints = null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (showPaths && toolpathData) {
|
|
606
|
+
displayToolpaths(showWrapped);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function displayModelMesh() {
|
|
611
|
+
if (!modelTriangles) return;
|
|
612
|
+
|
|
613
|
+
const geometry = new THREE.BufferGeometry();
|
|
614
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(modelTriangles, 3));
|
|
615
|
+
geometry.computeVertexNormals();
|
|
616
|
+
geometry.computeBoundingBox();
|
|
617
|
+
|
|
618
|
+
const material = new THREE.MeshPhongMaterial({
|
|
619
|
+
color: 0x00ffff,
|
|
620
|
+
shininess: 30,
|
|
621
|
+
transparent: true,
|
|
622
|
+
opacity: 0.6
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
modelMesh = new THREE.Mesh(geometry, material);
|
|
626
|
+
rotatedGroup.add(modelMesh);
|
|
627
|
+
|
|
628
|
+
// Calculate max Z for tool offset
|
|
629
|
+
modelMaxZ = geometry.boundingBox.max.z + 20;
|
|
630
|
+
|
|
631
|
+
// update tool position when terrain (re)loaded
|
|
632
|
+
toolMesh && (toolMesh.position.z = modelMaxZ); // Offset tool above model
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function displayToolMesh() {
|
|
636
|
+
if (!toolTriangles) return;
|
|
637
|
+
|
|
638
|
+
const geometry = new THREE.BufferGeometry();
|
|
639
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(toolTriangles, 3));
|
|
640
|
+
geometry.computeVertexNormals();
|
|
641
|
+
|
|
642
|
+
const material = new THREE.MeshPhongMaterial({
|
|
643
|
+
color: 0xff6400, // Orange color for tool
|
|
644
|
+
shininess: 30,
|
|
645
|
+
side: THREE.DoubleSide,
|
|
646
|
+
transparent: true,
|
|
647
|
+
opacity: 0.8
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
toolMesh = new THREE.Mesh(geometry, material);
|
|
651
|
+
toolMesh.position.z = modelMaxZ; // Offset tool above model
|
|
652
|
+
rotatedGroup.add(toolMesh);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function displayModelRaster(wrapped) {
|
|
656
|
+
if (!modelRasterData) return;
|
|
657
|
+
|
|
658
|
+
const positions = [];
|
|
659
|
+
const colors = [];
|
|
660
|
+
|
|
661
|
+
if (mode === 'planar') {
|
|
662
|
+
// Planar: terrain is dense (Z-only array)
|
|
663
|
+
const { positions: rasterPos, bounds, gridWidth, gridHeight } = modelRasterData;
|
|
664
|
+
const stepSize = resolution;
|
|
665
|
+
|
|
666
|
+
for (let gy = 0; gy < gridHeight; gy++) {
|
|
667
|
+
for (let gx = 0; gx < gridWidth; gx++) {
|
|
668
|
+
const idx = gy * gridWidth + gx;
|
|
669
|
+
const z = rasterPos[idx];
|
|
670
|
+
|
|
671
|
+
if (z > -1e9) {
|
|
672
|
+
const x = bounds.min.x + gx * stepSize;
|
|
673
|
+
const y = bounds.min.y + gy * stepSize;
|
|
674
|
+
|
|
675
|
+
positions.push(x, y, z);
|
|
676
|
+
colors.push(0, 1, 0); // Green
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
} else {
|
|
682
|
+
// Radial: modelRasterData is an array of strips
|
|
683
|
+
// Each strip has: { angle, positions (sparse XYZ), gridWidth, gridHeight, bounds }
|
|
684
|
+
if (!Array.isArray(modelRasterData)) {
|
|
685
|
+
console.error('[Display] modelRasterData is not an array of strips');
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (wrapped) {
|
|
690
|
+
// Wrap each strip around X-axis at its angle
|
|
691
|
+
console.log('[Display] Wrapping', modelRasterData.length, 'strips');
|
|
692
|
+
|
|
693
|
+
// Track overall X range for debug
|
|
694
|
+
let overallMinX = Infinity, overallMaxX = -Infinity;
|
|
695
|
+
let totalPointsRendered = 0;
|
|
696
|
+
|
|
697
|
+
for (const strip of modelRasterData) {
|
|
698
|
+
const { angle, positions: stripPositions } = strip;
|
|
699
|
+
|
|
700
|
+
if (!stripPositions || stripPositions.length === 0) continue;
|
|
701
|
+
|
|
702
|
+
const theta = angle * Math.PI / 180;
|
|
703
|
+
const cosTheta = Math.cos(theta);
|
|
704
|
+
const sinTheta = Math.sin(theta);
|
|
705
|
+
|
|
706
|
+
// Strip positions are sparse XYZ triplets
|
|
707
|
+
for (let i = 0; i < stripPositions.length; i += 3) {
|
|
708
|
+
const x = stripPositions[i];
|
|
709
|
+
const y_planar = stripPositions[i + 1]; // Y in planar coordinates
|
|
710
|
+
const terrainHeight = stripPositions[i + 2]; // Distance from X-axis
|
|
711
|
+
|
|
712
|
+
overallMinX = Math.min(overallMinX, x);
|
|
713
|
+
overallMaxX = Math.max(overallMaxX, x);
|
|
714
|
+
|
|
715
|
+
// Wrap: use terrainHeight as radial distance from X-axis
|
|
716
|
+
const y = terrainHeight * cosTheta;
|
|
717
|
+
const z = terrainHeight * sinTheta;
|
|
718
|
+
|
|
719
|
+
positions.push(x, y, z);
|
|
720
|
+
colors.push(0, 1, 0); // Green
|
|
721
|
+
totalPointsRendered++;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
console.log('[Display] Rendered', totalPointsRendered, 'points from', modelRasterData.length, 'strips');
|
|
726
|
+
console.log('[Display] X range: [' + overallMinX.toFixed(2) + ', ' + overallMaxX.toFixed(2) + ']');
|
|
727
|
+
|
|
728
|
+
// DEBUG: Check angle coverage
|
|
729
|
+
const angles = modelRasterData.map(s => s.angle).sort((a, b) => a - b);
|
|
730
|
+
console.log('[Display] Angle range: [' + angles[0].toFixed(1) + '°, ' + angles[angles.length-1].toFixed(1) + '°]');
|
|
731
|
+
console.log('[Display] First 5 angles:', angles.slice(0, 5).map(a => a.toFixed(1) + '°').join(', '));
|
|
732
|
+
console.log('[Display] Last 5 angles:', angles.slice(-5).map(a => a.toFixed(1) + '°').join(', '));
|
|
733
|
+
} else {
|
|
734
|
+
// Show unwrapped (planar) - lay out strips side by side
|
|
735
|
+
for (let stripIdx = 0; stripIdx < modelRasterData.length; stripIdx++) {
|
|
736
|
+
const strip = modelRasterData[stripIdx];
|
|
737
|
+
const { positions: stripPositions } = strip;
|
|
738
|
+
|
|
739
|
+
if (!stripPositions || stripPositions.length === 0) continue;
|
|
740
|
+
|
|
741
|
+
const stripY = stripIdx * resolution * 10; // Offset each strip for visibility
|
|
742
|
+
|
|
743
|
+
// Strip positions are sparse XYZ triplets
|
|
744
|
+
for (let i = 0; i < stripPositions.length; i += 3) {
|
|
745
|
+
const x = stripPositions[i];
|
|
746
|
+
const terrainHeight = stripPositions[i + 2];
|
|
747
|
+
|
|
748
|
+
positions.push(x, stripY, terrainHeight);
|
|
749
|
+
colors.push(0, 1, 0); // Green
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (positions.length > 0) {
|
|
756
|
+
const geometry = new THREE.BufferGeometry();
|
|
757
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
758
|
+
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
|
759
|
+
|
|
760
|
+
// Scale point size with resolution for proper spacing
|
|
761
|
+
const pointSize = resolution; // Leave some space between points
|
|
762
|
+
|
|
763
|
+
const material = new THREE.PointsMaterial({
|
|
764
|
+
size: pointSize,
|
|
765
|
+
vertexColors: true
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
modelRasterPoints = new THREE.Points(geometry, material);
|
|
769
|
+
rotatedGroup.add(modelRasterPoints);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function displayToolRaster() {
|
|
774
|
+
if (!toolRasterData) return;
|
|
775
|
+
|
|
776
|
+
const positions = [];
|
|
777
|
+
const colors = [];
|
|
778
|
+
|
|
779
|
+
// Tool raster is always sparse format [gridX, gridY, Z]
|
|
780
|
+
const { positions: rasterPos, bounds } = toolRasterData;
|
|
781
|
+
const stepSize = resolution;
|
|
782
|
+
|
|
783
|
+
for (let i = 0; i < rasterPos.length; i += 3) {
|
|
784
|
+
const gridX = rasterPos[i];
|
|
785
|
+
const gridY = rasterPos[i + 1];
|
|
786
|
+
const z = -rasterPos[i + 2];
|
|
787
|
+
const x = bounds.min.x + gridX * stepSize;
|
|
788
|
+
const y = bounds.min.y + gridY * stepSize;
|
|
789
|
+
|
|
790
|
+
positions.push(x, y, z);
|
|
791
|
+
colors.push(1, 0.4, 0);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (positions.length > 0) {
|
|
795
|
+
const geometry = new THREE.BufferGeometry();
|
|
796
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
797
|
+
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
|
798
|
+
|
|
799
|
+
// Scale point size with resolution for proper spacing
|
|
800
|
+
const pointSize = resolution;
|
|
801
|
+
const material = new THREE.PointsMaterial({
|
|
802
|
+
size: pointSize,
|
|
803
|
+
vertexColors: true
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
toolRasterPoints = new THREE.Points(geometry, material);
|
|
807
|
+
toolRasterPoints.position.z = modelMaxZ; // Offset tool above model
|
|
808
|
+
rotatedGroup.add(toolRasterPoints);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function displayToolpaths(wrapped) {
|
|
813
|
+
if (!toolpathData) return;
|
|
814
|
+
|
|
815
|
+
if (mode === 'planar') {
|
|
816
|
+
// Planar toolpaths
|
|
817
|
+
const { pathData, numScanlines, pointsPerLine } = toolpathData;
|
|
818
|
+
const bounds = toolpathData.generationBounds || modelRasterData.bounds;
|
|
819
|
+
const stepSize = resolution;
|
|
820
|
+
|
|
821
|
+
const totalPoints = numScanlines * pointsPerLine;
|
|
822
|
+
const MAX_DISPLAY_POINTS = 10000000; // 10M points max for display
|
|
823
|
+
|
|
824
|
+
// Check if we need to downsample
|
|
825
|
+
if (totalPoints > MAX_DISPLAY_POINTS) {
|
|
826
|
+
console.warn(`[Toolpath Display] Toolpath too large for visualization: ${(totalPoints/1e6).toFixed(1)}M points`);
|
|
827
|
+
console.warn(`[Toolpath Display] Skipping display (max: ${(MAX_DISPLAY_POINTS/1e6).toFixed(1)}M points)`);
|
|
828
|
+
console.warn(`[Toolpath Display] Toolpath was generated successfully - only visualization is skipped`);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Preallocate typed arrays for better performance
|
|
833
|
+
const positions = new Float32Array(totalPoints * 3);
|
|
834
|
+
const colors = new Float32Array(totalPoints * 3);
|
|
835
|
+
|
|
836
|
+
let arrayIdx = 0;
|
|
837
|
+
for (let line = 0; line < numScanlines; line++) {
|
|
838
|
+
for (let pt = 0; pt < pointsPerLine; pt++) {
|
|
839
|
+
const idx = line * pointsPerLine + pt;
|
|
840
|
+
const z = pathData[idx];
|
|
841
|
+
|
|
842
|
+
const x = bounds.min.x + pt * xStep * stepSize;
|
|
843
|
+
const y = bounds.min.y + line * yStep * stepSize;
|
|
844
|
+
|
|
845
|
+
positions[arrayIdx] = x;
|
|
846
|
+
positions[arrayIdx + 1] = y;
|
|
847
|
+
positions[arrayIdx + 2] = z;
|
|
848
|
+
|
|
849
|
+
colors[arrayIdx] = 1; // R
|
|
850
|
+
colors[arrayIdx + 1] = 0.4; // G
|
|
851
|
+
colors[arrayIdx + 2] = 0; // B
|
|
852
|
+
|
|
853
|
+
arrayIdx += 3;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Create geometry from preallocated arrays
|
|
858
|
+
const geometry = new THREE.BufferGeometry();
|
|
859
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
860
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
861
|
+
|
|
862
|
+
const material = new THREE.PointsMaterial({
|
|
863
|
+
size: resolution * 0.5,
|
|
864
|
+
vertexColors: true
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
toolpathPoints = new THREE.Points(geometry, material);
|
|
868
|
+
rotatedGroup.add(toolpathPoints);
|
|
869
|
+
|
|
870
|
+
return; // Exit early for planar mode
|
|
871
|
+
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Radial V2 toolpaths - each strip is independent
|
|
875
|
+
const { strips } = toolpathData;
|
|
876
|
+
const stepSize = resolution;
|
|
877
|
+
|
|
878
|
+
// Calculate total points across all strips
|
|
879
|
+
let totalPoints = 0;
|
|
880
|
+
for (const strip of strips) {
|
|
881
|
+
totalPoints += strip.numScanlines * strip.pointsPerLine;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const MAX_DISPLAY_POINTS = 10000000; // 10M points max for display
|
|
885
|
+
|
|
886
|
+
console.log('[Toolpath Display] Radial V2 mode:', strips.length, 'strips,', totalPoints, 'total points');
|
|
887
|
+
|
|
888
|
+
// Check if we need to skip visualization
|
|
889
|
+
if (totalPoints > MAX_DISPLAY_POINTS) {
|
|
890
|
+
console.warn(`[Toolpath Display] Toolpath too large for visualization: ${(totalPoints/1e6).toFixed(1)}M points`);
|
|
891
|
+
console.warn(`[Toolpath Display] Skipping display (max: ${(MAX_DISPLAY_POINTS/1e6).toFixed(1)}M points)`);
|
|
892
|
+
console.warn(`[Toolpath Display] Toolpath was generated successfully - only visualization is skipped`);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// DEBUG: Check angle distribution AND data
|
|
897
|
+
if (strips.length > 0) {
|
|
898
|
+
const angleChecks = [0, 180, 359, 360, 361, 540, 719].filter(i => i < strips.length);
|
|
899
|
+
console.log('[Toolpath Display] Angle check at indices:', angleChecks.map(i => `${i}=${strips[i].angle.toFixed(1)}°`).join(', '));
|
|
900
|
+
// Check if pathData is actually different between strips
|
|
901
|
+
if (strips.length > 360) {
|
|
902
|
+
const samples0 = strips[0].pathData.slice(0, 5).map(v => v.toFixed(3)).join(',');
|
|
903
|
+
const samples360 = strips[360].pathData.slice(0, 5).map(v => v.toFixed(3)).join(',');
|
|
904
|
+
console.log('[Toolpath Display] Data check: strip 0 first 5 values:', samples0);
|
|
905
|
+
console.log('[Toolpath Display] Data check: strip 360 first 5 values:', samples360);
|
|
906
|
+
console.log('[Toolpath Display] Data is', samples0 === samples360 ? 'SAME (BUG!)' : 'DIFFERENT (OK)');
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (strips.length > 0) {
|
|
911
|
+
const firstStrip = strips[0];
|
|
912
|
+
// Note: modelRasterData may be null with new pipeline (rasterize + toolpath in one step)
|
|
913
|
+
const terrainStrip = modelRasterData ? modelRasterData.find(s => s.angle === firstStrip.angle) : null;
|
|
914
|
+
|
|
915
|
+
// Find min/max without stack overflow
|
|
916
|
+
let minVal = Infinity, maxVal = -Infinity;
|
|
917
|
+
for (let i = 0; i < firstStrip.pathData.length; i++) {
|
|
918
|
+
minVal = Math.min(minVal, firstStrip.pathData[i]);
|
|
919
|
+
maxVal = Math.max(maxVal, firstStrip.pathData[i]);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
console.log('[Toolpath Display] First strip sample:', {
|
|
923
|
+
angle: firstStrip.angle,
|
|
924
|
+
numScanlines: firstStrip.numScanlines,
|
|
925
|
+
pointsPerLine: firstStrip.pointsPerLine,
|
|
926
|
+
firstValues: Array.from(firstStrip.pathData.slice(0, 10)),
|
|
927
|
+
minValue: minVal,
|
|
928
|
+
maxValue: maxVal,
|
|
929
|
+
terrainBounds: terrainStrip?.bounds
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Preallocate typed arrays for better performance
|
|
934
|
+
const positions = new Float32Array(totalPoints * 3);
|
|
935
|
+
const colors = new Float32Array(totalPoints * 3);
|
|
936
|
+
let arrayIdx = 0;
|
|
937
|
+
|
|
938
|
+
if (wrapped) {
|
|
939
|
+
// Wrap each strip around X-axis at its angle
|
|
940
|
+
// Each strip should have numScanlines=1 (single centerline)
|
|
941
|
+
for (const strip of strips) {
|
|
942
|
+
const { angle, pathData, numScanlines, pointsPerLine } = strip;
|
|
943
|
+
const terrainStrip = modelRasterData.find(s => s.angle === angle);
|
|
944
|
+
const stripBounds = terrainStrip?.bounds || { min: { x: -100, y: 0, z: 0 }, max: { x: 100, y: 10, z: 20 } };
|
|
945
|
+
|
|
946
|
+
// Rotate around X-axis at this angle
|
|
947
|
+
const theta = angle * Math.PI / 180;
|
|
948
|
+
const cosTheta = Math.cos(theta);
|
|
949
|
+
const sinTheta = Math.sin(theta);
|
|
950
|
+
|
|
951
|
+
// Should only be 1 scanline for radial
|
|
952
|
+
for (let line = 0; line < numScanlines; line++) {
|
|
953
|
+
for (let pt = 0; pt < pointsPerLine; pt++) {
|
|
954
|
+
const idx = line * pointsPerLine + pt;
|
|
955
|
+
const radius = pathData[idx]; // Tool tip radius from X-axis
|
|
956
|
+
|
|
957
|
+
const gridX = pt * xStep;
|
|
958
|
+
const x = stripBounds.min.x + gridX * stepSize;
|
|
959
|
+
|
|
960
|
+
// Wrap around X-axis
|
|
961
|
+
const yWrapped = radius * cosTheta;
|
|
962
|
+
const zWrapped = radius * sinTheta;
|
|
963
|
+
|
|
964
|
+
// Use direct indexing instead of push()
|
|
965
|
+
positions[arrayIdx] = x;
|
|
966
|
+
positions[arrayIdx + 1] = yWrapped;
|
|
967
|
+
positions[arrayIdx + 2] = zWrapped;
|
|
968
|
+
|
|
969
|
+
colors[arrayIdx] = 1; // R
|
|
970
|
+
colors[arrayIdx + 1] = 0.4; // G
|
|
971
|
+
colors[arrayIdx + 2] = 0; // B
|
|
972
|
+
|
|
973
|
+
arrayIdx += 3;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
} else {
|
|
978
|
+
// Show unwrapped (not very useful for radial, but supported)
|
|
979
|
+
for (const strip of strips) {
|
|
980
|
+
const { angle, pathData, numScanlines, pointsPerLine } = strip;
|
|
981
|
+
// Get bounds from the strip's terrain data (or use default if not available)
|
|
982
|
+
const stripBounds = (modelRasterData && modelRasterData.find(s => s.angle === angle)?.bounds) ||
|
|
983
|
+
{ min: { x: -100, y: 0, z: 0 }, max: { x: 100, y: 10, z: 20 } };
|
|
984
|
+
const yOffset = angle; // Use angle as Y offset for visualization
|
|
985
|
+
|
|
986
|
+
for (let line = 0; line < numScanlines; line++) {
|
|
987
|
+
const gridY = line * yStep;
|
|
988
|
+
const y = yOffset + gridY * stepSize;
|
|
989
|
+
|
|
990
|
+
for (let pt = 0; pt < pointsPerLine; pt++) {
|
|
991
|
+
const idx = line * pointsPerLine + pt;
|
|
992
|
+
const radius = pathData[idx];
|
|
993
|
+
|
|
994
|
+
const gridX = pt * xStep;
|
|
995
|
+
const x = stripBounds.min.x + gridX * stepSize;
|
|
996
|
+
|
|
997
|
+
// Use direct indexing instead of push()
|
|
998
|
+
positions[arrayIdx] = x;
|
|
999
|
+
positions[arrayIdx + 1] = y;
|
|
1000
|
+
positions[arrayIdx + 2] = radius;
|
|
1001
|
+
|
|
1002
|
+
colors[arrayIdx] = 1; // R
|
|
1003
|
+
colors[arrayIdx + 1] = 0.4; // G
|
|
1004
|
+
colors[arrayIdx + 2] = 0; // B
|
|
1005
|
+
|
|
1006
|
+
arrayIdx += 3;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (positions.length > 0) {
|
|
1013
|
+
const geometry = new THREE.BufferGeometry();
|
|
1014
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
1015
|
+
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
|
1016
|
+
|
|
1017
|
+
// Scale point size with resolution
|
|
1018
|
+
const pointSize = resolution * 1.5; // Slightly larger than raster points
|
|
1019
|
+
|
|
1020
|
+
const material = new THREE.PointsMaterial({
|
|
1021
|
+
size: pointSize,
|
|
1022
|
+
vertexColors: true
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
toolpathPoints = new THREE.Points(geometry, material);
|
|
1026
|
+
|
|
1027
|
+
// For wrapped radial mode, rotate 90° around X and center on model mesh
|
|
1028
|
+
if (wrapped) {
|
|
1029
|
+
toolpathPoints.rotation.x = Math.PI / 2;
|
|
1030
|
+
|
|
1031
|
+
// Center toolpath on model mesh
|
|
1032
|
+
if (modelMesh) {
|
|
1033
|
+
geometry.computeBoundingBox();
|
|
1034
|
+
const toolpathCenter = new THREE.Vector3();
|
|
1035
|
+
geometry.boundingBox.getCenter(toolpathCenter);
|
|
1036
|
+
|
|
1037
|
+
const modelCenter = new THREE.Vector3();
|
|
1038
|
+
modelMesh.geometry.boundingBox.getCenter(modelCenter);
|
|
1039
|
+
|
|
1040
|
+
// Offset toolpath to match model center
|
|
1041
|
+
toolpathPoints.position.copy(modelCenter).sub(toolpathCenter);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
rotatedGroup.add(toolpathPoints);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ============================================================================
|
|
1050
|
+
// UI Updates
|
|
1051
|
+
// ============================================================================
|
|
1052
|
+
|
|
1053
|
+
function updateInfo(text) {
|
|
1054
|
+
console.log(text);
|
|
1055
|
+
document.getElementById('info').textContent = text;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function updateButtonStates() {
|
|
1059
|
+
const hasModel = modelTriangles !== null;
|
|
1060
|
+
const hasTool = toolTriangles !== null;
|
|
1061
|
+
const hasAnySTL = hasModel || hasTool;
|
|
1062
|
+
|
|
1063
|
+
// Different requirements for planar vs radial mode
|
|
1064
|
+
let canGenerateToolpath;
|
|
1065
|
+
if (mode === 'planar') {
|
|
1066
|
+
// Planar: need both model and tool rasterized
|
|
1067
|
+
canGenerateToolpath = modelRasterData !== null && toolRasterData !== null;
|
|
1068
|
+
} else {
|
|
1069
|
+
// Radial: only need tool rasterized (model is rasterized during toolpath gen)
|
|
1070
|
+
canGenerateToolpath = toolRasterData !== null && hasModel;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
document.getElementById('rasterize').disabled = !hasAnySTL;
|
|
1074
|
+
document.getElementById('generate-toolpath').disabled = !canGenerateToolpath;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ============================================================================
|
|
1078
|
+
// Event Handlers
|
|
1079
|
+
// ============================================================================
|
|
1080
|
+
|
|
1081
|
+
function updateModeUI() {
|
|
1082
|
+
// Show/hide wrapped toggle and angle step for radial mode
|
|
1083
|
+
const wrappedContainer = document.getElementById('wrapped-container').classList;
|
|
1084
|
+
const angleStepContainer = document.getElementById('angle-step-container').classList;
|
|
1085
|
+
if (mode === 'radial') {
|
|
1086
|
+
wrappedContainer.remove('hide');
|
|
1087
|
+
angleStepContainer.remove('hide');
|
|
1088
|
+
} else {
|
|
1089
|
+
wrappedContainer.add('hide');
|
|
1090
|
+
angleStepContainer.add('hide');
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
1095
|
+
// Load saved parameters
|
|
1096
|
+
loadParameters();
|
|
1097
|
+
|
|
1098
|
+
// Initialize Three.js
|
|
1099
|
+
initThreeJS();
|
|
1100
|
+
|
|
1101
|
+
// Load cached STLs from IndexedDB
|
|
1102
|
+
const cachedModel = await getCachedSTL('model-stl');
|
|
1103
|
+
if (cachedModel) {
|
|
1104
|
+
// Handle both old format (raw ArrayBuffer) and new format (object with arrayBuffer and name)
|
|
1105
|
+
const isOldFormat = cachedModel instanceof ArrayBuffer;
|
|
1106
|
+
modelSTL = isOldFormat ? cachedModel : cachedModel.arrayBuffer;
|
|
1107
|
+
modelTriangles = parseSTL(modelSTL);
|
|
1108
|
+
document.getElementById('model-status').textContent = isOldFormat ? 'Cached model' : (cachedModel.name || 'Cached model');
|
|
1109
|
+
displayModelMesh();
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const cachedTool = await getCachedSTL('tool-stl');
|
|
1113
|
+
if (cachedTool) {
|
|
1114
|
+
// Handle both old format (raw ArrayBuffer) and new format (object with arrayBuffer and name)
|
|
1115
|
+
const isOldFormat = cachedTool instanceof ArrayBuffer;
|
|
1116
|
+
toolSTL = isOldFormat ? cachedTool : cachedTool.arrayBuffer;
|
|
1117
|
+
toolTriangles = parseSTL(toolSTL);
|
|
1118
|
+
document.getElementById('tool-status').textContent = isOldFormat ? 'Cached tool' : (cachedTool.name || 'Cached tool');
|
|
1119
|
+
displayToolMesh();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
updateButtonStates();
|
|
1123
|
+
|
|
1124
|
+
// Mode toggle
|
|
1125
|
+
document.querySelectorAll('input[name="mode"]').forEach(radio => {
|
|
1126
|
+
radio.addEventListener('change', (e) => {
|
|
1127
|
+
mode = e.target.value;
|
|
1128
|
+
updateModeUI();
|
|
1129
|
+
|
|
1130
|
+
// Clear raster data (needs re-rasterization with new mode)
|
|
1131
|
+
modelRasterData = null;
|
|
1132
|
+
toolRasterData = null;
|
|
1133
|
+
toolpathData = null;
|
|
1134
|
+
|
|
1135
|
+
saveParameters();
|
|
1136
|
+
updateInfo(`Mode changed to ${mode}`);
|
|
1137
|
+
updateButtonStates();
|
|
1138
|
+
updateVisualization();
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Resolution change
|
|
1143
|
+
document.getElementById('resolution').addEventListener('change', (e) => {
|
|
1144
|
+
resolution = parseFloat(e.target.value);
|
|
1145
|
+
|
|
1146
|
+
// Clear raster data (needs re-rasterization with new resolution)
|
|
1147
|
+
modelRasterData = null;
|
|
1148
|
+
toolRasterData = null;
|
|
1149
|
+
toolpathData = null;
|
|
1150
|
+
|
|
1151
|
+
saveParameters();
|
|
1152
|
+
updateInfo(`Resolution changed to ${resolution}mm`);
|
|
1153
|
+
updateButtonStates();
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// Z Floor change
|
|
1157
|
+
document.getElementById('z-floor').addEventListener('change', (e) => {
|
|
1158
|
+
zFloor = parseFloat(e.target.value);
|
|
1159
|
+
|
|
1160
|
+
// Clear raster data (needs re-rasterization with new zFloor)
|
|
1161
|
+
modelRasterData = null;
|
|
1162
|
+
toolRasterData = null;
|
|
1163
|
+
toolpathData = null;
|
|
1164
|
+
|
|
1165
|
+
saveParameters();
|
|
1166
|
+
updateInfo(`Z Floor changed to ${zFloor}`);
|
|
1167
|
+
updateButtonStates();
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
// Stepping controls
|
|
1171
|
+
document.getElementById('x-step').addEventListener('change', (e) => {
|
|
1172
|
+
xStep = parseInt(e.target.value);
|
|
1173
|
+
toolpathData = null; // Need to regenerate toolpath
|
|
1174
|
+
saveParameters();
|
|
1175
|
+
updateInfo(`X Step changed to ${xStep}`);
|
|
1176
|
+
updateButtonStates();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
document.getElementById('y-step').addEventListener('change', (e) => {
|
|
1180
|
+
yStep = parseInt(e.target.value);
|
|
1181
|
+
toolpathData = null; // Need to regenerate toolpath
|
|
1182
|
+
saveParameters();
|
|
1183
|
+
updateInfo(`Y Step changed to ${yStep}`);
|
|
1184
|
+
updateButtonStates();
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
document.getElementById('angle-step').addEventListener('change', (e) => {
|
|
1188
|
+
angleStep = parseFloat(e.target.value);
|
|
1189
|
+
if (mode === 'radial') {
|
|
1190
|
+
modelRasterData = null; // Need to re-rasterize with new angle step
|
|
1191
|
+
toolRasterData = null;
|
|
1192
|
+
toolpathData = null;
|
|
1193
|
+
}
|
|
1194
|
+
saveParameters();
|
|
1195
|
+
updateInfo(`Angle Step changed to ${angleStep}°`);
|
|
1196
|
+
updateButtonStates();
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
// Load Model button
|
|
1200
|
+
document.getElementById('load-model').addEventListener('click', async () => {
|
|
1201
|
+
const result = await loadSTLFile(true);
|
|
1202
|
+
if (result) {
|
|
1203
|
+
modelSTL = result.arrayBuffer;
|
|
1204
|
+
modelTriangles = result.triangles;
|
|
1205
|
+
document.getElementById('model-status').textContent = result.name;
|
|
1206
|
+
|
|
1207
|
+
// Clear raster data
|
|
1208
|
+
modelRasterData = null;
|
|
1209
|
+
toolpathData = null;
|
|
1210
|
+
|
|
1211
|
+
displayModelMesh();
|
|
1212
|
+
updateButtonStates();
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// Load Tool button
|
|
1217
|
+
document.getElementById('load-tool').addEventListener('click', async () => {
|
|
1218
|
+
const result = await loadSTLFile(false);
|
|
1219
|
+
if (result) {
|
|
1220
|
+
toolSTL = result.arrayBuffer;
|
|
1221
|
+
toolTriangles = result.triangles;
|
|
1222
|
+
document.getElementById('tool-status').textContent = result.name;
|
|
1223
|
+
|
|
1224
|
+
// Clear raster data
|
|
1225
|
+
toolRasterData = null;
|
|
1226
|
+
toolpathData = null;
|
|
1227
|
+
|
|
1228
|
+
displayToolMesh();
|
|
1229
|
+
updateButtonStates();
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// Rasterize button
|
|
1234
|
+
document.getElementById('rasterize').addEventListener('click', rasterizeAll);
|
|
1235
|
+
|
|
1236
|
+
// Generate Toolpath button
|
|
1237
|
+
document.getElementById('generate-toolpath').addEventListener('click', generateToolpath);
|
|
1238
|
+
|
|
1239
|
+
// View toggles
|
|
1240
|
+
['show-model', 'show-raster', 'show-paths', 'show-wrapped'].forEach(id => {
|
|
1241
|
+
const checkbox = document.getElementById(id);
|
|
1242
|
+
if (checkbox) {
|
|
1243
|
+
checkbox.addEventListener('change', () => {
|
|
1244
|
+
updateVisualization();
|
|
1245
|
+
// Save wrapped checkbox state
|
|
1246
|
+
if (id === 'show-wrapped') {
|
|
1247
|
+
saveParameters();
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
updateInfo('Ready - Load STL files to begin');
|
|
1254
|
+
});
|