bambu-printer-mcp 1.0.0
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 +339 -0
- package/README.md +85 -0
- package/dist/3mf_parser.d.ts +3 -0
- package/dist/3mf_parser.js +216 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +700 -0
- package/dist/printers/bambu.d.ts +50 -0
- package/dist/printers/bambu.js +350 -0
- package/dist/stl/stl-manipulator.d.ts +140 -0
- package/dist/stl/stl-manipulator.js +1102 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.js +1 -0
- package/package.json +52 -0
- package/src/3mf_parser.ts +223 -0
- package/src/index.ts +830 -0
- package/src/printers/bambu.ts +480 -0
- package/src/stl/stl-manipulator.ts +1377 -0
- package/src/types.ts +58 -0
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
|
3
|
+
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js';
|
|
4
|
+
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import * as crypto from 'crypto';
|
|
10
|
+
import { execFile } from 'child_process';
|
|
11
|
+
const readFileAsync = promisify(fs.readFile);
|
|
12
|
+
const writeFileAsync = promisify(fs.writeFile);
|
|
13
|
+
export class STLManipulator extends EventEmitter {
|
|
14
|
+
constructor(tempDir = path.join(process.cwd(), 'temp')) {
|
|
15
|
+
super();
|
|
16
|
+
this.activeOperations = new Map();
|
|
17
|
+
this.tempDir = tempDir;
|
|
18
|
+
// Ensure temp directory exists
|
|
19
|
+
if (!fs.existsSync(this.tempDir)) {
|
|
20
|
+
fs.mkdirSync(this.tempDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Generate a unique operation ID
|
|
25
|
+
*/
|
|
26
|
+
generateOperationId() {
|
|
27
|
+
return crypto.randomUUID();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Load STL file and return geometry and bounding box
|
|
31
|
+
*/
|
|
32
|
+
async loadSTL(stlFilePath, progressCallback) {
|
|
33
|
+
try {
|
|
34
|
+
if (progressCallback)
|
|
35
|
+
progressCallback(10, "Loading STL file...");
|
|
36
|
+
// Read the STL file
|
|
37
|
+
const stlData = await readFileAsync(stlFilePath);
|
|
38
|
+
if (progressCallback)
|
|
39
|
+
progressCallback(30, "Parsing STL data...");
|
|
40
|
+
// Load the STL data into a Three.js geometry
|
|
41
|
+
const loader = new STLLoader();
|
|
42
|
+
const rawArrayBuffer = stlData.buffer.slice(stlData.byteOffset, stlData.byteOffset + stlData.byteLength);
|
|
43
|
+
const geometry = loader.parse(rawArrayBuffer);
|
|
44
|
+
// Create a mesh from the geometry
|
|
45
|
+
const material = new THREE.MeshStandardMaterial();
|
|
46
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
47
|
+
// Compute the bounding box
|
|
48
|
+
geometry.computeBoundingBox();
|
|
49
|
+
const boundingBox = geometry.boundingBox;
|
|
50
|
+
if (progressCallback)
|
|
51
|
+
progressCallback(50, "STL loaded successfully");
|
|
52
|
+
return { geometry, boundingBox, mesh };
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error("Error loading STL file:", error);
|
|
56
|
+
throw new Error(`Failed to load STL file: ${error instanceof Error ? error.message : String(error)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Save a geometry to STL file
|
|
61
|
+
*/
|
|
62
|
+
async saveSTL(geometry, outputFilePath, progressCallback) {
|
|
63
|
+
try {
|
|
64
|
+
if (progressCallback)
|
|
65
|
+
progressCallback(80, "Exporting to STL...");
|
|
66
|
+
// Create mesh for export
|
|
67
|
+
const material = new THREE.MeshStandardMaterial();
|
|
68
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
69
|
+
// Export the mesh as STL
|
|
70
|
+
const exporter = new STLExporter();
|
|
71
|
+
const stlString = exporter.parse(mesh);
|
|
72
|
+
// Write the STL to file
|
|
73
|
+
await writeFileAsync(outputFilePath, stlString);
|
|
74
|
+
if (progressCallback)
|
|
75
|
+
progressCallback(100, "STL saved successfully");
|
|
76
|
+
return outputFilePath;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.error("Error saving STL file:", error);
|
|
80
|
+
throw new Error(`Failed to save STL file: ${error instanceof Error ? error.message : String(error)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get comprehensive information about an STL file
|
|
85
|
+
*/
|
|
86
|
+
async getSTLInfo(stlFilePath) {
|
|
87
|
+
try {
|
|
88
|
+
const { geometry, boundingBox } = await this.loadSTL(stlFilePath);
|
|
89
|
+
const fileStats = fs.statSync(stlFilePath);
|
|
90
|
+
// Count faces (each face is a triangle in STL)
|
|
91
|
+
const positionAttribute = geometry.getAttribute('position');
|
|
92
|
+
const vertexCount = positionAttribute.count;
|
|
93
|
+
const faceCount = vertexCount / 3;
|
|
94
|
+
// Calculate center and dimensions
|
|
95
|
+
const center = new THREE.Vector3();
|
|
96
|
+
boundingBox.getCenter(center);
|
|
97
|
+
const dimensions = new THREE.Vector3();
|
|
98
|
+
boundingBox.getSize(dimensions);
|
|
99
|
+
return {
|
|
100
|
+
filePath: stlFilePath,
|
|
101
|
+
fileName: path.basename(stlFilePath),
|
|
102
|
+
fileSize: fileStats.size,
|
|
103
|
+
boundingBox: {
|
|
104
|
+
min: boundingBox.min,
|
|
105
|
+
max: boundingBox.max,
|
|
106
|
+
center,
|
|
107
|
+
dimensions
|
|
108
|
+
},
|
|
109
|
+
vertexCount,
|
|
110
|
+
faceCount
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.error("Error getting STL info:", error);
|
|
115
|
+
throw new Error(`Failed to get STL info: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Scale an STL model uniformly or along specific axes
|
|
120
|
+
*/
|
|
121
|
+
async scaleSTL(stlFilePath, scaleFactors, progressCallback) {
|
|
122
|
+
const operationId = this.generateOperationId();
|
|
123
|
+
this.activeOperations.set(operationId, true);
|
|
124
|
+
try {
|
|
125
|
+
if (progressCallback)
|
|
126
|
+
progressCallback(0, "Starting scaling operation...");
|
|
127
|
+
// Load the STL file
|
|
128
|
+
const { geometry, mesh } = await this.loadSTL(stlFilePath, progressCallback);
|
|
129
|
+
if (!this.activeOperations.get(operationId)) {
|
|
130
|
+
throw new Error("Operation cancelled");
|
|
131
|
+
}
|
|
132
|
+
if (progressCallback)
|
|
133
|
+
progressCallback(60, "Applying scaling transformation...");
|
|
134
|
+
// Apply scaling
|
|
135
|
+
let scaleX, scaleY, scaleZ;
|
|
136
|
+
if (typeof scaleFactors === 'number') {
|
|
137
|
+
// Uniform scaling
|
|
138
|
+
scaleX = scaleY = scaleZ = scaleFactors;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Non-uniform scaling
|
|
142
|
+
[scaleX, scaleY, scaleZ] = scaleFactors;
|
|
143
|
+
}
|
|
144
|
+
const scaleMatrix = new THREE.Matrix4().makeScale(scaleX, scaleY, scaleZ);
|
|
145
|
+
geometry.applyMatrix4(scaleMatrix);
|
|
146
|
+
if (!this.activeOperations.get(operationId)) {
|
|
147
|
+
throw new Error("Operation cancelled");
|
|
148
|
+
}
|
|
149
|
+
// Generate output file path
|
|
150
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_scaled.stl';
|
|
151
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
152
|
+
// Save the modified STL
|
|
153
|
+
await this.saveSTL(geometry, outputFilePath, progressCallback);
|
|
154
|
+
this.emit('operationComplete', {
|
|
155
|
+
operationId,
|
|
156
|
+
type: 'scale',
|
|
157
|
+
success: true,
|
|
158
|
+
output: outputFilePath
|
|
159
|
+
});
|
|
160
|
+
return outputFilePath;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
this.emit('operationError', {
|
|
164
|
+
operationId,
|
|
165
|
+
type: 'scale',
|
|
166
|
+
error: error instanceof Error ? error.message : String(error)
|
|
167
|
+
});
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this.activeOperations.delete(operationId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Rotate an STL model around specific axes
|
|
176
|
+
*/
|
|
177
|
+
async rotateSTL(stlFilePath, rotationAngles, // [x, y, z] in degrees
|
|
178
|
+
progressCallback) {
|
|
179
|
+
const operationId = this.generateOperationId();
|
|
180
|
+
this.activeOperations.set(operationId, true);
|
|
181
|
+
try {
|
|
182
|
+
if (progressCallback)
|
|
183
|
+
progressCallback(0, "Starting rotation operation...");
|
|
184
|
+
// Load the STL file
|
|
185
|
+
const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback);
|
|
186
|
+
if (!this.activeOperations.get(operationId)) {
|
|
187
|
+
throw new Error("Operation cancelled");
|
|
188
|
+
}
|
|
189
|
+
if (progressCallback)
|
|
190
|
+
progressCallback(60, "Applying rotation transformation...");
|
|
191
|
+
// Convert degrees to radians
|
|
192
|
+
const [rotX, rotY, rotZ] = rotationAngles.map(angle => angle * Math.PI / 180);
|
|
193
|
+
// Get the center of the model
|
|
194
|
+
const center = new THREE.Vector3();
|
|
195
|
+
boundingBox.getCenter(center);
|
|
196
|
+
// Create translation matrices to rotate around the center
|
|
197
|
+
const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
|
|
198
|
+
const rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(rotX, rotY, rotZ, 'XYZ'));
|
|
199
|
+
const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
|
|
200
|
+
// Apply the transformations
|
|
201
|
+
geometry.applyMatrix4(toOriginMatrix);
|
|
202
|
+
geometry.applyMatrix4(rotationMatrix);
|
|
203
|
+
geometry.applyMatrix4(fromOriginMatrix);
|
|
204
|
+
if (!this.activeOperations.get(operationId)) {
|
|
205
|
+
throw new Error("Operation cancelled");
|
|
206
|
+
}
|
|
207
|
+
// Generate output file path
|
|
208
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_rotated.stl';
|
|
209
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
210
|
+
// Save the modified STL
|
|
211
|
+
await this.saveSTL(geometry, outputFilePath, progressCallback);
|
|
212
|
+
this.emit('operationComplete', {
|
|
213
|
+
operationId,
|
|
214
|
+
type: 'rotate',
|
|
215
|
+
success: true,
|
|
216
|
+
output: outputFilePath
|
|
217
|
+
});
|
|
218
|
+
return outputFilePath;
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
this.emit('operationError', {
|
|
222
|
+
operationId,
|
|
223
|
+
type: 'rotate',
|
|
224
|
+
error: error instanceof Error ? error.message : String(error)
|
|
225
|
+
});
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
this.activeOperations.delete(operationId);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Translate (move) an STL model along specific axes
|
|
234
|
+
*/
|
|
235
|
+
async translateSTL(stlFilePath, translationValues, // [x, y, z] in mm
|
|
236
|
+
progressCallback) {
|
|
237
|
+
const operationId = this.generateOperationId();
|
|
238
|
+
this.activeOperations.set(operationId, true);
|
|
239
|
+
try {
|
|
240
|
+
if (progressCallback)
|
|
241
|
+
progressCallback(0, "Starting translation operation...");
|
|
242
|
+
// Load the STL file
|
|
243
|
+
const { geometry } = await this.loadSTL(stlFilePath, progressCallback);
|
|
244
|
+
if (!this.activeOperations.get(operationId)) {
|
|
245
|
+
throw new Error("Operation cancelled");
|
|
246
|
+
}
|
|
247
|
+
if (progressCallback)
|
|
248
|
+
progressCallback(60, "Applying translation transformation...");
|
|
249
|
+
// Apply translation
|
|
250
|
+
const [translateX, translateY, translateZ] = translationValues;
|
|
251
|
+
const translationMatrix = new THREE.Matrix4().makeTranslation(translateX, translateY, translateZ);
|
|
252
|
+
geometry.applyMatrix4(translationMatrix);
|
|
253
|
+
if (!this.activeOperations.get(operationId)) {
|
|
254
|
+
throw new Error("Operation cancelled");
|
|
255
|
+
}
|
|
256
|
+
// Generate output file path
|
|
257
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_translated.stl';
|
|
258
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
259
|
+
// Save the modified STL
|
|
260
|
+
await this.saveSTL(geometry, outputFilePath, progressCallback);
|
|
261
|
+
this.emit('operationComplete', {
|
|
262
|
+
operationId,
|
|
263
|
+
type: 'translate',
|
|
264
|
+
success: true,
|
|
265
|
+
output: outputFilePath
|
|
266
|
+
});
|
|
267
|
+
return outputFilePath;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
this.emit('operationError', {
|
|
271
|
+
operationId,
|
|
272
|
+
type: 'translate',
|
|
273
|
+
error: error instanceof Error ? error.message : String(error)
|
|
274
|
+
});
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
this.activeOperations.delete(operationId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Cancel an ongoing operation
|
|
283
|
+
*/
|
|
284
|
+
cancelOperation(operationId) {
|
|
285
|
+
if (this.activeOperations.has(operationId)) {
|
|
286
|
+
this.activeOperations.set(operationId, false);
|
|
287
|
+
this.emit('operationCancelled', { operationId });
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Generate an SVG visualization of an STL file from multiple angles
|
|
294
|
+
* @param stlFilePath Path to the STL file
|
|
295
|
+
* @param width Width of each view in pixels
|
|
296
|
+
* @param height Height of each view in pixels
|
|
297
|
+
* @param progressCallback Optional callback for progress updates
|
|
298
|
+
* @returns Path to the generated SVG file
|
|
299
|
+
*/
|
|
300
|
+
async generateVisualization(stlFilePath, width = 300, height = 300, progressCallback) {
|
|
301
|
+
const operationId = this.generateOperationId();
|
|
302
|
+
this.activeOperations.set(operationId, true);
|
|
303
|
+
try {
|
|
304
|
+
if (progressCallback)
|
|
305
|
+
progressCallback(0, "Starting visualization generation...");
|
|
306
|
+
// Load the STL file
|
|
307
|
+
const { geometry, boundingBox, mesh } = await this.loadSTL(stlFilePath, progressCallback);
|
|
308
|
+
if (!this.activeOperations.get(operationId)) {
|
|
309
|
+
throw new Error("Operation cancelled");
|
|
310
|
+
}
|
|
311
|
+
if (progressCallback)
|
|
312
|
+
progressCallback(50, "Setting up 3D scene...");
|
|
313
|
+
// Create a scene
|
|
314
|
+
const scene = new THREE.Scene();
|
|
315
|
+
scene.add(mesh);
|
|
316
|
+
// Create a camera
|
|
317
|
+
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
|
318
|
+
// Calculate the ideal camera position based on bounding box
|
|
319
|
+
const size = new THREE.Vector3();
|
|
320
|
+
boundingBox.getSize(size);
|
|
321
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
322
|
+
const fov = camera.fov * (Math.PI / 180);
|
|
323
|
+
const cameraDistance = (maxDim / 2) / Math.tan(fov / 2) * 1.5; // 1.5 is a factor for some padding
|
|
324
|
+
// Add lighting to the scene
|
|
325
|
+
const ambientLight = new THREE.AmbientLight(0x404040); // soft white light
|
|
326
|
+
scene.add(ambientLight);
|
|
327
|
+
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
|
328
|
+
directionalLight.position.set(1, 1, 1).normalize();
|
|
329
|
+
scene.add(directionalLight);
|
|
330
|
+
if (!this.activeOperations.get(operationId)) {
|
|
331
|
+
throw new Error("Operation cancelled");
|
|
332
|
+
}
|
|
333
|
+
if (progressCallback)
|
|
334
|
+
progressCallback(60, "Generating SVG representation...");
|
|
335
|
+
// Since we can't use the DOM-dependent SVGRenderer in a Node.js environment,
|
|
336
|
+
// let's create a simple representation of the model using its bounding box
|
|
337
|
+
// This is a simplified visual representation
|
|
338
|
+
// Create SVG content with a simple representation of the STL model
|
|
339
|
+
const viewBox = `0 0 ${width * 2} ${height * 2}`;
|
|
340
|
+
let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${width * 2}" height="${height * 2}">`;
|
|
341
|
+
// Add a title and model info
|
|
342
|
+
svgContent += `
|
|
343
|
+
<text x="10" y="20" font-family="Arial" font-size="16" fill="black">
|
|
344
|
+
STL Visualization: ${path.basename(stlFilePath)}
|
|
345
|
+
</text>
|
|
346
|
+
<text x="10" y="40" font-family="Arial" font-size="12" fill="black">
|
|
347
|
+
Dimensions: ${size.x.toFixed(2)} x ${size.y.toFixed(2)} x ${size.z.toFixed(2)} mm
|
|
348
|
+
</text>
|
|
349
|
+
`;
|
|
350
|
+
// Define views
|
|
351
|
+
const views = [
|
|
352
|
+
{ name: "front", transform: "rotateY(0deg)" },
|
|
353
|
+
{ name: "side", transform: "rotateY(90deg)" },
|
|
354
|
+
{ name: "top", transform: "rotateX(90deg)" },
|
|
355
|
+
{ name: "isometric", transform: "rotateX(30deg) rotateY(45deg)" }
|
|
356
|
+
];
|
|
357
|
+
// Draw each view
|
|
358
|
+
for (let i = 0; i < views.length; i++) {
|
|
359
|
+
const view = views[i];
|
|
360
|
+
const x = (i % 2) * width + width / 2;
|
|
361
|
+
const y = Math.floor(i / 2) * height + height / 2 + 50; // Add 50px for the header text
|
|
362
|
+
// Calculate a representative size for the simple cube visualization
|
|
363
|
+
const cubeSize = Math.min(width, height) * 0.4;
|
|
364
|
+
// Draw a simple cube representation
|
|
365
|
+
svgContent += `
|
|
366
|
+
<g transform="translate(${x}, ${y})">
|
|
367
|
+
<rect x="${-cubeSize / 2}" y="${-cubeSize / 2}" width="${cubeSize}" height="${cubeSize}"
|
|
368
|
+
style="fill:#e0e0e0;stroke:#000;stroke-width:1;opacity:0.8;${view.transform}" />
|
|
369
|
+
<text x="0" y="${cubeSize / 2 + 30}" text-anchor="middle" font-family="Arial" font-size="12" fill="black">
|
|
370
|
+
${view.name}
|
|
371
|
+
</text>
|
|
372
|
+
</g>
|
|
373
|
+
`;
|
|
374
|
+
if (progressCallback)
|
|
375
|
+
progressCallback(60 + (i + 1) * 10, `Generated ${view.name} view`);
|
|
376
|
+
}
|
|
377
|
+
// Add STL information
|
|
378
|
+
svgContent += `
|
|
379
|
+
<g transform="translate(20, ${height * 2 - 60})">
|
|
380
|
+
<text font-family="Arial" font-size="14" fill="black">
|
|
381
|
+
File: ${path.basename(stlFilePath)}
|
|
382
|
+
</text>
|
|
383
|
+
<text y="20" font-family="Arial" font-size="12" fill="black">
|
|
384
|
+
Vertices: ${mesh.geometry.attributes.position.count / 3}
|
|
385
|
+
</text>
|
|
386
|
+
<text y="40" font-family="Arial" font-size="12" fill="black">
|
|
387
|
+
Dimensions: W:${size.x.toFixed(2)}mm × H:${size.y.toFixed(2)}mm × D:${size.z.toFixed(2)}mm
|
|
388
|
+
</text>
|
|
389
|
+
</g>
|
|
390
|
+
`;
|
|
391
|
+
// Close the SVG
|
|
392
|
+
svgContent += '</svg>';
|
|
393
|
+
if (progressCallback)
|
|
394
|
+
progressCallback(90, "Visualization generated");
|
|
395
|
+
if (progressCallback)
|
|
396
|
+
progressCallback(90, "Saving visualization...");
|
|
397
|
+
// Write the SVG to file
|
|
398
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_visualization.svg';
|
|
399
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
400
|
+
await writeFileAsync(outputFilePath, svgContent);
|
|
401
|
+
if (progressCallback)
|
|
402
|
+
progressCallback(100, "Visualization saved successfully");
|
|
403
|
+
this.emit('operationComplete', {
|
|
404
|
+
operationId,
|
|
405
|
+
type: 'visualization',
|
|
406
|
+
success: true,
|
|
407
|
+
output: outputFilePath
|
|
408
|
+
});
|
|
409
|
+
return outputFilePath;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
console.error("Error generating visualization:", error);
|
|
413
|
+
this.emit('operationError', {
|
|
414
|
+
operationId,
|
|
415
|
+
type: 'visualization',
|
|
416
|
+
error: error instanceof Error ? error.message : String(error)
|
|
417
|
+
});
|
|
418
|
+
throw new Error(`Failed to generate visualization: ${error instanceof Error ? error.message : String(error)}`);
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
this.activeOperations.delete(operationId);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Apply a specific transformation to a selected section of an STL file
|
|
426
|
+
* This allows for targeted modifications of specific parts of a model
|
|
427
|
+
*/
|
|
428
|
+
async modifySection(stlFilePath, selection, transformation, progressCallback) {
|
|
429
|
+
const operationId = this.generateOperationId();
|
|
430
|
+
this.activeOperations.set(operationId, true);
|
|
431
|
+
try {
|
|
432
|
+
if (progressCallback)
|
|
433
|
+
progressCallback(0, "Starting section modification...");
|
|
434
|
+
// Load the STL file
|
|
435
|
+
const { geometry, boundingBox, mesh } = await this.loadSTL(stlFilePath, progressCallback);
|
|
436
|
+
if (!this.activeOperations.get(operationId)) {
|
|
437
|
+
throw new Error("Operation cancelled");
|
|
438
|
+
}
|
|
439
|
+
if (progressCallback)
|
|
440
|
+
progressCallback(40, "Identifying section to modify...");
|
|
441
|
+
// Convert named sections to actual bounding boxes
|
|
442
|
+
let selectionBox;
|
|
443
|
+
if (selection === 'top') {
|
|
444
|
+
// Select top third of the model
|
|
445
|
+
const height = boundingBox.max.y - boundingBox.min.y;
|
|
446
|
+
const topThreshold = boundingBox.max.y - (height / 3);
|
|
447
|
+
selectionBox = new THREE.Box3(new THREE.Vector3(boundingBox.min.x, topThreshold, boundingBox.min.z), new THREE.Vector3(boundingBox.max.x, boundingBox.max.y, boundingBox.max.z));
|
|
448
|
+
}
|
|
449
|
+
else if (selection === 'bottom') {
|
|
450
|
+
// Select bottom third of the model
|
|
451
|
+
const height = boundingBox.max.y - boundingBox.min.y;
|
|
452
|
+
const bottomThreshold = boundingBox.min.y + (height / 3);
|
|
453
|
+
selectionBox = new THREE.Box3(new THREE.Vector3(boundingBox.min.x, boundingBox.min.y, boundingBox.min.z), new THREE.Vector3(boundingBox.max.x, bottomThreshold, boundingBox.max.z));
|
|
454
|
+
}
|
|
455
|
+
else if (selection === 'center') {
|
|
456
|
+
// Select middle third of the model
|
|
457
|
+
const height = boundingBox.max.y - boundingBox.min.y;
|
|
458
|
+
const bottomThreshold = boundingBox.min.y + (height / 3);
|
|
459
|
+
const topThreshold = boundingBox.max.y - (height / 3);
|
|
460
|
+
selectionBox = new THREE.Box3(new THREE.Vector3(boundingBox.min.x, bottomThreshold, boundingBox.min.z), new THREE.Vector3(boundingBox.max.x, topThreshold, boundingBox.max.z));
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
// Use the provided bounding box
|
|
464
|
+
selectionBox = selection;
|
|
465
|
+
}
|
|
466
|
+
if (progressCallback)
|
|
467
|
+
progressCallback(50, "Applying transformation to selected section...");
|
|
468
|
+
// Get position attribute for direct manipulation
|
|
469
|
+
const positionAttribute = geometry.getAttribute('position');
|
|
470
|
+
const positions = positionAttribute.array;
|
|
471
|
+
// Create transformation matrix based on the requested operation
|
|
472
|
+
let transformMatrix = new THREE.Matrix4();
|
|
473
|
+
const center = new THREE.Vector3();
|
|
474
|
+
selectionBox.getCenter(center);
|
|
475
|
+
// Matrices for transforming around the selection center
|
|
476
|
+
const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
|
|
477
|
+
const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
|
|
478
|
+
// Build the appropriate transformation matrix
|
|
479
|
+
switch (transformation.type) {
|
|
480
|
+
case 'scale':
|
|
481
|
+
if (typeof transformation.value === 'number') {
|
|
482
|
+
transformMatrix = new THREE.Matrix4().makeScale(transformation.value, transformation.value, transformation.value);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
const [scaleX, scaleY, scaleZ] = transformation.value;
|
|
486
|
+
transformMatrix = new THREE.Matrix4().makeScale(scaleX, scaleY, scaleZ);
|
|
487
|
+
}
|
|
488
|
+
break;
|
|
489
|
+
case 'rotate':
|
|
490
|
+
const rotValues = (typeof transformation.value === 'number')
|
|
491
|
+
? [0, 0, transformation.value * Math.PI / 180]
|
|
492
|
+
: transformation.value.map(v => v * Math.PI / 180);
|
|
493
|
+
transformMatrix = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(rotValues[0], rotValues[1], rotValues[2], 'XYZ'));
|
|
494
|
+
break;
|
|
495
|
+
case 'translate':
|
|
496
|
+
if (typeof transformation.value === 'number') {
|
|
497
|
+
const translateValue = transformation.value;
|
|
498
|
+
transformMatrix = new THREE.Matrix4().makeTranslation(translateValue, translateValue, translateValue);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
const [transX, transY, transZ] = transformation.value;
|
|
502
|
+
transformMatrix = new THREE.Matrix4().makeTranslation(transX, transY, transZ);
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
default:
|
|
506
|
+
throw new Error(`Unsupported transformation type: ${transformation.type}`);
|
|
507
|
+
}
|
|
508
|
+
// Build complete transformation (to origin, transform, back from origin)
|
|
509
|
+
const finalTransform = new THREE.Matrix4()
|
|
510
|
+
.multiply(fromOriginMatrix)
|
|
511
|
+
.multiply(transformMatrix)
|
|
512
|
+
.multiply(toOriginMatrix);
|
|
513
|
+
// Create temporary vector for calculations
|
|
514
|
+
const tempVector = new THREE.Vector3();
|
|
515
|
+
try {
|
|
516
|
+
// Apply transformation only to vertices within the selection box
|
|
517
|
+
for (let i = 0; i < positionAttribute.count; i++) {
|
|
518
|
+
tempVector.fromBufferAttribute(positionAttribute, i);
|
|
519
|
+
// Check if this vertex is within our selection box
|
|
520
|
+
if (selectionBox.containsPoint(tempVector)) {
|
|
521
|
+
// Apply the transformation to this vertex
|
|
522
|
+
tempVector.applyMatrix4(finalTransform);
|
|
523
|
+
// Update the position in the buffer
|
|
524
|
+
positionAttribute.setXYZ(i, tempVector.x, tempVector.y, tempVector.z);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Mark the attribute as needing an update
|
|
528
|
+
positionAttribute.needsUpdate = true;
|
|
529
|
+
// Update the geometry's bounding box
|
|
530
|
+
geometry.computeBoundingBox();
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
console.error("Error modifying vertices:", error);
|
|
534
|
+
throw new Error(`Failed to modify section: ${error instanceof Error ? error.message : String(error)}`);
|
|
535
|
+
}
|
|
536
|
+
if (!this.activeOperations.get(operationId)) {
|
|
537
|
+
throw new Error("Operation cancelled");
|
|
538
|
+
}
|
|
539
|
+
// Generate output file path
|
|
540
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_modified.stl';
|
|
541
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
542
|
+
// Save the modified STL
|
|
543
|
+
await this.saveSTL(geometry, outputFilePath, progressCallback);
|
|
544
|
+
this.emit('operationComplete', {
|
|
545
|
+
operationId,
|
|
546
|
+
type: 'modifySection',
|
|
547
|
+
success: true,
|
|
548
|
+
output: outputFilePath
|
|
549
|
+
});
|
|
550
|
+
return outputFilePath;
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
this.emit('operationError', {
|
|
554
|
+
operationId,
|
|
555
|
+
type: 'modifySection',
|
|
556
|
+
error: error instanceof Error ? error.message : String(error)
|
|
557
|
+
});
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
finally {
|
|
561
|
+
this.activeOperations.delete(operationId);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Enhanced version of extendBase with progress reporting
|
|
566
|
+
* @param stlFilePath Path to the input STL file
|
|
567
|
+
* @param extensionInches Amount to extend base in inches
|
|
568
|
+
* @param progressCallback Optional callback for progress updates
|
|
569
|
+
* @returns Path to the modified STL file
|
|
570
|
+
*/
|
|
571
|
+
async extendBase(stlFilePath, extensionInches, progressCallback) {
|
|
572
|
+
const operationId = this.generateOperationId();
|
|
573
|
+
this.activeOperations.set(operationId, true);
|
|
574
|
+
try {
|
|
575
|
+
if (progressCallback)
|
|
576
|
+
progressCallback(0, "Starting base extension operation...");
|
|
577
|
+
console.log(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
|
|
578
|
+
// Load the STL file
|
|
579
|
+
const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback);
|
|
580
|
+
if (!this.activeOperations.get(operationId)) {
|
|
581
|
+
throw new Error("Operation cancelled");
|
|
582
|
+
}
|
|
583
|
+
if (progressCallback)
|
|
584
|
+
progressCallback(60, "Creating extended base geometry...");
|
|
585
|
+
// Find the minimum Y value (assuming Y is up, which is common in 3D printing)
|
|
586
|
+
const minY = boundingBox.min.y;
|
|
587
|
+
// Convert inches to millimeters (STL files typically use mm)
|
|
588
|
+
const extensionMm = extensionInches * 25.4;
|
|
589
|
+
// Create a transformation matrix to move the mesh up by the extension amount
|
|
590
|
+
const matrix = new THREE.Matrix4().makeTranslation(0, extensionMm, 0);
|
|
591
|
+
geometry.applyMatrix4(matrix);
|
|
592
|
+
// Create a box geometry for the base extension
|
|
593
|
+
const baseWidth = boundingBox.max.x - boundingBox.min.x;
|
|
594
|
+
const baseDepth = boundingBox.max.z - boundingBox.min.z;
|
|
595
|
+
const baseGeometry = new THREE.BoxGeometry(baseWidth, extensionMm, baseDepth);
|
|
596
|
+
// Position the base geometry
|
|
597
|
+
const baseMatrix = new THREE.Matrix4().makeTranslation((boundingBox.min.x + boundingBox.max.x) / 2, minY + extensionMm / 2, (boundingBox.min.z + boundingBox.max.z) / 2);
|
|
598
|
+
baseGeometry.applyMatrix4(baseMatrix);
|
|
599
|
+
if (!this.activeOperations.get(operationId)) {
|
|
600
|
+
throw new Error("Operation cancelled");
|
|
601
|
+
}
|
|
602
|
+
if (progressCallback)
|
|
603
|
+
progressCallback(70, "Merging geometries...");
|
|
604
|
+
if (progressCallback)
|
|
605
|
+
progressCallback(75, "Creating merged geometry...");
|
|
606
|
+
// Create material for both meshes
|
|
607
|
+
const material = new THREE.MeshStandardMaterial();
|
|
608
|
+
// Create individual meshes
|
|
609
|
+
const originalMesh = new THREE.Mesh(geometry, material);
|
|
610
|
+
const baseMesh = new THREE.Mesh(baseGeometry, material);
|
|
611
|
+
// Export each mesh separately and merge the STL strings
|
|
612
|
+
const exporter = new STLExporter();
|
|
613
|
+
const originalStl = exporter.parse(originalMesh);
|
|
614
|
+
const baseStl = exporter.parse(baseMesh);
|
|
615
|
+
// Generate output file path
|
|
616
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_extended.stl';
|
|
617
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
618
|
+
if (progressCallback)
|
|
619
|
+
progressCallback(90, "Saving extended STL...");
|
|
620
|
+
// Write the combined STL data to file
|
|
621
|
+
await writeFileAsync(outputFilePath, originalStl + baseStl);
|
|
622
|
+
if (progressCallback)
|
|
623
|
+
progressCallback(100, "STL saved successfully");
|
|
624
|
+
this.emit('operationComplete', {
|
|
625
|
+
operationId,
|
|
626
|
+
type: 'extendBase',
|
|
627
|
+
success: true,
|
|
628
|
+
output: outputFilePath
|
|
629
|
+
});
|
|
630
|
+
console.log(`Modified STL saved to ${outputFilePath}`);
|
|
631
|
+
return outputFilePath;
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
console.error("Error extending STL base:", error);
|
|
635
|
+
this.emit('operationError', {
|
|
636
|
+
operationId,
|
|
637
|
+
type: 'extendBase',
|
|
638
|
+
error: error instanceof Error ? error.message : String(error)
|
|
639
|
+
});
|
|
640
|
+
throw new Error(`Failed to extend STL base: ${error instanceof Error ? error.message : String(error)}`);
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
this.activeOperations.delete(operationId);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Slice an STL or 3MF file using the specified slicer
|
|
648
|
+
* @param stlFilePath Path to the input STL or 3MF file
|
|
649
|
+
* @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, bambustudio)
|
|
650
|
+
* @param slicerPath Path to the slicer executable
|
|
651
|
+
* @param slicerProfile Optional path to the slicer profile/config file
|
|
652
|
+
* @param progressCallback Optional callback for progress updates
|
|
653
|
+
* @returns Path to the generated G-code or sliced 3MF file
|
|
654
|
+
*/
|
|
655
|
+
async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback) {
|
|
656
|
+
const operationId = this.generateOperationId();
|
|
657
|
+
this.activeOperations.set(operationId, true);
|
|
658
|
+
if (!fs.existsSync(slicerPath)) {
|
|
659
|
+
throw new Error(`Slicer executable not found at: ${slicerPath}`);
|
|
660
|
+
}
|
|
661
|
+
if (slicerProfile && !fs.existsSync(slicerProfile)) {
|
|
662
|
+
console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
|
|
663
|
+
// Allow proceeding without profile, slicer might handle it
|
|
664
|
+
}
|
|
665
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
|
|
666
|
+
let outputFilePath = path.join(this.tempDir, outputFileName);
|
|
667
|
+
let args = [];
|
|
668
|
+
try {
|
|
669
|
+
if (progressCallback)
|
|
670
|
+
progressCallback(0, `Starting slicing with ${slicerType}...`);
|
|
671
|
+
switch (slicerType) {
|
|
672
|
+
case 'prusaslicer':
|
|
673
|
+
case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
|
|
674
|
+
args = [
|
|
675
|
+
'--slice',
|
|
676
|
+
'--output', outputFilePath,
|
|
677
|
+
'--load', slicerProfile || '', // Pass profile path
|
|
678
|
+
stlFilePath
|
|
679
|
+
];
|
|
680
|
+
// Remove empty profile arg if not provided
|
|
681
|
+
args = args.filter(arg => arg !== '');
|
|
682
|
+
break;
|
|
683
|
+
case 'orcaslicer': // Add OrcaSlicer case
|
|
684
|
+
args = [
|
|
685
|
+
// OrcaSlicer might use --load-settings like Bambu, or --load like Prusa
|
|
686
|
+
// Assuming --load-settings based on discussion, adjust if needed.
|
|
687
|
+
// Also assuming profile path points to the main .ini or .json config.
|
|
688
|
+
'--load-settings', slicerProfile || '',
|
|
689
|
+
'--output', outputFilePath, // Assuming --output works like PrusaSlicer
|
|
690
|
+
stlFilePath
|
|
691
|
+
];
|
|
692
|
+
// Alternative if --output doesn't specify filename:
|
|
693
|
+
// args = [
|
|
694
|
+
// '--load-settings', slicerProfile || '',
|
|
695
|
+
// '--outputdir', this.tempDir,
|
|
696
|
+
// stlFilePath
|
|
697
|
+
// ];
|
|
698
|
+
// Remove empty profile arg if not provided
|
|
699
|
+
args = args.filter(arg => arg !== '');
|
|
700
|
+
break;
|
|
701
|
+
case 'cura':
|
|
702
|
+
// CuraEngine CLI args are different, often requiring -s for settings
|
|
703
|
+
// Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
|
|
704
|
+
// This requires parsing the profile or passing individual settings.
|
|
705
|
+
// Keeping it simple for now, assuming profile contains necessary info.
|
|
706
|
+
args = [
|
|
707
|
+
'slice',
|
|
708
|
+
'-l', stlFilePath,
|
|
709
|
+
'-o', outputFilePath
|
|
710
|
+
];
|
|
711
|
+
if (slicerProfile) {
|
|
712
|
+
args.push('-j', slicerProfile); // Load settings from profile definition file
|
|
713
|
+
}
|
|
714
|
+
break;
|
|
715
|
+
case 'bambustudio':
|
|
716
|
+
// Bambu Studio CLI: slice and export as 3MF with embedded gcode
|
|
717
|
+
// For 3MF input: slice in place and export sliced 3MF
|
|
718
|
+
// For STL input: slice and export as 3MF
|
|
719
|
+
{
|
|
720
|
+
const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
|
|
721
|
+
const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
|
|
722
|
+
const bambuOutputPath = path.join(this.tempDir, outputBase + '_sliced.3mf');
|
|
723
|
+
args = [
|
|
724
|
+
'--slice', '0', // Slice all plates
|
|
725
|
+
'--export-3mf', bambuOutputPath,
|
|
726
|
+
];
|
|
727
|
+
if (slicerProfile) {
|
|
728
|
+
args.push('--load-settings', slicerProfile);
|
|
729
|
+
}
|
|
730
|
+
args.push(stlFilePath);
|
|
731
|
+
// Override outputFilePath for bambustudio since it produces 3MF, not gcode
|
|
732
|
+
outputFilePath = bambuOutputPath;
|
|
733
|
+
}
|
|
734
|
+
break;
|
|
735
|
+
default:
|
|
736
|
+
throw new Error(`Unsupported slicer type: ${slicerType}`);
|
|
737
|
+
}
|
|
738
|
+
if (progressCallback)
|
|
739
|
+
progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
|
|
740
|
+
console.log(`Executing: ${slicerPath} ${args.join(' ')}`);
|
|
741
|
+
// Execute the slicer
|
|
742
|
+
await new Promise((resolve, reject) => {
|
|
743
|
+
const process = execFile(slicerPath, args, (error, stdout, stderr) => {
|
|
744
|
+
if (error) {
|
|
745
|
+
console.error(`Slicer Error: ${error.message}`);
|
|
746
|
+
console.error(`Slicer Stderr: ${stderr}`);
|
|
747
|
+
reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
console.log(`Slicer Stdout: ${stdout}`);
|
|
751
|
+
if (stderr) {
|
|
752
|
+
console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
|
|
753
|
+
}
|
|
754
|
+
resolve();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
// Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
|
|
758
|
+
// process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
|
|
759
|
+
// process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
|
|
760
|
+
// Check for cancellation periodically
|
|
761
|
+
const checkCancel = setInterval(() => {
|
|
762
|
+
if (!this.activeOperations.get(operationId)) {
|
|
763
|
+
clearInterval(checkCancel);
|
|
764
|
+
try {
|
|
765
|
+
process.kill(); // Attempt to kill the slicer process
|
|
766
|
+
reject(new Error("Slicing operation cancelled"));
|
|
767
|
+
}
|
|
768
|
+
catch (killError) {
|
|
769
|
+
console.error("Error attempting to kill slicer process:", killError);
|
|
770
|
+
reject(new Error("Slicing operation cancelled, but failed to kill process."));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}, 500);
|
|
774
|
+
process.on('exit', () => clearInterval(checkCancel));
|
|
775
|
+
process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
|
|
776
|
+
});
|
|
777
|
+
if (!this.activeOperations.get(operationId)) {
|
|
778
|
+
// This check might be redundant if the promise rejected on kill, but good safety
|
|
779
|
+
throw new Error("Slicing operation cancelled after process finished");
|
|
780
|
+
}
|
|
781
|
+
if (!fs.existsSync(outputFilePath)) {
|
|
782
|
+
throw new Error(`Slicer finished but output file not found: ${outputFilePath}`);
|
|
783
|
+
}
|
|
784
|
+
if (progressCallback)
|
|
785
|
+
progressCallback(100, "Slicing completed successfully");
|
|
786
|
+
this.emit('operationComplete', {
|
|
787
|
+
operationId,
|
|
788
|
+
type: 'slice',
|
|
789
|
+
success: true,
|
|
790
|
+
output: outputFilePath
|
|
791
|
+
});
|
|
792
|
+
return outputFilePath;
|
|
793
|
+
}
|
|
794
|
+
catch (error) {
|
|
795
|
+
console.error(`Slicing failed for ${stlFilePath}:`, error);
|
|
796
|
+
this.emit('operationError', {
|
|
797
|
+
operationId,
|
|
798
|
+
type: 'slice',
|
|
799
|
+
error: error instanceof Error ? error.message : String(error)
|
|
800
|
+
});
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
this.activeOperations.delete(operationId);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Enhanced version of confirmTemperatures with better error handling
|
|
809
|
+
* @param gcodePath Path to the G-code file
|
|
810
|
+
* @param expected Expected temperature settings
|
|
811
|
+
* @param progressCallback Optional callback for progress updates
|
|
812
|
+
* @returns Object with comparison results
|
|
813
|
+
*/
|
|
814
|
+
async confirmTemperatures(gcodePath, expected, progressCallback) {
|
|
815
|
+
const operationId = this.generateOperationId();
|
|
816
|
+
this.activeOperations.set(operationId, true);
|
|
817
|
+
try {
|
|
818
|
+
if (progressCallback)
|
|
819
|
+
progressCallback(0, "Starting temperature verification...");
|
|
820
|
+
// Verify the G-code file exists
|
|
821
|
+
if (!fs.existsSync(gcodePath)) {
|
|
822
|
+
throw new Error(`G-code file not found: ${gcodePath}`);
|
|
823
|
+
}
|
|
824
|
+
if (progressCallback)
|
|
825
|
+
progressCallback(20, "Reading G-code file...");
|
|
826
|
+
// Read the G-code file
|
|
827
|
+
const gcode = await readFileAsync(gcodePath, 'utf8');
|
|
828
|
+
const lines = gcode.split('\n');
|
|
829
|
+
if (!this.activeOperations.get(operationId)) {
|
|
830
|
+
throw new Error("Operation cancelled");
|
|
831
|
+
}
|
|
832
|
+
if (progressCallback)
|
|
833
|
+
progressCallback(50, "Analyzing temperature commands...");
|
|
834
|
+
// Extract temperature settings from G-code
|
|
835
|
+
const actual = {};
|
|
836
|
+
const allTemperatures = { extruder: [], bed: [] };
|
|
837
|
+
for (const line of lines) {
|
|
838
|
+
// Look for extruder temperature (M104 or M109)
|
|
839
|
+
const extruderMatch = line.match(/M10[49] S(\d+)/);
|
|
840
|
+
if (extruderMatch) {
|
|
841
|
+
const temp = parseInt(extruderMatch[1], 10);
|
|
842
|
+
allTemperatures.extruder.push(temp);
|
|
843
|
+
// Keep the first temperature for compatibility with original function
|
|
844
|
+
if (!actual.extruder) {
|
|
845
|
+
actual.extruder = temp;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// Look for bed temperature (M140 or M190)
|
|
849
|
+
const bedMatch = line.match(/M1[49]0 S(\d+)/);
|
|
850
|
+
if (bedMatch) {
|
|
851
|
+
const temp = parseInt(bedMatch[1], 10);
|
|
852
|
+
allTemperatures.bed.push(temp);
|
|
853
|
+
// Keep the first temperature for compatibility with original function
|
|
854
|
+
if (!actual.bed) {
|
|
855
|
+
actual.bed = temp;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (progressCallback)
|
|
860
|
+
progressCallback(80, "Comparing temperatures...");
|
|
861
|
+
// Compare actual with expected
|
|
862
|
+
let match = true;
|
|
863
|
+
if (expected.extruder !== undefined && actual.extruder !== expected.extruder) {
|
|
864
|
+
match = false;
|
|
865
|
+
}
|
|
866
|
+
if (expected.bed !== undefined && actual.bed !== expected.bed) {
|
|
867
|
+
match = false;
|
|
868
|
+
}
|
|
869
|
+
if (progressCallback)
|
|
870
|
+
progressCallback(100, "Temperature verification complete");
|
|
871
|
+
this.emit('operationComplete', {
|
|
872
|
+
operationId,
|
|
873
|
+
type: 'confirmTemperatures',
|
|
874
|
+
success: true,
|
|
875
|
+
result: { match, actual, expected, allTemperatures }
|
|
876
|
+
});
|
|
877
|
+
return { match, actual, expected, allTemperatures };
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
console.error("Error confirming temperatures:", error);
|
|
881
|
+
this.emit('operationError', {
|
|
882
|
+
operationId,
|
|
883
|
+
type: 'confirmTemperatures',
|
|
884
|
+
error: error instanceof Error ? error.message : String(error)
|
|
885
|
+
});
|
|
886
|
+
throw new Error(`Failed to confirm temperatures: ${error instanceof Error ? error.message : String(error)}`);
|
|
887
|
+
}
|
|
888
|
+
finally {
|
|
889
|
+
this.activeOperations.delete(operationId);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Merge vertices within a specified tolerance.
|
|
894
|
+
*/
|
|
895
|
+
async mergeVertices(stlFilePath, tolerance = 0.01, // Default tolerance in mm
|
|
896
|
+
progressCallback) {
|
|
897
|
+
const operationId = this.generateOperationId();
|
|
898
|
+
this.activeOperations.set(operationId, true);
|
|
899
|
+
try {
|
|
900
|
+
if (progressCallback)
|
|
901
|
+
progressCallback(0, "Starting vertex merging...");
|
|
902
|
+
const { geometry } = await this.loadSTL(stlFilePath, progressCallback); // 10-50% progress
|
|
903
|
+
if (!this.activeOperations.get(operationId))
|
|
904
|
+
throw new Error("Operation cancelled");
|
|
905
|
+
if (progressCallback)
|
|
906
|
+
progressCallback(60, "Merging vertices...");
|
|
907
|
+
const originalVertexCount = geometry.attributes.position.count;
|
|
908
|
+
const mergedGeometry = BufferGeometryUtils.mergeVertices(geometry, tolerance);
|
|
909
|
+
const newVertexCount = mergedGeometry.attributes.position.count;
|
|
910
|
+
console.log(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
|
|
911
|
+
if (progressCallback)
|
|
912
|
+
progressCallback(70, `Vertices merged: ${originalVertexCount} -> ${newVertexCount}`);
|
|
913
|
+
if (!this.activeOperations.get(operationId))
|
|
914
|
+
throw new Error("Operation cancelled");
|
|
915
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_merged.stl';
|
|
916
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
917
|
+
await this.saveSTL(mergedGeometry, outputFilePath, progressCallback); // 80-100% progress
|
|
918
|
+
this.emit('operationComplete', { operationId, type: 'mergeVertices', success: true, output: outputFilePath, verticesRemoved: originalVertexCount - newVertexCount });
|
|
919
|
+
return outputFilePath;
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
this.emit('operationError', { operationId, type: 'mergeVertices', error: error instanceof Error ? error.message : String(error) });
|
|
923
|
+
throw error;
|
|
924
|
+
}
|
|
925
|
+
finally {
|
|
926
|
+
this.activeOperations.delete(operationId);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Center the model at the origin (0,0,0).
|
|
931
|
+
*/
|
|
932
|
+
async centerModel(stlFilePath, progressCallback) {
|
|
933
|
+
const operationId = this.generateOperationId();
|
|
934
|
+
this.activeOperations.set(operationId, true);
|
|
935
|
+
try {
|
|
936
|
+
if (progressCallback)
|
|
937
|
+
progressCallback(0, "Starting centering operation...");
|
|
938
|
+
const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback); // 10-50% progress
|
|
939
|
+
if (!this.activeOperations.get(operationId))
|
|
940
|
+
throw new Error("Operation cancelled");
|
|
941
|
+
if (progressCallback)
|
|
942
|
+
progressCallback(60, "Calculating center...");
|
|
943
|
+
const center = new THREE.Vector3();
|
|
944
|
+
boundingBox.getCenter(center);
|
|
945
|
+
if (center.lengthSq() < 0.0001) { // Already centered (or very close)
|
|
946
|
+
if (progressCallback)
|
|
947
|
+
progressCallback(100, "Model is already centered.");
|
|
948
|
+
console.log("Model already centered. No changes made.");
|
|
949
|
+
this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: stlFilePath, message: "Model already centered." });
|
|
950
|
+
return stlFilePath; // Return original path
|
|
951
|
+
}
|
|
952
|
+
if (progressCallback)
|
|
953
|
+
progressCallback(70, `Applying translation: (${-center.x.toFixed(2)}, ${-center.y.toFixed(2)}, ${-center.z.toFixed(2)})`);
|
|
954
|
+
const translationMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
|
|
955
|
+
geometry.applyMatrix4(translationMatrix);
|
|
956
|
+
geometry.computeBoundingBox(); // Recompute bounds after moving
|
|
957
|
+
if (!this.activeOperations.get(operationId))
|
|
958
|
+
throw new Error("Operation cancelled");
|
|
959
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_centered.stl';
|
|
960
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
961
|
+
await this.saveSTL(geometry, outputFilePath, progressCallback); // 80-100% progress
|
|
962
|
+
this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: outputFilePath });
|
|
963
|
+
return outputFilePath;
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
this.emit('operationError', { operationId, type: 'centerModel', error: error instanceof Error ? error.message : String(error) });
|
|
967
|
+
throw error;
|
|
968
|
+
}
|
|
969
|
+
finally {
|
|
970
|
+
this.activeOperations.delete(operationId);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Rotate the model so its largest flat face lies on the XY plane (Z=0).
|
|
975
|
+
*/
|
|
976
|
+
async layFlat(stlFilePath, progressCallback) {
|
|
977
|
+
const operationId = this.generateOperationId();
|
|
978
|
+
this.activeOperations.set(operationId, true);
|
|
979
|
+
try {
|
|
980
|
+
if (progressCallback)
|
|
981
|
+
progressCallback(0, "Starting lay flat operation...");
|
|
982
|
+
const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback); // 10-50%
|
|
983
|
+
if (!this.activeOperations.get(operationId))
|
|
984
|
+
throw new Error("Operation cancelled");
|
|
985
|
+
if (progressCallback)
|
|
986
|
+
progressCallback(55, "Analyzing faces...");
|
|
987
|
+
const positionAttribute = geometry.getAttribute('position');
|
|
988
|
+
if (!positionAttribute)
|
|
989
|
+
throw new Error("Geometry has no position attribute.");
|
|
990
|
+
const faces = [];
|
|
991
|
+
const vA = new THREE.Vector3();
|
|
992
|
+
const vB = new THREE.Vector3();
|
|
993
|
+
const vC = new THREE.Vector3();
|
|
994
|
+
const faceNormal = new THREE.Vector3();
|
|
995
|
+
const cb = new THREE.Vector3(), ab = new THREE.Vector3();
|
|
996
|
+
// Iterate through faces (triangles)
|
|
997
|
+
for (let i = 0; i < positionAttribute.count; i += 3) {
|
|
998
|
+
vA.fromBufferAttribute(positionAttribute, i);
|
|
999
|
+
vB.fromBufferAttribute(positionAttribute, i + 1);
|
|
1000
|
+
vC.fromBufferAttribute(positionAttribute, i + 2);
|
|
1001
|
+
// Calculate face normal
|
|
1002
|
+
cb.subVectors(vC, vB);
|
|
1003
|
+
ab.subVectors(vA, vB);
|
|
1004
|
+
cb.cross(ab);
|
|
1005
|
+
faceNormal.copy(cb).normalize();
|
|
1006
|
+
// Calculate face area (using cross product magnitude / 2)
|
|
1007
|
+
const area = cb.length() / 2;
|
|
1008
|
+
faces.push({
|
|
1009
|
+
normal: faceNormal.clone(),
|
|
1010
|
+
area: area,
|
|
1011
|
+
vertices: [vA.clone(), vB.clone(), vC.clone()] // Store vertices if needed for center calculation
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
if (faces.length === 0)
|
|
1015
|
+
throw new Error("No faces found in geometry.");
|
|
1016
|
+
if (!this.activeOperations.get(operationId))
|
|
1017
|
+
throw new Error("Operation cancelled");
|
|
1018
|
+
if (progressCallback)
|
|
1019
|
+
progressCallback(65, "Grouping faces by normal...");
|
|
1020
|
+
// Group faces by similar normal vectors (tolerance for floating point errors)
|
|
1021
|
+
const normalGroups = new Map();
|
|
1022
|
+
const tolerance = 1e-5;
|
|
1023
|
+
for (const face of faces) {
|
|
1024
|
+
let foundGroup = false;
|
|
1025
|
+
for (const [key, group] of normalGroups.entries()) {
|
|
1026
|
+
if (face.normal.distanceToSquared(group.representativeNormal) < tolerance) {
|
|
1027
|
+
group.totalArea += face.area;
|
|
1028
|
+
// Optional: Average the normal? For now, just use the first one.
|
|
1029
|
+
foundGroup = true;
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (!foundGroup) {
|
|
1034
|
+
const key = `${face.normal.x.toFixed(5)},${face.normal.y.toFixed(5)},${face.normal.z.toFixed(5)}`;
|
|
1035
|
+
if (!normalGroups.has(key)) { // Ensure unique key even if floats slightly differ after toFixed
|
|
1036
|
+
normalGroups.set(key, { totalArea: face.area, representativeNormal: face.normal });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (normalGroups.size === 0)
|
|
1041
|
+
throw new Error("Could not group faces by normal.");
|
|
1042
|
+
if (!this.activeOperations.get(operationId))
|
|
1043
|
+
throw new Error("Operation cancelled");
|
|
1044
|
+
if (progressCallback)
|
|
1045
|
+
progressCallback(75, "Finding largest flat surface...");
|
|
1046
|
+
// Find the group with the largest total area (excluding near-vertical faces)
|
|
1047
|
+
let largestArea = 0;
|
|
1048
|
+
let targetNormal = null;
|
|
1049
|
+
const upVector = new THREE.Vector3(0, 1, 0); // Assuming Y is up for most models initially?
|
|
1050
|
+
const downVector = new THREE.Vector3(0, 0, -1); // Target normal (pointing down)
|
|
1051
|
+
for (const group of normalGroups.values()) {
|
|
1052
|
+
// Check if the normal is reasonably horizontal (not too close to vertical Z)
|
|
1053
|
+
// Angle threshold could be adjusted (e.g., > 5 degrees from Z axis)
|
|
1054
|
+
if (Math.abs(group.representativeNormal.z) < 0.99 && group.totalArea > largestArea) {
|
|
1055
|
+
largestArea = group.totalArea;
|
|
1056
|
+
targetNormal = group.representativeNormal;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!targetNormal) {
|
|
1060
|
+
// If no suitable horizontal face found, maybe pick the lowest Z face?
|
|
1061
|
+
// For now, throw an error or return unchanged.
|
|
1062
|
+
console.warn("Could not find a suitable large flat face to lay down. No changes made.");
|
|
1063
|
+
this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
|
|
1064
|
+
return stlFilePath;
|
|
1065
|
+
}
|
|
1066
|
+
if (progressCallback)
|
|
1067
|
+
progressCallback(80, "Calculating rotation...");
|
|
1068
|
+
// Calculate the rotation needed to align targetNormal with downVector
|
|
1069
|
+
const quaternion = new THREE.Quaternion().setFromUnitVectors(targetNormal, downVector);
|
|
1070
|
+
const rotationMatrix = new THREE.Matrix4().makeRotationFromQuaternion(quaternion);
|
|
1071
|
+
// Rotate around the model's center
|
|
1072
|
+
const center = new THREE.Vector3();
|
|
1073
|
+
boundingBox.getCenter(center);
|
|
1074
|
+
const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
|
|
1075
|
+
const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
|
|
1076
|
+
// Apply rotation
|
|
1077
|
+
geometry.applyMatrix4(toOriginMatrix);
|
|
1078
|
+
geometry.applyMatrix4(rotationMatrix);
|
|
1079
|
+
geometry.applyMatrix4(fromOriginMatrix);
|
|
1080
|
+
// Translate so the new bottom is at Z=0
|
|
1081
|
+
geometry.computeBoundingBox(); // Recompute bounds after rotation
|
|
1082
|
+
const newMinZ = geometry.boundingBox.min.z;
|
|
1083
|
+
const translateToZeroMatrix = new THREE.Matrix4().makeTranslation(0, 0, -newMinZ);
|
|
1084
|
+
geometry.applyMatrix4(translateToZeroMatrix);
|
|
1085
|
+
geometry.computeBoundingBox(); // Final bounds
|
|
1086
|
+
if (!this.activeOperations.get(operationId))
|
|
1087
|
+
throw new Error("Operation cancelled");
|
|
1088
|
+
const outputFileName = path.basename(stlFilePath, '.stl') + '_flat.stl';
|
|
1089
|
+
const outputFilePath = path.join(this.tempDir, outputFileName);
|
|
1090
|
+
await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
|
|
1091
|
+
this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath });
|
|
1092
|
+
return outputFilePath;
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
this.emit('operationError', { operationId, type: 'layFlat', error: error instanceof Error ? error.message : String(error) });
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
finally {
|
|
1099
|
+
this.activeOperations.delete(operationId);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|