mcp-3d-printer-server 1.2.13 → 1.2.15

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.
@@ -33,7 +33,7 @@ export interface BambuSliceOptions {
33
33
  orient?: boolean; // --orient 1: auto-orient for printability
34
34
  arrange?: boolean; // --arrange 1: auto-arrange objects on plate
35
35
  ensureOnBed?: boolean; // --ensure-on-bed: lift floating models onto bed
36
- cloneObjects?: string; // --clone-objects "1,3,1,10": clone counts per object
36
+ cloneObjects?: string; // --clone-objects N: total copies of the loaded model
37
37
  skipObjects?: string; // --skip-objects "3,5,10": skip specific objects
38
38
  loadFilaments?: string; // "f1.json;f2.json": positional filament profiles
39
39
  loadFilamentIds?: string; // --load-filament-ids "1,2,3,1": filament-to-object mapping
@@ -567,12 +567,91 @@ const PROFILE_METADATA_KEYS = new Set([
567
567
  "compatible_printers", "compatible_printers_condition", "compatible_prints", "compatible_prints_condition",
568
568
  ]);
569
569
 
570
+ /** How the CLI receives the model: Orca STL transforms are applied to a mesh copy first. */
571
+ export interface BambuCliInput {
572
+ slicerType: BambuCliSlicerType;
573
+ is3mf: boolean;
574
+ /** scale and rotations are already applied to the mesh passed to the CLI. */
575
+ meshTransformed: boolean;
576
+ }
577
+
578
+ /**
579
+ * Decide how the CLI receives the model. OrcaSlicer's CLI (upstream and FULU)
580
+ * crashes with SIGSEGV in PartPlate::check_outside, which needs the GUI's
581
+ * Plater, when --scale or --rotate-x/-y move an object below the bed while it
582
+ * arranges, the default for STL input. An Orca STL's transforms are therefore
583
+ * applied to a mesh copy; combinations the CLI cannot handle are refused.
584
+ */
585
+ export function planBambuCliInput(slicerType: BambuCliSlicerType, is3mf: boolean, options: BambuSliceOptions): BambuCliInput {
586
+ const input = { slicerType, is3mf, meshTransformed: false };
587
+ if (slicerType === "bambustudio") return input;
588
+ const tilts = !!options.rotateX || !!options.rotateY;
589
+ if (is3mf) {
590
+ if (options.arrange === true && ((options.scale ?? 1) > 1 || tilts)) {
591
+ throw new Error(
592
+ "OrcaSlicer's CLI crashes when scale above 1, rotate_x or rotate_y moves a 3MF object below the bed while arranging. " +
593
+ "Set arrange to false, or apply the transform in the slicer and export the project."
594
+ );
595
+ }
596
+ return input;
597
+ }
598
+ if (options.repetitions !== undefined && options.cloneObjects) {
599
+ throw new Error("Use either repetitions or clone_objects for an STL with OrcaSlicer, not both.");
600
+ }
601
+ return { ...input, meshTransformed: options.scale !== undefined || tilts };
602
+ }
603
+
604
+ /**
605
+ * Bambu Studio and OrcaSlicer CLI errors (libslic3r/Utils.hpp). The process
606
+ * exits with the low byte of the code; on Linux result.json names the error.
607
+ */
608
+ const BAMBU_CLI_ERRORS: Record<number, string> = {
609
+ [-2]: "invalid parameters to the slicer",
610
+ [-3]: "input files not found",
611
+ [-5]: "preset file cannot be parsed",
612
+ [-6]: "model file cannot be parsed",
613
+ [-9]: "copying objects failed",
614
+ [-17]: "printer is not compatible with the process preset",
615
+ [-21]: "auto-arranging objects failed",
616
+ [-22]: "auto-orienting objects failed",
617
+ [-50]: "a plate is empty or has no object fully inside it; arrange the model or place it on the plate",
618
+ [-51]: "incorrect slicing parameters",
619
+ [-52]: "objects extend over the bed boundary",
620
+ [-60]: "no printable objects remain after skipping",
621
+ [-61]: "filaments are not compatible with the plate type",
622
+ [-62]: "the filaments' temperatures differ too much",
623
+ [-63]: "objects collide in print-by-object mode",
624
+ [-64]: "objects collide",
625
+ [-100]: "slicing the model failed",
626
+ [-101]: "G-code conflicts after slicing",
627
+ [-102]: "G-code in an unprintable area",
628
+ };
629
+
630
+ /**
631
+ * The CLI's own reason for a failed run: result.json in the run's fresh output
632
+ * directory (written on Linux for handled errors), else the exit-code table.
633
+ */
634
+ export function bambuCliFailureReason(exitCode: number | null, outputDir: string): string | undefined {
635
+ try {
636
+ const result = JSON.parse(fs.readFileSync(path.join(outputDir, "result.json"), "utf8"));
637
+ if (typeof result?.error_string === "string" && result.error_string.trim()) {
638
+ return `${result.error_string.trim()}${result.return_code !== undefined ? ` [${result.return_code}]` : ""}`;
639
+ }
640
+ } catch {
641
+ // result.json is written only on Linux and only for handled errors.
642
+ }
643
+ if (exitCode === null) return undefined;
644
+ const code = Object.keys(BAMBU_CLI_ERRORS).map(Number).find((value) => value === exitCode || (value & 0xff) === exitCode);
645
+ return code === undefined ? undefined : `${BAMBU_CLI_ERRORS[code]} [${code}]`;
646
+ }
647
+
570
648
  /** CLI flags for the prepared bundle and caller options (input path appended by the caller). */
571
649
  export function buildBambuCliArgs(
572
650
  bundle: BambuSettingsBundle,
573
651
  outputDir: string,
574
652
  exportName: string,
575
- options: BambuSliceOptions
653
+ options: BambuSliceOptions,
654
+ input: BambuCliInput
576
655
  ): string[] {
577
656
  const args = [
578
657
  "--slice", String(options.slicePlate ?? 0),
@@ -590,11 +669,17 @@ export function buildBambuCliArgs(
590
669
  if (options.skipModifiedGcodes) args.push("--skip-modified-gcodes");
591
670
  if (options.orient !== undefined) args.push("--orient", options.orient ? "1" : "0");
592
671
  if (options.arrange !== undefined) args.push("--arrange", options.arrange ? "1" : "0");
593
- if (options.repetitions !== undefined) args.push("--repetitions", String(options.repetitions));
594
- if (options.scale !== undefined) args.push("--scale", String(options.scale));
595
- if (options.rotate !== undefined) args.push("--rotate", String(options.rotate));
596
- if (options.rotateX !== undefined) args.push("--rotate-x", String(options.rotateX));
597
- if (options.rotateY !== undefined) args.push("--rotate-y", String(options.rotateY));
672
+ if (options.repetitions !== undefined) {
673
+ // OrcaSlicer rejects --repetitions for one STL; it copies the loaded model with --clone-objects.
674
+ if (input.slicerType !== "bambustudio" && !input.is3mf) args.push("--clone-objects", String(options.repetitions));
675
+ else args.push("--repetitions", String(options.repetitions));
676
+ }
677
+ if (!input.meshTransformed) {
678
+ if (options.scale !== undefined) args.push("--scale", String(options.scale));
679
+ if (options.rotate !== undefined) args.push("--rotate", String(options.rotate));
680
+ if (options.rotateX !== undefined) args.push("--rotate-x", String(options.rotateX));
681
+ if (options.rotateY !== undefined) args.push("--rotate-y", String(options.rotateY));
682
+ }
598
683
  if (options.cloneObjects) args.push("--clone-objects", options.cloneObjects);
599
684
  if (options.skipObjects) args.push("--skip-objects", options.skipObjects);
600
685
  if (bundle.filamentPaths.length > 0) {
@@ -11,6 +11,8 @@ export interface SlicerRunOptions {
11
11
  slicerType: string;
12
12
  /** Polled while the slicer runs; returning true kills it. */
13
13
  isCancelled?: () => boolean;
14
+ /** The slicer's own reason for a nonzero exit, read before its output directory is removed. */
15
+ failureReason?: (exitCode: number | null) => string | undefined;
14
16
  }
15
17
 
16
18
  export interface SlicerRunResult {
@@ -51,7 +53,7 @@ function describeOutput(stdoutTail: string, stderrTail: string): string {
51
53
  * cancellation rejects with a SlicerError that includes the evidence.
52
54
  */
53
55
  export function runSlicerProcess(options: SlicerRunOptions): Promise<SlicerRunResult> {
54
- const { command, args, timeoutMs, slicerType, isCancelled } = options;
56
+ const { command, args, timeoutMs, slicerType, isCancelled, failureReason } = options;
55
57
  return new Promise((resolve, reject) => {
56
58
  const stdout = new TailBuffer();
57
59
  const stderr = new TailBuffer();
@@ -107,9 +109,10 @@ export function runSlicerProcess(options: SlicerRunOptions): Promise<SlicerRunRe
107
109
  : signal
108
110
  ? `Slicer was terminated by signal ${signal}`
109
111
  : `Slicer exited with code ${code}`;
112
+ const cliReason = timedOut || cancelled ? undefined : failureReason?.(code);
110
113
  settle(() => reject(new SlicerError(
111
114
  "execution",
112
- `${reason} (${slicerType}: ${command}).\n${describeOutput(stdoutTail, stderrTail)}`,
115
+ `${reason} (${slicerType}: ${command})${cliReason ? `: ${cliReason}` : ""}.\n${describeOutput(stdoutTail, stderrTail)}`,
113
116
  {
114
117
  slicerType,
115
118
  slicerPath: command,
@@ -11,7 +11,9 @@ import { detectProfilesRoot } from '../slicer/profile-flatten.js';
11
11
  import {
12
12
  BambuCliProfilePreparer,
13
13
  assertSlicedProjectOutput,
14
+ bambuCliFailureReason,
14
15
  buildBambuCliArgs,
16
+ planBambuCliInput,
15
17
  type BambuCliSlicerType,
16
18
  type BambuSliceOptions,
17
19
  } from '../slicer/bambu-cli.js';
@@ -182,6 +184,27 @@ export class STLManipulator extends EventEmitter {
182
184
  return timeoutMs;
183
185
  }
184
186
 
187
+ /**
188
+ * Apply slice_stl's scale and rotations to a copy of the mesh as OrcaSlicer's
189
+ * CLI would, about the model's centre. Orca adds each --rotate* to the
190
+ * object's Euler angles (ModelVolume::rotate), so the result rotates about X,
191
+ * then Y, then Z whatever the argument order, after scaling. The slicer then
192
+ * places the result on the plate.
193
+ */
194
+ private async transformMeshForCli(stlFilePath: string, options: BambuSliceOptions, outputDir: string): Promise<string> {
195
+ const { geometry, boundingBox } = await this.loadSTL(stlFilePath);
196
+ const center = new THREE.Vector3();
197
+ boundingBox.getCenter(center);
198
+ const radians = (degrees: number) => degrees * Math.PI / 180;
199
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z));
200
+ if (options.scale !== undefined) geometry.applyMatrix4(new THREE.Matrix4().makeScale(options.scale, options.scale, options.scale));
201
+ if (options.rotateX) geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(radians(options.rotateX)));
202
+ if (options.rotateY) geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(radians(options.rotateY)));
203
+ if (options.rotate) geometry.applyMatrix4(new THREE.Matrix4().makeRotationZ(radians(options.rotate)));
204
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(center.x, center.y, center.z));
205
+ return this.saveSTL(geometry, path.join(outputDir, `${path.basename(stlFilePath, path.extname(stlFilePath))}_transformed.stl`));
206
+ }
207
+
185
208
  /**
186
209
  * Load STL file and return geometry and bounding box
187
210
  */
@@ -1060,6 +1083,7 @@ export class STLManipulator extends EventEmitter {
1060
1083
  }
1061
1084
  options.loadFilaments = legacyFilaments;
1062
1085
  }
1086
+ const cliInput = planBambuCliInput(cliType, is3mf, options);
1063
1087
  const profilesRoot = detectProfilesRoot(slicerPath, cliType);
1064
1088
  const preparer = new BambuCliProfilePreparer(path.join(this.tempDir, 'slicer-profiles'));
1065
1089
  const bundle = await preparer.prepare(
@@ -1072,7 +1096,9 @@ export class STLManipulator extends EventEmitter {
1072
1096
  options
1073
1097
  );
1074
1098
  bambuOutputDir = fs.mkdtempSync(path.join(this.tempDir, `${operationId}-bambu-output-`));
1075
- args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options), stlFilePath];
1099
+ // The transformed copy lives in the run's output directory, which is removed afterwards.
1100
+ const inputPath = cliInput.meshTransformed ? await this.transformMeshForCli(stlFilePath, options, bambuOutputDir) : stlFilePath;
1101
+ args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options, cliInput), inputPath];
1076
1102
  bambuExport = {
1077
1103
  producedPath: path.join(bambuOutputDir, exportName),
1078
1104
  // One folder per operation: concurrent slices of same-named inputs
@@ -1135,12 +1161,14 @@ export class STLManipulator extends EventEmitter {
1135
1161
  if (progressCallback) progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1136
1162
  console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1137
1163
 
1164
+ const cliOutputDir = bambuOutputDir;
1138
1165
  await runSlicerProcess({
1139
1166
  command: slicerPath,
1140
1167
  args,
1141
1168
  timeoutMs: this.resolveSlicerTimeoutMs(),
1142
1169
  slicerType,
1143
1170
  isCancelled: () => !this.activeOperations.get(operationId),
1171
+ failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
1144
1172
  });
1145
1173
 
1146
1174
  if (!this.activeOperations.get(operationId)) {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { STLManipulator, ProgressCallback } from '../stl/stl-manipulator.js';
4
4
  import fs from 'fs';
5
+ import os from 'os';
5
6
  import path from 'path';
6
7
  import * as THREE from 'three';
7
8
 
@@ -11,6 +12,9 @@ if (!fs.existsSync(TEST_DIR)) {
11
12
  fs.mkdirSync(TEST_DIR, { recursive: true });
12
13
  }
13
14
 
15
+ // Operation outputs go to a scratch folder removed after the run
16
+ const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stl-manipulator-test-'));
17
+
14
18
  // Path for a sample STL file
15
19
  const sampleStlPath = path.join(TEST_DIR, 'sample_cube.stl');
16
20
 
@@ -48,8 +52,8 @@ async function testSTLManipulator() {
48
52
  // Create a sample STL for testing
49
53
  const stlPath = await createSampleCube();
50
54
 
51
- // Create an instance of the STL manipulator with the test directory
52
- const manipulator = new STLManipulator(TEST_DIR);
55
+ // Write operation outputs to a scratch folder, not the tracked test directory
56
+ const manipulator = new STLManipulator(outputDir);
53
57
 
54
58
  // Listen for events from the manipulator
55
59
  manipulator.on('operationComplete', (data) => {
@@ -116,7 +120,9 @@ async function testSTLManipulator() {
116
120
  console.log('\nAll tests completed successfully!');
117
121
  } catch (error) {
118
122
  console.error('An error occurred during testing:', error);
119
- process.exit(1);
123
+ process.exitCode = 1;
124
+ } finally {
125
+ fs.rmSync(outputDir, { recursive: true, force: true });
120
126
  }
121
127
  }
122
128