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.
@@ -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
- return [...BAMBU_PROFILE_ROOTS, ...configuredBambuProfileDirs()].filter((root) => fs.existsSync(root));
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 this.getAvailableProfileRoots()) {
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 this.getAvailableProfileRoots()) {
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 outPath = path.join(this.tempDir, `${outputBase}_${suffix}.json`);
303
- fs.writeFileSync(outPath, JSON.stringify(value, null, 2));
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
- ): { settingsArg?: string; filamentPaths: string[] } {
314
- const machinePath = this.findProfileFile('machine', printerPreset);
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 = this.findProfileFile('process', inheritedProcessName);
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 = this.findProfileFile('filament', String(profileName));
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 = this.findProfileFile('process', defaultProcessName);
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 = this.findProfileFile('filament', String(profileName));
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: Array.from(new Set(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: { settingsArg?: string; filamentPaths: string[] },
473
+ bundle: BambuSettingsBundle,
455
474
  bambuOptions?: BambuSliceOptions,
456
475
  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;
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 bedType = this.resolveBambuStudioBedType(bambuOptions?.bedType);
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
- 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;
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
- // 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;
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,