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.
@@ -0,0 +1,216 @@
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
+ import type { BambuSliceOptions } from '../stl/stl-manipulator.js';
8
+
9
+ const MAX_BYTES = 100_000_000;
10
+ const SUPPORTED_OPTIONS = new Set([
11
+ 'arrange', 'orient', 'loadFilaments', 'filamentColours', 'bedType',
12
+ 'nozzleType', 'nozzleVolumeType', 'slicePlate',
13
+ ]);
14
+
15
+ function configuration(): { base: string; headers: Record<string, string>; mode: string; timeout: number } {
16
+ let url: URL;
17
+ try { url = new URL(process.env.SLICER_API_URL ?? ''); }
18
+ catch { throw new Error('Remote slicing requires a valid trusted SLICER_API_URL.'); }
19
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
20
+ throw new Error('SLICER_API_URL must use HTTP(S) without embedded credentials, query or fragment.');
21
+ }
22
+ const mode = process.env.SLICER_API_MODE || 'sync';
23
+ if (!['sync', 'async'].includes(mode)) throw new Error('SLICER_API_MODE must be sync or async.');
24
+ const timeout = Number(process.env.SLICER_API_TIMEOUT_MS ?? 600000);
25
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) {
26
+ throw new Error('SLICER_API_TIMEOUT_MS must be an integer from 1 to 3600000.');
27
+ }
28
+ const headers: Record<string, string> = {};
29
+ const token = process.env.SLICER_API_TOKEN;
30
+ if (token) {
31
+ const header = process.env.SLICER_API_TOKEN_HEADER || 'Authorization';
32
+ if (!/^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$/.test(header) ||
33
+ ['host', 'content-type', 'content-length', 'connection', 'transfer-encoding'].includes(header.toLowerCase()) ||
34
+ /[^\x20-\x7e]/.test(token)) {
35
+ throw new Error('Invalid remote slicing authentication configuration.');
36
+ }
37
+ headers[header] = header.toLowerCase() === 'authorization' ? `Bearer ${token}` : token;
38
+ }
39
+ return { base: url.href.replace(/\/+$/, ''), headers, mode, timeout };
40
+ }
41
+
42
+ export function validateRemoteSliceOptions(options: BambuSliceOptions = {}): number {
43
+ for (const [key, value] of Object.entries(options)) {
44
+ if (value !== undefined && !SUPPORTED_OPTIONS.has(key)) {
45
+ throw new Error(`Remote slicing option ${key} is unsupported by orca-slicer-api; use a local backend for this workflow.`);
46
+ }
47
+ }
48
+ // Upstream evaluates multipart values by truthiness, so the string "false"
49
+ // becomes true. Never silently turn an explicit false into an enabled flag.
50
+ if (options.arrange === false || options.orient === false) {
51
+ throw new Error('Remote arrange=false and orient=false are unsupported by the upstream multipart API.');
52
+ }
53
+ const plate = options.slicePlate ?? 1;
54
+ if (!Number.isInteger(plate) || plate < 1) throw new Error('Remote slicing requires one positive plate number; slicing all plates is unsupported.');
55
+ return plate;
56
+ }
57
+
58
+ /** The remote API forwards this display name to Orca's --curr-bed-type. */
59
+ function remoteBedType(value: unknown): string {
60
+ const names: Record<string, string> = {
61
+ textured_plate: 'Textured PEI Plate', textured_pei_plate: 'Textured PEI Plate',
62
+ cool_plate: 'Cool Plate', engineering_plate: 'Engineering Plate',
63
+ hot_plate: 'High Temp Plate', high_temp_plate: 'High Temp Plate',
64
+ };
65
+ const key = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]+/g, '_') : '';
66
+ if (typeof names[key] !== 'string') throw new Error('Unsupported remote bed type; use textured_plate, cool_plate, engineering_plate, or hot_plate.');
67
+ return names[key];
68
+ }
69
+
70
+ async function readResponse(response: Response, limit: number): Promise<Buffer> {
71
+ const size = Number(response.headers.get('content-length'));
72
+ if (size > limit) { await response.body?.cancel(); throw new Error('Remote slicing response exceeds the size limit.'); }
73
+ if (!response.body) throw new Error('Remote slicing returned an empty response.');
74
+ const reader = response.body.getReader();
75
+ const chunks: Uint8Array[] = [];
76
+ let length = 0;
77
+ try {
78
+ for (;;) {
79
+ const { value, done } = await reader.read();
80
+ if (done) break;
81
+ length += value.byteLength;
82
+ if (length > limit) throw new Error('Remote slicing response exceeds the size limit.');
83
+ chunks.push(value);
84
+ }
85
+ } finally { await reader.cancel(); }
86
+ return Buffer.concat(chunks);
87
+ }
88
+
89
+ async function readJob(response: Response): Promise<Record<string, unknown>> {
90
+ try {
91
+ const job: unknown = JSON.parse((await readResponse(response, 65536)).toString('utf8'));
92
+ if (job && typeof job === 'object' && !Array.isArray(job)) return job as Record<string, unknown>;
93
+ } catch { /* Do not echo an untrusted response, which may contain credentials. */ }
94
+ throw new Error('Remote slicing returned invalid job metadata.');
95
+ }
96
+
97
+ /** Upload only locally prepared profiles, then inspect the exact downloaded 3MF. */
98
+ export async function sliceWithOrcaApi(input: {
99
+ inputPath: string;
100
+ machinePath: string;
101
+ processPath: string;
102
+ filamentPaths: string[];
103
+ outputPath: string;
104
+ options?: BambuSliceOptions;
105
+ isCancelled: () => boolean;
106
+ }): Promise<string> {
107
+ const options = input.options ?? {};
108
+ const plate = validateRemoteSliceOptions(options);
109
+ if (input.filamentPaths.length !== 1 || (options.filamentColours && options.filamentColours.length !== 1)) {
110
+ throw new Error('orca-slicer-api supports exactly one filament slot; multi-slot projects require a local slicer.');
111
+ }
112
+ const config = configuration();
113
+ const machine = JSON.parse(await fs.readFile(input.machinePath, 'utf8'));
114
+ const filament = JSON.parse(await fs.readFile(input.filamentPaths[0], 'utf8'));
115
+ const processProfile = JSON.parse(await fs.readFile(input.processPath, 'utf8'));
116
+ const bedType = remoteBedType(options.bedType ?? processProfile.curr_bed_type ?? 'textured_plate');
117
+ const model = normalizeModel(machine.printer_model);
118
+ const nozzles = Array.isArray(machine.nozzle_diameter) ? machine.nozzle_diameter.map(Number) : [];
119
+ const materialValues: unknown[] = Array.isArray(filament.filament_type) ? filament.filament_type : [];
120
+ const materials = materialValues.map(normalizeMaterial);
121
+ if (!model || !nozzles.length || nozzles.some((n: number) => ![0.2, 0.4, 0.6, 0.8].includes(n)) ||
122
+ materials.length !== 1 || !materials[0]) {
123
+ throw new Error('Remote slicing requires explicit model, nozzle and single-material metadata in the prepared profiles.');
124
+ }
125
+ const controller = new AbortController();
126
+ let timedOut = false;
127
+ const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, config.timeout);
128
+ const cancelCheck = setInterval(() => { if (input.isCancelled()) controller.abort(); }, 100);
129
+ let requestId: string | undefined;
130
+ let terminalJob = false;
131
+ let wroteOutput = false;
132
+ const request = async (route: string, init: RequestInit = {}): Promise<Response> => {
133
+ if (input.isCancelled()) controller.abort();
134
+ // Do not pass multipart data to fetch after cancellation: undici may start
135
+ // its body producer before checking the signal and close it mid-enqueue.
136
+ controller.signal.throwIfAborted();
137
+ let response: Response;
138
+ try {
139
+ response = await fetch(config.base + route, { ...init, headers: config.headers, signal: controller.signal, redirect: 'manual' });
140
+ } catch { throw new Error('Remote slicing HTTP request failed; check the trusted service configuration.'); }
141
+ if (!response.ok) { await response.body?.cancel(); throw new Error(`Remote slicing HTTP ${response.status}.`); }
142
+ return response;
143
+ };
144
+ try {
145
+ const ext = path.extname(input.inputPath).toLowerCase();
146
+ if (!['.stl', '.3mf'].includes(ext)) throw new Error('Remote slicing supports only STL or 3MF input.');
147
+ const bytes = await fs.readFile(input.inputPath);
148
+ if (bytes.length > MAX_BYTES) throw new Error('Remote slicing input exceeds the 100 MB upstream limit.');
149
+ const form = new FormData();
150
+ form.set('file', new Blob([new Uint8Array(bytes)], { type: ext === '.3mf' ? 'model/3mf' : 'model/stl' }), `model${ext}`);
151
+ for (const [field, filePath, type] of [
152
+ ['printerProfile', input.machinePath, 'machine'],
153
+ ['presetProfile', input.processPath, 'process'],
154
+ ['filamentProfile', input.filamentPaths[0], 'filament'],
155
+ ]) {
156
+ const profile = JSON.parse(await fs.readFile(filePath, 'utf8'));
157
+ // The remote validator requires these metadata fields even for standalone
158
+ // user profiles. They do not change the resolved machine/process settings.
159
+ const data = { ...profile, type, from: profile.from || 'User' };
160
+ form.set(field, new Blob([JSON.stringify(data)], { type: 'application/json' }), `${field}.json`);
161
+ }
162
+ form.set('exportType', '3mf');
163
+ form.set('plate', String(plate));
164
+ form.set('bedType', bedType);
165
+ form.set('resolveProfileInheritance', 'false');
166
+ if (options.arrange === true) form.set('arrange', 'true');
167
+ if (options.orient === true) form.set('orient', 'true');
168
+ let response = await request(config.mode === 'async' ? '/slice-async' : '/slice', { method: 'POST', body: form });
169
+ if (config.mode === 'async') {
170
+ const submitted = await readJob(response);
171
+ if (typeof submitted.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(submitted.requestId)) {
172
+ throw new Error('Remote slicing returned an invalid request ID.');
173
+ }
174
+ requestId = submitted.requestId;
175
+ for (;;) {
176
+ // Construct the documented same-service paths. Returned URLs cannot
177
+ // redirect authenticated requests to an arbitrary origin or route.
178
+ const job = await readJob(await request(`/slice-async/${requestId}`));
179
+ if (job.status === 'completed' || job.status === 'failed') {
180
+ terminalJob = true;
181
+ if (job.status === 'failed') throw new Error('Remote slicing job failed; inspect the service logs.');
182
+ break;
183
+ }
184
+ if (job.status !== 'pending' && job.status !== 'processing') throw new Error('Remote slicing returned an unknown job state.');
185
+ await delay(500, undefined, { signal: controller.signal });
186
+ }
187
+ response = await request(`/slice-async/${requestId}/result`);
188
+ }
189
+ const result = await readResponse(response, MAX_BYTES);
190
+ await fs.writeFile(input.outputPath, result, { flag: 'wx', mode: 0o600 });
191
+ wroteOutput = true;
192
+ const inspection = await inspectPrintFile(input.outputPath, {
193
+ model, nozzleDiameters: nozzles, plateIndex: plate - 1, bedType,
194
+ });
195
+ if (JSON.stringify(inspection.materials) !== JSON.stringify(materials)) {
196
+ throw new Error('Remote sliced file materials contradict the prepared filament profile.');
197
+ }
198
+ if (input.isCancelled()) throw new Error('Remote slicing cancelled.');
199
+ return input.outputPath;
200
+ } catch (error) {
201
+ if (wroteOutput) await fs.rm(input.outputPath, { force: true });
202
+ if (controller.signal.aborted) {
203
+ throw new Error(`Remote slicing ${timedOut ? 'timed out' : 'cancelled'}; the remote job may continue. No retry was sent.`);
204
+ }
205
+ throw error;
206
+ } finally {
207
+ // DELETE only removes finished jobs; upstream has no active-job cancellation.
208
+ // Cleanup never retries submission or masks a valid result / original error.
209
+ if (requestId && terminalJob && !controller.signal.aborted) {
210
+ try { await (await request(`/slice-async/${requestId}`, { method: 'DELETE' })).body?.cancel(); }
211
+ catch { console.error('Remote slicing finished-job cleanup failed; service retention applies.'); }
212
+ }
213
+ clearTimeout(deadline);
214
+ clearInterval(cancelCheck);
215
+ }
216
+ }
@@ -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
 
