bambu-printer-mcp 1.1.6 → 1.1.9

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.
@@ -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
@@ -174,6 +175,17 @@ export interface BambuSliceOptions {
174
175
  slicePlate?: number; // --slice N: which plate (default: 0 = all)
175
176
  }
176
177
 
178
+ interface BambuSettingsBundle {
179
+ settingsArg?: string;
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 };
185
+ /** Identity provenance before the process is merged into a generated file. */
186
+ processSource?: { filePath: string; profile: Record<string, unknown> };
187
+ }
188
+
177
189
  export class STLManipulator extends EventEmitter {
178
190
  private tempDir: string;
179
191
  private activeOperations: Map<string, boolean> = new Map();
@@ -195,18 +207,24 @@ export class STLManipulator extends EventEmitter {
195
207
  }
196
208
 
197
209
  private getAvailableProfileRoots(): string[] {
198
- return [...BAMBU_PROFILE_ROOTS, ...configuredBambuProfileDirs()].filter((root) => fs.existsSync(root));
210
+ const selectedRoot = process.env.BAMBU_PROFILES_ROOT;
211
+ return [
212
+ ...(selectedRoot ? [path.join(selectedRoot, 'BBL')] : []),
213
+ ...configuredBambuProfileDirs(),
214
+ ...BAMBU_PROFILE_ROOTS,
215
+ ].filter((root) => fs.existsSync(root));
199
216
  }
200
217
 
201
218
  private findProfileFile(
202
219
  category: 'machine' | 'process' | 'filament',
203
- profileName?: string
220
+ profileName?: string,
221
+ roots = this.getAvailableProfileRoots()
204
222
  ): string | undefined {
205
223
  if (!profileName) {
206
224
  return undefined;
207
225
  }
208
226
 
209
- for (const root of this.getAvailableProfileRoots()) {
227
+ for (const root of roots) {
210
228
  const candidate = path.join(root, category, `${profileName}.json`);
211
229
  if (fs.existsSync(candidate)) {
212
230
  return candidate;
@@ -216,10 +234,10 @@ export class STLManipulator extends EventEmitter {
216
234
  return undefined;
217
235
  }
218
236
 
219
- private buildFilamentIdIndex(): Map<string, string> {
237
+ private buildFilamentIdIndex(roots = this.getAvailableProfileRoots()): Map<string, string> {
220
238
  const index = new Map<string, string>();
221
239
 
222
- for (const root of this.getAvailableProfileRoots()) {
240
+ for (const root of roots) {
223
241
  const filamentDir = path.join(root, 'filament');
224
242
  if (!fs.existsSync(filamentDir)) {
225
243
  continue;
@@ -299,8 +317,10 @@ export class STLManipulator extends EventEmitter {
299
317
  }
300
318
 
301
319
  private writeTempJson(outputBase: string, suffix: string, value: any): string {
302
- const outPath = path.join(this.tempDir, `${outputBase}_${suffix}.json`);
303
- fs.writeFileSync(outPath, JSON.stringify(value, null, 2));
320
+ const serialized = JSON.stringify(value, null, 2);
321
+ const hash = crypto.createHash('sha256').update(serialized).digest('hex').slice(0, 16);
322
+ const outPath = path.join(this.tempDir, `${outputBase}_${suffix}_${hash}.json`);
323
+ fs.writeFileSync(outPath, serialized);
304
324
  return outPath;
305
325
  }
306
326
 
@@ -309,15 +329,25 @@ export class STLManipulator extends EventEmitter {
309
329
  slicerType: 'orcaslicer' | 'orcaslicer-bambulab' | 'bambustudio',
310
330
  slicerProfile?: string,
311
331
  printerPreset?: string,
312
- bambuOptions?: BambuSliceOptions
313
- ): { settingsArg?: string; filamentPaths: string[] } {
314
- const machinePath = this.findProfileFile('machine', printerPreset);
332
+ bambuOptions?: BambuSliceOptions,
333
+ activeProfilesRoot?: string
334
+ ): BambuSettingsBundle {
335
+ const roots = activeProfilesRoot
336
+ ? [path.join(activeProfilesRoot, 'BBL'), ...this.getAvailableProfileRoots()]
337
+ : this.getAvailableProfileRoots();
338
+ const findProfile = (kind: 'machine' | 'process' | 'filament', name?: string) =>
339
+ this.findProfileFile(kind, name, roots);
340
+ const machinePath = findProfile('machine', printerPreset);
341
+ if (slicerType === 'bambustudio' && printerPreset && !machinePath) {
342
+ throw new Error(`Printer profile "${printerPreset}" was not found. Set BAMBU_PROFILES_ROOT to the matching BambuStudio profile tree; refusing to use printer defaults.`);
343
+ }
315
344
  const machineConfig = machinePath ? this.readJsonFile(machinePath) : null;
316
345
  const hasSlicerProfile = !!slicerProfile && fs.existsSync(slicerProfile);
317
346
  const filamentPaths: string[] = [];
318
347
 
319
348
  let processPath: string | undefined;
320
349
  let parsedProfile: any = null;
350
+ let processSource: BambuSettingsBundle['processSource'];
321
351
 
322
352
  if (hasSlicerProfile) {
323
353
  try {
@@ -328,6 +358,7 @@ export class STLManipulator extends EventEmitter {
328
358
  }
329
359
 
330
360
  if (parsedProfile && typeof parsedProfile === 'object') {
361
+ processSource = { filePath: slicerProfile!, profile: parsedProfile };
331
362
  const inheritedProcessName =
332
363
  (typeof parsedProfile.inherits === 'string' && parsedProfile.inherits) ||
333
364
  (typeof parsedProfile.print_settings_id === 'string' &&
@@ -341,7 +372,7 @@ export class STLManipulator extends EventEmitter {
341
372
  ? machineConfig.default_print_profile
342
373
  : undefined);
343
374
 
344
- const inheritedProcessPath = this.findProfileFile('process', inheritedProcessName);
375
+ const inheritedProcessPath = findProfile('process', inheritedProcessName);
345
376
  const inheritedProcess =
346
377
  inheritedProcessPath && fs.existsSync(inheritedProcessPath)
347
378
  ? this.readJsonFile(inheritedProcessPath)
@@ -361,22 +392,26 @@ export class STLManipulator extends EventEmitter {
361
392
  : mergedProcess
362
393
  );
363
394
 
364
- const defaultFilamentProfiles = Array.isArray(parsedProfile.default_filament_profile)
395
+ const defaultFilamentProfiles = !bambuOptions?.loadFilaments && Array.isArray(parsedProfile.default_filament_profile)
365
396
  ? parsedProfile.default_filament_profile
366
397
  : [];
367
398
  for (const profileName of defaultFilamentProfiles) {
368
- const filamentPath = this.findProfileFile('filament', String(profileName));
399
+ const filamentPath = findProfile('filament', String(profileName));
369
400
  if (filamentPath) {
370
401
  filamentPaths.push(filamentPath);
402
+ } else {
403
+ throw new Error(`Filament profile "${String(profileName)}" was not found; refusing to omit its slot.`);
371
404
  }
372
405
  }
373
406
 
374
- if (filamentPaths.length === 0 && Array.isArray(parsedProfile.filament_ids)) {
375
- const filamentIdIndex = this.buildFilamentIdIndex();
407
+ if (!bambuOptions?.loadFilaments && filamentPaths.length === 0 && Array.isArray(parsedProfile.filament_ids)) {
408
+ const filamentIdIndex = this.buildFilamentIdIndex(roots);
376
409
  for (const filamentId of parsedProfile.filament_ids) {
377
410
  const filamentPath = filamentIdIndex.get(String(filamentId));
378
411
  if (filamentPath) {
379
412
  filamentPaths.push(filamentPath);
413
+ } else {
414
+ throw new Error(`Filament ID "${String(filamentId)}" could not be resolved; refusing to omit its slot.`);
380
415
  }
381
416
  }
382
417
  }
@@ -389,7 +424,7 @@ export class STLManipulator extends EventEmitter {
389
424
  typeof machineConfig?.default_print_profile === 'string'
390
425
  ? machineConfig.default_print_profile
391
426
  : undefined;
392
- const defaultProcessPath = this.findProfileFile('process', defaultProcessName);
427
+ const defaultProcessPath = findProfile('process', defaultProcessName);
393
428
  if (defaultProcessPath) {
394
429
  processPath =
395
430
  slicerType === 'orcaslicer'
@@ -408,9 +443,11 @@ export class STLManipulator extends EventEmitter {
408
443
  Array.isArray(machineConfig?.default_filament_profile)
409
444
  ) {
410
445
  for (const profileName of machineConfig.default_filament_profile) {
411
- const filamentPath = this.findProfileFile('filament', String(profileName));
446
+ const filamentPath = findProfile('filament', String(profileName));
412
447
  if (filamentPath) {
413
448
  filamentPaths.push(filamentPath);
449
+ } else {
450
+ throw new Error(`Filament profile "${String(profileName)}" was not found; refusing to omit its slot.`);
414
451
  }
415
452
  }
416
453
  }
@@ -431,80 +468,139 @@ export class STLManipulator extends EventEmitter {
431
468
 
432
469
  return {
433
470
  settingsArg: settingsParts.length > 0 ? settingsParts.join(';') : undefined,
434
- filamentPaths: Array.from(new Set(filamentPaths)),
471
+ filamentPaths,
472
+ processSource,
435
473
  };
436
474
  }
437
475
 
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
- */
476
+ /** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
453
477
  private async maybeFlattenBundle(
454
- bundle: { settingsArg?: string; filamentPaths: string[] },
478
+ bundle: BambuSettingsBundle,
455
479
  bambuOptions?: BambuSliceOptions,
456
480
  activeSlicerPath?: string
457
- ): Promise<{ settingsArg?: string; filamentPaths: string[] }> {
458
- const flag = process.env.BAMBU_CLI_FLATTEN;
459
- if (flag !== 'true' && flag !== '1') return bundle;
460
- if (!bundle.settingsArg) return bundle;
461
-
462
- // settingsArg is "machine.json;process.json".
463
- const parts = bundle.settingsArg.split(';').filter(Boolean);
464
- if (parts.length < 2) return bundle;
465
- const [machinePath, processPath] = parts;
466
-
467
- // Pull leaf names from each JSON's `name` field. If any is missing or
468
- // looks non-BBL (e.g. a user-saved custom config), bail out and
469
- // return the original bundle untouched.
470
- const machineLeaf = this.readLeafName(machinePath);
471
- const processLeaf = this.readLeafName(processPath);
472
- const filamentLeaves = bundle.filamentPaths
473
- .map((p) => this.readLeafName(p))
474
- .filter((n): n is string => Boolean(n));
475
-
476
- if (!machineLeaf || !processLeaf || filamentLeaves.length === 0) {
477
- console.log(
478
- '[cli-flatten] skipping: could not derive BBL leaf names from bundle paths'
479
- );
480
- return bundle;
481
+ ): Promise<BambuSettingsBundle> {
482
+ const parts = bundle.settingsArg?.split(';').filter(Boolean) ?? [];
483
+ const readProfile = (filePath: string): Record<string, unknown> => {
484
+ try {
485
+ const data: unknown = this.readJsonFile(filePath);
486
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
487
+ throw new Error('expected a JSON profile object');
488
+ }
489
+ return data as Record<string, unknown>;
490
+ } catch (err: any) {
491
+ throw new Error(`Cannot read slicer profile "${filePath}": ${err?.message ?? err}`);
492
+ }
493
+ };
494
+ const settings = parts.map(readProfile);
495
+ // Read every slot: filtering unreadable names would change positional mapping.
496
+ const filaments = bundle.filamentPaths.map(readProfile);
497
+ const profilesRoot = detectProfilesRoot(activeSlicerPath || process.env.SLICER_PATH);
498
+ const profileRoots = [path.join(profilesRoot, 'BBL'), ...this.getAvailableProfileRoots()];
499
+ const hasReferences = (profile: Record<string, unknown>) =>
500
+ (typeof profile.inherits === 'string' && profile.inherits.length > 0) ||
501
+ (profile.include !== undefined && profile.include !== null);
502
+ const isBundledFile = (filePath: string) =>
503
+ profileRoots.some(root => {
504
+ const relative = path.relative(root, filePath);
505
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
506
+ });
507
+ const isBundled = (filePath: string, profile: Record<string, unknown>) =>
508
+ String(profile.from).toLowerCase() === 'system' || isBundledFile(filePath);
509
+ const needsResolution = (filePath: string, profile: Record<string, unknown>) =>
510
+ hasReferences(profile) || isBundled(filePath, profile);
511
+ if (![...settings, ...filaments].some((profile, i) =>
512
+ needsResolution([...parts, ...bundle.filamentPaths][i], profile))) return bundle;
513
+ if (parts.length !== 2) {
514
+ throw new Error('Resolving BBL profiles requires both a machine and process profile; refusing to slice with unresolved settings.');
481
515
  }
482
516
 
483
- const bedType = this.resolveBambuStudioBedType(bambuOptions?.bedType);
517
+ const [machinePath, processPath] = parts;
518
+ const [machine, processProfile] = settings;
519
+ const leafName = (filePath: string, profile: Record<string, unknown>): string => {
520
+ // A user preset inherits its system identity, while keeping its own values.
521
+ const name = !isBundled(filePath, profile) && typeof profile.inherits === 'string' && profile.inherits
522
+ ? profile.inherits
523
+ : profile.name;
524
+ if (typeof name !== 'string' || !name) {
525
+ if (needsResolution(filePath, profile)) throw new Error(`Cannot determine the profile name for "${filePath}".`);
526
+ return path.basename(filePath, '.json');
527
+ }
528
+ return name;
529
+ };
530
+ const flat = await flattenForCli({
531
+ machineLeaf: leafName(machinePath, machine),
532
+ // A generated path loses bundled provenance; inherited metadata can also
533
+ // make a custom preset look like a system profile. Use the original source
534
+ // only for identity and retain the prepared values in sourceProfiles below.
535
+ processLeaf: bundle.processSource
536
+ ? leafName(bundle.processSource.filePath, bundle.processSource.profile)
537
+ : leafName(processPath, processProfile),
538
+ filamentLeaves: filaments.map((profile, i) => leafName(bundle.filamentPaths[i], profile)),
539
+ profilesRoot,
540
+ tempDir: this.tempDir,
541
+ bedType: this.resolveBambuStudioBedType(bambuOptions?.bedType),
542
+ filamentColours: bambuOptions?.filamentColours ?? bundle.filamentColours,
543
+ projectTowerPosition: bundle.projectTowerPosition,
544
+ sourceProfiles: {
545
+ machine: isBundledFile(machinePath) ? undefined : machine,
546
+ process: isBundledFile(processPath) ? undefined : processProfile,
547
+ filaments: filaments.map((profile, i) => isBundledFile(bundle.filamentPaths[i]) ? undefined : profile),
548
+ },
549
+ });
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
+ };
559
+ return {
560
+ settingsArg: [
561
+ needsResolution(machinePath, machine) ? flat.machinePath : machinePath,
562
+ needsResolution(processPath, processProfile) ? flat.processPath
563
+ : withCliFields(processPath, processProfile, flat.processPath, ['wipe_tower_x', 'wipe_tower_y']),
564
+ ].join(';'),
565
+ filamentPaths: filaments.map((profile, i) =>
566
+ needsResolution(bundle.filamentPaths[i], profile) ? flat.filamentPaths[i]
567
+ : withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
568
+ };
569
+ }
484
570
 
485
- try {
486
- const profilesRoot = detectProfilesRoot(activeSlicerPath || process.env.SLICER_PATH);
487
- const flat = await flattenForCli({
488
- machineLeaf,
489
- processLeaf,
490
- filamentLeaves,
491
- profilesRoot,
492
- tempDir: this.tempDir,
493
- bedType,
494
- });
495
- console.log(
496
- `[cli-flatten] applied for ${machineLeaf} (cliOverlay=${flat.meta.cliOverlayApplied})`
497
- );
498
- return {
499
- settingsArg: `${flat.machinePath};${flat.processPath}`,
500
- filamentPaths: flat.filamentPaths,
501
- };
502
- } catch (err: any) {
503
- console.error(
504
- `[cli-flatten] failed, falling back to unflattened bundle: ${err?.message ?? err}`
505
- );
506
- return bundle;
571
+ /** --load-filaments is positional; a single override must cover every project slot. */
572
+ private async expandProjectFilaments(
573
+ inputPath: string,
574
+ bundle: BambuSettingsBundle
575
+ ): Promise<BambuSettingsBundle> {
576
+ if (!inputPath.toLowerCase().endsWith('.3mf') || bundle.filamentPaths.length === 0) return bundle;
577
+ const JSZip = (await import('jszip')).default;
578
+ const zip = await JSZip.loadAsync(await fs.promises.readFile(inputPath));
579
+ const file = zip.file('Metadata/project_settings.config');
580
+ // Geometry-only 3MFs have no embedded filament slots.
581
+ if (!file) return bundle;
582
+ const config: unknown = JSON.parse(await file.async('string'));
583
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
584
+ throw new Error('Cannot determine project filament slots: invalid project_settings.config.');
585
+ }
586
+ const data = config as Record<string, unknown>;
587
+ const count = Math.max(0, ...['filament_settings_id', 'filament_type', 'filament_colour', 'filament_diameter']
588
+ .map(key => Array.isArray(data[key]) ? data[key].length : 0));
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]]));
597
+ if (bundle.filamentPaths.length === 1) {
598
+ return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]), filamentColours: colours, projectTowerPosition };
599
+ }
600
+ if (bundle.filamentPaths.length !== count) {
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.`);
507
602
  }
603
+ return { ...bundle, filamentColours: colours, projectTowerPosition };
508
604
  }
509
605
 
510
606
  private resolveBambuStudioBedType(bedType?: string): string | undefined {
@@ -518,16 +614,6 @@ export class STLManipulator extends EventEmitter {
518
614
  return BAMBU_CLI_BED_TYPES[normalized] || bedType;
519
615
  }
520
616
 
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
617
  /**
532
618
  * Load STL file and return geometry and bounding box
533
619
  */
@@ -1510,14 +1596,14 @@ export class STLManipulator extends EventEmitter {
1510
1596
  slicerType,
1511
1597
  slicerProfile,
1512
1598
  printerPreset,
1513
- bambuOptions
1599
+ bambuOptions,
1600
+ slicerType === 'bambustudio' ? detectProfilesRoot(slicerPath) : undefined
1514
1601
  );
1515
- // Opt-in flatten step: walks the BBL `inherits` chain so the
1516
- // CLI accepts what we hand it. See maybeFlattenBundle docstring.
1517
- const settingsBundle =
1518
- slicerType === 'bambustudio'
1519
- ? await this.maybeFlattenBundle(rawBundle, bambuOptions, slicerPath)
1520
- : rawBundle;
1602
+ // Resolve every BBL dependency before launching the slicer.
1603
+ const settingsBundle = slicerType === 'bambustudio'
1604
+ ? await this.maybeFlattenBundle(
1605
+ await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, slicerPath)
1606
+ : rawBundle;
1521
1607
  args = [
1522
1608
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1523
1609
  '--outputdir', outputDir,