mcp-3d-printer-server 1.2.9 → 1.2.11

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 (86) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/CONTRIBUTORS.md +49 -0
  3. package/README.md +528 -811
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +1 -0
  6. package/dist/blender-mcp-bridge.js +209 -21
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +614 -110
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -126
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +517 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +365 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +16 -9
  58. package/server.json +29 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +200 -19
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +686 -137
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -164
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +354 -0
  80. package/src/safety/printer-state.ts +339 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
@@ -7,7 +7,11 @@ import path from 'path';
7
7
  import { promisify } from 'util';
8
8
  import { EventEmitter } from 'events';
9
9
  import * as crypto from 'crypto';
10
- import { execFile } from 'child_process';
10
+ import { detectProfilesRoot } from '../slicer/profile-flatten.js';
11
+ import { BambuCliProfilePreparer, assertSlicedProjectOutput, buildBambuCliArgs, } from '../slicer/bambu-cli.js';
12
+ import { runSlicerProcess } from '../slicer/run-slicer.js';
13
+ import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
14
+ import { scanGcodeThermal } from '../safety/gcode-thermal.js';
11
15
  const readFileAsync = promisify(fs.readFile);
12
16
  const writeFileAsync = promisify(fs.writeFile);
13
17
  const DEFAULT_SLICER_TIMEOUT_MS = 10 * 60 * 1000;
