bambu-printer-mcp 1.1.15 → 1.1.17

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/dist/index.js CHANGED
@@ -497,6 +497,30 @@ function parseBooleanEnv(rawValue, fallback) {
497
497
  return false;
498
498
  return fallback;
499
499
  }
500
+ // Someone confirming a hardware action may have to walk to the printer, so the
501
+ // SDK's 60-second request default is too short for human confirmation.
502
+ const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600000;
503
+ const MIN_CONFIRMATION_TIMEOUT_MS = 1000;
504
+ const MAX_CONFIRMATION_TIMEOUT_MS = 3600000;
505
+ /** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
506
+ function parseConfirmationTimeout(value) {
507
+ const parsed = Number(value?.trim() || NaN);
508
+ return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
509
+ ? parsed
510
+ : DEFAULT_CONFIRMATION_TIMEOUT_MS;
511
+ }
512
+ function describeDuration(ms) {
513
+ return ms >= 60000 ? `${Math.round(ms / 6000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
514
+ }
515
+ function isElicitationTimeout(error) {
516
+ return error?.code === ErrorCode.RequestTimeout;
517
+ }
518
+ /** Only a missing method or capability means the client cannot show the prompt. */
519
+ function isElicitationUnsupported(error) {
520
+ const message = String(error?.message ?? error);
521
+ return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
522
+ /does not support (form |url )?elicitation/i.test(message);
523
+ }
500
524
  function parsePort(value, fallback) {
501
525
  if (!value)
502
526
  return fallback;
@@ -822,6 +846,7 @@ class BambuPrinterMCPServer {
822
846
  constructor() {
823
847
  this.blender = new BlenderMcpBridge();
824
848
  this.shuttingDown = false;
849
+ this.confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
825
850
  this.runtimeConfig = readRuntimeConfig();
826
851
  this.server = new Server({
827
852
  name: "bambu-printer-mcp",
@@ -842,11 +867,17 @@ class BambuPrinterMCPServer {
842
867
  properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
843
868
  required: ["confirmed"],
844
869
  },
845
- });
870
+ }, { timeout: this.confirmationTimeoutMs });
846
871
  return response.action === "accept" && response.content?.confirmed === true;
847
872
  }
848
- catch {
849
- throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
873
+ catch (error) {
874
+ if (isElicitationTimeout(error)) {
875
+ throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
876
+ }
877
+ if (isElicitationUnsupported(error)) {
878
+ throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
879
+ }
880
+ throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
850
881
  }
851
882
  });
852
883
  this.bambuNetwork = new BambuNetworkBridge();
@@ -902,17 +933,19 @@ class BambuPrinterMCPServer {
902
933
  },
903
934
  required: ["bambu_model"],
904
935
  },
905
- });
936
+ }, { timeout: this.confirmationTimeoutMs });
906
937
  if (result.action === "accept" && result.content?.bambu_model) {
907
938
  return validateBambuModel(String(result.content.bambu_model));
908
939
  }
909
940
  throw new Error("Printer model selection was cancelled. Cannot proceed without knowing the printer model.");
910
941
  }
