bambu-printer-mcp 1.1.32 → 1.1.34

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.
@@ -11,6 +11,7 @@ import * as crypto from 'crypto';
11
11
  import { execFile } from 'child_process';
12
12
  import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from '../slicer/profile-flatten.js';
13
13
  import { profileJsonFiles } from '../slicer/profile-files.js';
14
+ import { materializeOrcaProfilesRoot, stageOrcaCliProfiles } from '../slicer/orca-profile-cache.js';
14
15
  import { sliceWithOrcaApi, validateRemoteSliceOptions } from '../slicer/remote-slicer.js';
15
16
  const readFileAsync = promisify(fs.readFile);
16
17
  const writeFileAsync = promisify(fs.writeFile);
@@ -111,6 +112,54 @@ const PROFILE_METADATA_KEYS = new Set([
111
112
  'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
112
113
  'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
113
114
  ]);
115
+ function describeSlicerFailure(error, stdout, stderr, cli) {
116
+ const status = typeof error.signal === "string" && error.signal
117
+ ? `crashed with ${error.signal}`
118
+ : typeof error.code === "number" ? `exited with code ${error.code}` : "failed";
119
+ const reason = cli ? bambuCliFailureReason(error.code, cli) : undefined;
120
+ const output = stderr.trim() ? "" : stdout.trim().split("\n").slice(-20).join("\n");
121
+ return `Slicer failed: the slicer ${status}${reason ? ` (${reason})` : ""}. ${error.message.trim()}${output ? `\nSlicer output: ${output}` : ""}`;
122
+ }
123
+ // Bambu Studio and OrcaSlicer CLI errors (libslic3r/Utils.hpp). The process
124
+ // exits with the low byte of the code; on Linux result.json names the error.
125
+ const BAMBU_CLI_ERRORS = {
126
+ [-2]: "invalid parameters to the slicer",
127
+ [-3]: "input files not found",
128
+ [-5]: "preset file cannot be parsed",
129
+ [-6]: "model file cannot be parsed",
130
+ [-9]: "copying objects failed",
131
+ [-17]: "printer is not compatible with the process preset",
132
+ [-21]: "auto-arranging objects failed",
133
+ [-22]: "auto-orienting objects failed",
134
+ [-50]: "a plate is empty or has no object fully inside it; arrange the model or place it on the plate",
135
+ [-51]: "incorrect slicing parameters",
136
+ [-52]: "objects extend over the bed boundary",
137
+ [-60]: "no printable objects remain after skipping",
138
+ [-61]: "filaments are not compatible with the plate type",
139
+ [-62]: "the filaments' temperatures differ too much",
140
+ [-63]: "objects collide in print-by-object mode",
141
+ [-64]: "objects collide",
142
+ [-100]: "slicing the model failed",
143
+ [-101]: "G-code conflicts after slicing",
144
+ [-102]: "G-code in an unprintable area",
145
+ };
146
+ function bambuCliFailureReason(exitCode, cli) {
147
+ try {
148
+ if (fs.statSync(cli.resultPath).mtimeMs >= cli.startedAt) {
149
+ const result = JSON.parse(fs.readFileSync(cli.resultPath, "utf8"));
150
+ if (typeof result.error_string === "string" && result.error_string.trim()) {
151
+ return `${result.error_string.trim()} [${result.return_code}]`;
152
+ }
153
+ }
154
+ }
155
+ catch {
156
+ // result.json is written only on Linux and only for handled errors.
157
+ }
158
+ if (typeof exitCode !== "number")
159
+ return undefined;
160
+ const code = Object.keys(BAMBU_CLI_ERRORS).map(Number).find(value => (value & 0xff) === exitCode);
161
+ return code === undefined ? undefined : `${BAMBU_CLI_ERRORS[code]} [${code}]`;
162
+ }
114
163
  export class STLManipulator extends EventEmitter {
115
164
  constructor(tempDir = path.join(process.cwd(), 'temp')) {
116
165
  super();
@@ -184,7 +233,7 @@ export class STLManipulator extends EventEmitter {
184
233
  }
185
234
  return value;
186
235
  }
187
- sanitizeProcessForOrca(processConfig, printerPreset) {
236
+ sanitizeProcessForOrca(processConfig, printerPreset, preserveExtrusionMode = false) {
188
237
  const sanitized = { ...processConfig };
189
238
  sanitized.type = 'process';
190
239
  if (!sanitized.from || sanitized.from === 'project') {
@@ -200,7 +249,7 @@ export class STLManipulator extends EventEmitter {
200
249
  else if (sanitized.prime_tower_brim_width !== undefined) {
201
250
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
202
251
  }
203
- return this.useAbsoluteExtrusionForOrca(sanitized);
252
+ return preserveExtrusionMode ? sanitized : this.useAbsoluteExtrusionForOrca(sanitized);
204
253
  }
205
254
  /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
206
255
  useAbsoluteExtrusionForOrca(config) {
@@ -218,7 +267,7 @@ export class STLManipulator extends EventEmitter {
218
267
  fs.writeFileSync(outPath, serialized);
219
268
  return outPath;
220
269
  }
221
- async resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, activeProfilesRoot) {
270
+ async resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, activeProfilesRoot, preserveOrcaExtrusionMode = false) {
222
271
  const roots = activeProfilesRoot
223
272
  ? [path.join(activeProfilesRoot, 'BBL')]
224
273
  : this.getAvailableProfileRoots();
@@ -279,7 +328,7 @@ export class STLManipulator extends EventEmitter {
279
328
  type: 'process',
280
329
  };
281
330
  processPath = this.writeTempJson(outputBase, slicerType === 'orcaslicer' ? 'process_orca' : 'process', slicerType === 'orcaslicer'
282
- ? this.sanitizeProcessForOrca(mergedProcess, printerPreset)
331
+ ? this.sanitizeProcessForOrca(mergedProcess, printerPreset, preserveOrcaExtrusionMode)
283
332
  : mergedProcess);
284
333
  const defaultFilamentProfiles = !bambuOptions?.loadFilaments && Array.isArray(parsedProfile.default_filament_profile)
285
334
  ? parsedProfile.default_filament_profile
@@ -317,7 +366,7 @@ export class STLManipulator extends EventEmitter {
317
366
  if (defaultProcessPath) {
318
367
  processPath =
319
368
  slicerType === 'orcaslicer'
320
- ? this.writeTempJson(outputBase, 'process_default_orca', this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset))
369
+ ? this.writeTempJson(outputBase, 'process_default_orca', this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset, preserveOrcaExtrusionMode))
321
370
  : defaultProcessPath;
322
371
  }
323
372
  }
@@ -744,6 +793,32 @@ export class STLManipulator extends EventEmitter {
744
793
  this.activeOperations.delete(operationId);
745
794
  }
746
795
  }
796
+ /**
797
+ * Apply slice_stl's scale and rotations to a copy of the mesh as OrcaSlicer's
798
+ * CLI would, about the model's centre. Orca adds each --rotate* to the
799
+ * object's Euler angles (ModelVolume::rotate), so the result rotates about X,
800
+ * then Y, then Z whatever the argument order, after scaling. The slicer then
801
+ * places the result on the plate.
802
+ */
803
+ async transformMeshForCli(stlFilePath, options) {
804
+ const { geometry, boundingBox } = await this.loadSTL(stlFilePath);
805
+ const center = new THREE.Vector3();
806
+ boundingBox.getCenter(center);
807
+ const radians = (degrees) => degrees * Math.PI / 180;
808
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z));
809
+ if (options.scale !== undefined)
810
+ geometry.applyMatrix4(new THREE.Matrix4().makeScale(options.scale, options.scale, options.scale));
811
+ if (options.rotateX)
812
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(radians(options.rotateX)));
813
+ if (options.rotateY)
814
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(radians(options.rotateY)));
815
+ if (options.rotate)
816
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationZ(radians(options.rotate)));
817
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(center.x, center.y, center.z));
818
+ const outputFilePath = path.join(this.tempDir, `${path.basename(stlFilePath, path.extname(stlFilePath))}_transformed_${this.generateOperationId()}.stl`);
819
+ await this.saveSTL(geometry, outputFilePath);
820
+ return outputFilePath;
821
+ }
747
822
  /**
748
823
  * Rotate an STL model around specific axes
749
824
  */
