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.
- package/CONTRIBUTORS.md +3 -1
- package/README.md +4 -2
- package/dist/ams-mapping.d.ts +18 -2
- package/dist/ams-mapping.js +36 -5
- package/dist/index.js +53 -45
- package/dist/printers/bambu.js +14 -16
- package/dist/safety/limits.d.ts +6 -0
- package/dist/safety/limits.js +9 -0
- package/dist/safety/print-file.d.ts +2 -0
- package/dist/safety/print-file.js +36 -6
- package/dist/safety/printer-state.d.ts +2 -0
- package/dist/safety/printer-state.js +35 -9
- package/dist/slicer/remote-slicer.d.ts +12 -0
- package/dist/slicer/remote-slicer.js +240 -0
- package/dist/stl/stl-manipulator.d.ts +1 -1
- package/dist/stl/stl-manipulator.js +70 -42
- package/package.json +1 -1
- package/src/ams-mapping.ts +38 -5
- package/src/index.ts +49 -42
- package/src/printers/bambu.ts +14 -13
- package/src/safety/limits.ts +9 -0
- package/src/safety/print-file.ts +34 -6
- package/src/safety/printer-state.ts +32 -9
- package/src/slicer/remote-slicer.ts +216 -0
- package/src/stl/stl-manipulator.ts +68 -43
|
@@ -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
|
-
|
|
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) ===
|
|
289
|
-
if (
|
|
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 &&
|
|
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
|
|
384
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
1280
|
-
const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase,
|
|
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 (
|
|
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 (
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
package/src/ams-mapping.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
+
}
|