911
942
  catch (elicitError) {
943
+ if (isElicitationTimeout(elicitError)) {
944
+ throw new Error(`No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
945
+ "Set BAMBU_MODEL or pass bambu_model in the tool call.");
946
+ }
912
947
  // Elicitation not supported by this client — fall back to a clear error
913
- const msg = elicitError?.message || String(elicitError);
914
- if (elicitError?.code === -32601 || elicitError?.code === -32600 ||
915
- msg.includes("does not support") || msg.includes("elicitation")) {
948
+ if (isElicitationUnsupported(elicitError)) {
916
949
  throw new Error("bambu_model is required but your MCP client does not support elicitation. " +
917
950
  `Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
918
951
  `Valid models: ${VALID_BAMBU_MODELS.join(", ")}`);
@@ -41,6 +41,7 @@ import crypto from "node:crypto";
41
41
  */
42
42
  async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
43
43
  const index = new Map();
44
+ index.malformed = [];
44
45
  const subdirs = ["machine", "process", "filament"];
45
46
  const directories = [
46
47
  ...subdirs.map(sub => path.join(profilesRoot, vendor, sub)),
@@ -72,11 +73,15 @@ async function buildNameIndex(profilesRoot, vendor, userProfileRoots = []) {
72
73
  data = JSON.parse(raw);
73
74
  }
74
75
  catch {
75
- // Malformed profile -- skip, don't poison the index.
76
+ // Malformed profile -- skip, don't poison the index. A reference to
77
+ // it still fails, and the error names the skipped file.
78
+ index.malformed.push(filePath);
76
79
  continue;
77
80
  }
78
- if (!data || typeof data !== 'object' || Array.isArray(data))
81
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
82
+ index.malformed.push(filePath);
79
83
  continue;
84
+ }
80
85
  const name = data["name"];
81
86
  if (typeof name !== "string" || name.length === 0)
82
87
  continue;
@@ -106,7 +111,7 @@ function flattenByName(leafName, index, visiting = new Set()) {
106
111
  }
107
112
  const entry = index.get(leafName);
108
113
  if (!entry) {
109
- throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.`);
114
+ throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
110
115
  }
111
116
  visiting.add(leafName);
112
117
  try {
@@ -116,6 +121,13 @@ function flattenByName(leafName, index, visiting = new Set()) {
116
121
  visiting.delete(leafName);
117
122
  }
118
123
  }
124
+ function describeMalformed(index) {
125
+ const malformed = index.malformed ?? [];
126
+ if (malformed.length === 0)
127
+ return "";
128
+ const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
129
+ return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
130
+ }
119
131
  function flattenData(data, index, visiting = new Set()) {
120
132
  const parent = data["inherits"];
121
133
  const merged = typeof parent === "string" && parent.length > 0
@@ -157,7 +169,7 @@ function applyIncludes(target, profile, index, visiting) {
157
169
  const entry = index.get(name);
158
170
  if (!entry) {
159
171
  throw new Error(`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
160
- `Refusing to fall back to inherited defaults (wrong G-code for this printer).`);
172
+ `Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`);
161
173
  }
162
174
  for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
163
175
  if (!INCLUDE_METADATA_KEYS.has(key))
@@ -62,10 +62,20 @@ export declare class STLManipulator extends EventEmitter {
62
62
  private readJsonFile;
63
63
  private stripAbsoluteExtruderResets;
64
64
  private sanitizeProcessForOrca;
65
+ /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
66
+ private useAbsoluteExtrusionForOrca;
65
67
  private writeTempJson;
66
68
  private resolveBambuLikeSettingsBundle;
67
69
  /** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
68
70
  private maybeFlattenBundle;
71
+ /**
72
+ * The CLI applies every key in every --load-settings/--load-filaments file,
73
+ * so machine settings carried by a process or filament file (for example a
74
+ * template 3MF's project_settings from another printer) would replace the
75
+ * selected preset's start G-code, model, and bed geometry. The machine
76
+ * preset owns those keys; drop them from the other profiles.
77
+ */
78
+ private isolateMachineSettings;
69
79
  /** --load-filaments is positional; a single override must cover every project slot. */
70
80
  private expandProjectFilaments;
71
81
  private resolveBambuStudioBedType;
@@ -100,6 +100,11 @@ function configuredBambuProfileDirs() {
100
100
  ];
101
101
  return roots.flatMap((root) => appNames.map((appName) => path.join(root, appName, 'system', 'BBL')));
102
102
  }