@@ -1240,6 +1315,9 @@ export class STLManipulator extends EventEmitter {
1240
1315
  const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
1241
1316
  let outputFilePath = path.join(this.tempDir, outputFileName);
1242
1317
  let args = [];
1318
+ let cliResult;
1319
+ const orcaTemporaryDirectories = [];
1320
+ const temporaryFiles = [];
1243
1321
  try {
1244
1322
  if (progressCallback)
1245
1323
  progressCallback(0, `Starting slicing with ${slicerType}...`);
@@ -1291,8 +1369,13 @@ export class STLManipulator extends EventEmitter {
1291
1369
  const profileSlicerType = remote ? 'orcaslicer' : slicerType;
1292
1370
  const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
1293
1371
  const outputDir = path.dirname(bambuOutputPath);
1294
- const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1295
- const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, profileSlicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1372
+ const selectedProfilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1373
+ const profilesRoot = !remote && profileSlicerType === 'orcaslicer'
1374
+ ? await materializeOrcaProfilesRoot(selectedProfilesRoot, this.tempDir)
1375
+ : selectedProfilesRoot;
1376
+ if (profilesRoot !== selectedProfilesRoot)
1377
+ orcaTemporaryDirectories.push(profilesRoot);
1378
+ const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, profileSlicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot, profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot);
1296
1379
  // Resolve every BBL dependency before launching the slicer.
1297
1380
  const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1298
1381
  // The machine preset assumes the stock hotend; record the installed one so
@@ -1312,14 +1395,21 @@ export class STLManipulator extends EventEmitter {
1312
1395
  // absolute-extrusion normalization after resolving those ancestors.
1313
1396
  // The machine owns the extrusion mode and layer G-code once process
1314
1397
  // files lose machine keys, so normalize it there as well.
1315
- if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1398
+ if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg && profilesRoot === selectedProfilesRoot) {
1316
1399
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1317
1400
  settingsBundle.settingsArg = [
1318
1401
  this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
1319
1402
  this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1320
1403
  ].join(';');
1321
1404
  }
1322
- const cliBundle = this.isolateMachineSettings(settingsBundle);
1405
+ let cliBundle = this.isolateMachineSettings(settingsBundle);
1406
+ let orcaExtraArgs = [];
1407
+ if (!remote && profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot) {
1408
+ const staged = await stageOrcaCliProfiles(profilesRoot, this.tempDir, cliBundle);
1409
+ cliBundle = { ...cliBundle, settingsArg: staged.settingsArg, filamentPaths: staged.filamentPaths };
1410
+ orcaExtraArgs = staged.extraArgs;
1411
+ orcaTemporaryDirectories.push(staged.dataDir);
1412
+ }
1323
1413
  if (remote) {
1324
1414
  const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
1325
1415
  if (!machinePath || !processPath)
@@ -1331,7 +1421,29 @@ export class STLManipulator extends EventEmitter {
1331
1421
  });
1332
1422
  break;
1333
1423
  }
1424
+ // OrcaSlicer's CLI crashes (SIGSEGV in PartPlate::check_outside,
1425
+ // which needs the GUI's Plater) when --scale or --rotate-x/-y move an
1426
+ // object below the bed and it arranges, the default for STL; it also
1427
+ // rejects --repetitions for one STL. Apply STL transforms to the mesh
1428
+ // in the CLI's order instead, and refuse the 3MF case it cannot fix.
1429
+ const orcaCli = slicerType === 'orcaslicer' || slicerType === 'orcaslicer-bambulab';
1430
+ const tilts = !!bambuOptions?.rotateX || !!bambuOptions?.rotateY;
1431
+ const meshTransform = orcaCli && !is3mf && (bambuOptions?.scale !== undefined || tilts);
1432
+ if (orcaCli && is3mf && bambuOptions?.arrange === true && ((bambuOptions.scale ?? 1) > 1 || tilts)) {
1433
+ throw new Error("OrcaSlicer's CLI crashes when scale above 1, rotate_x or rotate_y moves a 3MF object below the bed while arranging. " +
1434
+ "Set arrange to false, or apply the transform in the slicer and export the project.");
1435
+ }
1436
+ if (orcaCli && !is3mf && bambuOptions?.repetitions !== undefined && bambuOptions.cloneObjects) {
1437
+ throw new Error("Use either repetitions or clone_objects for an STL with OrcaSlicer, not both.");
1438
+ }
1439
+ const inputPath = meshTransform ? await this.transformMeshForCli(stlFilePath, bambuOptions) : stlFilePath;
1440
+ if (inputPath !== stlFilePath)
1441
+ temporaryFiles.push(inputPath);
1442
+ cliResult = { resultPath: path.join(outputDir, 'result.json'), startedAt: Date.now() };
1443
+ // A result.json left by an earlier run must not explain this one.
1444
+ await fs.promises.rm(cliResult.resultPath, { force: true });
1334
1445
  args = [
1446
+ ...orcaExtraArgs,
1335
1447
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1336
1448
  '--outputdir', outputDir,
1337
1449
  '--export-3mf', path.basename(bambuOutputPath),
@@ -1358,16 +1470,23 @@ export class STLManipulator extends EventEmitter {
1358
1470
  args.push('--orient', bambuOptions.orient ? '1' : '0');
1359
1471
  if (bambuOptions?.arrange !== undefined)
1360
1472
  args.push('--arrange', bambuOptions.arrange ? '1' : '0');
1361
- if (bambuOptions?.repetitions !== undefined)
1362
- args.push('--repetitions', String(bambuOptions.repetitions));
1363
- if (bambuOptions?.scale !== undefined)
1364
- args.push('--scale', String(bambuOptions.scale));
1365
- if (bambuOptions?.rotate !== undefined)
1366
- args.push('--rotate', String(bambuOptions.rotate));
1367
- if (bambuOptions?.rotateX !== undefined)
1368
- args.push('--rotate-x', String(bambuOptions.rotateX));
1369
- if (bambuOptions?.rotateY !== undefined)
1370
- args.push('--rotate-y', String(bambuOptions.rotateY));
1473
+ if (bambuOptions?.repetitions !== undefined) {
1474
+ // OrcaSlicer counts copies of a single loaded STL with --clone-objects.
1475
+ if (orcaCli && !is3mf)
1476
+ args.push('--clone-objects', String(bambuOptions.repetitions));
1477
+ else
1478
+ args.push('--repetitions', String(bambuOptions.repetitions));
1479
+ }
1480
+ if (!meshTransform) {
1481
+ if (bambuOptions?.scale !== undefined)
1482
+ args.push('--scale', String(bambuOptions.scale));
1483
+ if (bambuOptions?.rotate !== undefined)
1484
+ args.push('--rotate', String(bambuOptions.rotate));
1485
+ if (bambuOptions?.rotateX !== undefined)
1486
+ args.push('--rotate-x', String(bambuOptions.rotateX));
1487
+ if (bambuOptions?.rotateY !== undefined)
1488
+ args.push('--rotate-y', String(bambuOptions.rotateY));
1489
+ }
1371
1490
  if (bambuOptions?.cloneObjects)
1372
1491
  args.push('--clone-objects', bambuOptions.cloneObjects);
1373
1492
  if (bambuOptions?.skipObjects)
@@ -1378,7 +1497,7 @@ export class STLManipulator extends EventEmitter {
1378
1497
  }
1379
1498
  if (bambuOptions?.loadFilamentIds)
1380
1499
  args.push('--load-filament-ids', bambuOptions.loadFilamentIds);
1381
- args.push(stlFilePath);
1500
+ args.push(inputPath);
1382
1501
  // Override outputFilePath for bambustudio since it produces 3MF, not gcode
1383
1502
  outputFilePath = bambuOutputPath;
1384
1503
  }
@@ -1396,7 +1515,7 @@ export class STLManipulator extends EventEmitter {
1396
1515
  if (error) {
1397
1516
  console.error(`Slicer Error: ${error.message}`);
1398
1517
  console.error(`Slicer Stderr: ${stderr}`);
1399
- reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1518
+ reject(new Error(describeSlicerFailure(error, stdout, stderr, cliResult)));
1400
1519
  }
1401
1520
  else {
1402
1521
  console.error(`Slicer Stdout: ${stdout}`);
@@ -1455,6 +1574,14 @@ export class STLManipulator extends EventEmitter {
1455
1574
  }
1456
1575
  finally {
1457
1576
  this.activeOperations.delete(operationId);
1577
+ for (const file of temporaryFiles) {
1578
+ await fs.promises.rm(file, { force: true }).catch(error => console.error('Could not remove a temporary mesh:', error));
1579
+ }
1580
+ for (const directory of orcaTemporaryDirectories.reverse()) {
1581
+ await fs.promises.rm(directory, { recursive: true, force: true }).catch(error => {
1582
+ console.error('Could not remove temporary Orca profiles:', error);
1583
+ });
1584
+ }
1458
1585
  }
1459
1586
  }
1460
1587
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.32",
3
+ "version": "1.1.34",
4
4
  "description": "MCP server for Bambu Lab 3D printers with STL manipulation, slicing, and print management",
5
5
  "main": "dist/index.js",
6
6
  "repository": {