@@ -27,6 +31,16 @@ export class STLManipulator extends EventEmitter {
27
31
  generateOperationId() {
28
32
  return crypto.randomUUID();
29
33
  }
34
+ /**
35
+ * Each operation writes into its own folder. Outputs named only after the
36
+ * input (for example bracket_extended.stl) would otherwise let two
37
+ * concurrent requests replace each other's file before it is used or printed.
38
+ */
39
+ operationOutputPath(operationId, fileName) {
40
+ const directory = path.join(this.tempDir, `${operationId}-out`);
41
+ fs.mkdirSync(directory, { recursive: true });
42
+ return path.join(directory, fileName);
43
+ }
30
44
  validateSlicerExecutable(slicerPath) {
31
45
  if (!slicerPath.trim()) {
32
46
  throw new Error("Slicer executable path is required.");
@@ -84,28 +98,6 @@ export class STLManipulator extends EventEmitter {
84
98
  }
85
99
  return path.join(outputDir, plateOutputs[0]);
86
100
  }
87
- resolveBambuMachinePresetPath(slicerPath, printerPreset) {
88
- if (fs.existsSync(printerPreset)) {
89
- return printerPreset;
90
- }
91
- const presetFile = printerPreset.endsWith(".json") ? printerPreset : `${printerPreset}.json`;
92
- const candidateDirs = [];
93
- const macAppMarker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
94
- const macAppIndex = slicerPath.indexOf(macAppMarker);
95
- if (macAppIndex >= 0) {
96
- candidateDirs.push(path.join(slicerPath.slice(0, macAppIndex + `${path.sep}Contents`.length), "Resources", "profiles", "BBL", "machine"));
97
- }
98
- const executableDir = path.dirname(slicerPath);
99
- candidateDirs.push(path.join(executableDir, "..", "Resources", "profiles", "BBL", "machine"), path.join(executableDir, "..", "share", "OrcaSlicer", "resources", "profiles", "BBL", "machine"), path.join(executableDir, "..", "resources", "profiles", "BBL", "machine"), path.join(executableDir, "resources", "profiles", "BBL", "machine"));
100
- for (const candidateDir of candidateDirs) {
101
- const candidate = path.resolve(candidateDir, presetFile);
102
- if (fs.existsSync(candidate)) {
103
- return candidate;
104
- }
105
- }
106
- throw new Error(`Could not find Bambu machine preset "${presetFile}" near slicer executable ${slicerPath}. ` +
107
- "Pass slicer_profile with an explicit machine/process settings file, or use a slicer bundle that includes Resources/profiles/BBL/machine.");
108
- }
109
101
  resolveSlicerTimeoutMs() {
110
102
  const rawTimeout = process.env.SLICER_TIMEOUT_MS?.trim();
111
103
  if (!rawTimeout) {
@@ -240,7 +232,7 @@ export class STLManipulator extends EventEmitter {
240
232
  }
241
233
  // Generate output file path
242
234
  const outputFileName = path.basename(stlFilePath, '.stl') + '_scaled.stl';
243
- const outputFilePath = path.join(this.tempDir, outputFileName);
235
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
244
236
  // Save the modified STL
245
237
  await this.saveSTL(geometry, outputFilePath, progressCallback);
246
238
  this.emit('operationComplete', {
@@ -298,7 +290,7 @@ export class STLManipulator extends EventEmitter {
298
290
  }
299
291
  // Generate output file path
300
292
  const outputFileName = path.basename(stlFilePath, '.stl') + '_rotated.stl';
301
- const outputFilePath = path.join(this.tempDir, outputFileName);
293
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
302
294
  // Save the modified STL
303
295
  await this.saveSTL(geometry, outputFilePath, progressCallback);
304
296
  this.emit('operationComplete', {
@@ -347,7 +339,7 @@ export class STLManipulator extends EventEmitter {
347
339
  }
348
340
  // Generate output file path
349
341
  const outputFileName = path.basename(stlFilePath, '.stl') + '_translated.stl';
350
- const outputFilePath = path.join(this.tempDir, outputFileName);
342
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
351
343
  // Save the modified STL
352
344
  await this.saveSTL(geometry, outputFilePath, progressCallback);
353
345
  this.emit('operationComplete', {
@@ -488,7 +480,7 @@ export class STLManipulator extends EventEmitter {
488
480
  progressCallback(90, "Saving visualization...");
489
481
  // Write the SVG to file
490
482
  const outputFileName = path.basename(stlFilePath, '.stl') + '_visualization.svg';
491
- const outputFilePath = path.join(this.tempDir, outputFileName);
483
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
492
484
  await writeFileAsync(outputFilePath, svgContent);
493
485
  if (progressCallback)
494
486
  progressCallback(100, "Visualization saved successfully");
@@ -630,7 +622,7 @@ export class STLManipulator extends EventEmitter {
630
622
  }
631
623
  // Generate output file path
632
624
  const outputFileName = path.basename(stlFilePath, '.stl') + '_modified.stl';
633
- const outputFilePath = path.join(this.tempDir, outputFileName);
625
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
634
626
  // Save the modified STL
635
627
  await this.saveSTL(geometry, outputFilePath, progressCallback);
636
628
  this.emit('operationComplete', {
@@ -666,7 +658,7 @@ export class STLManipulator extends EventEmitter {
666
658
  try {
667
659
  if (progressCallback)
668
660
  progressCallback(0, "Starting base extension operation...");
669
- console.log(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
661
+ console.error(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
670
662
  // Load the STL file
671
663
  const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback);
672
664
  if (!this.activeOperations.get(operationId)) {
@@ -674,19 +666,19 @@ export class STLManipulator extends EventEmitter {
674
666
  }
675
667
  if (progressCallback)
676
668
  progressCallback(60, "Creating extended base geometry...");
677
- // Find the minimum Y value (assuming Y is up, which is common in 3D printing)
678
- const minY = boundingBox.min.y;
669
+ // STL files and slicers are Z-up: the base goes under the model.
670
+ const minZ = boundingBox.min.z;
679
671
  // Convert inches to millimeters (STL files typically use mm)
680
672
  const extensionMm = extensionInches * 25.4;
681
- // Create a transformation matrix to move the mesh up by the extension amount
682
- const matrix = new THREE.Matrix4().makeTranslation(0, extensionMm, 0);
673
+ // Raise the model by the extension amount
674
+ const matrix = new THREE.Matrix4().makeTranslation(0, 0, extensionMm);
683
675
  geometry.applyMatrix4(matrix);
684
- // Create a box geometry for the base extension
676
+ // A box spanning the model's X/Y footprint fills the new space below it
685
677
  const baseWidth = boundingBox.max.x - boundingBox.min.x;
686
- const baseDepth = boundingBox.max.z - boundingBox.min.z;
687
- const baseGeometry = new THREE.BoxGeometry(baseWidth, extensionMm, baseDepth);
678
+ const baseDepth = boundingBox.max.y - boundingBox.min.y;
679
+ const baseGeometry = new THREE.BoxGeometry(baseWidth, baseDepth, extensionMm);
688
680
  // Position the base geometry
689
- const baseMatrix = new THREE.Matrix4().makeTranslation((boundingBox.min.x + boundingBox.max.x) / 2, minY + extensionMm / 2, (boundingBox.min.z + boundingBox.max.z) / 2);
681
+ const baseMatrix = new THREE.Matrix4().makeTranslation((boundingBox.min.x + boundingBox.max.x) / 2, (boundingBox.min.y + boundingBox.max.y) / 2, minZ + extensionMm / 2);
690
682
  baseGeometry.applyMatrix4(baseMatrix);
691
683
  if (!this.activeOperations.get(operationId)) {
692
684
  throw new Error("Operation cancelled");
@@ -706,7 +698,7 @@ export class STLManipulator extends EventEmitter {
706
698
  const baseStl = exporter.parse(baseMesh);
707
699
  // Generate output file path
708
700
  const outputFileName = path.basename(stlFilePath, '.stl') + '_extended.stl';
709
- const outputFilePath = path.join(this.tempDir, outputFileName);
701
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
710
702
  if (progressCallback)
711
703
  progressCallback(90, "Saving extended STL...");
712
704
  // Write the combined STL data to file
@@ -719,7 +711,7 @@ export class STLManipulator extends EventEmitter {
719
711
  success: true,
720
712
  output: outputFilePath
721
713
  });
722
- console.log(`Modified STL saved to ${outputFilePath}`);
714
+ console.error(`Modified STL saved to ${outputFilePath}`);
723
715
  return outputFilePath;
724
716
  }
725
717
  catch (error) {
@@ -735,188 +727,184 @@ export class STLManipulator extends EventEmitter {
735
727
  this.activeOperations.delete(operationId);
736
728
  }
737
729
  }
730
+ /**
731
+ * Bambu-compatible CLI slicing applies when the slicer is BambuStudio or
732
+ * FULU OrcaSlicer-bambulab, or when generic OrcaSlicer is given an explicit
733
+ * Bambu machine preset. Generic OrcaSlicer without a preset keeps its
734
+ * user-supplied machine/process profile path.
735
+ */
736
+ static usesBambuCliPreparation(slicerType, printerPreset) {
737
+ return (slicerType === 'bambustudio' ||
738
+ slicerType === 'orcaslicer-bambulab' ||
739
+ (slicerType === 'orcaslicer' && Boolean(printerPreset)));
740
+ }
738
741
  /**
739
742
  * Slice an STL or 3MF file using the specified slicer
740
743
  * @param stlFilePath Path to the input STL or 3MF file
741
744
  * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio)
742
745
  * @param slicerPath Path to the slicer executable
743
- * @param slicerProfile Optional path to the slicer profile/config file
746
+ * @param slicerProfile Optional slicer profile/config file. For Bambu-compatible
747
+ * CLI slicing this is a process profile; the machine comes from printerPreset.
744
748
  * @param progressCallback Optional callback for progress updates
749
+ * @param printerPreset Bambu machine preset name, e.g. "Bambu Lab P1S 0.4 nozzle"
750
+ * @param filamentProfile Optional filament profile path(s), ';'-separated
751
+ * @param bambuOptions Optional Bambu-compatible CLI options (added last for compatibility)
745
752
  * @returns Path to the generated G-code or sliced 3MF file
746
753
  */
747
- async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile) {
754
+ async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions) {
748
755
  const operationId = this.generateOperationId();
749
756
  this.activeOperations.set(operationId, true);
750
- this.validateSlicerExecutable(slicerPath);
751
- const orcaProfiles = this.parseOrcaProfileArgs(slicerProfile, filamentProfile);
752
- if (slicerType === 'orcaslicer') {
753
- this.warnForMissingProfiles(orcaProfiles.settingsProfiles, 'Slicer profile');
754
- this.warnForMissingProfiles(orcaProfiles.filamentProfiles, 'Filament profile');
755
- }
756
- else if (slicerProfile && !fs.existsSync(slicerProfile)) {
757
- console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
758
- // Allow proceeding without profile, slicer might handle it
759
- }
760
- if (slicerType !== 'orcaslicer' && filamentProfile && !fs.existsSync(filamentProfile)) {
761
- console.warn(`Filament profile specified but not found: ${filamentProfile}. Slicer might use defaults.`);
762
- }
763
- const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
764
- let outputFilePath = path.join(this.tempDir, outputFileName);
757
+ const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
765
758
  let orcaOutputDir;
766
- let args = [];
759
+ let bambuOutputDir;
767
760
  try {
761
+ try {
762
+ this.validateSlicerExecutable(slicerPath);
763
+ }
764
+ catch (error) {
765
+ throw asPreparationError(error, { slicerType, slicerPath });
766
+ }
767
+ if (!fs.existsSync(stlFilePath)) {
768
+ throw new SlicerError('preparation', `Input model not found: ${stlFilePath}`, { slicerType, slicerPath });
769
+ }
770
+ const orcaProfiles = this.parseOrcaProfileArgs(slicerProfile, filamentProfile);
771
+ if (!bambuCli) {
772
+ if (slicerType === 'orcaslicer') {
773
+ this.warnForMissingProfiles(orcaProfiles.settingsProfiles, 'Slicer profile');
774
+ this.warnForMissingProfiles(orcaProfiles.filamentProfiles, 'Filament profile');
775
+ }
776
+ else if (slicerProfile && !fs.existsSync(slicerProfile)) {
777
+ console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
778
+ }
779
+ }
780
+ const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
781
+ let outputFilePath = this.operationOutputPath(operationId, outputFileName);
782
+ let args = [];
783
+ let bambuExport;
768
784
  if (progressCallback)
769
785
  progressCallback(0, `Starting slicing with ${slicerType}...`);
770
- switch (slicerType) {
771
- case 'prusaslicer':
772
- case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
773
- args = [
774
- '--slice',
775
- '--output', outputFilePath,
776
- '--load', slicerProfile || '', // Pass profile path
777
- stlFilePath
778
- ];
779
- // Remove empty profile arg if not provided
780
- args = args.filter(arg => arg !== '');
781
- break;
782
- case 'orcaslicer': // Add OrcaSlicer case
783
- orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
784
- fs.mkdirSync(orcaOutputDir, { recursive: true });
785
- args = [
786
- '--slice', '0',
787
- '--outputdir', orcaOutputDir,
788
- ];
789
- this.appendOrcaProfileArgs(args, orcaProfiles);
790
- args.push(stlFilePath);
791
- break;
792
- case 'cura':
793
- // CuraEngine CLI args are different, often requiring -s for settings
794
- // Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
795
- // This requires parsing the profile or passing individual settings.
796
- // Keeping it simple for now, assuming profile contains necessary info.
797
- args = [
798
- 'slice',
799
- '-l', stlFilePath,
800
- '-o', outputFilePath
801
- ];
802
- if (slicerProfile) {
803
- args.push('-j', slicerProfile); // Load settings from profile definition file
786
+ if (bambuCli) {
787
+ // Bambu-compatible CLI: resolve the exact machine preset and every
788
+ // profile dependency, then slice and export a 3MF with plate G-code.
789
+ const cliType = slicerType;
790
+ const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
791
+ const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
792
+ const exportName = `${outputBase}_sliced.3mf`;
793
+ try {
794
+ if (orcaProfiles.settingsProfiles.length > 1) {
795
+ throw new Error(`slicer_profile (or SLICER_PROFILE) lists ${orcaProfiles.settingsProfiles.length} settings files, but Bambu-compatible CLI slicing ` +
796
+ 'takes one process profile. Set slicer_profile/SLICER_PROFILE to a process profile only (or leave it empty for the ' +
797
+ "preset's default process); the machine preset comes from bambu_model/BAMBU_MODEL and nozzle_diameter/NOZZLE_DIAMETER. " +
798
+ 'Pass filaments with filament_profile/load_filaments. For a non-Bambu printer, use slicer_type orcaslicer without bambu_model ' +
799
+ 'to keep the machine;process|filament format.');
804
800
  }
805
- break;
806
- case 'orcaslicer-bambulab':
807
- case 'bambustudio':
808
- // Bambu project slicers: slice and export as 3MF with embedded gcode.
809
- // orcaslicer-bambulab is the FULU fork that restores BambuNetwork support.
810
- // For 3MF input: slice in place and export sliced 3MF
811
- // For STL input: slice and export as 3MF
812
- {
813
- const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
814
- const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
815
- const bambuOutputPath = path.join(this.tempDir, outputBase + '_sliced.3mf');
801
+ const options = { ...bambuOptions };
802
+ if (orcaProfiles.filamentProfiles.length > 0) {
803
+ const legacyFilaments = orcaProfiles.filamentProfiles.join(';');
804
+ if (options.loadFilaments && this.splitProfileList(options.loadFilaments).join(';') !== legacyFilaments) {
805
+ throw new Error('Provide either load_filaments or filament_profile, not conflicting values.');
806
+ }
807
+ options.loadFilaments = legacyFilaments;
808
+ }
809
+ const profilesRoot = detectProfilesRoot(slicerPath, cliType);
810
+ const preparer = new BambuCliProfilePreparer(path.join(this.tempDir, 'slicer-profiles'));
811
+ const bundle = await preparer.prepare(stlFilePath, outputBase, cliType, profilesRoot, printerPreset, orcaProfiles.settingsProfiles[0], options);
812
+ bambuOutputDir = fs.mkdtempSync(path.join(this.tempDir, `${operationId}-bambu-output-`));
813
+ args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options), stlFilePath];
814
+ bambuExport = {
815
+ producedPath: path.join(bambuOutputDir, exportName),
816
+ // One folder per operation: concurrent slices of same-named inputs
817
+ // must never replace each other's result before it is printed.
818
+ finalPath: this.operationOutputPath(operationId, exportName),
819
+ };
820
+ outputFilePath = bambuExport.finalPath;
821
+ }
822
+ catch (error) {
823
+ throw asPreparationError(error, { slicerType, slicerPath });
824
+ }
825
+ }
826
+ else {
827
+ switch (slicerType) {
828
+ case 'prusaslicer':
829
+ case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
816
830
  args = [
817
- '--slice', '0', // Slice all plates
818
- '--export-3mf', bambuOutputPath,
831
+ '--slice',
832
+ '--output', outputFilePath,
833
+ '--load', slicerProfile || '', // Pass profile path
834
+ stlFilePath
835
+ ];
836
+ // Remove empty profile arg if not provided
837
+ args = args.filter(arg => arg !== '');
838
+ break;
839
+ case 'orcaslicer':
840
+ orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
841
+ fs.mkdirSync(orcaOutputDir, { recursive: true });
842
+ args = [
843
+ '--slice', '0',
844
+ '--outputdir', orcaOutputDir,
845
+ ];
846
+ this.appendOrcaProfileArgs(args, orcaProfiles);
847
+ args.push(stlFilePath);
848
+ break;
849
+ case 'cura':
850
+ // CuraEngine CLI args are different, often requiring -s for settings
851
+ // Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
852
+ args = [
853
+ 'slice',
854
+ '-l', stlFilePath,
855
+ '-o', outputFilePath
819
856
  ];
820
- // FULU Orca/Bambu Studio load machine presets as JSON settings files.
821
- if (printerPreset && !slicerProfile) {
822
- args.push('--load-settings', this.resolveBambuMachinePresetPath(slicerPath, printerPreset));
823
- }
824
857
  if (slicerProfile) {
825
- args.push('--load-settings', slicerProfile);
826
- }
827
- if (filamentProfile) {
828
- args.push('--load-filaments', filamentProfile);
858
+ args.push('-j', slicerProfile); // Load settings from profile definition file
829
859
  }
830
- args.push(stlFilePath);
831
- // Override outputFilePath for bambustudio since it produces 3MF, not gcode
832
- outputFilePath = bambuOutputPath;
833
- }
834
- break;
835
- default:
836
- throw new Error(`Unsupported slicer type: ${slicerType}`);
860
+ break;
861
+ default:
862
+ throw new SlicerError('preparation', `Unsupported slicer type: ${slicerType}`, { slicerType, slicerPath });
863
+ }
837
864
  }
865
+ // Never mistake a previous run's file for this run's result (or overwrite the input).
866
+ if (path.resolve(outputFilePath) === path.resolve(stlFilePath)) {
867
+ throw new SlicerError('preparation', `Slicer output would overwrite the input file: ${stlFilePath}`, { slicerType, slicerPath });
868
+ }
869
+ if (fs.existsSync(outputFilePath))
870
+ fs.rmSync(outputFilePath, { force: true });
838
871
  if (progressCallback)
839
872
  progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
840
- console.log(`Executing: ${slicerPath} ${args.join(' ')}`);
841
- // Execute the slicer
842
- await new Promise((resolve, reject) => {
843
- const timeoutMs = this.resolveSlicerTimeoutMs();
844
- let settled = false;
845
- let slicerProcessKilledByTimeout = false;
846
- let timeoutHandle;
847
- const settle = (callback) => {
848
- if (settled) {
849
- return;
850
- }
851
- settled = true;
852
- if (timeoutHandle) {
853
- clearTimeout(timeoutHandle);
854
- }
855
- callback();
856
- };
857
- const process = execFile(slicerPath, args, (error, stdout, stderr) => {
858
- if (error) {
859
- console.error(`Slicer Error: ${error.message}`);
860
- console.error(`Slicer Stderr: ${stderr}`);
861
- const timeoutMessage = slicerProcessKilledByTimeout
862
- ? ` Slicer exceeded timeout ${timeoutMs}ms.`
863
- : "";
864
- settle(() => reject(new Error(`Slicer failed: ${error.message}.${timeoutMessage} Stderr: ${stderr}`)));
865
- }
866
- else {
867
- console.log(`Slicer Stdout: ${stdout}`);
868
- if (stderr) {
869
- console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
870
- }
871
- settle(() => resolve());
872
- }
873
- });
874
- timeoutHandle = setTimeout(() => {
875
- slicerProcessKilledByTimeout = true;
876
- try {
877
- process.kill("SIGKILL");
878
- }
879
- catch (killError) {
880
- console.error("Error attempting to kill timed-out slicer process:", killError);
881
- }
882
- settle(() => reject(new Error(`Slicer exceeded timeout ${timeoutMs}ms.`)));
883
- }, timeoutMs);
884
- // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
885
- // process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
886
- // process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
887
- // Check for cancellation periodically
888
- const checkCancel = setInterval(() => {
889
- if (!this.activeOperations.get(operationId)) {
890
- clearInterval(checkCancel);
891
- try {
892
- process.kill(); // Attempt to kill the slicer process
893
- settle(() => reject(new Error("Slicing operation cancelled")));
894
- }
895
- catch (killError) {
896
- console.error("Error attempting to kill slicer process:", killError);
897
- settle(() => reject(new Error("Slicing operation cancelled, but failed to kill process.")));
898
- }
899
- }
900
- }, 500);
901
- process.on('exit', () => clearInterval(checkCancel));
902
- process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
873
+ console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
874
+ await runSlicerProcess({
875
+ command: slicerPath,
876
+ args,
877
+ timeoutMs: this.resolveSlicerTimeoutMs(),
878
+ slicerType,
879
+ isCancelled: () => !this.activeOperations.get(operationId),
903
880
  });
904
881
  if (!this.activeOperations.get(operationId)) {
905
- // This check might be redundant if the promise rejected on kill, but good safety
906
- throw new Error("Slicing operation cancelled after process finished");
882
+ throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
907
883
  }
908
- if (slicerType === 'orcaslicer') {
909
- if (!orcaOutputDir) {
910
- throw new Error("Internal error: OrcaSlicer output directory was not initialized.");
884
+ try {
885
+ if (bambuExport) {
886
+ if (!fs.existsSync(bambuExport.producedPath)) {
887
+ throw new Error(`Slicer exited successfully but did not write ${bambuExport.producedPath}.`);
888
+ }
889
+ await assertSlicedProjectOutput(bambuExport.producedPath);
890
+ fs.mkdirSync(path.dirname(bambuExport.finalPath), { recursive: true });
891
+ fs.renameSync(bambuExport.producedPath, bambuExport.finalPath);
892
+ }
893
+ else if (slicerType === 'orcaslicer') {
894
+ if (!orcaOutputDir) {
895
+ throw new Error("Internal error: OrcaSlicer output directory was not initialized.");
896
+ }
897
+ const orcaGcodePath = this.resolveOrcaGcodeOutput(orcaOutputDir);
898
+ fs.renameSync(orcaGcodePath, outputFilePath);
911
899
  }
912
- const orcaGcodePath = this.resolveOrcaGcodeOutput(orcaOutputDir);
913
- if (fs.existsSync(outputFilePath)) {
914
- fs.unlinkSync(outputFilePath);
900
+ if (!fs.existsSync(outputFilePath)) {
901
+ throw new Error(`Slicer finished but output file not found: ${outputFilePath}`);
915
902
  }
916
- fs.renameSync(orcaGcodePath, outputFilePath);
917
903
  }
918
- if (!fs.existsSync(outputFilePath)) {
919
- throw new Error(`Slicer finished but output file not found: ${outputFilePath}`);
904
+ catch (error) {
905
+ if (error instanceof SlicerError)
906
+ throw error;
907
+ throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath });
920
908
  }
921
909
  if (progressCallback)
922
910
  progressCallback(100, "Slicing completed successfully");
@@ -929,7 +917,7 @@ export class STLManipulator extends EventEmitter {
929
917
  return outputFilePath;
930
918
  }
931
919
  catch (error) {
932
- console.error(`Slicing failed for ${stlFilePath}:`, error);
920
+ console.error(`Slicing failed for ${stlFilePath}:`, error instanceof Error ? error.message : error);
933
921
  this.emit('operationError', {
934
922
  operationId,
935
923
  type: 'slice',
@@ -938,8 +926,10 @@ export class STLManipulator extends EventEmitter {
938
926
  throw error;
939
927
  }
940
928
  finally {
941
- if (orcaOutputDir && fs.existsSync(orcaOutputDir)) {
942
- fs.rmSync(orcaOutputDir, { recursive: true, force: true });
929
+ for (const dir of [orcaOutputDir, bambuOutputDir]) {
930
+ if (dir && fs.existsSync(dir)) {
931
+ fs.rmSync(dir, { recursive: true, force: true });
932
+ }
943
933
  }
944
934
  this.activeOperations.delete(operationId);
945
935
  }
@@ -965,45 +955,37 @@ export class STLManipulator extends EventEmitter {
965
955
  progressCallback(20, "Reading G-code file...");
966
956
  // Read the G-code file
967
957
  const gcode = await readFileAsync(gcodePath, 'utf8');
968
- const lines = gcode.split('\n');
969
958
  if (!this.activeOperations.get(operationId)) {
970
959
  throw new Error("Operation cancelled");
971
960
  }
972
961
  if (progressCallback)
973
962
  progressCallback(50, "Analyzing temperature commands...");
974
- // Extract temperature settings from G-code
975
- const actual = {};
976
- const allTemperatures = { extruder: [], bed: [] };
977
- for (const line of lines) {
978
- // Look for extruder temperature (M104 or M109)
979
- const extruderMatch = line.match(/M10[49] S(\d+)/);
980
- if (extruderMatch) {
981
- const temp = parseInt(extruderMatch[1], 10);
982
- allTemperatures.extruder.push(temp);
983
- // Keep the first temperature for compatibility with original function
984
- if (!actual.extruder) {
985
- actual.extruder = temp;
986
- }
987
- }
988
- // Look for bed temperature (M140 or M190)
989
- const bedMatch = line.match(/M1[49]0 S(\d+)/);
990
- if (bedMatch) {
991
- const temp = parseInt(bedMatch[1], 10);
992
- allTemperatures.bed.push(temp);
993
- // Keep the first temperature for compatibility with original function
994
- if (!actual.bed) {
995
- actual.bed = temp;
996
- }
997
- }
998
- }
963
+ // Extract every heater target with the shared print-safety scanner: S and
964
+ // R forms, tool-addressed commands, RepRapFirmware G10/M568 and Klipper
965
+ // SET_HEATER_TEMPERATURE. Unparseable thermal syntax fails closed.
966
+ const scan = scanGcodeThermal(gcode);
967
+ const allTemperatures = {
968
+ extruder: scan.targets.filter((target) => target.heater === "nozzle").map((target) => target.value),
969
+ bed: scan.targets.filter((target) => target.heater === "bed").map((target) => target.value),
970
+ };
971
+ // First positive target, kept for compatibility with the original report.
972
+ const actual = {
973
+ extruder: allTemperatures.extruder.find((value) => value > 0),
974
+ bed: allTemperatures.bed.find((value) => value > 0),
975
+ };
976
+ const peak = {
977
+ extruder: allTemperatures.extruder.length ? Math.max(...allTemperatures.extruder) : undefined,
978
+ bed: allTemperatures.bed.length ? Math.max(...allTemperatures.bed) : undefined,
979
+ };
999
980
  if (progressCallback)
1000
981
  progressCallback(80, "Comparing temperatures...");
1001
- // Compare actual with expected
982
+ // An expected temperature matches only when it is the job's highest
983
+ // target, so a later or R-form higher target is always a mismatch.
1002
984
  let match = true;
1003
- if (expected.extruder !== undefined && actual.extruder !== expected.extruder) {
985
+ if (expected.extruder !== undefined && peak.extruder !== expected.extruder) {
1004
986
  match = false;
1005
987
  }
1006
- if (expected.bed !== undefined && actual.bed !== expected.bed) {
988
+ if (expected.bed !== undefined && peak.bed !== expected.bed) {
1007
989
  match = false;
1008
990
  }
1009
991
  if (progressCallback)
@@ -1012,9 +994,9 @@ export class STLManipulator extends EventEmitter {
1012
994
  operationId,
1013
995
  type: 'confirmTemperatures',
1014
996
  success: true,
1015
- result: { match, actual, expected, allTemperatures }
997
+ result: { match, actual, peak, expected, allTemperatures }
1016
998
  });
1017
- return { match, actual, expected, allTemperatures };
999
+ return { match, actual, peak, expected, allTemperatures };
1018
1000
  }
1019
1001
  catch (error) {
1020
1002
  console.error("Error confirming temperatures:", error);
@@ -1047,13 +1029,13 @@ export class STLManipulator extends EventEmitter {
1047
1029
  const originalVertexCount = geometry.attributes.position.count;
1048
1030
  const mergedGeometry = BufferGeometryUtils.mergeVertices(geometry, tolerance);
1049
1031
  const newVertexCount = mergedGeometry.attributes.position.count;
1050
- console.log(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1032
+ console.error(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1051
1033
  if (progressCallback)
1052
1034
  progressCallback(70, `Vertices merged: ${originalVertexCount} -> ${newVertexCount}`);
1053
1035
  if (!this.activeOperations.get(operationId))
1054
1036
  throw new Error("Operation cancelled");
1055
1037
  const outputFileName = path.basename(stlFilePath, '.stl') + '_merged.stl';
1056
- const outputFilePath = path.join(this.tempDir, outputFileName);
1038
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
1057
1039
  await this.saveSTL(mergedGeometry, outputFilePath, progressCallback); // 80-100% progress
1058
1040
  this.emit('operationComplete', { operationId, type: 'mergeVertices', success: true, output: outputFilePath, verticesRemoved: originalVertexCount - newVertexCount });
1059
1041
  return outputFilePath;
@@ -1085,7 +1067,7 @@ export class STLManipulator extends EventEmitter {
1085
1067
  if (center.lengthSq() < 0.0001) { // Already centered (or very close)
1086
1068
  if (progressCallback)
1087
1069
  progressCallback(100, "Model is already centered.");
1088
- console.log("Model already centered. No changes made.");
1070
+ console.error("Model already centered. No changes made.");
1089
1071
  this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: stlFilePath, message: "Model already centered." });
1090
1072
  return stlFilePath; // Return original path
1091
1073
  }
@@ -1097,7 +1079,7 @@ export class STLManipulator extends EventEmitter {
1097
1079
  if (!this.activeOperations.get(operationId))
1098
1080
  throw new Error("Operation cancelled");
1099
1081
  const outputFileName = path.basename(stlFilePath, '.stl') + '_centered.stl';
1100
- const outputFilePath = path.join(this.tempDir, outputFileName);
1082
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
1101
1083
  await this.saveSTL(geometry, outputFilePath, progressCallback); // 80-100% progress
1102
1084
  this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: outputFilePath });
1103
1085
  return outputFilePath;
@@ -1226,7 +1208,7 @@ export class STLManipulator extends EventEmitter {
1226
1208
  if (!this.activeOperations.get(operationId))
1227
1209
  throw new Error("Operation cancelled");
1228
1210
  const outputFileName = path.basename(stlFilePath, '.stl') + '_flat.stl';
1229
- const outputFilePath = path.join(this.tempDir, outputFileName);
1211
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
1230
1212
  await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
1231
1213
  this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath });
1232
1214
  return outputFilePath;