103
+ /** Keys every profile kind carries; they describe the file, not machine hardware. */
104
+ const PROFILE_METADATA_KEYS = new Set([
105
+ 'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
106
+ 'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
107
+ ]);
103
108
  export class STLManipulator extends EventEmitter {
104
109
  constructor(tempDir = path.join(process.cwd(), 'temp')) {
105
110
  super();
@@ -193,11 +198,16 @@ export class STLManipulator extends EventEmitter {
193
198
  else if (sanitized.prime_tower_brim_width !== undefined) {
194
199
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
195
200
  }
196
- sanitized.use_relative_e_distances = '0';
197
- sanitized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(sanitized.before_layer_change_gcode);
198
- sanitized.layer_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_gcode);
199
- sanitized.layer_change_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_change_gcode);
200
- return sanitized;
201
+ return this.useAbsoluteExtrusionForOrca(sanitized);
202
+ }
203
+ /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
204
+ useAbsoluteExtrusionForOrca(config) {
205
+ const normalized = { ...config };
206
+ normalized.use_relative_e_distances = '0';
207
+ normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.before_layer_change_gcode);
208
+ normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
209
+ normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(normalized.layer_change_gcode);
210
+ return normalized;
201
211
  }
202
212
  writeTempJson(outputBase, suffix, value) {
203
213
  const serialized = JSON.stringify(value, null, 2);
@@ -426,6 +436,36 @@ export class STLManipulator extends EventEmitter {
426
436
  : withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ['filament_colour'])),
427
437
  };
428
438
  }
