bambu-printer-mcp 1.1.5 → 1.1.8
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 +26 -0
- package/README.md +100 -27
- package/dist/blender-mcp-bridge.d.ts +10 -0
- package/dist/blender-mcp-bridge.js +389 -0
- package/dist/index.js +166 -102
- package/dist/printers/bambu.js +31 -13
- package/dist/slicer/profile-flatten.d.ts +15 -4
- package/dist/slicer/profile-flatten.js +181 -74
- package/dist/stl/stl-manipulator.d.ts +3 -17
- package/dist/stl/stl-manipulator.js +142 -85
- package/package.json +8 -4
- package/patches/bambu-node+3.22.21.patch +18 -2
- package/scripts/install-patches.mjs +64 -0
- package/src/blender-mcp-bridge.ts +344 -0
- package/src/index.ts +237 -123
- package/src/printers/bambu.ts +29 -13
- package/src/slicer/profile-flatten.ts +202 -80
- package/src/stl/stl-manipulator.ts +161 -98
|
@@ -174,6 +174,13 @@ export interface BambuSliceOptions {
|
|
|
174
174
|
slicePlate?: number; // --slice N: which plate (default: 0 = all)
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
interface BambuSettingsBundle {
|
|
178
|
+
settingsArg?: string;
|
|
179
|
+
filamentPaths: string[];
|
|
180
|
+
/** Identity provenance before the process is merged into a generated file. */
|
|
181
|
+
processSource?: { filePath: string; profile: Record<string, unknown> };
|
|
182
|
+
}
|
|
183
|
+
|
|
177
184
|
export class STLManipulator extends EventEmitter {
|
|
178
185
|
private tempDir: string;
|
|
179
186
|
private activeOperations: Map<string, boolean> = new Map();
|
|
@@ -195,18 +202,24 @@ export class STLManipulator extends EventEmitter {
|
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
private getAvailableProfileRoots(): string[] {
|
|
198
|
-
|
|
205
|
+
const selectedRoot = process.env.BAMBU_PROFILES_ROOT;
|
|
206
|
+
return [
|
|
207
|
+
...(selectedRoot ? [path.join(selectedRoot, 'BBL')] : []),
|
|
208
|
+
...configuredBambuProfileDirs(),
|
|
209
|
+
...BAMBU_PROFILE_ROOTS,
|
|
210
|
+
].filter((root) => fs.existsSync(root));
|
|
199
211
|
}
|
|
200
212
|
|
|
201
213
|
private findProfileFile(
|
|
202
214
|
category: 'machine' | 'process' | 'filament',
|
|
203
|
-
profileName?: string
|
|
215
|
+
profileName?: string,
|
|
216
|
+
roots = this.getAvailableProfileRoots()
|
|
204
217
|
): string | undefined {
|
|
205
218
|
if (!profileName) {
|
|
206
219
|
return undefined;
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
for (const root of
|
|
222
|
+
for (const root of roots) {
|
|
210
223
|
const candidate = path.join(root, category, `${profileName}.json`);
|
|
211
224
|
if (fs.existsSync(candidate)) {
|
|
212
225
|
return candidate;
|
|
@@ -216,10 +229,10 @@ export class STLManipulator extends EventEmitter {
|
|
|
216
229
|
return undefined;
|
|
217
230
|
}
|
|
218
231
|
|
|
219
|
-
private buildFilamentIdIndex(): Map<string, string> {
|
|
232
|
+
private buildFilamentIdIndex(roots = this.getAvailableProfileRoots()): Map<string, string> {
|
|
220
233
|
const index = new Map<string, string>();
|
|
221
234
|
|
|
222
|
-
for (const root of
|
|
235
|
+
for (const root of roots) {
|
|
223
236
|
const filamentDir = path.join(root, 'filament');
|
|
224
237
|
if (!fs.existsSync(filamentDir)) {
|
|
225
238
|
continue;
|
|
@@ -299,8 +312,10 @@ export class STLManipulator extends EventEmitter {
|
|
|
299
312
|
}
|
|
300
313
|
|
|
301
314
|
private writeTempJson(outputBase: string, suffix: string, value: any): string {
|
|
302
|
-
const
|
|
303
|
-
|
|
315
|
+
const serialized = JSON.stringify(value, null, 2);
|
|
316
|
+
const hash = crypto.createHash('sha256').update(serialized).digest('hex').slice(0, 16);
|
|
317
|
+
const outPath = path.join(this.tempDir, `${outputBase}_${suffix}_${hash}.json`);
|
|
318
|
+
fs.writeFileSync(outPath, serialized);
|
|
304
319
|
return outPath;
|
|
305
320
|
}
|
|
306
321
|
|
|
@@ -309,15 +324,25 @@ export class STLManipulator extends EventEmitter {
|
|
|
309
324
|
slicerType: 'orcaslicer' | 'orcaslicer-bambulab' | 'bambustudio',
|
|
310
325
|
slicerProfile?: string,
|
|
311
326
|
printerPreset?: string,
|
|
312
|
-
bambuOptions?: BambuSliceOptions
|
|
313
|
-
|
|
314
|
-
|
|
327
|
+
bambuOptions?: BambuSliceOptions,
|
|
328
|
+
activeProfilesRoot?: string
|
|
329
|
+
): BambuSettingsBundle {
|
|
330
|
+
const roots = activeProfilesRoot
|
|
331
|
+
? [path.join(activeProfilesRoot, 'BBL'), ...this.getAvailableProfileRoots()]
|
|
332
|
+
: this.getAvailableProfileRoots();
|
|
333
|
+
const findProfile = (kind: 'machine' | 'process' | 'filament', name?: string) =>
|
|
334
|
+
this.findProfileFile(kind, name, roots);
|
|
335
|
+
const machinePath = findProfile('machine', printerPreset);
|
|
336
|
+
if (slicerType === 'bambustudio' && printerPreset && !machinePath) {
|
|
337
|
+
throw new Error(`Printer profile "${printerPreset}" was not found. Set BAMBU_PROFILES_ROOT to the matching BambuStudio profile tree; refusing to use printer defaults.`);
|
|
338
|
+
}
|
|
315
339
|
const machineConfig = machinePath ? this.readJsonFile(machinePath) : null;
|
|
316
340
|
const hasSlicerProfile = !!slicerProfile && fs.existsSync(slicerProfile);
|
|
317
341
|
const filamentPaths: string[] = [];
|
|
318
342
|
|
|
319
343
|
let processPath: string | undefined;
|
|
320
344
|
let parsedProfile: any = null;
|
|
345
|
+
let processSource: BambuSettingsBundle['processSource'];
|
|
321
346
|
|
|
322
347
|
if (hasSlicerProfile) {
|
|
323
348
|
try {
|
|
@@ -328,6 +353,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
328
353
|
}
|
|
329
354
|
|
|
330
355
|
if (parsedProfile && typeof parsedProfile === 'object') {
|
|
356
|
+
processSource = { filePath: slicerProfile!, profile: parsedProfile };
|
|
331
357
|
const inheritedProcessName =
|
|
332
358
|
(typeof parsedProfile.inherits === 'string' && parsedProfile.inherits) ||
|
|
333
359
|
(typeof parsedProfile.print_settings_id === 'string' &&
|
|
@@ -341,7 +367,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
341
367
|
? machineConfig.default_print_profile
|
|
342
368
|
: undefined);
|
|
343
369
|
|
|
344
|
-
const inheritedProcessPath =
|
|
370
|
+
const inheritedProcessPath = findProfile('process', inheritedProcessName);
|
|
345
371
|
const inheritedProcess =
|
|
346
372
|
inheritedProcessPath && fs.existsSync(inheritedProcessPath)
|
|
347
373
|
? this.readJsonFile(inheritedProcessPath)
|
|
@@ -361,22 +387,26 @@ export class STLManipulator extends EventEmitter {
|
|
|
361
387
|
: mergedProcess
|
|
362
388
|
);
|
|
363
389
|
|
|
364
|
-
const defaultFilamentProfiles = Array.isArray(parsedProfile.default_filament_profile)
|
|
390
|
+
const defaultFilamentProfiles = !bambuOptions?.loadFilaments && Array.isArray(parsedProfile.default_filament_profile)
|
|
365
391
|
? parsedProfile.default_filament_profile
|
|
366
392
|
: [];
|
|
367
393
|
for (const profileName of defaultFilamentProfiles) {
|
|
368
|
-
const filamentPath =
|
|
394
|
+
const filamentPath = findProfile('filament', String(profileName));
|
|
369
395
|
if (filamentPath) {
|
|
370
396
|
filamentPaths.push(filamentPath);
|
|
397
|
+
} else {
|
|
398
|
+
throw new Error(`Filament profile "${String(profileName)}" was not found; refusing to omit its slot.`);
|
|
371
399
|
}
|
|
372
400
|
}
|
|
373
401
|
|
|
374
|
-
if (filamentPaths.length === 0 && Array.isArray(parsedProfile.filament_ids)) {
|
|
375
|
-
const filamentIdIndex = this.buildFilamentIdIndex();
|
|
402
|
+
if (!bambuOptions?.loadFilaments && filamentPaths.length === 0 && Array.isArray(parsedProfile.filament_ids)) {
|
|
403
|
+
const filamentIdIndex = this.buildFilamentIdIndex(roots);
|
|
376
404
|
for (const filamentId of parsedProfile.filament_ids) {
|
|
377
405
|
const filamentPath = filamentIdIndex.get(String(filamentId));
|
|
378
406
|
if (filamentPath) {
|
|
379
407
|
filamentPaths.push(filamentPath);
|
|
408
|
+
} else {
|
|
409
|
+
throw new Error(`Filament ID "${String(filamentId)}" could not be resolved; refusing to omit its slot.`);
|
|
380
410
|
}
|
|
381
411
|
}
|
|
382
412
|
}
|
|
@@ -389,7 +419,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
389
419
|
typeof machineConfig?.default_print_profile === 'string'
|
|
390
420
|
? machineConfig.default_print_profile
|
|
391
421
|
: undefined;
|
|
392
|
-
const defaultProcessPath =
|
|
422
|
+
const defaultProcessPath = findProfile('process', defaultProcessName);
|
|
393
423
|
if (defaultProcessPath) {
|
|
394
424
|
processPath =
|
|
395
425
|
slicerType === 'orcaslicer'
|
|
@@ -408,9 +438,11 @@ export class STLManipulator extends EventEmitter {
|
|
|
408
438
|
Array.isArray(machineConfig?.default_filament_profile)
|
|
409
439
|
) {
|
|
410
440
|
for (const profileName of machineConfig.default_filament_profile) {
|
|
411
|
-
const filamentPath =
|
|
441
|
+
const filamentPath = findProfile('filament', String(profileName));
|
|
412
442
|
if (filamentPath) {
|
|
413
443
|
filamentPaths.push(filamentPath);
|
|
444
|
+
} else {
|
|
445
|
+
throw new Error(`Filament profile "${String(profileName)}" was not found; refusing to omit its slot.`);
|
|
414
446
|
}
|
|
415
447
|
}
|
|
416
448
|
}
|
|
@@ -431,80 +463,121 @@ export class STLManipulator extends EventEmitter {
|
|
|
431
463
|
|
|
432
464
|
return {
|
|
433
465
|
settingsArg: settingsParts.length > 0 ? settingsParts.join(';') : undefined,
|
|
434
|
-
filamentPaths
|
|
466
|
+
filamentPaths,
|
|
467
|
+
processSource,
|
|
435
468
|
};
|
|
436
469
|
}
|
|
437
470
|
|
|
438
|
-
/**
|
|
439
|
-
* Optionally rewrite a Bambu-like settings bundle so the paths point at
|
|
440
|
-
* fully-flattened temp configs instead of the BBL-shipped leaf JSONs.
|
|
441
|
-
*
|
|
442
|
-
* BambuStudio's CLI does not resolve the `inherits` chain when loading
|
|
443
|
-
* profiles via --load-settings / --load-filaments, which causes a
|
|
444
|
-
* cluster of upstream bugs (see https://github.com/bambulab/BambuStudio/issues/9636
|
|
445
|
-
* and #9968). Our flattener (src/slicer/profile-flatten.ts) reproduces
|
|
446
|
-
* what the GUI does at slice time so the CLI accepts the configs.
|
|
447
|
-
*
|
|
448
|
-
* Opt-in via `BAMBU_CLI_FLATTEN=true`. When the env var is unset or
|
|
449
|
-
* not "true"/"1", returns the bundle unchanged so behavior is
|
|
450
|
-
* backward-compatible. When enabled, only BBL-shipped leaves get
|
|
451
|
-
* flattened; user-provided custom configs pass through untouched.
|
|
452
|
-
*/
|
|
471
|
+
/** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
|
|
453
472
|
private async maybeFlattenBundle(
|
|
454
|
-
bundle:
|
|
473
|
+
bundle: BambuSettingsBundle,
|
|
455
474
|
bambuOptions?: BambuSliceOptions,
|
|
456
475
|
activeSlicerPath?: string
|
|
457
|
-
): Promise<
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
476
|
+
): Promise<BambuSettingsBundle> {
|
|
477
|
+
const parts = bundle.settingsArg?.split(';').filter(Boolean) ?? [];
|
|
478
|
+
const readProfile = (filePath: string): Record<string, unknown> => {
|
|
479
|
+
try {
|
|
480
|
+
const data: unknown = this.readJsonFile(filePath);
|
|
481
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
482
|
+
throw new Error('expected a JSON profile object');
|
|
483
|
+
}
|
|
484
|
+
return data as Record<string, unknown>;
|
|
485
|
+
} catch (err: any) {
|
|
486
|
+
throw new Error(`Cannot read slicer profile "${filePath}": ${err?.message ?? err}`);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const settings = parts.map(readProfile);
|
|
490
|
+
// Read every slot: filtering unreadable names would change positional mapping.
|
|
491
|
+
const filaments = bundle.filamentPaths.map(readProfile);
|
|
492
|
+
const profilesRoot = detectProfilesRoot(activeSlicerPath || process.env.SLICER_PATH);
|
|
493
|
+
const profileRoots = [path.join(profilesRoot, 'BBL'), ...this.getAvailableProfileRoots()];
|
|
494
|
+
const hasReferences = (profile: Record<string, unknown>) =>
|
|
495
|
+
(typeof profile.inherits === 'string' && profile.inherits.length > 0) ||
|
|
496
|
+
(profile.include !== undefined && profile.include !== null);
|
|
497
|
+
const isBundledFile = (filePath: string) =>
|
|
498
|
+
profileRoots.some(root => {
|
|
499
|
+
const relative = path.relative(root, filePath);
|
|
500
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
501
|
+
});
|
|
502
|
+
const isBundled = (filePath: string, profile: Record<string, unknown>) =>
|
|
503
|
+
String(profile.from).toLowerCase() === 'system' || isBundledFile(filePath);
|
|
504
|
+
const needsResolution = (filePath: string, profile: Record<string, unknown>) =>
|
|
505
|
+
hasReferences(profile) || isBundled(filePath, profile);
|
|
506
|
+
if (![...settings, ...filaments].some((profile, i) =>
|
|
507
|
+
needsResolution([...parts, ...bundle.filamentPaths][i], profile))) return bundle;
|
|
508
|
+
if (parts.length !== 2) {
|
|
509
|
+
throw new Error('Resolving BBL profiles requires both a machine and process profile; refusing to slice with unresolved settings.');
|
|
481
510
|
}
|
|
482
511
|
|
|
483
|
-
const
|
|
512
|
+
const [machinePath, processPath] = parts;
|
|
513
|
+
const [machine, processProfile] = settings;
|
|
514
|
+
const leafName = (filePath: string, profile: Record<string, unknown>): string => {
|
|
515
|
+
// A user preset inherits its system identity, while keeping its own values.
|
|
516
|
+
const name = !isBundled(filePath, profile) && typeof profile.inherits === 'string' && profile.inherits
|
|
517
|
+
? profile.inherits
|
|
518
|
+
: profile.name;
|
|
519
|
+
if (typeof name !== 'string' || !name) {
|
|
520
|
+
if (needsResolution(filePath, profile)) throw new Error(`Cannot determine the profile name for "${filePath}".`);
|
|
521
|
+
return path.basename(filePath, '.json');
|
|
522
|
+
}
|
|
523
|
+
return name;
|
|
524
|
+
};
|
|
525
|
+
const flat = await flattenForCli({
|
|
526
|
+
machineLeaf: leafName(machinePath, machine),
|
|
527
|
+
// A generated path loses bundled provenance; inherited metadata can also
|
|
528
|
+
// make a custom preset look like a system profile. Use the original source
|
|
529
|
+
// only for identity and retain the prepared values in sourceProfiles below.
|
|
530
|
+
processLeaf: bundle.processSource
|
|
531
|
+
? leafName(bundle.processSource.filePath, bundle.processSource.profile)
|
|
532
|
+
: leafName(processPath, processProfile),
|
|
533
|
+
filamentLeaves: filaments.map((profile, i) => leafName(bundle.filamentPaths[i], profile)),
|
|
534
|
+
profilesRoot,
|
|
535
|
+
tempDir: this.tempDir,
|
|
536
|
+
bedType: this.resolveBambuStudioBedType(bambuOptions?.bedType),
|
|
537
|
+
sourceProfiles: {
|
|
538
|
+
machine: isBundledFile(machinePath) ? undefined : machine,
|
|
539
|
+
process: isBundledFile(processPath) ? undefined : processProfile,
|
|
540
|
+
filaments: filaments.map((profile, i) => isBundledFile(bundle.filamentPaths[i]) ? undefined : profile),
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
// Standalone custom files have no BBL dependencies; preserve their identity
|
|
544
|
+
// and content. Other profiles retain user values through sourceProfiles.
|
|
545
|
+
return {
|
|
546
|
+
settingsArg: [
|
|
547
|
+
needsResolution(machinePath, machine) ? flat.machinePath : machinePath,
|
|
548
|
+
needsResolution(processPath, processProfile) ? flat.processPath : processPath,
|
|
549
|
+
].join(';'),
|
|
550
|
+
filamentPaths: filaments.map((profile, i) =>
|
|
551
|
+
needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i] : bundle.filamentPaths[i]),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
484
554
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
);
|
|
506
|
-
|
|
555
|
+
/** --load-filaments is positional; a single override must cover every project slot. */
|
|
556
|
+
private async expandProjectFilaments(
|
|
557
|
+
inputPath: string,
|
|
558
|
+
bundle: BambuSettingsBundle
|
|
559
|
+
): Promise<BambuSettingsBundle> {
|
|
560
|
+
if (!inputPath.toLowerCase().endsWith('.3mf') || bundle.filamentPaths.length === 0) return bundle;
|
|
561
|
+
const JSZip = (await import('jszip')).default;
|
|
562
|
+
const zip = await JSZip.loadAsync(await fs.promises.readFile(inputPath));
|
|
563
|
+
const file = zip.file('Metadata/project_settings.config');
|
|
564
|
+
// Geometry-only 3MFs have no embedded filament slots.
|
|
565
|
+
if (!file) return bundle;
|
|
566
|
+
const config: unknown = JSON.parse(await file.async('string'));
|
|
567
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
568
|
+
throw new Error('Cannot determine project filament slots: invalid project_settings.config.');
|
|
569
|
+
}
|
|
570
|
+
const data = config as Record<string, unknown>;
|
|
571
|
+
const count = Math.max(0, ...['filament_settings_id', 'filament_type', 'filament_colour', 'filament_diameter']
|
|
572
|
+
.map(key => Array.isArray(data[key]) ? data[key].length : 0));
|
|
573
|
+
if (count === 0) throw new Error('Cannot determine project filament slots from project_settings.config.');
|
|
574
|
+
if (bundle.filamentPaths.length === 1) {
|
|
575
|
+
return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]) };
|
|
576
|
+
}
|
|
577
|
+
if (bundle.filamentPaths.length !== count) {
|
|
578
|
+
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.`);
|
|
507
579
|
}
|
|
580
|
+
return bundle;
|
|
508
581
|
}
|
|
509
582
|
|
|
510
583
|
private resolveBambuStudioBedType(bedType?: string): string | undefined {
|
|
@@ -518,16 +591,6 @@ export class STLManipulator extends EventEmitter {
|
|
|
518
591
|
return BAMBU_CLI_BED_TYPES[normalized] || bedType;
|
|
519
592
|
}
|
|
520
593
|
|
|
521
|
-
/** Read a profile JSON's top-level `name` field, or null if unreadable. */
|
|
522
|
-
private readLeafName(filePath: string): string | null {
|
|
523
|
-
try {
|
|
524
|
-
const data = this.readJsonFile(filePath);
|
|
525
|
-
return typeof data?.name === 'string' && data.name.length > 0 ? data.name : null;
|
|
526
|
-
} catch {
|
|
527
|
-
return null;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
594
|
/**
|
|
532
595
|
* Load STL file and return geometry and bounding box
|
|
533
596
|
*/
|
|
@@ -1510,14 +1573,14 @@ export class STLManipulator extends EventEmitter {
|
|
|
1510
1573
|
slicerType,
|
|
1511
1574
|
slicerProfile,
|
|
1512
1575
|
printerPreset,
|
|
1513
|
-
bambuOptions
|
|
1576
|
+
bambuOptions,
|
|
1577
|
+
slicerType === 'bambustudio' ? detectProfilesRoot(slicerPath) : undefined
|
|
1514
1578
|
);
|
|
1515
|
-
//
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
: rawBundle;
|
|
1579
|
+
// Resolve every BBL dependency before launching the slicer.
|
|
1580
|
+
const settingsBundle = slicerType === 'bambustudio'
|
|
1581
|
+
? await this.maybeFlattenBundle(
|
|
1582
|
+
await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, slicerPath)
|
|
1583
|
+
: rawBundle;
|
|
1521
1584
|
args = [
|
|
1522
1585
|
'--slice', String(bambuOptions?.slicePlate ?? 0),
|
|
1523
1586
|
'--outputdir', outputDir,
|