mcp-3d-printer-server 1.0.0 → 1.0.1

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