mcp-3d-printer-server 1.2.9 → 1.2.12

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 +222 -0
  2. package/CONTRIBUTORS.md +56 -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 +610 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +371 -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 +56 -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 +429 -0
  80. package/src/safety/printer-state.ts +344 -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,20 @@ 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 { detectProfilesRoot } from '../slicer/profile-flatten.js';
11
+ import {
12
+ BambuCliProfilePreparer,
13
+ assertSlicedProjectOutput,
14
+ buildBambuCliArgs,
15
+ type BambuCliSlicerType,
16
+ type BambuSliceOptions,
17
+ } from '../slicer/bambu-cli.js';
18
+ import { runSlicerProcess } from '../slicer/run-slicer.js';
19
+ import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
20
+
21
+ export type { BambuSliceOptions } from '../slicer/bambu-cli.js';
10
22
  import { execFile } from 'child_process';
23
+ import { scanGcodeThermal } from '../safety/gcode-thermal.js';
11
24
 
12
25
  const readFileAsync = promisify(fs.readFile);
13
26
  const writeFileAsync = promisify(fs.writeFile);
@@ -69,6 +82,17 @@ export class STLManipulator extends EventEmitter {
69
82
  return crypto.randomUUID();
70
83
  }
71
84
 
85
+ /**
86
+ * Each operation writes into its own folder. Outputs named only after the
87
+ * input (for example bracket_extended.stl) would otherwise let two
88
+ * concurrent requests replace each other's file before it is used or printed.
89
+ */
90
+ private operationOutputPath(operationId: string, fileName: string): string {
91
+ const directory = path.join(this.tempDir, `${operationId}-out`);
92
+ fs.mkdirSync(directory, { recursive: true });
93
+ return path.join(directory, fileName);
94
+ }
95
+
72
96
  private validateSlicerExecutable(slicerPath: string): void {
73
97
  if (!slicerPath.trim()) {
74
98
  throw new Error("Slicer executable path is required.");
@@ -143,49 +167,6 @@ export class STLManipulator extends EventEmitter {
143
167
  return path.join(outputDir, plateOutputs[0]);
144
168
  }
145
169
 
146
- private resolveBambuMachinePresetPath(slicerPath: string, printerPreset: string): string {
147
- if (fs.existsSync(printerPreset)) {
148
- return printerPreset;
149
- }
150
-
151
- const presetFile = printerPreset.endsWith(".json") ? printerPreset : `${printerPreset}.json`;
152
- const candidateDirs: string[] = [];
153
- const macAppMarker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
154
- const macAppIndex = slicerPath.indexOf(macAppMarker);
155
-
156
- if (macAppIndex >= 0) {
157
- candidateDirs.push(
158
- path.join(
159
- slicerPath.slice(0, macAppIndex + `${path.sep}Contents`.length),
160
- "Resources",
161
- "profiles",
162
- "BBL",
163
- "machine"
164
- )
165
- );
166
- }
167
-
168
- const executableDir = path.dirname(slicerPath);
169
- candidateDirs.push(
170
- path.join(executableDir, "..", "Resources", "profiles", "BBL", "machine"),
171
- path.join(executableDir, "..", "share", "OrcaSlicer", "resources", "profiles", "BBL", "machine"),
172
- path.join(executableDir, "..", "resources", "profiles", "BBL", "machine"),
173
- path.join(executableDir, "resources", "profiles", "BBL", "machine")
174
- );
175
-
176
- for (const candidateDir of candidateDirs) {
177
- const candidate = path.resolve(candidateDir, presetFile);
178
- if (fs.existsSync(candidate)) {
179
- return candidate;
180
- }
181
- }
182
-
183
- throw new Error(
184
- `Could not find Bambu machine preset "${presetFile}" near slicer executable ${slicerPath}. ` +
185
- "Pass slicer_profile with an explicit machine/process settings file, or use a slicer bundle that includes Resources/profiles/BBL/machine."
186
- );
187
- }
188
-
189
170
  private resolveSlicerTimeoutMs(): number {
190
171
  const rawTimeout = process.env.SLICER_TIMEOUT_MS?.trim();
191
172
  if (!rawTimeout) {
@@ -360,7 +341,7 @@ export class STLManipulator extends EventEmitter {
360
341
 
361
342
  // Generate output file path
362
343
  const outputFileName = path.basename(stlFilePath, '.stl') + '_scaled.stl';
363
- const outputFilePath = path.join(this.tempDir, outputFileName);
344
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
364
345
 
365
346
  // Save the modified STL
366
347
  await this.saveSTL(geometry, outputFilePath, progressCallback);
@@ -434,7 +415,7 @@ export class STLManipulator extends EventEmitter {
434
415
 
435
416
  // Generate output file path
436
417
  const outputFileName = path.basename(stlFilePath, '.stl') + '_rotated.stl';
437
- const outputFilePath = path.join(this.tempDir, outputFileName);
418
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
438
419
 
439
420
  // Save the modified STL
440
421
  await this.saveSTL(geometry, outputFilePath, progressCallback);
@@ -494,7 +475,7 @@ export class STLManipulator extends EventEmitter {
494
475
 
495
476
  // Generate output file path
496
477
  const outputFileName = path.basename(stlFilePath, '.stl') + '_translated.stl';
497
- const outputFilePath = path.join(this.tempDir, outputFileName);
478
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
498
479
 
499
480
  // Save the modified STL
500
481
  await this.saveSTL(geometry, outputFilePath, progressCallback);
@@ -662,7 +643,7 @@ export class STLManipulator extends EventEmitter {
662
643
 
663
644
  // Write the SVG to file
664
645
  const outputFileName = path.basename(stlFilePath, '.stl') + '_visualization.svg';
665
- const outputFilePath = path.join(this.tempDir, outputFileName);
646
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
666
647
 
667
648
  await writeFileAsync(outputFilePath, svgContent);
668
649
 
@@ -846,7 +827,7 @@ export class STLManipulator extends EventEmitter {
846
827
 
847
828
  // Generate output file path
848
829
  const outputFileName = path.basename(stlFilePath, '.stl') + '_modified.stl';
849
- const outputFilePath = path.join(this.tempDir, outputFileName);
830
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
850
831
 
851
832
  // Save the modified STL
852
833
  await this.saveSTL(geometry, outputFilePath, progressCallback);
@@ -889,7 +870,7 @@ export class STLManipulator extends EventEmitter {
889
870
 
890
871
  try {
891
872
  if (progressCallback) progressCallback(0, "Starting base extension operation...");
892
- console.log(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
873
+ console.error(`Extending base of ${stlFilePath} by ${extensionInches} inches`);
893
874
 
894
875
  // Load the STL file
895
876
  const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback);
@@ -900,30 +881,30 @@ export class STLManipulator extends EventEmitter {
900
881
 
901
882
  if (progressCallback) progressCallback(60, "Creating extended base geometry...");
902
883
 
903
- // Find the minimum Y value (assuming Y is up, which is common in 3D printing)
904
- const minY = boundingBox.min.y;
884
+ // STL files and slicers are Z-up: the base goes under the model.
885
+ const minZ = boundingBox.min.z;
905
886
 
906
887
  // Convert inches to millimeters (STL files typically use mm)
907
888
  const extensionMm = extensionInches * 25.4;
908
889
 
909
- // Create a transformation matrix to move the mesh up by the extension amount
910
- const matrix = new THREE.Matrix4().makeTranslation(0, extensionMm, 0);
890
+ // Raise the model by the extension amount
891
+ const matrix = new THREE.Matrix4().makeTranslation(0, 0, extensionMm);
911
892
  geometry.applyMatrix4(matrix);
912
893
 
913
- // Create a box geometry for the base extension
894
+ // A box spanning the model's X/Y footprint fills the new space below it
914
895
  const baseWidth = boundingBox.max.x - boundingBox.min.x;
915
- const baseDepth = boundingBox.max.z - boundingBox.min.z;
896
+ const baseDepth = boundingBox.max.y - boundingBox.min.y;
916
897
  const baseGeometry = new THREE.BoxGeometry(
917
898
  baseWidth,
918
- extensionMm,
919
- baseDepth
899
+ baseDepth,
900
+ extensionMm
920
901
  );
921
902
 
922
903
  // Position the base geometry
923
904
  const baseMatrix = new THREE.Matrix4().makeTranslation(
924
905
  (boundingBox.min.x + boundingBox.max.x) / 2,
925
- minY + extensionMm / 2,
926
- (boundingBox.min.z + boundingBox.max.z) / 2
906
+ (boundingBox.min.y + boundingBox.max.y) / 2,
907
+ minZ + extensionMm / 2
927
908
  );
928
909
  baseGeometry.applyMatrix4(baseMatrix);
929
910
 
@@ -950,7 +931,7 @@ export class STLManipulator extends EventEmitter {
950
931
 
951
932
  // Generate output file path
952
933
  const outputFileName = path.basename(stlFilePath, '.stl') + '_extended.stl';
953
- const outputFilePath = path.join(this.tempDir, outputFileName);
934
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
954
935
 
955
936
  if (progressCallback) progressCallback(90, "Saving extended STL...");
956
937
 
@@ -966,7 +947,7 @@ export class STLManipulator extends EventEmitter {
966
947
  output: outputFilePath
967
948
  });
968
949
 
969
- console.log(`Modified STL saved to ${outputFilePath}`);
950
+ console.error(`Modified STL saved to ${outputFilePath}`);
970
951
  return outputFilePath;
971
952
  } catch (error) {
972
953
  console.error("Error extending STL base:", error);
@@ -983,13 +964,31 @@ export class STLManipulator extends EventEmitter {
983
964
  }
984
965
  }
985
966
 
967
+ /**
968
+ * Bambu-compatible CLI slicing applies when the slicer is BambuStudio or
969
+ * FULU OrcaSlicer-bambulab, or when generic OrcaSlicer is given an explicit
970
+ * Bambu machine preset. Generic OrcaSlicer without a preset keeps its
971
+ * user-supplied machine/process profile path.
972
+ */
973
+ static usesBambuCliPreparation(slicerType: SlicerType, printerPreset?: string): boolean {
974
+ return (
975
+ slicerType === 'bambustudio' ||
976
+ slicerType === 'orcaslicer-bambulab' ||
977
+ (slicerType === 'orcaslicer' && Boolean(printerPreset))
978
+ );
979
+ }
980
+
986
981
  /**
987
982
  * Slice an STL or 3MF file using the specified slicer
988
983
  * @param stlFilePath Path to the input STL or 3MF file
989
984
  * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer, orcaslicer-bambulab, bambustudio)
990
985
  * @param slicerPath Path to the slicer executable
991
- * @param slicerProfile Optional path to the slicer profile/config file
986
+ * @param slicerProfile Optional slicer profile/config file. For Bambu-compatible
987
+ * CLI slicing this is a process profile; the machine comes from printerPreset.
992
988
  * @param progressCallback Optional callback for progress updates
989
+ * @param printerPreset Bambu machine preset name, e.g. "Bambu Lab P1S 0.4 nozzle"
990
+ * @param filamentProfile Optional filament profile path(s), ';'-separated
991
+ * @param bambuOptions Optional Bambu-compatible CLI options (added last for compatibility)
993
992
  * @returns Path to the generated G-code or sliced 3MF file
994
993
  */
995
994
  async sliceSTL(
@@ -999,199 +998,181 @@ export class STLManipulator extends EventEmitter {
999
998
  slicerProfile?: string,
1000
999
  progressCallback?: ProgressCallback,
1001
1000
  printerPreset?: string,
1002
- filamentProfile?: string
1001
+ filamentProfile?: string,
1002
+ bambuOptions?: BambuSliceOptions
1003
1003
  ): Promise<string> {
1004
1004
  const operationId = this.generateOperationId();
1005
1005
  this.activeOperations.set(operationId, true);
1006
+ const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
1006
1007
 
1007
- this.validateSlicerExecutable(slicerPath);
1008
- const orcaProfiles = this.parseOrcaProfileArgs(slicerProfile, filamentProfile);
1009
- if (slicerType === 'orcaslicer') {
1010
- this.warnForMissingProfiles(orcaProfiles.settingsProfiles, 'Slicer profile');
1011
- this.warnForMissingProfiles(orcaProfiles.filamentProfiles, 'Filament profile');
1012
- } else if (slicerProfile && !fs.existsSync(slicerProfile)) {
1013
- console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
1014
- // Allow proceeding without profile, slicer might handle it
1015
- }
1016
- if (slicerType !== 'orcaslicer' && filamentProfile && !fs.existsSync(filamentProfile)) {
1017
- console.warn(`Filament profile specified but not found: ${filamentProfile}. Slicer might use defaults.`);
1018
- }
1019
-
1020
- const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
1021
- let outputFilePath = path.join(this.tempDir, outputFileName);
1022
1008
  let orcaOutputDir: string | undefined;
1023
-
1024
- let args: string[] = [];
1009
+ let bambuOutputDir: string | undefined;
1025
1010
 
1026
1011
  try {
1027
- if (progressCallback) progressCallback(0, `Starting slicing with ${slicerType}...`);
1012
+ try {
1013
+ this.validateSlicerExecutable(slicerPath);
1014
+ } catch (error) {
1015
+ throw asPreparationError(error, { slicerType, slicerPath });
1016
+ }
1017
+ if (!fs.existsSync(stlFilePath)) {
1018
+ throw new SlicerError('preparation', `Input model not found: ${stlFilePath}`, { slicerType, slicerPath });
1019
+ }
1028
1020
 
1029
- switch (slicerType) {
1030
- case 'prusaslicer':
1031
- case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
1032
- args = [
1033
- '--slice',
1034
- '--output', outputFilePath,
1035
- '--load', slicerProfile || '', // Pass profile path
1036
- stlFilePath
1037
- ];
1038
- // Remove empty profile arg if not provided
1039
- args = args.filter(arg => arg !== '');
1040
- break;
1021
+ const orcaProfiles = this.parseOrcaProfileArgs(slicerProfile, filamentProfile);
1022
+ if (!bambuCli) {
1023
+ if (slicerType === 'orcaslicer') {
1024
+ this.warnForMissingProfiles(orcaProfiles.settingsProfiles, 'Slicer profile');
1025
+ this.warnForMissingProfiles(orcaProfiles.filamentProfiles, 'Filament profile');
1026
+ } else if (slicerProfile && !fs.existsSync(slicerProfile)) {
1027
+ console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
1028
+ }
1029
+ }
1030
+
1031
+ const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
1032
+ let outputFilePath = this.operationOutputPath(operationId, outputFileName);
1033
+ let args: string[] = [];
1034
+ let bambuExport: { producedPath: string; finalPath: string } | undefined;
1041
1035
 
1042
- case 'orcaslicer': // Add OrcaSlicer case
1043
- orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
1044
- fs.mkdirSync(orcaOutputDir, { recursive: true });
1045
- args = [
1046
- '--slice', '0',
1047
- '--outputdir', orcaOutputDir,
1048
- ];
1049
- this.appendOrcaProfileArgs(args, orcaProfiles);
1050
- args.push(stlFilePath);
1051
- break;
1052
-
1053
- case 'cura':
1054
- // CuraEngine CLI args are different, often requiring -s for settings
1055
- // Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
1056
- // This requires parsing the profile or passing individual settings.
1057
- // Keeping it simple for now, assuming profile contains necessary info.
1058
- args = [
1059
- 'slice',
1060
- '-l', stlFilePath,
1061
- '-o', outputFilePath
1062
- ];
1063
- if (slicerProfile) {
1064
- args.push('-j', slicerProfile); // Load settings from profile definition file
1036
+ if (progressCallback) progressCallback(0, `Starting slicing with ${slicerType}...`);
1037
+
1038
+ if (bambuCli) {
1039
+ // Bambu-compatible CLI: resolve the exact machine preset and every
1040
+ // profile dependency, then slice and export a 3MF with plate G-code.
1041
+ const cliType = slicerType as BambuCliSlicerType;
1042
+ const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
1043
+ const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
1044
+ const exportName = `${outputBase}_sliced.3mf`;
1045
+ try {
1046
+ if (orcaProfiles.settingsProfiles.length > 1) {
1047
+ throw new Error(
1048
+ `slicer_profile (or SLICER_PROFILE) lists ${orcaProfiles.settingsProfiles.length} settings files, but Bambu-compatible CLI slicing ` +
1049
+ 'takes one process profile. Set slicer_profile/SLICER_PROFILE to a process profile only (or leave it empty for the ' +
1050
+ "preset's default process); the machine preset comes from bambu_model/BAMBU_MODEL and nozzle_diameter/NOZZLE_DIAMETER. " +
1051
+ 'Pass filaments with filament_profile/load_filaments. For a non-Bambu printer, use slicer_type orcaslicer without bambu_model ' +
1052
+ 'to keep the machine;process|filament format.'
1053
+ );
1065
1054
  }
1066
- break;
1055
+ const options: BambuSliceOptions = { ...bambuOptions };
1056
+ if (orcaProfiles.filamentProfiles.length > 0) {
1057
+ const legacyFilaments = orcaProfiles.filamentProfiles.join(';');
1058
+ if (options.loadFilaments && this.splitProfileList(options.loadFilaments).join(';') !== legacyFilaments) {
1059
+ throw new Error('Provide either load_filaments or filament_profile, not conflicting values.');
1060
+ }
1061
+ options.loadFilaments = legacyFilaments;
1062
+ }
1063
+ const profilesRoot = detectProfilesRoot(slicerPath, cliType);
1064
+ const preparer = new BambuCliProfilePreparer(path.join(this.tempDir, 'slicer-profiles'));
1065
+ const bundle = await preparer.prepare(
1066
+ stlFilePath,
1067
+ outputBase,
1068
+ cliType,
1069
+ profilesRoot,
1070
+ printerPreset,
1071
+ orcaProfiles.settingsProfiles[0],
1072
+ options
1073
+ );
1074
+ bambuOutputDir = fs.mkdtempSync(path.join(this.tempDir, `${operationId}-bambu-output-`));
1075
+ args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options), stlFilePath];
1076
+ bambuExport = {
1077
+ producedPath: path.join(bambuOutputDir, exportName),
1078
+ // One folder per operation: concurrent slices of same-named inputs
1079
+ // must never replace each other's result before it is printed.
1080
+ finalPath: this.operationOutputPath(operationId, exportName),
1081
+ };
1082
+ outputFilePath = bambuExport.finalPath;
1083
+ } catch (error) {
1084
+ throw asPreparationError(error, { slicerType, slicerPath });
1085
+ }
1086
+ } else {
1087
+ switch (slicerType) {
1088
+ case 'prusaslicer':
1089
+ case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
1090
+ args = [
1091
+ '--slice',
1092
+ '--output', outputFilePath,
1093
+ '--load', slicerProfile || '', // Pass profile path
1094
+ stlFilePath
1095
+ ];
1096
+ // Remove empty profile arg if not provided
1097
+ args = args.filter(arg => arg !== '');
1098
+ break;
1067
1099
 
1068
- case 'orcaslicer-bambulab':
1069
- case 'bambustudio':
1070
- // Bambu project slicers: slice and export as 3MF with embedded gcode.
1071
- // orcaslicer-bambulab is the FULU fork that restores BambuNetwork support.
1072
- // For 3MF input: slice in place and export sliced 3MF
1073
- // For STL input: slice and export as 3MF
1074
- {
1075
- const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
1076
- const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
1077
- const bambuOutputPath = path.join(this.tempDir, outputBase + '_sliced.3mf');
1100
+ case 'orcaslicer':
1101
+ orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
1102
+ fs.mkdirSync(orcaOutputDir, { recursive: true });
1078
1103
  args = [
1079
- '--slice', '0', // Slice all plates
1080
- '--export-3mf', bambuOutputPath,
1104
+ '--slice', '0',
1105
+ '--outputdir', orcaOutputDir,
1106
+ ];
1107
+ this.appendOrcaProfileArgs(args, orcaProfiles);
1108
+ args.push(stlFilePath);
1109
+ break;
1110
+
1111
+ case 'cura':
1112
+ // CuraEngine CLI args are different, often requiring -s for settings
1113
+ // Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
1114
+ args = [
1115
+ 'slice',
1116
+ '-l', stlFilePath,
1117
+ '-o', outputFilePath
1081
1118
  ];
1082
- // FULU Orca/Bambu Studio load machine presets as JSON settings files.
1083
- if (printerPreset && !slicerProfile) {
1084
- args.push('--load-settings', this.resolveBambuMachinePresetPath(slicerPath, printerPreset));
1085
- }
1086
1119
  if (slicerProfile) {
1087
- args.push('--load-settings', slicerProfile);
1120
+ args.push('-j', slicerProfile); // Load settings from profile definition file
1088
1121
  }
1089
- if (filamentProfile) {
1090
- args.push('--load-filaments', filamentProfile);
1091
- }
1092
- args.push(stlFilePath);
1093
- // Override outputFilePath for bambustudio since it produces 3MF, not gcode
1094
- outputFilePath = bambuOutputPath;
1095
- }
1096
- break;
1122
+ break;
1097
1123
 
1098
- default:
1099
- throw new Error(`Unsupported slicer type: ${slicerType}`);
1124
+ default:
1125
+ throw new SlicerError('preparation', `Unsupported slicer type: ${slicerType}`, { slicerType, slicerPath });
1126
+ }
1100
1127
  }
1101
1128
 
1102
- if (progressCallback) progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1103
- console.log(`Executing: ${slicerPath} ${args.join(' ')}`);
1104
-
1105
- // Execute the slicer
1106
- await new Promise<void>((resolve, reject) => {
1107
- const timeoutMs = this.resolveSlicerTimeoutMs();
1108
- let settled = false;
1109
- let slicerProcessKilledByTimeout = false;
1110
- let timeoutHandle: NodeJS.Timeout | undefined;
1111
-
1112
- const settle = (callback: () => void) => {
1113
- if (settled) {
1114
- return;
1115
- }
1116
- settled = true;
1117
- if (timeoutHandle) {
1118
- clearTimeout(timeoutHandle);
1119
- }
1120
- callback();
1121
- };
1122
-
1123
- const process = execFile(slicerPath, args, (error, stdout, stderr) => {
1124
- if (error) {
1125
- console.error(`Slicer Error: ${error.message}`);
1126
- console.error(`Slicer Stderr: ${stderr}`);
1127
- const timeoutMessage = slicerProcessKilledByTimeout
1128
- ? ` Slicer exceeded timeout ${timeoutMs}ms.`
1129
- : "";
1130
- settle(() => reject(new Error(`Slicer failed: ${error.message}.${timeoutMessage} Stderr: ${stderr}`)));
1131
- } else {
1132
- console.log(`Slicer Stdout: ${stdout}`);
1133
- if (stderr) {
1134
- console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1135
- }
1136
- settle(() => resolve());
1137
- }
1138
- });
1139
-
1140
- timeoutHandle = setTimeout(() => {
1141
- slicerProcessKilledByTimeout = true;
1142
- try {
1143
- process.kill("SIGKILL");
1144
- } catch (killError) {
1145
- console.error("Error attempting to kill timed-out slicer process:", killError);
1146
- }
1147
- settle(() => reject(new Error(`Slicer exceeded timeout ${timeoutMs}ms.`)));
1148
- }, timeoutMs);
1149
-
1150
- // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1151
- // process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
1152
- // process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
1153
-
1154
- // Check for cancellation periodically
1155
- const checkCancel = setInterval(() => {
1156
- if (!this.activeOperations.get(operationId)) {
1157
- clearInterval(checkCancel);
1158
- try {
1159
- process.kill(); // Attempt to kill the slicer process
1160
- settle(() => reject(new Error("Slicing operation cancelled")));
1161
- } catch (killError) {
1162
- console.error("Error attempting to kill slicer process:", killError);
1163
- settle(() => reject(new Error("Slicing operation cancelled, but failed to kill process.")));
1164
- }
1165
- }
1166
- }, 500);
1167
-
1168
- process.on('exit', () => clearInterval(checkCancel));
1169
- process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
1129
+ // Never mistake a previous run's file for this run's result (or overwrite the input).
1130
+ if (path.resolve(outputFilePath) === path.resolve(stlFilePath)) {
1131
+ throw new SlicerError('preparation', `Slicer output would overwrite the input file: ${stlFilePath}`, { slicerType, slicerPath });
1132
+ }
1133
+ if (fs.existsSync(outputFilePath)) fs.rmSync(outputFilePath, { force: true });
1170
1134
 
1135
+ if (progressCallback) progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1136
+ console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1137
+
1138
+ await runSlicerProcess({
1139
+ command: slicerPath,
1140
+ args,
1141
+ timeoutMs: this.resolveSlicerTimeoutMs(),
1142
+ slicerType,
1143
+ isCancelled: () => !this.activeOperations.get(operationId),
1171
1144
  });
1172
1145
 
1173
1146
  if (!this.activeOperations.get(operationId)) {
1174
- // This check might be redundant if the promise rejected on kill, but good safety
1175
- throw new Error("Slicing operation cancelled after process finished");
1147
+ throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
1176
1148
  }
1177
1149
 
1178
- if (slicerType === 'orcaslicer') {
1179
- if (!orcaOutputDir) {
1180
- throw new Error("Internal error: OrcaSlicer output directory was not initialized.");
1181
- }
1182
- const orcaGcodePath = this.resolveOrcaGcodeOutput(orcaOutputDir);
1183
- if (fs.existsSync(outputFilePath)) {
1184
- fs.unlinkSync(outputFilePath);
1150
+ try {
1151
+ if (bambuExport) {
1152
+ if (!fs.existsSync(bambuExport.producedPath)) {
1153
+ throw new Error(`Slicer exited successfully but did not write ${bambuExport.producedPath}.`);
1154
+ }
1155
+ await assertSlicedProjectOutput(bambuExport.producedPath);
1156
+ fs.mkdirSync(path.dirname(bambuExport.finalPath), { recursive: true });
1157
+ fs.renameSync(bambuExport.producedPath, bambuExport.finalPath);
1158
+ } else if (slicerType === 'orcaslicer') {
1159
+ if (!orcaOutputDir) {
1160
+ throw new Error("Internal error: OrcaSlicer output directory was not initialized.");
1161
+ }
1162
+ const orcaGcodePath = this.resolveOrcaGcodeOutput(orcaOutputDir);
1163
+ fs.renameSync(orcaGcodePath, outputFilePath);
1185
1164
  }
1186
- fs.renameSync(orcaGcodePath, outputFilePath);
1187
- }
1188
-
1189
- if (!fs.existsSync(outputFilePath)) {
1165
+
1166
+ if (!fs.existsSync(outputFilePath)) {
1190
1167
  throw new Error(`Slicer finished but output file not found: ${outputFilePath}`);
1168
+ }
1169
+ } catch (error) {
1170
+ if (error instanceof SlicerError) throw error;
1171
+ throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath });
1191
1172
  }
1192
1173
 
1193
1174
  if (progressCallback) progressCallback(100, "Slicing completed successfully");
1194
-
1175
+
1195
1176
  this.emit('operationComplete', {
1196
1177
  operationId,
1197
1178
  type: 'slice',
@@ -1201,16 +1182,18 @@ export class STLManipulator extends EventEmitter {
1201
1182
 
1202
1183
  return outputFilePath;
1203
1184
  } catch (error) {
1204
- console.error(`Slicing failed for ${stlFilePath}:`, error);
1185
+ console.error(`Slicing failed for ${stlFilePath}:`, error instanceof Error ? error.message : error);
1205
1186
  this.emit('operationError', {
1206
1187
  operationId,
1207
1188
  type: 'slice',
1208
1189
  error: error instanceof Error ? error.message : String(error)
1209
1190
  });
1210
- throw error;
1191
+ throw error;
1211
1192
  } finally {
1212
- if (orcaOutputDir && fs.existsSync(orcaOutputDir)) {
1213
- fs.rmSync(orcaOutputDir, { recursive: true, force: true });
1193
+ for (const dir of [orcaOutputDir, bambuOutputDir]) {
1194
+ if (dir && fs.existsSync(dir)) {
1195
+ fs.rmSync(dir, { recursive: true, force: true });
1196
+ }
1214
1197
  }
1215
1198
  this.activeOperations.delete(operationId);
1216
1199
  }
@@ -1233,6 +1216,7 @@ export class STLManipulator extends EventEmitter {
1233
1216
  ): Promise<{
1234
1217
  match: boolean;
1235
1218
  actual: { extruder?: number; bed?: number };
1219
+ peak: { extruder?: number; bed?: number };
1236
1220
  expected: { extruder?: number; bed?: number };
1237
1221
  allTemperatures: { extruder: number[]; bed: number[] };
1238
1222
  }> {
@@ -1251,7 +1235,6 @@ export class STLManipulator extends EventEmitter {
1251
1235
 
1252
1236
  // Read the G-code file
1253
1237
  const gcode = await readFileAsync(gcodePath, 'utf8');
1254
- const lines = gcode.split('\n');
1255
1238
 
1256
1239
  if (!this.activeOperations.get(operationId)) {
1257
1240
  throw new Error("Operation cancelled");
@@ -1259,57 +1242,46 @@ export class STLManipulator extends EventEmitter {
1259
1242
 
1260
1243
  if (progressCallback) progressCallback(50, "Analyzing temperature commands...");
1261
1244
 
1262
- // Extract temperature settings from G-code
1263
- const actual: { extruder?: number; bed?: number } = {};
1264
- const allTemperatures: { extruder: number[]; bed: number[] } = { extruder: [], bed: [] };
1265
-
1266
- for (const line of lines) {
1267
- // Look for extruder temperature (M104 or M109)
1268
- const extruderMatch = line.match(/M10[49] S(\d+)/);
1269
- if (extruderMatch) {
1270
- const temp = parseInt(extruderMatch[1], 10);
1271
- allTemperatures.extruder.push(temp);
1272
-
1273
- // Keep the first temperature for compatibility with original function
1274
- if (!actual.extruder) {
1275
- actual.extruder = temp;
1276
- }
1277
- }
1278
-
1279
- // Look for bed temperature (M140 or M190)
1280
- const bedMatch = line.match(/M1[49]0 S(\d+)/);
1281
- if (bedMatch) {
1282
- const temp = parseInt(bedMatch[1], 10);
1283
- allTemperatures.bed.push(temp);
1284
-
1285
- // Keep the first temperature for compatibility with original function
1286
- if (!actual.bed) {
1287
- actual.bed = temp;
1288
- }
1289
- }
1290
- }
1291
-
1245
+ // Extract every heater target with the shared print-safety scanner: S and
1246
+ // R forms, tool-addressed commands, RepRapFirmware G10/M568 and Klipper
1247
+ // SET_HEATER_TEMPERATURE. Unparseable thermal syntax fails closed.
1248
+ const scan = scanGcodeThermal(gcode);
1249
+ const allTemperatures: { extruder: number[]; bed: number[] } = {
1250
+ extruder: scan.targets.filter((target) => target.heater === "nozzle").map((target) => target.value),
1251
+ bed: scan.targets.filter((target) => target.heater === "bed").map((target) => target.value),
1252
+ };
1253
+ // First positive target, kept for compatibility with the original report.
1254
+ const actual: { extruder?: number; bed?: number } = {
1255
+ extruder: allTemperatures.extruder.find((value) => value > 0),
1256
+ bed: allTemperatures.bed.find((value) => value > 0),
1257
+ };
1258
+ const peak = {
1259
+ extruder: allTemperatures.extruder.length ? Math.max(...allTemperatures.extruder) : undefined,
1260
+ bed: allTemperatures.bed.length ? Math.max(...allTemperatures.bed) : undefined,
1261
+ };
1262
+
1292
1263
  if (progressCallback) progressCallback(80, "Comparing temperatures...");
1293
-
1294
- // Compare actual with expected
1264
+
1265
+ // An expected temperature matches only when it is the job's highest
1266
+ // target, so a later or R-form higher target is always a mismatch.
1295
1267
  let match = true;
1296
- if (expected.extruder !== undefined && actual.extruder !== expected.extruder) {
1268
+ if (expected.extruder !== undefined && peak.extruder !== expected.extruder) {
1297
1269
  match = false;
1298
1270
  }
1299
- if (expected.bed !== undefined && actual.bed !== expected.bed) {
1271
+ if (expected.bed !== undefined && peak.bed !== expected.bed) {
1300
1272
  match = false;
1301
1273
  }
1302
-
1274
+
1303
1275
  if (progressCallback) progressCallback(100, "Temperature verification complete");
1304
-
1276
+
1305
1277
  this.emit('operationComplete', {
1306
1278
  operationId,
1307
1279
  type: 'confirmTemperatures',
1308
1280
  success: true,
1309
- result: { match, actual, expected, allTemperatures }
1281
+ result: { match, actual, peak, expected, allTemperatures }
1310
1282
  });
1311
-
1312
- return { match, actual, expected, allTemperatures };
1283
+
1284
+ return { match, actual, peak, expected, allTemperatures };
1313
1285
  } catch (error) {
1314
1286
  console.error("Error confirming temperatures:", error);
1315
1287
 
@@ -1348,13 +1320,13 @@ export class STLManipulator extends EventEmitter {
1348
1320
  const mergedGeometry = BufferGeometryUtils.mergeVertices(geometry, tolerance);
1349
1321
  const newVertexCount = mergedGeometry.attributes.position.count;
1350
1322
 
1351
- console.log(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1323
+ console.error(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1352
1324
  if (progressCallback) progressCallback(70, `Vertices merged: ${originalVertexCount} -> ${newVertexCount}`);
1353
1325
 
1354
1326
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1355
1327
 
1356
1328
  const outputFileName = path.basename(stlFilePath, '.stl') + '_merged.stl';
1357
- const outputFilePath = path.join(this.tempDir, outputFileName);
1329
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
1358
1330
 
1359
1331
  await this.saveSTL(mergedGeometry, outputFilePath, progressCallback); // 80-100% progress
1360
1332
 
@@ -1392,7 +1364,7 @@ export class STLManipulator extends EventEmitter {
1392
1364
 
1393
1365
  if (center.lengthSq() < 0.0001) { // Already centered (or very close)
1394
1366
  if (progressCallback) progressCallback(100, "Model is already centered.");
1395
- console.log("Model already centered. No changes made.");
1367
+ console.error("Model already centered. No changes made.");
1396
1368
  this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: stlFilePath, message: "Model already centered." });
1397
1369
  return stlFilePath; // Return original path
1398
1370
  }
@@ -1406,7 +1378,7 @@ export class STLManipulator extends EventEmitter {
1406
1378
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1407
1379
 
1408
1380
  const outputFileName = path.basename(stlFilePath, '.stl') + '_centered.stl';
1409
- const outputFilePath = path.join(this.tempDir, outputFileName);
1381
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
1410
1382
 
1411
1383
  await this.saveSTL(geometry, outputFilePath, progressCallback); // 80-100% progress
1412
1384
 
@@ -1553,7 +1525,7 @@ export class STLManipulator extends EventEmitter {
1553
1525
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1554
1526
 
1555
1527
  const outputFileName = path.basename(stlFilePath, '.stl') + '_flat.stl';
1556
- const outputFilePath = path.join(this.tempDir, outputFileName);
1528
+ const outputFilePath = this.operationOutputPath(operationId, outputFileName);
1557
1529
 
1558
1530
  await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
1559
1531