bambu-printer-mcp 1.1.8 → 1.1.10
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 +10 -3
- package/README.md +188 -370
- package/dist/index.js +39 -11
- package/dist/printers/bambu.d.ts +3 -2
- package/dist/printers/bambu.js +18 -5
- package/dist/slicer/profile-flatten.d.ts +14 -0
- package/dist/slicer/profile-flatten.js +88 -0
- package/dist/stl/stl-manipulator.d.ts +1 -0
- package/dist/stl/stl-manipulator.js +25 -6
- package/package.json +1 -1
- package/src/index.ts +36 -11
- package/src/printers/bambu.ts +20 -5
- package/src/slicer/profile-flatten.ts +103 -0
- package/src/stl/stl-manipulator.ts +29 -6
package/src/printers/bambu.ts
CHANGED
|
@@ -47,6 +47,7 @@ const MODEL_ID_TO_NAME: Record<string, string> = {
|
|
|
47
47
|
O1D: "H2D",
|
|
48
48
|
O1E: "H2D Pro",
|
|
49
49
|
O1S: "H2S",
|
|
50
|
+
N6: "X2D",
|
|
50
51
|
N2S: "A1",
|
|
51
52
|
A1M: "A1 Mini",
|
|
52
53
|
C11: "P1P",
|
|
@@ -58,6 +59,15 @@ const MODEL_ID_TO_NAME: Record<string, string> = {
|
|
|
58
59
|
|
|
59
60
|
const H2_MODEL_NAMES = new Set(["h2", "h2c", "h2d", "h2dpro", "h2d pro", "h2s"]);
|
|
60
61
|
|
|
62
|
+
export function assertDirectPrintSupported(model: string | undefined, serial?: string): void {
|
|
63
|
+
if (model?.trim().toLowerCase() === "x2d" || serial?.trim().toUpperCase().startsWith("20P")) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"X2D direct printing is not supported pending native eMMC transport support. " +
|
|
66
|
+
"X2D status and slicing remain available; use a supported slicer to print."
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
function isH2ModelName(model: unknown): boolean {
|
|
62
72
|
return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
|
|
63
73
|
}
|
|
@@ -151,6 +161,7 @@ class TolerantBambuClient extends BambuClient {
|
|
|
151
161
|
if (sn.startsWith("094")) return "H2D";
|
|
152
162
|
if (sn.startsWith("239")) return "H2C";
|
|
153
163
|
if (sn.startsWith("31B")) return "H2DPRO";
|
|
164
|
+
if (sn.startsWith("20P")) return "X2D";
|
|
154
165
|
if (sn.startsWith("00M")) return "X1C";
|
|
155
166
|
if (sn.startsWith("00W")) return "X1";
|
|
156
167
|
if (sn.startsWith("03W")) return "X1E";
|
|
@@ -568,6 +579,7 @@ export class BambuImplementation {
|
|
|
568
579
|
token: string,
|
|
569
580
|
options: BambuPrintOptionsInternal
|
|
570
581
|
): Promise<any> {
|
|
582
|
+
assertDirectPrintSupported(options.bambuModel, serial);
|
|
571
583
|
if (!options.filePath.toLowerCase().endsWith(".3mf")) {
|
|
572
584
|
throw new Error("print3mf requires a .3mf input file.");
|
|
573
585
|
}
|
|
@@ -1159,8 +1171,10 @@ export class BambuImplementation {
|
|
|
1159
1171
|
token: string,
|
|
1160
1172
|
filePath: string,
|
|
1161
1173
|
filename: string,
|
|
1162
|
-
print: boolean
|
|
1174
|
+
print: boolean,
|
|
1175
|
+
bambuModel?: string
|
|
1163
1176
|
) {
|
|
1177
|
+
if (print) assertDirectPrintSupported(bambuModel, serial);
|
|
1164
1178
|
await fs.access(filePath);
|
|
1165
1179
|
|
|
1166
1180
|
const normalizedFileName = filename.replace(/^\/+/, "");
|
|
@@ -1180,7 +1194,7 @@ export class BambuImplementation {
|
|
|
1180
1194
|
|
|
1181
1195
|
if (print) {
|
|
1182
1196
|
if (remotePath.toLowerCase().endsWith(".gcode")) {
|
|
1183
|
-
response.startResult = await this.startJob(host, serial, token, remotePath);
|
|
1197
|
+
response.startResult = await this.startJob(host, serial, token, remotePath, bambuModel);
|
|
1184
1198
|
} else if (remotePath.toLowerCase().endsWith(".3mf")) {
|
|
1185
1199
|
response.note =
|
|
1186
1200
|
"3MF upload complete. Use print_3mf to start a project print with plate and metadata options.";
|
|
@@ -1194,7 +1208,8 @@ export class BambuImplementation {
|
|
|
1194
1208
|
return response;
|
|
1195
1209
|
}
|
|
1196
1210
|
|
|
1197
|
-
async startJob(host: string, serial: string, token: string, filename: string) {
|
|
1211
|
+
async startJob(host: string, serial: string, token: string, filename: string, bambuModel?: string) {
|
|
1212
|
+
assertDirectPrintSupported(bambuModel, serial);
|
|
1198
1213
|
const lower = filename.toLowerCase();
|
|
1199
1214
|
if (lower.endsWith(".3mf") && !lower.endsWith(".gcode.3mf")) {
|
|
1200
1215
|
throw new Error("Use print_3mf for .3mf project files.");
|
|
@@ -1281,7 +1296,7 @@ export class BambuImplementation {
|
|
|
1281
1296
|
// P1/A1 series still use the proprietary TCP-on-6000 framed JPEG path
|
|
1282
1297
|
// (per https://github.com/Doridian/OpenBambuAPI/blob/main/video.md).
|
|
1283
1298
|
const TCP_CAMERA_MODELS = new Set(["a1", "a1mini", "p1s", "p1p"]);
|
|
1284
|
-
// X1, P2S,
|
|
1299
|
+
// X1, P2S, H2 (H2S/H2D/H2C), AND X2D all use RTSP on port 322. The
|
|
1285
1300
|
// OpenBambuAPI doc only mentions X1/P2S, but the HA bambulab
|
|
1286
1301
|
// integration's models.py shows the printer reports its own
|
|
1287
1302
|
// `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
|
|
@@ -1289,7 +1304,7 @@ export class BambuImplementation {
|
|
|
1289
1304
|
// confirmed by local H2 camera transport probes).
|
|
1290
1305
|
const RTSP_MODELS = new Set([
|
|
1291
1306
|
"x1", "x1c", "x1carbon", "x1e", "p2s",
|
|
1292
|
-
"h2", "h2s", "h2d", "h2c", "h2dpro",
|
|
1307
|
+
"h2", "h2s", "h2d", "h2c", "h2dpro", "x2d",
|
|
1293
1308
|
]);
|
|
1294
1309
|
|
|
1295
1310
|
if (!model) {
|
|
@@ -78,6 +78,14 @@ export interface FlattenOptions {
|
|
|
78
78
|
process?: Record<string, unknown>;
|
|
79
79
|
filaments?: (Record<string, unknown> | undefined)[];
|
|
80
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* Positional `#RRGGBB` colour per filament slot (e.g. from the input 3MF
|
|
83
|
+
* project or the caller). Missing entries keep the profile's own colour or
|
|
84
|
+
* fall back to DEFAULT_FILAMENT_COLOUR.
|
|
85
|
+
*/
|
|
86
|
+
filamentColours?: (string | undefined)[];
|
|
87
|
+
/** Saved per-plate positions, used only when the process sets no position. */
|
|
88
|
+
projectTowerPosition?: { wipe_tower_x?: unknown; wipe_tower_y?: unknown };
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
interface IndexedProfile {
|
|
@@ -308,6 +316,91 @@ function enforceMatchingNozzles(arr: unknown[]): void {
|
|
|
308
316
|
}
|
|
309
317
|
|
|
310
318
|
/** Best-effort extruder count for fallback nozzle_volume_type sizing. */
|
|
319
|
+
/** BambuStudio's built-in filament_colour default. */
|
|
320
|
+
export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
|
|
321
|
+
const FILAMENT_COLOUR_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* No BBL filament profile defines `filament_colour`; the GUI fills it from
|
|
325
|
+
* the AMS. Without it the CLI keeps a single-entry colour vector however many
|
|
326
|
+
* filaments are loaded and crashes (access violation) as soon as a slice uses
|
|
327
|
+
* any filament after the first. Give every slot exactly one colour.
|
|
328
|
+
*/
|
|
329
|
+
function ensureFilamentColour(flat: Record<string, unknown>, colour?: string): void {
|
|
330
|
+
if (colour !== undefined) {
|
|
331
|
+
if (!FILAMENT_COLOUR_RE.test(colour)) {
|
|
332
|
+
throw new Error(`Invalid filament colour "${colour}"; expected #RRGGBB.`);
|
|
333
|
+
}
|
|
334
|
+
flat["filament_colour"] = [colour];
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const own = flat["filament_colour"];
|
|
338
|
+
if (Array.isArray(own) && typeof own[0] === "string" && FILAMENT_COLOUR_RE.test(own[0])) {
|
|
339
|
+
flat["filament_colour"] = [own[0]];
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
flat["filament_colour"] = [DEFAULT_FILAMENT_COLOUR];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// BambuStudio's defaults when a process sets no prime tower position/width.
|
|
346
|
+
const DEFAULT_WIPE_TOWER_X = 15;
|
|
347
|
+
const DEFAULT_WIPE_TOWER_Y = 220;
|
|
348
|
+
const DEFAULT_PRIME_TOWER_WIDTH = 35;
|
|
349
|
+
// Clearance inside the shared nozzle area for the auto-sized tower brim and
|
|
350
|
+
// the CLI's own safety margin (X2D fails at 5 mm from the edge, passes at 6).
|
|
351
|
+
const WIPE_TOWER_MARGIN = 15;
|
|
352
|
+
|
|
353
|
+
interface Rect { minX: number; maxX: number; minY: number; maxY: number }
|
|
354
|
+
|
|
355
|
+
function parseArea(area: string): Rect {
|
|
356
|
+
const points = area.split(",").map((p) => p.trim().split("x").map(Number));
|
|
357
|
+
if (points.length < 3 || points.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
|
|
358
|
+
throw new Error(`Malformed extruder_printable_area entry "${area}".`);
|
|
359
|
+
}
|
|
360
|
+
const xs = points.map((p) => p[0]);
|
|
361
|
+
const ys = points.map((p) => p[1]);
|
|
362
|
+
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* On multi-nozzle machines (H2D, X2D) each nozzle reaches only part of the
|
|
367
|
+
* bed (`extruder_printable_area`), and the prime tower must be reachable by
|
|
368
|
+
* every nozzle. The CLI's default tower position (x=15) lies outside the
|
|
369
|
+
* X2D/H2D right-nozzle area, so slicing fails with "G-code outside of the
|
|
370
|
+
* printable area". When the process sets no position and the default does
|
|
371
|
+
* not fit, move the tower inside the area all nozzles share.
|
|
372
|
+
*/
|
|
373
|
+
function placePrimeTowerForAllNozzles(
|
|
374
|
+
machineFlat: Record<string, unknown>,
|
|
375
|
+
processFlat: Record<string, unknown>
|
|
376
|
+
): void {
|
|
377
|
+
if (processFlat["wipe_tower_x"] !== undefined || processFlat["wipe_tower_y"] !== undefined) return;
|
|
378
|
+
if (["0", "false"].includes(String(processFlat["enable_prime_tower"]).toLowerCase())) return;
|
|
379
|
+
const areas = machineFlat["extruder_printable_area"];
|
|
380
|
+
if (!Array.isArray(areas) || areas.length < 2) return;
|
|
381
|
+
const rects = areas.map((a) => {
|
|
382
|
+
if (typeof a !== "string") throw new Error("Malformed extruder_printable_area in machine profile.");
|
|
383
|
+
return parseArea(a);
|
|
384
|
+
});
|
|
385
|
+
const shared: Rect = {
|
|
386
|
+
minX: Math.max(...rects.map((r) => r.minX)) + WIPE_TOWER_MARGIN,
|
|
387
|
+
maxX: Math.min(...rects.map((r) => r.maxX)) - WIPE_TOWER_MARGIN,
|
|
388
|
+
minY: Math.max(...rects.map((r) => r.minY)) + WIPE_TOWER_MARGIN,
|
|
389
|
+
maxY: Math.min(...rects.map((r) => r.maxY)) - WIPE_TOWER_MARGIN,
|
|
390
|
+
};
|
|
391
|
+
const width = Number(processFlat["prime_tower_width"] ?? DEFAULT_PRIME_TOWER_WIDTH);
|
|
392
|
+
const size = Number.isFinite(width) && width > 0 ? width : DEFAULT_PRIME_TOWER_WIDTH;
|
|
393
|
+
if (shared.maxX - shared.minX < size || shared.maxY - shared.minY < size) {
|
|
394
|
+
throw new Error("No bed area is reachable by every nozzle for the prime tower.");
|
|
395
|
+
}
|
|
396
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
|
|
397
|
+
const x = clamp(DEFAULT_WIPE_TOWER_X, shared.minX, shared.maxX - size);
|
|
398
|
+
const y = clamp(DEFAULT_WIPE_TOWER_Y, shared.minY, shared.maxY - size);
|
|
399
|
+
if (x === DEFAULT_WIPE_TOWER_X && y === DEFAULT_WIPE_TOWER_Y) return;
|
|
400
|
+
processFlat["wipe_tower_x"] = [String(x)];
|
|
401
|
+
processFlat["wipe_tower_y"] = [String(y)];
|
|
402
|
+
}
|
|
403
|
+
|
|
311
404
|
function inferExtruderCount(flat: Record<string, unknown>): number {
|
|
312
405
|
for (const key of ["nozzle_diameter", "extruder_type", "extruder_variant_list"]) {
|
|
313
406
|
const v = flat[key];
|
|
@@ -586,7 +679,17 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
|
|
|
586
679
|
normalizeForCli(machineFlat, "machine", opts.machineLeaf);
|
|
587
680
|
normalizeForCli(processFlat, "process", opts.processLeaf);
|
|
588
681
|
filamentFlats.forEach((f, i) => normalizeForCli(f, "filament", opts.filamentLeaves[i]));
|
|
682
|
+
if (opts.filamentColours && opts.filamentColours.length !== filamentFlats.length) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`${opts.filamentColours.length} filament colours were supplied for ${filamentFlats.length} filament slots.`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
filamentFlats.forEach((f, i) => ensureFilamentColour(f, opts.filamentColours?.[i]));
|
|
589
688
|
applyBedType(processFlat, opts.bedType);
|
|
689
|
+
if (processFlat["wipe_tower_x"] === undefined && processFlat["wipe_tower_y"] === undefined) {
|
|
690
|
+
Object.assign(processFlat, opts.projectTowerPosition);
|
|
691
|
+
}
|
|
692
|
+
placePrimeTowerForAllNozzles(machineFlat, processFlat);
|
|
590
693
|
|
|
591
694
|
// Mirror the GUI's auto-extend behavior: when the caller explicitly
|
|
592
695
|
// chose a process or filament that wasn't pre-declared compatible with
|
|
@@ -162,6 +162,7 @@ export interface BambuSliceOptions {
|
|
|
162
162
|
skipObjects?: string; // --skip-objects "3,5,10": skip specific objects
|
|
163
163
|
loadFilaments?: string; // --load-filaments "f1.json;f2.json": filament profiles
|
|
164
164
|
loadFilamentIds?: string; // --load-filament-ids "1,2,3,1": filament-to-object mapping
|
|
165
|
+
filamentColours?: string[]; // positional #RRGGBB per loaded filament slot
|
|
165
166
|
bedType?: string; // Bambu bed type, e.g. textured_plate or cool_plate
|
|
166
167
|
enableTimelapse?: boolean; // --enable-timelapse: timelapse-aware slicing
|
|
167
168
|
allowMixTemp?: boolean; // --allow-mix-temp: allow mixed-temp filaments
|
|
@@ -177,6 +178,10 @@ export interface BambuSliceOptions {
|
|
|
177
178
|
interface BambuSettingsBundle {
|
|
178
179
|
settingsArg?: string;
|
|
179
180
|
filamentPaths: string[];
|
|
181
|
+
/** Positional slot colours declared by the input 3MF project. */
|
|
182
|
+
filamentColours?: string[];
|
|
183
|
+
/** Saved per-plate tower coordinates must survive default process loading. */
|
|
184
|
+
projectTowerPosition?: { wipe_tower_x?: unknown; wipe_tower_y?: unknown };
|
|
180
185
|
/** Identity provenance before the process is merged into a generated file. */
|
|
181
186
|
processSource?: { filePath: string; profile: Record<string, unknown> };
|
|
182
187
|
}
|
|
@@ -534,21 +539,32 @@ export class STLManipulator extends EventEmitter {
|
|
|
534
539
|
profilesRoot,
|
|
535
540
|
tempDir: this.tempDir,
|
|
536
541
|
bedType: this.resolveBambuStudioBedType(bambuOptions?.bedType),
|
|
542
|
+
filamentColours: bambuOptions?.filamentColours ?? bundle.filamentColours,
|
|
543
|
+
projectTowerPosition: bundle.projectTowerPosition,
|
|
537
544
|
sourceProfiles: {
|
|
538
545
|
machine: isBundledFile(machinePath) ? undefined : machine,
|
|
539
546
|
process: isBundledFile(processPath) ? undefined : processProfile,
|
|
540
547
|
filaments: filaments.map((profile, i) => isBundledFile(bundle.filamentPaths[i]) ? undefined : profile),
|
|
541
548
|
},
|
|
542
549
|
});
|
|
543
|
-
// Standalone
|
|
544
|
-
//
|
|
550
|
+
// Standalone files retain their own identity and settings, but still need
|
|
551
|
+
// the CLI colour/tower fields. Do not discard those required overlays when
|
|
552
|
+
// choosing the original profile instead of the normalized BBL output.
|
|
553
|
+
const withCliFields = (filePath: string, profile: Record<string, unknown>, flatPath: string, keys: string[]): string => {
|
|
554
|
+
const flattened = readProfile(flatPath);
|
|
555
|
+
const fields = Object.fromEntries(keys.filter(key => flattened[key] !== undefined).map(key => [key, flattened[key]]));
|
|
556
|
+
if (Object.entries(fields).every(([key, value]) => JSON.stringify(profile[key]) === JSON.stringify(value))) return filePath;
|
|
557
|
+
return this.writeTempJson(path.basename(filePath, '.json'), 'cli', { ...profile, ...fields });
|
|
558
|
+
};
|
|
545
559
|
return {
|
|
546
560
|
settingsArg: [
|
|
547
561
|
needsResolution(machinePath, machine) ? flat.machinePath : machinePath,
|
|
548
|
-
needsResolution(processPath, processProfile) ? flat.processPath
|
|
562
|
+
needsResolution(processPath, processProfile) ? flat.processPath
|
|
563
|
+
: withCliFields(processPath, processProfile, flat.processPath, ['wipe_tower_x', 'wipe_tower_y']),
|
|
549
564
|
].join(';'),
|
|
550
565
|
filamentPaths: filaments.map((profile, i) =>
|
|
551
|
-
needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i]
|
|
566
|
+
needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i]
|
|
567
|
+
: withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
|
|
552
568
|
};
|
|
553
569
|
}
|
|
554
570
|
|
|
@@ -571,13 +587,20 @@ export class STLManipulator extends EventEmitter {
|
|
|
571
587
|
const count = Math.max(0, ...['filament_settings_id', 'filament_type', 'filament_colour', 'filament_diameter']
|
|
572
588
|
.map(key => Array.isArray(data[key]) ? data[key].length : 0));
|
|
573
589
|
if (count === 0) throw new Error('Cannot determine project filament slots from project_settings.config.');
|
|
590
|
+
// Keep the project's slot colours; loaded profiles carry none of their own.
|
|
591
|
+
const colours = Array.isArray(data.filament_colour) && data.filament_colour.length === count &&
|
|
592
|
+
data.filament_colour.every(c => typeof c === 'string' && /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(c))
|
|
593
|
+
? data.filament_colour as string[]
|
|
594
|
+
: undefined;
|
|
595
|
+
const projectTowerPosition = Object.fromEntries(['wipe_tower_x', 'wipe_tower_y']
|
|
596
|
+
.filter(key => data[key] !== undefined).map(key => [key, data[key]]));
|
|
574
597
|
if (bundle.filamentPaths.length === 1) {
|
|
575
|
-
return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]) };
|
|
598
|
+
return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]), filamentColours: colours, projectTowerPosition };
|
|
576
599
|
}
|
|
577
600
|
if (bundle.filamentPaths.length !== count) {
|
|
578
601
|
throw new Error(`Project declares ${count} filament slots, but ${bundle.filamentPaths.length} profiles were supplied. Provide one profile for all slots or one per slot.`);
|
|
579
602
|
}
|
|
580
|
-
return bundle;
|
|
603
|
+
return { ...bundle, filamentColours: colours, projectTowerPosition };
|
|
581
604
|
}
|
|
582
605
|
|
|
583
606
|
private resolveBambuStudioBedType(bedType?: string): string | undefined {
|