mcp-3d-printer-server 1.2.13 → 1.2.14

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.
@@ -46,6 +46,14 @@ export declare class STLManipulator extends EventEmitter {
46
46
  private appendOrcaProfileArgs;
47
47
  private resolveOrcaGcodeOutput;
48
48
  private resolveSlicerTimeoutMs;
49
+ /**
50
+ * Apply slice_stl's scale and rotations to a copy of the mesh as OrcaSlicer's
51
+ * CLI would, about the model's centre. Orca adds each --rotate* to the
52
+ * object's Euler angles (ModelVolume::rotate), so the result rotates about X,
53
+ * then Y, then Z whatever the argument order, after scaling. The slicer then
54
+ * places the result on the plate.
55
+ */
56
+ private transformMeshForCli;
49
57
  /**
50
58
  * Load STL file and return geometry and bounding box
51
59
  */
@@ -8,7 +8,7 @@ import { promisify } from 'util';
8
8
  import { EventEmitter } from 'events';
9
9
  import * as crypto from 'crypto';
10
10
  import { detectProfilesRoot } from '../slicer/profile-flatten.js';
11
- import { BambuCliProfilePreparer, assertSlicedProjectOutput, buildBambuCliArgs, } from '../slicer/bambu-cli.js';
11
+ import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReason, buildBambuCliArgs, planBambuCliInput, } from '../slicer/bambu-cli.js';
12
12
  import { runSlicerProcess } from '../slicer/run-slicer.js';
13
13
  import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
14
14
  import { scanGcodeThermal } from '../safety/gcode-thermal.js';
@@ -110,6 +110,30 @@ export class STLManipulator extends EventEmitter {
110
110
  }
111
111
  return timeoutMs;
112
112
  }
113
+ /**
114
+ * Apply slice_stl's scale and rotations to a copy of the mesh as OrcaSlicer's
115
+ * CLI would, about the model's centre. Orca adds each --rotate* to the
116
+ * object's Euler angles (ModelVolume::rotate), so the result rotates about X,
117
+ * then Y, then Z whatever the argument order, after scaling. The slicer then
118
+ * places the result on the plate.
119
+ */
120
+ async transformMeshForCli(stlFilePath, options, outputDir) {
121
+ const { geometry, boundingBox } = await this.loadSTL(stlFilePath);
122
+ const center = new THREE.Vector3();
123
+ boundingBox.getCenter(center);
124
+ const radians = (degrees) => degrees * Math.PI / 180;
125
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z));
126
+ if (options.scale !== undefined)
127
+ geometry.applyMatrix4(new THREE.Matrix4().makeScale(options.scale, options.scale, options.scale));
128
+ if (options.rotateX)
129
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationX(radians(options.rotateX)));
130
+ if (options.rotateY)
131
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationY(radians(options.rotateY)));
132
+ if (options.rotate)
133
+ geometry.applyMatrix4(new THREE.Matrix4().makeRotationZ(radians(options.rotate)));
134
+ geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(center.x, center.y, center.z));
135
+ return this.saveSTL(geometry, path.join(outputDir, `${path.basename(stlFilePath, path.extname(stlFilePath))}_transformed.stl`));
136
+ }
113
137
  /**
114
138
  * Load STL file and return geometry and bounding box
115
139
  */
@@ -806,11 +830,14 @@ export class STLManipulator extends EventEmitter {
806
830
  }
807
831
  options.loadFilaments = legacyFilaments;
808
832
  }
833
+ const cliInput = planBambuCliInput(cliType, is3mf, options);
809
834
  const profilesRoot = detectProfilesRoot(slicerPath, cliType);
810
835
  const preparer = new BambuCliProfilePreparer(path.join(this.tempDir, 'slicer-profiles'));
811
836
  const bundle = await preparer.prepare(stlFilePath, outputBase, cliType, profilesRoot, printerPreset, orcaProfiles.settingsProfiles[0], options);