439
+ /**
440
+ * The CLI applies every key in every --load-settings/--load-filaments file,
441
+ * so machine settings carried by a process or filament file (for example a
442
+ * template 3MF's project_settings from another printer) would replace the
443
+ * selected preset's start G-code, model, and bed geometry. The machine
444
+ * preset owns those keys; drop them from the other profiles.
445
+ */
446
+ isolateMachineSettings(bundle) {
447
+ const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
448
+ if (!machinePath || !processPath)
449
+ return bundle;
450
+ const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
451
+ const isolate = (filePath, label) => {
452
+ const profile = this.readJsonFile(filePath);
453
+ const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
454
+ if (foreign.length === 0)
455
+ return filePath;
456
+ const isolated = { ...profile };
457
+ for (const key of foreign)
458
+ delete isolated[key];
459
+ console.error(`Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
460
+ `(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`);
461
+ return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
462
+ };
463
+ return {
464
+ ...bundle,
465
+ settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
466
+ filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
467
+ };
468
+ }
429
469
  /** --load-filaments is positional; a single override must cover every project slot. */
430
470
  async expandProjectFilaments(inputPath, bundle) {
431
471
  if (!inputPath.toLowerCase().endsWith('.3mf') || bundle.filamentPaths.length === 0)
@@ -1244,17 +1284,23 @@ export class STLManipulator extends EventEmitter {
1244
1284
  const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1245
1285
  // Inherited process G-code is now present; apply Orca's existing
1246
1286
  // absolute-extrusion normalization after resolving those ancestors.
1287
+ // The machine owns the extrusion mode and layer G-code once process
1288
+ // files lose machine keys, so normalize it there as well.
1247
1289
  if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1248
1290
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1249
- settingsBundle.settingsArg = [machinePath, this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset))].join(';');
1291
+ settingsBundle.settingsArg = [
1292
+ this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
1293
+ this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1294
+ ].join(';');
1250
1295
  }
1296
+ const cliBundle = this.isolateMachineSettings(settingsBundle);
1251
1297
  args = [
1252
1298
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1253
1299
  '--outputdir', outputDir,
1254
1300
  '--export-3mf', path.basename(bambuOutputPath),
1255
1301
  ];
1256
- if (settingsBundle.settingsArg) {
1257
- args.push('--load-settings', settingsBundle.settingsArg);
1302
+ if (cliBundle.settingsArg) {
1303
+ args.push('--load-settings', cliBundle.settingsArg);
1258
1304
  }
1259
1305
  // Always allow newer-version 3MF files (the CLI rejects them by default)
1260
1306
  args.push('--allow-newer-file');
@@ -1289,8 +1335,8 @@ export class STLManipulator extends EventEmitter {
1289
1335
  args.push('--clone-objects', bambuOptions.cloneObjects);
1290
1336
  if (bambuOptions?.skipObjects)
1291
1337
  args.push('--skip-objects', bambuOptions.skipObjects);
1292
- if (settingsBundle.filamentPaths.length > 0) {
1293
- args.push('--load-filaments', settingsBundle.filamentPaths.join(';'));
1338
+ if (cliBundle.filamentPaths.length > 0) {
1339
+ args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
1294
1340
  args.push('--load-defaultfila');
1295
1341
  }
1296
1342
  if (bambuOptions?.loadFilamentIds)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.15",
3
+ "version": "1.1.17",
4
4
  "description": "MCP server for Bambu Lab 3D printers with STL manipulation, slicing, and print management",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -689,6 +689,35 @@ function parseBooleanEnv(rawValue: string | undefined, fallback: boolean): boole
689
689
  return fallback;
690
690
  }
691
691
 
692
+ // Someone confirming a hardware action may have to walk to the printer, so the
693
+ // SDK's 60-second request default is too short for human confirmation.
694
+ const DEFAULT_CONFIRMATION_TIMEOUT_MS = 600_000;
695
+ const MIN_CONFIRMATION_TIMEOUT_MS = 1_000;
696
+ const MAX_CONFIRMATION_TIMEOUT_MS = 3_600_000;
697
+
698
+ /** Out-of-range values fall back to the default; Node clamps huge timer delays to 1 ms. */
699
+ function parseConfirmationTimeout(value: string | undefined): number {
700
+ const parsed = Number(value?.trim() || NaN);
701
+ return Number.isInteger(parsed) && parsed >= MIN_CONFIRMATION_TIMEOUT_MS && parsed <= MAX_CONFIRMATION_TIMEOUT_MS
702
+ ? parsed
703
+ : DEFAULT_CONFIRMATION_TIMEOUT_MS;
704
+ }
705
+
706
+ function describeDuration(ms: number): string {
707
+ return ms >= 60_000 ? `${Math.round(ms / 6_000) / 10} minute(s)` : `${Math.round(ms / 100) / 10} second(s)`;
708
+ }
709
+
710
+ function isElicitationTimeout(error: any): boolean {
711
+ return error?.code === ErrorCode.RequestTimeout;
712
+ }
713
+
714
+ /** Only a missing method or capability means the client cannot show the prompt. */
715
+ function isElicitationUnsupported(error: any): boolean {
716
+ const message = String(error?.message ?? error);
717
+ return error?.code === ErrorCode.MethodNotFound || error?.code === ErrorCode.InvalidRequest ||
718
+ /does not support (form |url )?elicitation/i.test(message);
719
+ }
720
+
692
721
  function parsePort(value: string | undefined, fallback: number): number {
693
722
  if (!value) return fallback;
694
723
  const parsed = Number(value);
@@ -1105,6 +1134,7 @@ class BambuPrinterMCPServer {
1105
1134
  private readonly runtimeConfig: RuntimeConfig;
1106
1135
  private httpRuntime?: { transport: StreamableHTTPServerTransport; httpServer: HttpServer };
1107
1136
  private shuttingDown = false;
1137
+ private readonly confirmationTimeoutMs = parseConfirmationTimeout(process.env.BAMBU_CONFIRMATION_TIMEOUT_MS);
1108
1138
 
1109
1139
  constructor() {
1110
1140
  this.runtimeConfig = readRuntimeConfig();
@@ -1131,10 +1161,16 @@ class BambuPrinterMCPServer {
1131
1161
  properties: { confirmed: { type: "boolean", title: "I checked the printer and confirm this operation", default: false } },
1132
1162
  required: ["confirmed"],
1133
1163
  },
1134
- });
1164
+ }, { timeout: this.confirmationTimeoutMs });
1135
1165
  return response.action === "accept" && response.content?.confirmed === true;
1136
- } catch {
1137
- throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
1166
+ } catch (error: any) {
1167
+ if (isElicitationTimeout(error)) {
1168
+ throw new Error(`No hardware confirmation within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. Retry when someone can check the printer, or raise BAMBU_CONFIRMATION_TIMEOUT_MS.`);
1169
+ }
1170
+ if (isElicitationUnsupported(error)) {
1171
+ throw new Error("Human hardware confirmation requires an MCP client with elicitation support. For deliberately headless operation, BAMBU_REQUIRE_CONFIRMATION=0 disables ordinary print/heat prompts; finished-bed clearance and hardware-error confirmation still require elicitation.");
1172
+ }
1173
+ throw new Error(`Human hardware confirmation failed: ${error?.message ?? error}. Nothing was sent to the printer.`);
1138
1174
  }
1139
1175
  });
1140
1176
  this.bambuNetwork = new BambuNetworkBridge();
@@ -1196,7 +1232,7 @@ class BambuPrinterMCPServer {
1196
1232
  },
1197
1233
  required: ["bambu_model"],
1198
1234
  },
1199
- });
1235
+ }, { timeout: this.confirmationTimeoutMs });
1200
1236
 
1201
1237
  if (result.action === "accept" && result.content?.bambu_model) {
1202
1238
  return validateBambuModel(String(result.content.bambu_model));
@@ -1206,12 +1242,14 @@ class BambuPrinterMCPServer {
1206
1242
  "Printer model selection was cancelled. Cannot proceed without knowing the printer model."
1207
1243
  );
1208
1244
  } catch (elicitError: any) {
1245
+ if (isElicitationTimeout(elicitError)) {
1246
+ throw new Error(
1247
+ `No printer model was selected within ${describeDuration(this.confirmationTimeoutMs)}; nothing was sent to the printer. ` +
1248
+ "Set BAMBU_MODEL or pass bambu_model in the tool call."
1249
+ );
1250
+ }
1209
1251
  // Elicitation not supported by this client — fall back to a clear error
1210
- const msg = elicitError?.message || String(elicitError);
1211
- if (
1212
- elicitError?.code === -32601 || elicitError?.code === -32600 ||
1213
- msg.includes("does not support") || msg.includes("elicitation")
1214
- ) {
1252
+ if (isElicitationUnsupported(elicitError)) {
1215
1253
  throw new Error(
1216
1254
  "bambu_model is required but your MCP client does not support elicitation. " +
1217
1255
  `Set the BAMBU_MODEL environment variable or pass bambu_model in the tool call. ` +
@@ -95,7 +95,8 @@ interface IndexedProfile {
95
95
  data: Record<string, unknown>;
96
96
  }
97
97
 
98
- type NameIndex = Map<string, IndexedProfile>;
98
+ /** Name index plus unreadable JSON files, reported when a name is missing. */
99
+ type NameIndex = Map<string, IndexedProfile> & { malformed?: string[] };
99
100
 
100
101
  /* -------------------------------------------------------------------------- */
101
102
  /* Indexing */
@@ -116,6 +117,7 @@ async function buildNameIndex(
116
117
  userProfileRoots: string[] = []
117
118
  ): Promise<NameIndex> {
118
119
  const index: NameIndex = new Map();
120
+ index.malformed = [];
119
121
  const subdirs: ProfileKind[] = ["machine", "process", "filament"];
120
122
 
121
123
  const directories = [
@@ -144,10 +146,15 @@ async function buildNameIndex(
144
146
  try {
145
147
  data = JSON.parse(raw) as Record<string, unknown>;
146
148
  } catch {
147
- // Malformed profile -- skip, don't poison the index.
149
+ // Malformed profile -- skip, don't poison the index. A reference to
150
+ // it still fails, and the error names the skipped file.
151
+ index.malformed!.push(filePath);
152
+ continue;
153
+ }
154
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
155
+ index.malformed!.push(filePath);
148
156
  continue;
149
157
  }
150
- if (!data || typeof data !== 'object' || Array.isArray(data)) continue;
151
158
  const name = data["name"];
152
159
  if (typeof name !== "string" || name.length === 0) continue;
153
160
  // First-write wins. BBL doesn't have name collisions in practice;
@@ -183,7 +190,7 @@ function flattenByName(
183
190
  }
184
191
  const entry = index.get(leafName);
185
192
  if (!entry) {
186
- throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.`);
193
+ throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.${describeMalformed(index)}`);
187
194
  }
188
195
  visiting.add(leafName);
189
196
  try {
@@ -193,6 +200,13 @@ function flattenByName(
193
200
  }
194
201
  }
195
202
 
203
+ function describeMalformed(index: NameIndex): string {
204
+ const malformed = index.malformed ?? [];
205
+ if (malformed.length === 0) return "";
206
+ const shown = malformed.slice(0, 5).map((file) => path.basename(file)).join(", ");
207
+ return ` Skipped ${malformed.length} unreadable or malformed profile file(s): ${shown}${malformed.length > 5 ? ", ..." : ""}.`;
208
+ }
209
+
196
210
  function flattenData(
197
211
  data: Record<string, unknown>,
198
212
  index: NameIndex,
@@ -249,7 +263,7 @@ function applyIncludes(
249
263
  if (!entry) {
250
264
  throw new Error(
251
265
  `Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
252
- `Refusing to fall back to inherited defaults (wrong G-code for this printer).`
266
+ `Refusing to fall back to inherited defaults (wrong G-code for this printer).${describeMalformed(index)}`
253
267
  );
254
268
  }
255
269
  for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
@@ -186,6 +186,12 @@ interface BambuSettingsBundle {
186
186
  processSource?: { filePath: string; profile: Record<string, unknown> };
187
187
  }
188
188
 
189
+ /** Keys every profile kind carries; they describe the file, not machine hardware. */
190
+ const PROFILE_METADATA_KEYS = new Set([
191
+ 'name', 'type', 'from', 'inherits', 'instantiation', 'setting_id', 'version', 'is_custom_defined',
192
+ 'compatible_printers', 'compatible_printers_condition', 'compatible_prints', 'compatible_prints_condition',
193
+ ]);
194
+
189
195
  export class STLManipulator extends EventEmitter {
190
196
  private tempDir: string;
191
197
  private activeOperations: Map<string, boolean> = new Map();
@@ -304,16 +310,22 @@ export class STLManipulator extends EventEmitter {
304
310
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
305
311
  }
306
312
 
307
- sanitized.use_relative_e_distances = '0';
308
- sanitized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(
309
- sanitized.before_layer_change_gcode
313
+ return this.useAbsoluteExtrusionForOrca(sanitized);
314
+ }
315
+
316
+ /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
317
+ private useAbsoluteExtrusionForOrca(config: any): any {
318
+ const normalized = { ...config };
319
+ normalized.use_relative_e_distances = '0';
320
+ normalized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(
321
+ normalized.before_layer_change_gcode
310
322
  );
311
- sanitized.layer_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_gcode);
312
- sanitized.layer_change_gcode = this.stripAbsoluteExtruderResets(
313
- sanitized.layer_change_gcode
323
+ normalized.layer_gcode = this.stripAbsoluteExtruderResets(normalized.layer_gcode);
324
+ normalized.layer_change_gcode = this.stripAbsoluteExtruderResets(
325
+ normalized.layer_change_gcode
314
326
  );
315
327
 
316
- return sanitized;
328
+ return normalized;
317
329
  }
318
330
 
319
331
  private writeTempJson(outputBase: string, suffix: string, value: any): string {
@@ -584,6 +596,36 @@ export class STLManipulator extends EventEmitter {
584
596
  };
585
597
  }
586
598
 
599
+ /**
600
+ * The CLI applies every key in every --load-settings/--load-filaments file,
601
+ * so machine settings carried by a process or filament file (for example a
602
+ * template 3MF's project_settings from another printer) would replace the
603
+ * selected preset's start G-code, model, and bed geometry. The machine
604
+ * preset owns those keys; drop them from the other profiles.
605
+ */
606
+ private isolateMachineSettings(bundle: BambuSettingsBundle): BambuSettingsBundle {
607
+ const [machinePath, processPath] = bundle.settingsArg?.split(';') ?? [];
608
+ if (!machinePath || !processPath) return bundle;
609
+ const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter(key => !PROFILE_METADATA_KEYS.has(key));
610
+ const isolate = (filePath: string, label: string): string => {
611
+ const profile = this.readJsonFile(filePath);
612
+ const foreign = machineKeys.filter(key => Object.prototype.hasOwnProperty.call(profile, key));
613
+ if (foreign.length === 0) return filePath;
614
+ const isolated = { ...profile };
615
+ for (const key of foreign) delete isolated[key];
616
+ console.error(
617
+ `Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
618
+ `(the selected machine preset owns them): ${foreign.slice(0, 8).join(', ')}${foreign.length > 8 ? ', ...' : ''}`
619
+ );
620
+ return this.writeTempJson(path.basename(filePath, '.json'), `${label}_without_machine`, isolated);
621
+ };
622
+ return {
623
+ ...bundle,
624
+ settingsArg: [machinePath, isolate(processPath, 'process')].join(';'),
625
+ filamentPaths: bundle.filamentPaths.map(filePath => isolate(filePath, 'filament')),
626
+ };
627
+ }
628
+
587
629
  /** --load-filaments is positional; a single override must cover every project slot. */
588
630
  private async expandProjectFilaments(
589
631
  inputPath: string,
@@ -1621,20 +1663,25 @@ export class STLManipulator extends EventEmitter {
1621
1663
  await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1622
1664
  // Inherited process G-code is now present; apply Orca's existing
1623
1665
  // absolute-extrusion normalization after resolving those ancestors.
1666
+ // The machine owns the extrusion mode and layer G-code once process
1667
+ // files lose machine keys, so normalize it there as well.
1624
1668
  if (slicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1625
1669
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1626
- settingsBundle.settingsArg = [machinePath, this.writeTempJson(
1627
- outputBase, 'process_orca_resolved',
1628
- this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)
1629
- )].join(';');
1670
+ settingsBundle.settingsArg = [
1671
+ this.writeTempJson(outputBase, 'machine_orca_resolved',
1672
+ this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
1673
+ this.writeTempJson(outputBase, 'process_orca_resolved',
1674
+ this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1675
+ ].join(';');
1630
1676
  }
1677
+ const cliBundle = this.isolateMachineSettings(settingsBundle);
1631
1678
  args = [
1632
1679
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1633
1680
  '--outputdir', outputDir,
1634
1681
  '--export-3mf', path.basename(bambuOutputPath),
1635
1682
  ];
1636
- if (settingsBundle.settingsArg) {
1637
- args.push('--load-settings', settingsBundle.settingsArg);
1683
+ if (cliBundle.settingsArg) {
1684
+ args.push('--load-settings', cliBundle.settingsArg);
1638
1685
  }
1639
1686
  // Always allow newer-version 3MF files (the CLI rejects them by default)
1640
1687
  args.push('--allow-newer-file');
@@ -1655,8 +1702,8 @@ export class STLManipulator extends EventEmitter {
1655
1702
  if (bambuOptions?.rotateY !== undefined) args.push('--rotate-y', String(bambuOptions.rotateY));
1656
1703
  if (bambuOptions?.cloneObjects) args.push('--clone-objects', bambuOptions.cloneObjects);
1657
1704
  if (bambuOptions?.skipObjects) args.push('--skip-objects', bambuOptions.skipObjects);
1658
- if (settingsBundle.filamentPaths.length > 0) {
1659
- args.push('--load-filaments', settingsBundle.filamentPaths.join(';'));
1705
+ if (cliBundle.filamentPaths.length > 0) {
1706
+ args.push('--load-filaments', cliBundle.filamentPaths.join(';'));
1660
1707
  args.push('--load-defaultfila');
1661
1708
  }
1662
1709
  if (bambuOptions?.loadFilamentIds) args.push('--load-filament-ids', bambuOptions.loadFilamentIds);