bambu-printer-mcp 1.1.28 → 1.1.29

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.
@@ -7,6 +7,8 @@ export interface PrinterStateRequirements {
7
7
  nozzleDiameters: number[];
8
8
  materials?: string[];
9
9
  usedFilamentPositions?: number[];
10
+ /** Verified physical nozzle index for each project filament, composed from the inspected plate mappings. */
11
+ filamentNozzleIndices?: number[];
10
12
  amsMapping?: number[];
11
13
  useAMS?: boolean;
12
14
  requireIdle?: boolean;
@@ -1,6 +1,6 @@
1
1
  import { HIDDEN_HMS } from "./hidden-hms.js";
2
2
  import { GetVersionCommand, PushAllCommand } from "bambu-node";
3
- import { normalizeMaterial, normalizeModel } from "./limits.js";
3
+ import { isDualNozzleModel, normalizeMaterial, normalizeModel } from "./limits.js";
4
4
  const MAX_REPORT_AGE_MS = 15000;
5
5
  const MODEL_IDS = {
6
6
  O1C: "H2C", O1C2: "H2C", O1D: "H2D", O1E: "H2D Pro", O1S: "H2S", N6: "X2D",
@@ -282,15 +282,24 @@ function trayRange(value, label) {
282
282
  throw new Error(`Reported filament ${label} temperature is malformed.`);
283
283
  return number;
284
284
  }
285
- function externalTray(raw) {
285
+ /** Dual-nozzle printers report one virtual external slot per nozzle, numbered as Bambu
286
+ * Studio does (VIRTUAL_TRAY_MAIN_ID 255 = right/main nozzle, VIRTUAL_TRAY_DEPUTY_ID 254 =
287
+ * left/deputy nozzle), so the requested tray selects its slot. Single-nozzle printers keep
288
+ * requiring exactly one slot 254 report, then the legacy vt_tray field. */
289
+ function externalTray(raw, trayIndex, model) {
286
290
  const slots = raw.vir_slot;
291
+ const dualNozzle = isDualNozzleModel(model);
287
292
  if (Array.isArray(slots)) {
288
- const selected = slots.filter((slot) => Number(slot?.id) === 254);
289
- if (slots.length === 1 && selected.length === 1)
293
+ const selected = slots.filter((slot) => Number(slot?.id) === trayIndex);
294
+ if (selected.length === 1 && (dualNozzle || slots.length === 1))
290
295
  return selected[0];
296
+ if (dualNozzle && selected.length === 0 && slots.length > 0)
297
+ throw new Error(`External spool ${trayIndex} is not in the fresh per-nozzle report; cannot verify the selected material.`);
291
298
  if (slots.length > 0)
292
299
  throw new Error("External spool report is ambiguous for this nozzle; cannot verify the selected material.");
293
300
  }
301
+ if (dualNozzle)
302
+ throw new Error(`Dual-nozzle external spool ${trayIndex} requires a fresh per-nozzle vir_slot report; legacy vt_tray cannot verify the selected nozzle.`);
294
303
  return object(raw.vt_tray) ? raw.vt_tray : undefined;
295
304
  }
296
305
  function mappedTray(raw, index) {
@@ -345,7 +354,7 @@ export function validatePrinterState(status, requirements) {
345
354
  }
346
355
  }
347
356
  const nozzles = requirements.nozzleDiameters.length ? reportedNozzles(raw) : [];
348
- if (requirements.nozzleDiameters.length && ["h2d", "h2dpro", "h2c", "x2d"].includes(model) &&
357
+ if (requirements.nozzleDiameters.length && isDualNozzleModel(model) &&
349
358
  !Array.isArray(raw.device?.nozzle?.info) && !Array.isArray(raw.nozzle_diameter)) {
350
359
  throw new Error("A scalar legacy nozzle report is ambiguous for this multi-nozzle printer; fresh per-nozzle configuration is required.");
351
360
  }
@@ -374,16 +383,33 @@ export function validatePrinterState(status, requirements) {
374
383
  if (requirements.verifyMaterials !== false && (requirements.materials !== undefined || requirements.nozzleDiameters.length)) {
375
384
  const materials = requirements.materials ?? [];
376
385
  const positions = requirements.usedFilamentPositions ?? materials.map((_, index) => index);
386
+ // 255 is the right/main nozzle external spool only on dual-nozzle printers.
387
+ const dualNozzle = isDualNozzleModel(model);
388
+ const maxTray = dualNozzle ? 255 : 254;
377
389
  if (!positions.length)
378
390
  throw new Error("A declared material is required for each used filament or external spool.");
379
391
  for (const position of positions) {
380
392
  const material = normalizeMaterial(materials[position]);
381
393
  if (!Number.isInteger(position) || position < 0 || !material)
382
394
  throw new Error(`A known declared material is required for filament position ${position}.`);
383
- const trayIndex = requirements.useAMS ? requirements.amsMapping?.[position] : 254;
384
- if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > 254)
395
+ const physicalNozzle = requirements.filamentNozzleIndices?.[position];
396
+ const externalForNozzle = physicalNozzle === 0 ? 255 : physicalNozzle === 1 ? 254 : undefined;
397
+ const trayIndex = requirements.useAMS || requirements.amsMapping !== undefined
398
+ ? requirements.amsMapping?.[position] : dualNozzle ? externalForNozzle : 254;
399
+ if (!requirements.useAMS && dualNozzle && requirements.amsMapping === undefined && externalForNozzle === undefined) {
400
+ throw new Error(`External spool for filament position ${position} is ambiguous; re-slice with complete filament_map and physical_extruder_map nozzle bindings or provide a verified sliced plate.`);
401
+ }
402
+ if (trayIndex === undefined || !Number.isInteger(trayIndex) || trayIndex < 0 || trayIndex > maxTray)
385
403
  throw new Error(`Complete AMS mapping is required for filament position ${position}.`);
386
- const tray = trayIndex === 254 ? externalTray(raw) : mappedTray(raw, trayIndex);
404
+ if (!requirements.useAMS && trayIndex < 254)
405
+ throw new Error("External-spool printing requires an external spool mapping; AMS trays require use_ams:true.");
406
+ if (dualNozzle && trayIndex >= 254) {
407
+ if (externalForNozzle === undefined)
408
+ throw new Error(`External spool ${trayIndex} needs a verified physical nozzle binding for filament position ${position}; re-slice with complete filament_map and physical_extruder_map metadata.`);
409
+ if (trayIndex !== externalForNozzle)
410
+ throw new Error(`External spool ${trayIndex} does not feed physical nozzle ${physicalNozzle} for filament position ${position}; use spool ${externalForNozzle}.`);
411
+ }
412
+ const tray = trayIndex >= 254 ? externalTray(raw, trayIndex, model) : mappedTray(raw, trayIndex);
387
413
  const reportedType = typeof tray?.tray_type === "string" ? tray.tray_type.trim() : "";
388
414
  if (reportedType && normalizeMaterial(reportedType) !== material)
389
415
  throw new Error(`Reported material ${reportedType} in slot ${trayIndex} contradicts declared ${materials[position]}.`);
@@ -404,7 +430,7 @@ export function manualHeatingRequirements(status, model, nozzleDiameter, materia
404
430
  const raw = status?.raw;
405
431
  if (!object(raw))
406
432
  throw new Error("A fresh printer report is required for manual nozzle heating.");
407
- if (["h2d", "h2dpro", "h2c", "x2d"].includes(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
433
+ if (isDualNozzleModel(normalized) || reportedNozzles(raw).some(nozzle => nozzle.index !== 0)) {
408
434
  throw new Error("Manual heating cannot verify the active nozzle and loaded material on this multi-nozzle printer. Use a checked sliced print instead.");
409
435
  }
410
436
  const requirements = {
@@ -0,0 +1,12 @@
1
+ import type { BambuSliceOptions } from '../stl/stl-manipulator.js';
2
+ export declare function validateRemoteSliceOptions(options?: BambuSliceOptions): number;
3
+ /** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
4
+ export declare function sliceWithOrcaApi(input: {
5
+ inputPath: string;
6
+ machinePath: string;
7
+ processPath: string;
8
+ filamentPaths: string[];
9
+ outputPath: string;
10
+ options?: BambuSliceOptions;
11
+ isCancelled: () => boolean;
12
+ }): Promise<string>;
@@ -0,0 +1,240 @@
1
+ /** AFKFelix/orca-slicer-api contract at a9a28f0a7b0c6f51b9be158509e9e9677532b868. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { inspectPrintFile } from '../safety/print-file.js';
6
+ import { normalizeMaterial, normalizeModel } from '../safety/limits.js';
7
+ const MAX_BYTES = 100000000;
8
+ const SUPPORTED_OPTIONS = new Set([
9
+ 'arrange', 'orient', 'loadFilaments', 'filamentColours', 'bedType',
10
+ 'nozzleType', 'nozzleVolumeType', 'slicePlate',
11
+ ]);
12
+ function configuration() {
13
+ let url;
14
+ try {
15
+ url = new URL(process.env.SLICER_API_URL ?? '');
16
+ }
17
+ catch {
18
+ throw new Error('Remote slicing requires a valid trusted SLICER_API_URL.');
19
+ }
20
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
21
+ throw new Error('SLICER_API_URL must use HTTP(S) without embedded credentials, query or fragment.');
22
+ }
23
+ const mode = process.env.SLICER_API_MODE || 'sync';
24
+ if (!['sync', 'async'].includes(mode))
25
+ throw new Error('SLICER_API_MODE must be sync or async.');
26
+ const timeout = Number(process.env.SLICER_API_TIMEOUT_MS ?? 600000);
27
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) {
28
+ throw new Error('SLICER_API_TIMEOUT_MS must be an integer from 1 to 3600000.');
29
+ }
30
+ const headers = {};
31
+ const token = process.env.SLICER_API_TOKEN;
32
+ if (token) {
33
+ const header = process.env.SLICER_API_TOKEN_HEADER || 'Authorization';
34
+ if (!/^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$/.test(header) ||
35
+ ['host', 'content-type', 'content-length', 'connection', 'transfer-encoding'].includes(header.toLowerCase()) ||
36
+ /[^\x20-\x7e]/.test(token)) {
37
+ throw new Error('Invalid remote slicing authentication configuration.');
38
+ }
39
+ headers[header] = header.toLowerCase() === 'authorization' ? `Bearer ${token}` : token;
40
+ }
41
+ return { base: url.href.replace(/\/+$/, ''), headers, mode, timeout };
42
+ }
43
+ export function validateRemoteSliceOptions(options = {}) {
44
+ for (const [key, value] of Object.entries(options)) {
45
+ if (value !== undefined && !SUPPORTED_OPTIONS.has(key)) {
46
+ throw new Error(`Remote slicing option ${key} is unsupported by orca-slicer-api; use a local backend for this workflow.`);
47
+ }
48
+ }
49
+ // Upstream evaluates multipart values by truthiness, so the string "false"
50
+ // becomes true. Never silently turn an explicit false into an enabled flag.
51
+ if (options.arrange === false || options.orient === false) {
52
+ throw new Error('Remote arrange=false and orient=false are unsupported by the upstream multipart API.');
53
+ }
54
+ const plate = options.slicePlate ?? 1;
55
+ if (!Number.isInteger(plate) || plate < 1)
56
+ throw new Error('Remote slicing requires one positive plate number; slicing all plates is unsupported.');
57
+ return plate;
58
+ }
59
+ /** The remote API forwards this display name to Orca's --curr-bed-type. */
60
+ function remoteBedType(value) {
61
+ const names = {
62
+ textured_plate: 'Textured PEI Plate', textured_pei_plate: 'Textured PEI Plate',
63
+ cool_plate: 'Cool Plate', engineering_plate: 'Engineering Plate',
64
+ hot_plate: 'High Temp Plate', high_temp_plate: 'High Temp Plate',
65
+ };
66
+ const key = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]+/g, '_') : '';
67
+ if (typeof names[key] !== 'string')
68
+ throw new Error('Unsupported remote bed type; use textured_plate, cool_plate, engineering_plate, or hot_plate.');
69
+ return names[key];
70
+ }
71
+ async function readResponse(response, limit) {
72
+ const size = Number(response.headers.get('content-length'));
73
+ if (size > limit) {
74
+ await response.body?.cancel();
75
+ throw new Error('Remote slicing response exceeds the size limit.');
76
+ }
77
+ if (!response.body)
78
+ throw new Error('Remote slicing returned an empty response.');
79
+ const reader = response.body.getReader();
80
+ const chunks = [];
81
+ let length = 0;
82
+ try {
83
+ for (;;) {
84
+ const { value, done } = await reader.read();
85
+ if (done)
86
+ break;
87
+ length += value.byteLength;
88
+ if (length > limit)
89
+ throw new Error('Remote slicing response exceeds the size limit.');
90
+ chunks.push(value);
91
+ }
92
+ }
93
+ finally {
94
+ await reader.cancel();
95
+ }
96
+ return Buffer.concat(chunks);
97
+ }
98
+ async function readJob(response) {
99
+ try {
100
+ const job = JSON.parse((await readResponse(response, 65536)).toString('utf8'));
101
+ if (job && typeof job === 'object' && !Array.isArray(job))
102
+ return job;
103
+ }
104
+ catch { /* Do not echo an untrusted response, which may contain credentials. */ }
105
+ throw new Error('Remote slicing returned invalid job metadata.');
106
+ }
107
+ /** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
108
+ export async function sliceWithOrcaApi(input) {
109
+ const options = input.options ?? {};
110
+ const plate = validateRemoteSliceOptions(options);
111
+ if (input.filamentPaths.length !== 1 || (options.filamentColours && options.filamentColours.length !== 1)) {
112
+ throw new Error('orca-slicer-api supports exactly one filament slot; multi-slot projects require a local slicer.');
113
+ }
114
+ const config = configuration();
115
+ const machine = JSON.parse(await fs.readFile(input.machinePath, 'utf8'));
116
+ const filament = JSON.parse(await fs.readFile(input.filamentPaths[0], 'utf8'));
117
+ const processProfile = JSON.parse(await fs.readFile(input.processPath, 'utf8'));
118
+ const bedType = remoteBedType(options.bedType ?? processProfile.curr_bed_type ?? 'textured_plate');
119
+ const model = normalizeModel(machine.printer_model);
120
+ const nozzles = Array.isArray(machine.nozzle_diameter) ? machine.nozzle_diameter.map(Number) : [];
121
+ const materialValues = Array.isArray(filament.filament_type) ? filament.filament_type : [];
122
+ const materials = materialValues.map(normalizeMaterial);
123
+ if (!model || !nozzles.length || nozzles.some((n) => ![0.2, 0.4, 0.6, 0.8].includes(n)) ||
124
+ materials.length !== 1 || !materials[0]) {
125
+ throw new Error('Remote slicing requires explicit model, nozzle and single-material metadata in the prepared profiles.');
126
+ }
127
+ const controller = new AbortController();
128
+ let timedOut = false;
129
+ const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, config.timeout);
130
+ const cancelCheck = setInterval(() => { if (input.isCancelled())
131
+ controller.abort(); }, 100);
132
+ let requestId;
133
+ let terminalJob = false;
134
+ let wroteOutput = false;
135
+ const request = async (route, init = {}) => {
136
+ if (input.isCancelled())
137
+ controller.abort();
138
+ // Do not pass multipart data to fetch after cancellation: undici may start
139
+ // its body producer before checking the signal and close it mid-enqueue.
140
+ controller.signal.throwIfAborted();
141
+ let response;
142
+ try {
143
+ response = await fetch(config.base + route, { ...init, headers: config.headers, signal: controller.signal, redirect: 'manual' });
144
+ }
145
+ catch {
146
+ throw new Error('Remote slicing HTTP request failed; check the trusted service configuration.');
147
+ }
148
+ if (!response.ok) {
149
+ await response.body?.cancel();
150
+ throw new Error(`Remote slicing HTTP ${response.status}.`);
151
+ }
152
+ return response;
153
+ };
154
+ try {
155
+ const ext = path.extname(input.inputPath).toLowerCase();
156
+ if (!['.stl', '.3mf'].includes(ext))
157
+ throw new Error('Remote slicing supports only STL or 3MF input.');
158
+ const bytes = await fs.readFile(input.inputPath);
159
+ if (bytes.length > MAX_BYTES)
160
+ throw new Error('Remote slicing input exceeds the 100 MB upstream limit.');
161
+ const form = new FormData();
162
+ form.set('file', new Blob([new Uint8Array(bytes)], { type: ext === '.3mf' ? 'model/3mf' : 'model/stl' }), `model${ext}`);
163
+ for (const [field, filePath, type] of [
164
+ ['printerProfile', input.machinePath, 'machine'],
165
+ ['presetProfile', input.processPath, 'process'],
166
+ ['filamentProfile', input.filamentPaths[0], 'filament'],
167
+ ]) {
168
+ const profile = JSON.parse(await fs.readFile(filePath, 'utf8'));
169
+ // The remote validator requires these metadata fields even for standalone
170
+ // user profiles. They do not change the resolved machine/process settings.
171
+ const data = { ...profile, type, from: profile.from || 'User' };
172
+ form.set(field, new Blob([JSON.stringify(data)], { type: 'application/json' }), `${field}.json`);
173
+ }
174
+ form.set('exportType', '3mf');
175
+ form.set('plate', String(plate));
176
+ form.set('bedType', bedType);
177
+ form.set('resolveProfileInheritance', 'false');
178
+ if (options.arrange === true)
179
+ form.set('arrange', 'true');
180
+ if (options.orient === true)
181
+ form.set('orient', 'true');
182
+ let response = await request(config.mode === 'async' ? '/slice-async' : '/slice', { method: 'POST', body: form });
183
+ if (config.mode === 'async') {
184
+ const submitted = await readJob(response);
185
+ if (typeof submitted.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(submitted.requestId)) {
186
+ throw new Error('Remote slicing returned an invalid request ID.');
187
+ }
188
+ requestId = submitted.requestId;
189
+ for (;;) {
190
+ // Construct the documented same-service paths. Returned URLs cannot
191
+ // redirect authenticated requests to an arbitrary origin or route.
192
+ const job = await readJob(await request(`/slice-async/${requestId}`));
193
+ if (job.status === 'completed' || job.status === 'failed') {
194
+ terminalJob = true;
195
+ if (job.status === 'failed')
196
+ throw new Error('Remote slicing job failed; inspect the service logs.');
197
+ break;
198
+ }
199
+ if (job.status !== 'pending' && job.status !== 'processing')
200
+ throw new Error('Remote slicing returned an unknown job state.');
201
+ await delay(500, undefined, { signal: controller.signal });
202
+ }
203
+ response = await request(`/slice-async/${requestId}/result`);
204
+ }
205
+ const result = await readResponse(response, MAX_BYTES);
206
+ await fs.writeFile(input.outputPath, result, { flag: 'wx', mode: 0o600 });
207
+ wroteOutput = true;
208
+ const inspection = await inspectPrintFile(input.outputPath, {
209
+ model, nozzleDiameters: nozzles, plateIndex: plate - 1, bedType,
210
+ });
211
+ if (JSON.stringify(inspection.materials) !== JSON.stringify(materials)) {
212
+ throw new Error('Remote sliced file materials contradict the prepared filament profile.');
213
+ }
214
+ if (input.isCancelled())
215
+ throw new Error('Remote slicing cancelled.');
216
+ return input.outputPath;
217
+ }
218
+ catch (error) {
219
+ if (wroteOutput)
220
+ await fs.rm(input.outputPath, { force: true });
221
+ if (controller.signal.aborted) {
222
+ throw new Error(`Remote slicing ${timedOut ? 'timed out' : 'cancelled'}; the remote job may continue. No retry was sent.`);
223
+ }
224
+ throw error;
225
+ }
226
+ finally {
227
+ // DELETE only removes finished jobs; upstream has no active-job cancellation.
228
+ // Cleanup never retries submission or masks a valid result / original error.
229
+ if (requestId && terminalJob && !controller.signal.aborted) {
230
+ try {
231
+ await (await request(`/slice-async/${requestId}`, { method: 'DELETE' })).body?.cancel();
232
+ }
233
+ catch {
234
+ console.error('Remote slicing finished-job cleanup failed; service retention applies.');
235
+ }
236
+ }
237
+ clearTimeout(deadline);
238
+ clearInterval(cancelCheck);
239
+ }
240
+ }
@@ -22,7 +22,7 @@ export type TransformationParams = {
22
22
  relative?: boolean;
23
23
  selectionBounds?: THREE.Box3;
24
24
  };
25
- export declare const SLICER_TYPES: readonly ["bambustudio", "orcaslicer", "orcaslicer-bambulab", "prusaslicer", "cura", "slic3r"];
25
+ export declare const SLICER_TYPES: readonly ["bambustudio", "orcaslicer", "orcaslicer-bambulab", "orca-slicer-api", "prusaslicer", "cura", "slic3r"];
26
26
  export type SlicerType = typeof SLICER_TYPES[number];
27
27
  export declare function normalizeSlicerType(rawSlicerType: string): SlicerType;
28
28
  export declare function isBambuCompatibleSlicer(slicerType: SlicerType): boolean;
@@ -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 { sliceWithOrcaApi, validateRemoteSliceOptions } from '../slicer/remote-slicer.js';
14
15
  const readFileAsync = promisify(fs.readFile);
15
16
  const writeFileAsync = promisify(fs.writeFile);
16
17
  const BAMBU_PROFILE_ROOTS = [
@@ -27,6 +28,7 @@ export const SLICER_TYPES = [
27
28
  'bambustudio',
28
29
  'orcaslicer',
29
30
  'orcaslicer-bambulab',
31
+ 'orca-slicer-api',
30
32
  'prusaslicer',
31
33
  'cura',
32
34
  'slic3r',
@@ -48,6 +50,8 @@ const SLICER_TYPE_ALIASES = {
48
50
  orcastudio: 'orcaslicer-bambulab',
49
51
  'orcaslicer-bmcu': 'orcaslicer-bambulab',
50
52
  'orca-bmcu': 'orcaslicer-bambulab',
53
+ 'orca-slicer-api': 'orca-slicer-api',
54
+ 'orcaslicer-api': 'orca-slicer-api',
51
55
  prusaslicer: 'prusaslicer',
52
56
  'prusa-slicer': 'prusaslicer',
53
57
  cura: 'cura',
@@ -65,7 +69,8 @@ export function normalizeSlicerType(rawSlicerType) {
65
69
  export function isBambuCompatibleSlicer(slicerType) {
66
70
  return (slicerType === 'bambustudio' ||
67
71
  slicerType === 'orcaslicer' ||
68
- slicerType === 'orcaslicer-bambulab');
72
+ slicerType === 'orcaslicer-bambulab' ||
73
+ slicerType === 'orca-slicer-api');
69
74
  }
70
75
  function isPathLikeExecutable(slicerPath) {
71
76
  return (path.isAbsolute(slicerPath) ||
@@ -1225,7 +1230,7 @@ export class STLManipulator extends EventEmitter {
1225
1230
  async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, bambuOptions) {
1226
1231
  const operationId = this.generateOperationId();
1227
1232
  this.activeOperations.set(operationId, true);
1228
- if (isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
1233
+ if (slicerType !== 'orca-slicer-api' && isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
1229
1234
  throw new Error(`Slicer executable not found at: ${slicerPath}`);
1230
1235
  }
1231
1236
  if (slicerProfile && !fs.existsSync(slicerProfile)) {
@@ -1267,6 +1272,7 @@ export class STLManipulator extends EventEmitter {
1267
1272
  case 'bambustudio':
1268
1273
  case 'orcaslicer':
1269
1274
  case 'orcaslicer-bambulab':
1275
+ case 'orca-slicer-api':
1270
1276
  // Bambu-compatible CLI: slice and export as 3MF with embedded gcode.
1271
1277
  // FULU's OrcaSlicer-bambulab fork keeps this Bambu-style path.
1272
1278
  // For 3MF input: slice in place and export sliced 3MF
@@ -1274,10 +1280,19 @@ export class STLManipulator extends EventEmitter {
1274
1280
  {
1275
1281
  const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
1276
1282
  const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
1277
- const bambuOutputPath = path.join(this.tempDir, outputBase + '_sliced.3mf');
1283
+ const remote = slicerType === 'orca-slicer-api';
1284
+ if (remote) {
1285
+ validateRemoteSliceOptions(bambuOptions);
1286
+ if (!process.env.BAMBU_PROFILES_ROOT)
1287
+ throw new Error('Remote slicing requires BAMBU_PROFILES_ROOT pointing to the matching Orca profile tree (a read-only mount is sufficient).');
1288
+ if (slicerProfile && !fs.existsSync(slicerProfile))
1289
+ throw new Error('Remote slicing process profile does not exist.');
1290
+ }
1291
+ const profileSlicerType = remote ? 'orcaslicer' : slicerType;
1292
+ const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
1278
1293
  const outputDir = path.dirname(bambuOutputPath);
1279
- const profilesRoot = detectProfilesRoot(slicerPath, slicerType);
1280
- const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1294
+ const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1295
+ const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, profileSlicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1281
1296
  // Resolve every BBL dependency before launching the slicer.
1282
1297
  const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1283
1298
  // The machine preset assumes the stock hotend; record the installed one so
@@ -1297,7 +1312,7 @@ export class STLManipulator extends EventEmitter {
1297
1312
  // absolute-extrusion normalization after resolving those ancestors.
1298
1313
  // The machine owns the extrusion mode and layer G-code once process
1299
1314
  // files lose machine keys, so normalize it there as well.
1300
- if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1315
+ if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1301
1316
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1302
1317
  settingsBundle.settingsArg = [
1303
1318
  this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
@@ -1305,6 +1320,17 @@ export class STLManipulator extends EventEmitter {
1305
1320
  ].join(';');
1306
1321
  }
1307
1322
  const cliBundle = this.isolateMachineSettings(settingsBundle);
1323
+ if (remote) {
1324
+ const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
1325
+ if (!machinePath || !processPath)
1326
+ throw new Error('Remote slicing requires complete prepared machine and process profiles.');
1327
+ outputFilePath = await sliceWithOrcaApi({
1328
+ inputPath: stlFilePath, machinePath, processPath,
1329
+ filamentPaths: cliBundle.filamentPaths, outputPath: bambuOutputPath,
1330
+ options: bambuOptions, isCancelled: () => !this.activeOperations.get(operationId),
1331
+ });
1332
+ break;
1333
+ }
1308
1334
  args = [
1309
1335
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1310
1336
  '--outputdir', outputDir,
@@ -1360,45 +1386,47 @@ export class STLManipulator extends EventEmitter {
1360
1386
  default:
1361
1387
  throw new Error(`Unsupported slicer type: ${slicerType}`);
1362
1388
  }
1363
- if (progressCallback)
1364
- progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1365
- console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1366
- // Execute the slicer
1367
- await new Promise((resolve, reject) => {
1368
- const process = execFile(slicerPath, args, (error, stdout, stderr) => {
1369
- if (error) {
1370
- console.error(`Slicer Error: ${error.message}`);
1371
- console.error(`Slicer Stderr: ${stderr}`);
1372
- reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1373
- }
1374
- else {
1375
- console.error(`Slicer Stdout: ${stdout}`);
1376
- if (stderr) {
1377
- console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1389
+ if (slicerType !== 'orca-slicer-api') {
1390
+ if (progressCallback)
1391
+ progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1392
+ console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1393
+ // Execute the slicer
1394
+ await new Promise((resolve, reject) => {
1395
+ const process = execFile(slicerPath, args, (error, stdout, stderr) => {
1396
+ if (error) {
1397
+ console.error(`Slicer Error: ${error.message}`);
1398
+ console.error(`Slicer Stderr: ${stderr}`);
1399
+ reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1378
1400
  }
1379
- resolve();
1380
- }
1381
- });
1382
- // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1383
- // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1384
- // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1385
- // Check for cancellation periodically
1386
- const checkCancel = setInterval(() => {
1387
- if (!this.activeOperations.get(operationId)) {
1388
- clearInterval(checkCancel);
1389
- try {
1390
- process.kill(); // Attempt to kill the slicer process
1391
- reject(new Error("Slicing operation cancelled"));
1401
+ else {
1402
+ console.error(`Slicer Stdout: ${stdout}`);
1403
+ if (stderr) {
1404
+ console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1405
+ }
1406
+ resolve();
1392
1407
  }
1393
- catch (killError) {
1394
- console.error("Error attempting to kill slicer process:", killError);
1395
- reject(new Error("Slicing operation cancelled, but failed to kill process."));
1408
+ });
1409
+ // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1410
+ // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1411
+ // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1412
+ // Check for cancellation periodically
1413
+ const checkCancel = setInterval(() => {
1414
+ if (!this.activeOperations.get(operationId)) {
1415
+ clearInterval(checkCancel);
1416
+ try {
1417
+ process.kill(); // Attempt to kill the slicer process
1418
+ reject(new Error("Slicing operation cancelled"));
1419
+ }
1420
+ catch (killError) {
1421
+ console.error("Error attempting to kill slicer process:", killError);
1422
+ reject(new Error("Slicing operation cancelled, but failed to kill process."));
1423
+ }
1396
1424
  }
1397
- }
1398
- }, 500);
1399
- process.on('exit', () => clearInterval(checkCancel));
1400
- process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
1401
- });
1425
+ }, 500);
1426
+ process.on('exit', () => clearInterval(checkCancel));
1427
+ process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
1428
+ });
1429
+ }
1402
1430
  if (!this.activeOperations.get(operationId)) {
1403
1431
  // This check might be redundant if the promise rejected on kill, but good safety
1404
1432
  throw new Error("Slicing operation cancelled after process finished");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.28",
3
+ "version": "1.1.29",
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": {
@@ -1,4 +1,15 @@
1
- export function normalizeBridgeAmsTrayValue(value: unknown, label: string): number {
1
+ /** Tray-value options. `dualNozzle` admits 255, the right/main nozzle external spool that
2
+ * dual-nozzle printers report beside the left/deputy external spool 254 (Bambu Studio
3
+ * VIRTUAL_TRAY_MAIN_ID / VIRTUAL_TRAY_DEPUTY_ID). Single-nozzle printers keep 254 only. */
4
+ export interface AmsTrayValueOptions { dualNozzle?: boolean }
5
+
6
+ export function describeAmsTrayRange(options: AmsTrayValueOptions = {}): string {
7
+ return options.dualNozzle
8
+ ? "[-1, 15] (absolute tray), 128-253 (AMS-HT), 254 (left/deputy external) or 255 (right/main external)"
9
+ : "[-1, 15] (absolute tray) or 128-254 (HT/external)";
10
+ }
11
+
12
+ export function normalizeBridgeAmsTrayValue(value: unknown, label: string, options: AmsTrayValueOptions = {}): number {
2
13
  let numeric: number;
3
14
  if (typeof value === "number") {
4
15
  numeric = value;
@@ -12,10 +23,10 @@ export function normalizeBridgeAmsTrayValue(value: unknown, label: string): numb
12
23
  !Number.isInteger(numeric) ||
13
24
  numeric < -1 ||
14
25
  (numeric > 15 && numeric < 128) ||
15
- numeric > 254
26
+ numeric > (options.dualNozzle ? 255 : 254)
16
27
  ) {
17
28
  throw new Error(
18
- `${label} must be an integer in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${JSON.stringify(value)}`
29
+ `${label} must be an integer in ${describeAmsTrayRange(options)}; got ${JSON.stringify(value)}`
19
30
  );
20
31
  }
21
32
 
@@ -27,7 +38,7 @@ function isNumericMappingKey(key: string): boolean {
27
38
  return Number.isInteger(numeric) && numeric >= 0;
28
39
  }
29
40
 
30
- export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping"): number[] {
41
+ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping", options: AmsTrayValueOptions = {}): number[] {
31
42
  return Object.entries(mapping)
32
43
  .sort(([leftKey], [rightKey]) => {
33
44
  const leftNumeric = isNumericMappingKey(leftKey);
@@ -37,7 +48,7 @@ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, labe
37
48
  if (rightNumeric) return 1;
38
49
  return 0;
39
50
  })
40
- .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`));
51
+ .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`, options));
41
52
  }
42
53
 
43
54
  export function hasAmsMappingInput(value: unknown): boolean {
@@ -45,3 +56,25 @@ export function hasAmsMappingInput(value: unknown): boolean {
45
56
  if (value && typeof value === "object") return Object.keys(value as Record<string, unknown>).length > 0;
46
57
  return value !== undefined && value !== null;
47
58
  }
59
+
60
+ /** Flat wire entries use -1 for dual-nozzle virtual trays; ams_mapping2 identifies their spool. */
61
+ export function bridgeAmsMappingValue(value: number, dualNozzle = false): number {
62
+ return dualNozzle && (value === 254 || value === 255) ? -1 : value;
63
+ }
64
+
65
+ /** Select an external spool only from a verified physical nozzle on dual-nozzle printers. */
66
+ export function externalTrayForNozzle(nozzle: number | undefined, dualNozzle = false): number {
67
+ if (!dualNozzle) return 254;
68
+ if (nozzle === 0) return 255;
69
+ if (nozzle === 1) return 254;
70
+ throw new Error("External-spool printing requires inspected filament_map and physical_extruder_map nozzle bindings.");
71
+ }
72
+
73
+ /** Virtual trays use slot zero on dual-nozzle printers; retain the historical single-nozzle form. */
74
+ export function bridgeAmsMapping2Entry(value: number, dualNozzle = false): { ams_id: number; slot_id: number } {
75
+ if (value < 0) return { ams_id: 255, slot_id: 255 };
76
+ if (value === 255) return dualNozzle ? { ams_id: 255, slot_id: 0 } : { ams_id: 255, slot_id: 255 };
77
+ if (value === 254) return { ams_id: 254, slot_id: dualNozzle ? 0 : 254 };
78
+ if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
79
+ return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
80
+ }