15
16
  const readFileAsync = promisify(fs.readFile);
16
17
  const writeFileAsync = promisify(fs.writeFile);
@@ -57,6 +58,7 @@ export const SLICER_TYPES = [
57
58
  'bambustudio',
58
59
  'orcaslicer',
59
60
  'orcaslicer-bambulab',
61
+ 'orca-slicer-api',
60
62
  'prusaslicer',
61
63
  'cura',
62
64
  'slic3r',
@@ -81,6 +83,8 @@ const SLICER_TYPE_ALIASES: Record<string, SlicerType> = {
81
83
  orcastudio: 'orcaslicer-bambulab',
82
84
  'orcaslicer-bmcu': 'orcaslicer-bambulab',
83
85
  'orca-bmcu': 'orcaslicer-bambulab',
86
+ 'orca-slicer-api': 'orca-slicer-api',
87
+ 'orcaslicer-api': 'orca-slicer-api',
84
88
  prusaslicer: 'prusaslicer',
85
89
  'prusa-slicer': 'prusaslicer',
86
90
  cura: 'cura',
@@ -105,7 +109,8 @@ export function isBambuCompatibleSlicer(slicerType: SlicerType): boolean {
105
109
  return (
106
110
  slicerType === 'bambustudio' ||
107
111
  slicerType === 'orcaslicer' ||
108
- slicerType === 'orcaslicer-bambulab'
112
+ slicerType === 'orcaslicer-bambulab' ||
113
+ slicerType === 'orca-slicer-api'
109
114
  );
110
115
  }
111
116
 
@@ -1591,7 +1596,7 @@ export class STLManipulator extends EventEmitter {
1591
1596
  const operationId = this.generateOperationId();
1592
1597
  this.activeOperations.set(operationId, true);
1593
1598
 
1594
- if (isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
1599
+ if (slicerType !== 'orca-slicer-api' && isPathLikeExecutable(slicerPath) && !fs.existsSync(slicerPath)) {
1595
1600
  throw new Error(`Slicer executable not found at: ${slicerPath}`);
1596
1601
  }
1597
1602
  if (slicerProfile && !fs.existsSync(slicerProfile)) {
@@ -1638,6 +1643,7 @@ export class STLManipulator extends EventEmitter {
1638
1643
  case 'bambustudio':
1639
1644
  case 'orcaslicer':
1640
1645
  case 'orcaslicer-bambulab':
1646
+ case 'orca-slicer-api':
1641
1647
  // Bambu-compatible CLI: slice and export as 3MF with embedded gcode.
1642
1648
  // FULU's OrcaSlicer-bambulab fork keeps this Bambu-style path.
1643
1649
  // For 3MF input: slice in place and export sliced 3MF
@@ -1645,12 +1651,19 @@ export class STLManipulator extends EventEmitter {
1645
1651
  {
1646
1652
  const is3mf = stlFilePath.toLowerCase().endsWith('.3mf');
1647
1653
  const outputBase = path.basename(stlFilePath, is3mf ? '.3mf' : '.stl');
1648
- const bambuOutputPath = path.join(this.tempDir, outputBase + '_sliced.3mf');
1654
+ const remote = slicerType === 'orca-slicer-api';
1655
+ if (remote) {
1656
+ validateRemoteSliceOptions(bambuOptions);
1657
+ if (!process.env.BAMBU_PROFILES_ROOT) throw new Error('Remote slicing requires BAMBU_PROFILES_ROOT pointing to the matching Orca profile tree (a read-only mount is sufficient).');
1658
+ if (slicerProfile && !fs.existsSync(slicerProfile)) throw new Error('Remote slicing process profile does not exist.');
1659
+ }
1660
+ const profileSlicerType = remote ? 'orcaslicer' : slicerType;
1661
+ const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
1649
1662
  const outputDir = path.dirname(bambuOutputPath);
1650
- const profilesRoot = detectProfilesRoot(slicerPath, slicerType);
1663
+ const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1651
1664
  const rawBundle = await this.resolveBambuLikeSettingsBundle(
1652
1665
  outputBase,
1653
- slicerType,
1666
+ profileSlicerType,
1654
1667
  slicerProfile,
1655
1668
  printerPreset,
1656
1669
  bambuOptions,
@@ -1676,7 +1689,7 @@ export class STLManipulator extends EventEmitter {
1676
1689
  // absolute-extrusion normalization after resolving those ancestors.
1677
1690
  // The machine owns the extrusion mode and layer G-code once process
1678
1691
  // files lose machine keys, so normalize it there as well.
1679
- if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1692
+ if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1680
1693
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1681
1694
  settingsBundle.settingsArg = [
1682
1695
  this.writeTempJson(outputBase, 'machine_orca_resolved',
@@ -1686,6 +1699,16 @@ export class STLManipulator extends EventEmitter {
1686
1699
  ].join(';');
1687
1700
  }
1688
1701
  const cliBundle = this.isolateMachineSettings(settingsBundle);
1702
+ if (remote) {
1703
+ const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
1704
+ if (!machinePath || !processPath) throw new Error('Remote slicing requires complete prepared machine and process profiles.');
1705
+ outputFilePath = await sliceWithOrcaApi({
1706
+ inputPath: stlFilePath, machinePath, processPath,
1707
+ filamentPaths: cliBundle.filamentPaths, outputPath: bambuOutputPath,
1708
+ options: bambuOptions, isCancelled: () => !this.activeOperations.get(operationId),
1709
+ });
1710
+ break;
1711
+ }
1689
1712
  args = [
1690
1713
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1691
1714
  '--outputdir', outputDir,
@@ -1729,47 +1752,49 @@ export class STLManipulator extends EventEmitter {
1729
1752
  throw new Error(`Unsupported slicer type: ${slicerType}`);
1730
1753
  }
1731
1754
 
1732
- if (progressCallback) progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1733
- console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1734
-
1735
- // Execute the slicer
1736
- await new Promise<void>((resolve, reject) => {
1737
- const process = execFile(slicerPath, args, (error, stdout, stderr) => {
1738
- if (error) {
1739
- console.error(`Slicer Error: ${error.message}`);
1740
- console.error(`Slicer Stderr: ${stderr}`);
1741
- reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1742
- } else {
1743
- console.error(`Slicer Stdout: ${stdout}`);
1744
- if (stderr) {
1745
- console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1746
- }
1747
- resolve();
1748
- }
1749
- });
1755
+ if (slicerType !== 'orca-slicer-api') {
1756
+ if (progressCallback) progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
1757
+ console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1758
+
1759
+ // Execute the slicer
1760
+ await new Promise<void>((resolve, reject) => {
1761
+ const process = execFile(slicerPath, args, (error, stdout, stderr) => {
1762
+ if (error) {
1763
+ console.error(`Slicer Error: ${error.message}`);
1764
+ console.error(`Slicer Stderr: ${stderr}`);
1765
+ reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
1766
+ } else {
1767
+ console.error(`Slicer Stdout: ${stdout}`);
1768
+ if (stderr) {
1769
+ console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
1770
+ }
1771
+ resolve();
1772
+ }
1773
+ });
1750
1774
 
1751
- // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1752
- // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1753
- // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1775
+ // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
1776
+ // process.stdout?.on('data', (data) => { console.error(`Slicer stdout: ${data}`); });
1777
+ // process.stderr?.on('data', (data) => { console.error(`Slicer stderr: ${data}`); });
1754
1778
 
1755
- // Check for cancellation periodically
1756
- const checkCancel = setInterval(() => {
1757
- if (!this.activeOperations.get(operationId)) {
1758
- clearInterval(checkCancel);
1759
- try {
1760
- process.kill(); // Attempt to kill the slicer process
1761
- reject(new Error("Slicing operation cancelled"));
1762
- } catch (killError) {
1763
- console.error("Error attempting to kill slicer process:", killError);
1764
- reject(new Error("Slicing operation cancelled, but failed to kill process."));
1765
- }
1766
- }
1767
- }, 500);
1779
+ // Check for cancellation periodically
1780
+ const checkCancel = setInterval(() => {
1781
+ if (!this.activeOperations.get(operationId)) {
1782
+ clearInterval(checkCancel);
1783
+ try {
1784
+ process.kill(); // Attempt to kill the slicer process
1785
+ reject(new Error("Slicing operation cancelled"));
1786
+ } catch (killError) {
1787
+ console.error("Error attempting to kill slicer process:", killError);
1788
+ reject(new Error("Slicing operation cancelled, but failed to kill process."));
1789
+ }
1790
+ }
1791
+ }, 500);
1768
1792
 
1769
- process.on('exit', () => clearInterval(checkCancel));
1770
- process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
1793
+ process.on('exit', () => clearInterval(checkCancel));
1794
+ process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
1771
1795
 
1772
- });
1796
+ });
1797
+ }
1773
1798
 
1774
1799
  if (!this.activeOperations.get(operationId)) {
1775
1800
  // This check might be redundant if the promise rejected on kill, but good safety