812
837
  bambuOutputDir = fs.mkdtempSync(path.join(this.tempDir, `${operationId}-bambu-output-`));
813
- args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options), stlFilePath];
838
+ // The transformed copy lives in the run's output directory, which is removed afterwards.
839
+ const inputPath = cliInput.meshTransformed ? await this.transformMeshForCli(stlFilePath, options, bambuOutputDir) : stlFilePath;
840
+ args = [...buildBambuCliArgs(bundle, bambuOutputDir, exportName, options, cliInput), inputPath];
814
841
  bambuExport = {
815
842
  producedPath: path.join(bambuOutputDir, exportName),
816
843
  // One folder per operation: concurrent slices of same-named inputs
@@ -871,12 +898,14 @@ export class STLManipulator extends EventEmitter {
871
898
  if (progressCallback)
872
899
  progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
873
900
  console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
901
+ const cliOutputDir = bambuOutputDir;
874
902
  await runSlicerProcess({
875
903
  command: slicerPath,
876
904
  args,
877
905
  timeoutMs: this.resolveSlicerTimeoutMs(),
878
906
  slicerType,
879
907
  isCancelled: () => !this.activeOperations.get(operationId),
908
+ failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
880
909
  });
881
910
  if (!this.activeOperations.get(operationId)) {
882
911
  throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-3d-printer-server",
3
3
  "mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
4
- "version": "1.2.13",
4
+ "version": "1.2.14",
5
5
  "description": "MCP server for connecting Claude with 3D printer management systems",
6
6
  "main": "dist/index.js",
7
7
  "repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
@@ -88,7 +88,7 @@
88
88
  "jszip": "^3.10.2",
89
89
  "three": "^0.173.0",
90
90
  "xml2js": "^0.6.2",
91
- "zod": "^3.25.76"
91
+ "zod": "^4.6.7"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@types/jszip": "^3.4.0",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.2.13",
10
+ "version": "1.2.14",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "mcp-3d-printer-server",
15
- "version": "1.2.13",
15
+ "version": "1.2.14",
16
16
  "runtimeHint": "npx",
17
17
  "transport": {
18
18
  "type": "stdio"
package/src/fulu-orca.ts CHANGED
@@ -99,6 +99,46 @@ export function isFuluPrintRpcMethod(method: string): boolean {
99
99
  return FULU_PRINT_RPC_METHODS.has(method.trim());
100
100
  }
101
101
 
102
+ const FULU_LINUX_DOCS = "https://github.com/DMontgomery40/mcp-3D-printer-server/blob/main/docs/FULU.md#linux";
103
+
104
+ /**
105
+ * FULU's Linux host opens its network and source libraries by bare name from
106
+ * its working directory instead of PJARCZAK_BAMBU_PLUGIN_DIR
107
+ * (FULU-Foundation/OrcaSlicer-bambulab#51), so name them in that directory
108
+ * unless they are already set. macOS wrappers run the host in a Linux guest
109
+ * that may not see host paths, so they are left alone.
110
+ */
111
+ export function linuxPluginLibraryEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): Record<string, string> {
112
+ const pluginDir = env.PJARCZAK_BAMBU_PLUGIN_DIR?.trim();
113
+ if (platform !== "linux" || !pluginDir) return {};
114
+ const libraries: Record<string, string> = {};
115
+ if (!env.PJARCZAK_BAMBU_NETWORK_SO?.trim()) libraries.PJARCZAK_BAMBU_NETWORK_SO = path.resolve(pluginDir, "libbambu_networking.so");
116
+ if (!env.PJARCZAK_BAMBU_SOURCE_SO?.trim()) libraries.PJARCZAK_BAMBU_SOURCE_SO = path.resolve(pluginDir, "libBambuSource.so");
117
+ return libraries;
118
+ }
119
+
120
+ /** FULU's Linux host exits 101 when it cannot reopen a socket stdout (FULU-Foundation/OrcaSlicer-bambulab#50). */
121
+ const STDOUT_SOCKET_HINT =
122
+ "Exit code 101 means FULU's Linux host could not reopen its stdout, which fails when stdout is a socket, as Node.js provides " +
123
+ "(https://github.com/FULU-Foundation/OrcaSlicer-bambulab/issues/50). Launch the host through a wrapper that pipes its output through cat: " +
124
+ FULU_LINUX_DOCS;
125
+
126
+ /** Actionable hints for a bridge run: the exit-101 socket bug and a host that cannot open the plug-in libraries. */
127
+ function bridgeHints(exitCode: number | null, responses: Record<string, unknown>): string[] {
128
+ const hints: string[] = [];
129
+ if (exitCode === 101) hints.push(STDOUT_SOCKET_HINT);
130
+ const fileOpenFailure = (Object.values(responses) as Array<{ network_status?: unknown; error?: unknown } | null>)
131
+ .find((value) => [value?.network_status, value?.error].some((text) => typeof text === "string" && /file open failed/i.test(text)));
132
+ if (fileOpenFailure) {
133
+ hints.push(
134
+ `FULU BambuNetwork bridge could not open its networking library (${String(fileOpenFailure.network_status ?? fileOpenFailure.error)}). ` +
135
+ "Set PJARCZAK_BAMBU_PLUGIN_DIR to the directory containing libbambu_networking.so and libBambuSource.so, " +
136
+ `or set PJARCZAK_BAMBU_NETWORK_SO and PJARCZAK_BAMBU_SOURCE_SO to their absolute paths: ${FULU_LINUX_DOCS}`
137
+ );
138
+ }
139
+ return hints;
140
+ }
141
+
102
142
  function normalizePlatform(value: string | undefined): SupportedPlatform {
103
143
  const normalized = (value || process.platform).trim().toLowerCase();
104
144
  if (normalized === "mac" || normalized === "macos" || normalized === "darwin") {
@@ -284,7 +324,7 @@ function setupCommands(platform: SupportedPlatform, pluginDir: string, runtimeDi
284
324
  const verify = path.join(pluginDir, "verify_runtime_macos.sh");
285
325
  const bridgeHost = path.join(runtimeDir || "", "pjarczak_bambu_linux_host");
286
326
  const wrapper = path.join(pluginDir, "pjarczak-bambu-linux-host-wrapper");
287
- return [
327
+ const commands = [
288
328
  {
289
329
  label: "install macOS Lima runtime",
290
330
  command: `bash ${quoteShell(install)} -PackageDir ${quoteShell(pluginDir)} -PluginDir ${quoteShell(pluginDir)}`,
@@ -297,11 +337,15 @@ function setupCommands(platform: SupportedPlatform, pluginDir: string, runtimeDi
297
337
  label: "verify macOS Lima runtime core before Linux plugin payload is present",
298
338
  command: `bash ${quoteShell(verify)} -PackageDir ${quoteShell(pluginDir)} -PluginDir ${quoteShell(pluginDir)} -AllowMissingLinuxPlugin`,
299
339
  },
300
- {
340
+ ];
341
+ // The wrapper ships in FULU's macOS bundle; suggest it only where it exists.
342
+ if (fs.existsSync(wrapper)) {
343
+ commands.push({
301
344
  label: "bridge command for FULU BambuNetwork runtime",
302
345
  command: `${quoteShell(wrapper)} ${quoteShell(bridgeHost)}`,
303
- },
304
- ];
346
+ });
347
+ }
348
+ return commands;
305
349
  }
306
350
 
307
351
  if (platform === "win32") {
@@ -336,6 +380,11 @@ function setupCommands(platform: SupportedPlatform, pluginDir: string, runtimeDi
336
380
  label: "configure MCP slicer path",
337
381
  command: "SLICER_TYPE=orcaslicer-bambulab SLICER_PATH=/path/to/orcaslicer",
338
382
  },
383
+ {
384
+ label: "bridge command for FULU's Linux host, piped through cat (FULU #50)",
385
+ command: `FULU_BAMBU_BRIDGE_COMMAND="${quoteShell(path.join(pluginDir, "pjarczak_bambu_linux_host"))} | cat" ` +
386
+ "PJARCZAK_BAMBU_PLUGIN_DIR=\"$HOME/.config/OrcaSlicer/plugins\"",
387
+ },
339
388
  ];
340
389
  }
341
390
 
@@ -350,6 +399,7 @@ function defaultBridgeCommand(platform: SupportedPlatform, pluginDir: string, ru
350
399
 
351
400
  if (platform === "darwin") {
352
401
  const wrapper = path.join(pluginDir, "pjarczak-bambu-linux-host-wrapper");
402
+ if (!fs.existsSync(wrapper)) return undefined;
353
403
  const runtimeHost = path.join(
354
404
  runtimeDir || defaultRuntimeDir(platform) || "",
355
405
  "pjarczak_bambu_linux_host"
@@ -358,8 +408,10 @@ function defaultBridgeCommand(platform: SupportedPlatform, pluginDir: string, ru
358
408
  }
359
409
 
360
410
  if (platform === "linux") {
411
+ // The v1.0.0 host exits 101 on the socket stdout Node.js gives a child
412
+ // (FULU-Foundation/OrcaSlicer-bambulab#50); a shell pipe through cat avoids it.
361
413
  const host = path.join(pluginDir, "pjarczak_bambu_linux_host");
362
- return fs.existsSync(host) ? quoteShell(host) : undefined;
414
+ return fs.existsSync(host) ? `${quoteShell(host)} | cat` : undefined;
363
415
  }
364
416
 
365
417
  return undefined;
@@ -417,6 +469,7 @@ async function runBridgeRequests(
417
469
  return await new Promise((resolve, reject) => {
418
470
  const child = spawn(command, {
419
471
  shell: true,
472
+ env: { ...process.env, ...linuxPluginLibraryEnv(process.env, process.platform) },
420
473
  stdio: ["pipe", "pipe", "pipe"],
421
474
  });
422
475
 
@@ -433,13 +486,35 @@ async function runBridgeRequests(
433
486
  callback();
434
487
  };
435
488
 
489
+ const unanswered = new Set(requests.map((request) => request.id));
436
490
  const timer = setTimeout(() => {
491
+ child.stdin?.end();
437
492
  child.kill("SIGKILL");
438
- settle(() => reject(new Error(`FULU bridge request timed out after ${timeoutMs}ms.`)));
493
+ // Keep what the host did say: a hung host is diagnosed from its stderr and partial answers.
494
+ const responses = Object.fromEntries(parseBridgeFrames(Buffer.concat(stdoutChunks))
495
+ .filter((frame) => frame.type === FRAME_TYPE_JSON_RESPONSE)
496
+ .map((frame) => [String(frame.id), (() => { try { return JSON.parse(frame.payload.toString("utf8")); } catch { return null; } })()]));
497
+ const stderr = Buffer.concat(stderrChunks).toString("utf8").trim().slice(-1000);
498
+ const hints = bridgeHints(null, responses);
499
+ settle(() => reject(new Error(
500
+ `FULU bridge request timed out after ${timeoutMs}ms with ${unanswered.size} of ${requests.length} request(s) unanswered.` +
501
+ (stderr ? ` Host stderr: ${stderr}` : "") + (hints.length ? ` ${hints.join(" ")}` : "")
502
+ )));
439
503
  }, timeoutMs);
440
504
 
441
- child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
505
+ // FULU's host exits on stdin EOF without answering requests still in
506
+ // flight, so stdin closes only once every request has a response.
507
+ child.stdout?.on("data", (chunk: Buffer) => {
508
+ stdoutChunks.push(chunk);
509
+ if (!unanswered.size) return;
510
+ for (const frame of parseBridgeFrames(Buffer.concat(stdoutChunks))) {
511
+ if (frame.type === FRAME_TYPE_JSON_RESPONSE) unanswered.delete(frame.id);
512
+ }
513
+ if (!unanswered.size) child.stdin?.end();
514
+ });
442
515
  child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
516
+ // A host that exits early (exit code 101) closes the pipe; close reports the result.
517
+ child.stdin?.on("error", () => {});
443
518
  child.on("error", (error) => {
444
519
  settle(() => reject(error));
445
520
  });
@@ -478,7 +553,7 @@ async function runBridgeRequests(
478
553
  for (const request of requests) {
479
554
  child.stdin?.write(writeJsonRequestFrame(request.id, request.method, request.payload));
480
555
  }
481
- child.stdin?.end();
556
+ if (!unanswered.size) child.stdin?.end();
482
557
  });
483
558
  }
484
559
 
@@ -493,9 +568,13 @@ async function probeFuluBridge(command: string, timeoutMs: number): Promise<Reco
493
568
  timeoutMs
494
569
  );
495
570
 
571
+ const handshake = probe.responses["1"] as { ok?: unknown; network_loaded?: unknown } | undefined;
572
+ const hints = bridgeHints(probe.exitCode, probe.responses);
496
573
  return {
497
574
  exitCode: probe.exitCode,
498
- ok: probe.ok && Boolean(probe.responses["1"]),
575
+ // A handshake from a host that could not load the networking plug-in is not a ready bridge.
576
+ ok: probe.ok && Boolean(handshake) && handshake?.ok !== false && handshake?.network_loaded !== false,
577
+ ...(hints.length ? { hints } : {}),
499
578
  stderr: probe.stderr,
500
579
  logs: probe.logs,
501
580
  handshake: probe.responses["1"] || null,
@@ -546,6 +625,7 @@ export async function invokeFuluBridgeRpc(options: FuluBridgeRpcOptions): Promis
546
625
  options.timeoutMs || 5000
547
626
  );
548
627
 
628
+ const hints = bridgeHints(bridgeResult.exitCode, bridgeResult.responses);
549
629
  return {
550
630
  method,
551
631
  readOnly,
@@ -554,6 +634,7 @@ export async function invokeFuluBridgeRpc(options: FuluBridgeRpcOptions): Promis
554
634
  bambuModel: options.bambuModel || undefined,
555
635
  exitCode: bridgeResult.exitCode,
556
636
  ok: bridgeResult.ok,
637
+ ...(hints.length ? { hints } : {}),
557
638
  stderr: bridgeResult.stderr,
558
639
  logs: bridgeResult.logs,
559
640
  response: bridgeResult.responses["1"] || null,
@@ -601,6 +682,12 @@ export async function inspectFuluOrcaSetup(options: FuluOrcaSetupOptions): Promi
601
682
  pluginDir: pluginDir || null,
602
683
  runtimeDir: runtimeDir || null,
603
684
  bridgeCommand: bridgeCommand || null,
685
+ // Linux only: absolute plug-in library paths passed to FULU's host (FULU #51).
686
+ ...(platform === "linux" ? { pluginLibraries: {
687
+ ...Object.fromEntries(["PJARCZAK_BAMBU_NETWORK_SO", "PJARCZAK_BAMBU_SOURCE_SO"]
688
+ .filter((name) => process.env[name]?.trim()).map((name) => [name, process.env[name]!.trim()])),
689
+ ...linuxPluginLibraryEnv(process.env, process.platform),
690
+ } } : {}),
604
691
  fileChecks,
605
692
  missingRequiredFiles,
606
693
  optionalMissingFiles,
@@ -626,7 +713,12 @@ export async function inspectFuluOrcaSetup(options: FuluOrcaSetupOptions): Promi
626
713
  "Bambu print start still enforces BAMBU_MODEL before generating or sending project G-code.",
627
714
  "Raw bridge print methods are disabled; print_3mf inspects the exact plate, checks the live printer and asks for human confirmation.",
628
715
  "The FULU BambuNetwork bridge is a separate runtime from the LAN MQTT/FTPS path; use bridge probing to verify that runtime before relying on cloud behavior.",
629
- "FULU macOS release bundles may ship the bridge dylib and host shims before the Linux plugin .so payload is present; missing linux-plugin entries mean the slicer can still be tested, but the BambuNetwork bridge is not fully ready.",
716
+ ...(platform === "darwin"
717
+ ? ["FULU macOS release bundles may ship the bridge dylib and host shims before the Linux plugin .so payload is present; missing linux-plugin entries mean the slicer can still be tested, but the BambuNetwork bridge is not fully ready."]
718
+ : []),
719
+ ...(platform === "linux"
720
+ ? ["FULU's v1.0.0 Linux host exits 101 when its stdout is a Node.js socket (FULU #50) and opens the plug-in libraries from its working directory (FULU #51). Pipe the host through cat and set PJARCZAK_BAMBU_PLUGIN_DIR; the server then passes the absolute library paths."]
721
+ : []),
630
722
  ],
631
723
  };
632
724
  }
package/src/index.ts CHANGED
@@ -270,11 +270,11 @@ const SLICE_TOOL_PROPERTIES = {
270
270
  description: "Template directory override when resolving template_name (default: BAMBU_TEMPLATE_DIR or ~/Sync/bambu/templates).",
271
271
  },
272
272
  uptodate: { type: "boolean", description: "Bambu-compatible slicing: refresh 3MF preset configs to the installed slicer version (--uptodate)." },
273
- repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies (--repetitions)." },
273
+ repetitions: { type: "number", description: "Bambu-compatible slicing: print N identical copies (--repetitions). OrcaSlicer copies an STL with --clone-objects; do not combine with clone_objects." },
274
274
  orient: { type: "boolean", description: "Bambu-compatible slicing: auto-orient for printability (--orient)." },
275
- arrange: { type: "boolean", description: "Bambu-compatible slicing: auto-arrange objects on the plate (--arrange). Set false to keep an existing layout." },
275
+ arrange: { type: "boolean", description: "Bambu-compatible slicing: auto-arrange objects on the plate (--arrange); STL input is arranged by default. Set false to keep an existing layout: an STL that is not already on the plate then fails to slice." },
276
276
  ensure_on_bed: { type: "boolean", description: "Bambu-compatible slicing: lower floating models onto the bed (--ensure-on-bed)." },
277
- clone_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated clone counts per object index, e.g. '1,3,1,10'." },
277
+ clone_objects: { type: "string", description: "Bambu-compatible slicing: clone the loaded model, total copies as a single integer, e.g. '3' (--clone-objects). The Bambu-style CLIs count copies per input file and this tool loads one file, so a comma-separated list is rejected by the slicer." },
278
278
  skip_objects: { type: "string", description: "Bambu-compatible slicing: comma-separated object indices to skip, e.g. '3,5,10'." },
279
279
  enable_timelapse: { type: "boolean", description: "Bambu-compatible slicing: insert timelapse parking moves (--enable-timelapse)." },
280
280
  allow_mix_temp: { type: "boolean", description: "Bambu-compatible slicing: allow filaments with different temperature requirements on one plate." },
@@ -12,6 +12,7 @@ import {
12
12
  manualHeatingRequirements,
13
13
  publishBambuCommand,
14
14
  readFreshPrinterStatus,
15
+ unchippedNozzles,
15
16
  validatePrinterState,
16
17
  type PrinterStateRequirements,
17
18
  } from "../safety/printer-state.js";
@@ -184,11 +185,14 @@ export class BambuImplementation extends PrinterImplementation {
184
185
 
185
186
  async confirmPrintPreflight(serial: string, status: any, inspection: PrintFileInspection): Promise<string | undefined> {
186
187
  const finishedJob = this.finishedJobIdentity(status);
188
+ const unchipped = unchippedNozzles(status?.raw);
187
189
  await this.confirmHardwareAction(
188
190
  `Start a checked print on ${inspection.model.toUpperCase()} (${serial})? Nozzles: ${inspection.nozzleDiameters.join(", ")} mm. ` +
189
191
  `Materials: ${inspection.materials.join(", ")}. Peak targets: nozzle ${inspection.maxNozzleTemperature}°C, bed ${inspection.maxBedTemperature}°C, chamber ${inspection.maxChamberTemperature}°C. ` +
190
192
  `File SHA-256: ${inspection.sha256}. Confirm the physical spool labels and that the build plate is clear.` +
191
- (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : ""),
193
+ (finishedJob !== undefined ? " The printer reports FINISH: remove the previous part and debris before confirming." : "") +
194
+ (unchipped.length ? ` Printer ${unchipped.length === 1 ? `nozzle ${unchipped[0]} reports` : `nozzles ${unchipped.join(" and ")} report`} no identity chip, ` +
195
+ `so ${unchipped.length === 1 ? "its" : "their"} diameter and type are the printer's configured settings; confirm they match the installed ${unchipped.length === 1 ? "hotend" : "hotends"}.` : ""),
192
196
  finishedJob !== undefined
193
197
  );
194
198
  return finishedJob;
@@ -261,8 +265,12 @@ export class BambuImplementation extends PrinterImplementation {
261
265
  console.warn("PushAllCommand failed, continuing with cached status", error);
262
266
  }
263
267
 
264
- if (!printer.data || Object.keys(printer.data).length === 0) {
265
- await new Promise((resolve) => setTimeout(resolve, 1500));
268
+ // The first push after connecting can be a sparse hello (model and
269
+ // modules); the full status follows. Returning before it merges reported
270
+ // status UNKNOWN, so wait for gcode_state within the same 1.5 s bound.
271
+ const deadline = Date.now() + 1500;
272
+ while (typeof printer.data?.gcode_state !== "string" && Date.now() < deadline) {
273
+ await sleep(50);
266
274
  }
267
275
 
268
276
  const data = printer.data;
@@ -50,7 +50,13 @@ export async function requireHumanConfirmation(
50
50
  physicalCheck = false
51
51
  ): Promise<void> {
52
52
  if (!physicalCheck && confirmationOptOut(scope)) return;
53
- if (!confirm || !(await confirm(message))) {
54
- throw new Error("Hardware safety confirmation was declined or unavailable. Use an MCP client with elicitation support. No command was sent.");
53
+ if (!confirm) throw new Error(CONFIRMATION_UNAVAILABLE);
54
+ if (!(await confirm(message))) {
55
+ throw new Error("Hardware safety confirmation was declined or cancelled in the MCP client. No command was sent. " + (physicalCheck
56
+ ? "This confirmation covers a physical check at the printer and always needs a person to accept it."
57
+ : "Some clients decline prompts automatically, for example in a non-interactive permission mode. A deliberately headless " +
58
+ `setup that approves jobs another way can set ${scope === "bambu"
59
+ ? "PRINT_REQUIRE_CONFIRMATION=0 (or BAMBU_REQUIRE_CONFIRMATION=0)"
60
+ : "PRINT_REQUIRE_CONFIRMATION=0"} to skip ordinary print and heating prompts.`));
55
61
  }
56
62
  }
@@ -148,6 +148,43 @@ function nozzleTypeAndFlow(value: unknown): {type?: string; flow?: string} {
148
148
  const type = token(value);
149
149
  return {type: ["stainlesssteel","hardenedsteel","tungstencarbide","brass"].includes(type) ? type : undefined};
150
150
  }
151
+ /**
152
+ * A nozzle without an identity chip reports serial "N/A" and keeps the
153
+ * unreliable bit set permanently (stock H2D HS01 nozzles), so its diameter
154
+ * and type are configured settings, as on single-nozzle printers.
155
+ */
156
+ function hasNoIdentityChip(entry: any): boolean {
157
+ return typeof entry?.sn === "string" && entry.sn.trim().toUpperCase() === "N/A";
158
+ }
159
+
160
+ /** Bit 0 (information unreliable) is accepted only from a nozzle without an identity chip. */
161
+ function unreliableWithoutChip(entry: any, stat: number): boolean {
162
+ return (stat & 1) !== 0 && hasNoIdentityChip(entry);
163
+ }
164
+
165
+ /** Nozzles whose reported diameter and type are configured settings rather than chip readings. */
166
+ export function unchippedNozzles(raw: unknown): number[] {
167
+ const info = (raw as any)?.device?.nozzle?.info;
168
+ if (!Array.isArray(info)) return [];
169
+ return info.flatMap(entry => {
170
+ const index = finiteNumber(entry?.id);
171
+ const stat = finiteNumber(entry?.stat);
172
+ return index !== undefined && Number.isInteger(index) && index >= 0 && index < 16 &&
173
+ stat !== undefined && Number.isInteger(stat) && stat >= 0 && unreliableWithoutChip(entry, stat) ? [index] : [];
174
+ });
175
+ }
176
+
177
+ const loggedUnchippedNozzles = new Set<string>();
178
+ /** Name each unchipped nozzle a check accepted once per printer on stderr, so headless runs still record it. */
179
+ function logUnchippedNozzles(raw: Record<string, any>, serial: string): void {
180
+ for (const index of unchippedNozzles(raw)) {
181
+ const key = `${serial}:${index}`;
182
+ if (loggedUnchippedNozzles.has(key)) continue;
183
+ loggedUnchippedNozzles.add(key);
184
+ console.error(`Printer ${serial || "(unknown serial)"} nozzle ${index} reports no identity chip; its diameter and type are the printer's configured settings.`);
185
+ }
186
+ }
187
+
151
188
  function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
152
189
  const info = raw.device?.nozzle?.info;
153
190
  const output: ReportedNozzle[] = [];
@@ -159,8 +196,11 @@ function reportedNozzles(raw: Record<string,any>): ReportedNozzle[] {
159
196
  const diameter = finiteNumber(entry?.diameter);
160
197
  if (index === undefined || !Number.isInteger(index) || index < 0 || diameter === undefined || diameter <= 0) throw new Error("Printer reported malformed nozzle identity or diameter.");
161
198
  if (entry.stat !== undefined) {
199
+ // BambuStudio DevNozzle: bit 0 set means the information is unreliable;
200
+ // bits 1-2 are 0 normal, 1 abnormal, 2 unknown.
162
201
  const stat = finiteNumber(entry.stat);
163
- if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 7) !== 0) throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
202
+ if (stat === undefined || !Number.isInteger(stat) || stat < 0 || (stat & 6) !== 0) throw new Error(`Printer nozzle ${index} information is unreliable, abnormal or unknown.`);
203
+ if ((stat & 1) !== 0 && !unreliableWithoutChip(entry, stat)) throw new Error(`Printer nozzle ${index} information is unreliable; refresh the nozzle information on the printer.`);
164
204
  }
165
205
  if (output.some(nozzle => nozzle.index === index)) throw new Error("Printer reported ambiguous duplicate nozzle indices.");
166
206
  output.push({index,diameter,...nozzleTypeAndFlow(entry.type)});
@@ -316,6 +356,7 @@ export function validatePrinterState(status: any, requirements: PrinterStateRequ
316
356
  filaments.push({position,material,trayIndex,nozzleMin,nozzleMax});
317
357
  }
318
358
  }
359
+ if (nozzles.length) logUnchippedNozzles(raw, identity.observedSerial ?? String(status.serial ?? ""));
319
360
  return {model,reportedNozzles:nozzles,filaments};
320
361
  }
321
362
 
@@ -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,