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.
@@ -10,11 +10,13 @@
10
10
  *
11
11
  * This module:
12
12
  * 1. Indexes every BBL profile JSON by its `name` field.
13
- * 2. Recursively walks `inherits`, deep-merging parent into child.
13
+ * 2. Recursively walks `inherits`, deep-merging parent into child, and
14
+ * applies each level's `include` templates (G-code templates for
15
+ * machines, per-variant defaults for filaments) the way the GUI does.
14
16
  * 3. Derives `nozzle_volume_type` from `default_nozzle_volume_type[0]`
15
17
  * (the GUI does this implicitly; the CLI doesn't).
16
- * 4. Merges CLI-specific machine_limits from `BBL/cli_config.json` so the
17
- * printer doesn't run unsafe accelerations / jerks.
18
+ * 4. Validates the model in `BBL/cli_config.json` and merges its CLI-specific
19
+ * machine_limits where supplied for safe accelerations / jerks.
18
20
  * 5. Writes the flattened JSON to a temp file the caller passes to
19
21
  * BambuStudio CLI.
20
22
  *
@@ -32,6 +34,9 @@ export interface FlattenedProfiles {
32
34
  machineLeafName: string;
33
35
  processLeafName: string;
34
36
  filamentLeafNames: string[];
37
+ /** False only for an explicit standalone custom machine. */
38
+ cliConfigValidated: boolean;
39
+ /** Some validated official model configs intentionally have no limits. */
35
40
  cliOverlayApplied: boolean;
36
41
  };
37
42
  }
@@ -59,7 +64,27 @@ export interface FlattenOptions {
59
64
  nozzleVolumeType?: "Standard" | "High Flow";
60
65
  /** BambuStudio display name, e.g. "Textured PEI Plate" or "Cool Plate". */
61
66
  bedType?: string;
67
+ /** Actual input configs, including user overrides on top of BBL parents. */
68
+ sourceProfiles?: {
69
+ machine?: Record<string, unknown>;
70
+ process?: Record<string, unknown>;
71
+ filaments?: (Record<string, unknown> | undefined)[];
72
+ };
73
+ /**
74
+ * Positional `#RRGGBB` colour per filament slot (e.g. from the input 3MF
75
+ * project or the caller). Missing entries keep the profile's own colour or
76
+ * fall back to DEFAULT_FILAMENT_COLOUR.
77
+ */
78
+ filamentColours?: (string | undefined)[];
79
+ /** Saved per-plate positions, used only when the process sets no position. */
80
+ projectTowerPosition?: {
81
+ wipe_tower_x?: unknown;
82
+ wipe_tower_y?: unknown;
83
+ };
62
84
  }
85
+ /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
86
+ /** BambuStudio's built-in filament_colour default. */
87
+ export declare const DEFAULT_FILAMENT_COLOUR = "#00AE42";
63
88
  /**
64
89
  * Flatten the leaf profiles, post-process for CLI, and write to temp files.
65
90
  *
@@ -68,7 +93,7 @@ export interface FlattenOptions {
68
93
  export declare function flattenForCli(opts: FlattenOptions): Promise<FlattenedProfiles>;
69
94
  /**
70
95
  * Given the SLICER_PATH (path to BambuStudio executable), walk up to the
71
- * Resources/profiles directory. Falls back to common platform paths.
96
+ * profile directory for that installation (macOS, Windows, or Linux prefix).
72
97
  *
73
98
  * Override via BAMBU_PROFILES_ROOT env.
74
99
  */
@@ -10,11 +10,13 @@
10
10
  *
11
11
  * This module:
12
12
  * 1. Indexes every BBL profile JSON by its `name` field.
13
- * 2. Recursively walks `inherits`, deep-merging parent into child.
13
+ * 2. Recursively walks `inherits`, deep-merging parent into child, and
14
+ * applies each level's `include` templates (G-code templates for
15
+ * machines, per-variant defaults for filaments) the way the GUI does.
14
16
  * 3. Derives `nozzle_volume_type` from `default_nozzle_volume_type[0]`
15
17
  * (the GUI does this implicitly; the CLI doesn't).
16
- * 4. Merges CLI-specific machine_limits from `BBL/cli_config.json` so the
17
- * printer doesn't run unsafe accelerations / jerks.
18
+ * 4. Validates the model in `BBL/cli_config.json` and merges its CLI-specific
19
+ * machine_limits where supplied for safe accelerations / jerks.
18
20
  * 5. Writes the flattened JSON to a temp file the caller passes to
19
21
  * BambuStudio CLI.
20
22
  *
@@ -22,6 +24,7 @@
22
24
  * fail loud rather than producing dangerous gcode for hardware we don't own.
23
25
  */
24
26
  import fs from "node:fs/promises";
27
+ import { existsSync, realpathSync } from "node:fs";
25
28
  import path from "node:path";
26
29
  import crypto from "node:crypto";
27
30
  /* -------------------------------------------------------------------------- */
@@ -92,31 +95,66 @@ async function buildNameIndex(profilesRoot, vendor) {
92
95
  * - Unknown name (broken `inherits` reference).
93
96
  * - Cycles (A -> B -> A).
94
97
  */
95
- function flattenByName(leafName, index) {
96
- const chain = [];
97
- const visited = new Set();
98
- let cursor = leafName;
99
- while (cursor) {
100
- if (visited.has(cursor)) {
101
- throw new Error(`Profile inheritance cycle detected at "${cursor}" (chain: ${[...visited].join(" -> ")})`);
98
+ function flattenByName(leafName, index, visiting = new Set()) {
99
+ if (visiting.has(leafName)) {
100
+ throw new Error(`Profile inheritance cycle detected (inherits/include): ${[...visiting, leafName].join(" -> ")}`);
101
+ }
102
+ const entry = index.get(leafName);
103
+ if (!entry) {
104
+ throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.`);
105
+ }
106
+ visiting.add(leafName);
107
+ try {
108
+ return flattenData(entry.data, index, visiting);
109
+ }
110
+ finally {
111
+ visiting.delete(leafName);
112
+ }
113
+ }
114
+ function flattenData(data, index, visiting = new Set()) {
115
+ const parent = data["inherits"];
116
+ const merged = typeof parent === "string" && parent.length > 0
117
+ ? flattenByName(parent, index, visiting)
118
+ : {};
119
+ // Includes may themselves inherit or include templates. Each level wins
120
+ // over its parent, then the including profile's own settings win last.
121
+ applyIncludes(merged, data, index, visiting);
122
+ Object.assign(merged, data);
123
+ delete merged["include"];
124
+ return merged;
125
+ }
126
+ /** Keys of an include template that describe the template, not the config. */
127
+ const INCLUDE_METADATA_KEYS = new Set(["name", "type", "from", "instantiation", "inherits", "include", "setting_id"]);
128
+ /**
129
+ * Recent BBL profiles (e.g. "Bambu Lab P2S 0.4 nozzle") no longer carry
130
+ * their G-code inline: `machine_start_gcode`, `machine_end_gcode`,
131
+ * `change_filament_gcode`, ... live in separate "... template <key>"
132
+ * profiles listed under `include`. Skipping them silently falls back to
133
+ * the generic G-code inherited from `fdm_machine_common` & co, which is
134
+ * wrong for the printer (no AMS filament load, other printer's moves).
135
+ */
136
+ function applyIncludes(target, profile, index, visiting) {
137
+ const raw = profile["include"];
138
+ if (raw === undefined || raw === null)
139
+ return;
140
+ if (typeof raw !== "string" && !Array.isArray(raw)) {
141
+ throw new Error(`Profile "${String(profile["name"])}" has an invalid include; expected a name or array of names.`);
142
+ }
143
+ const names = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
144
+ for (const name of names) {
145
+ if (typeof name !== "string" || name.trim().length === 0) {
146
+ throw new Error(`Profile "${String(profile["name"])}" has an invalid include reference; every entry must be a nonempty name.`);
102
147
  }
103
- visited.add(cursor);
104
- const entry = index.get(cursor);
148
+ const entry = index.get(name);
105
149
  if (!entry) {
106
- throw new Error(`Profile "${cursor}" not found in index. ` +
107
- `Inherits chain so far: ${[...visited].join(" -> ")}. ` +
108
- `This usually means the leaf name is misspelled or the profile tree is incomplete.`);
150
+ throw new Error(`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
151
+ `Refusing to fall back to inherited defaults (wrong G-code for this printer).`);
152
+ }
153
+ for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
154
+ if (!INCLUDE_METADATA_KEYS.has(key))
155
+ target[key] = value;
109
156
  }
110
- chain.push(entry.data);
111
- const parent = entry.data["inherits"];
112
- cursor = typeof parent === "string" && parent.length > 0 ? parent : undefined;
113
- }
114
- // Merge root-most parent first, leaf last (so leaf wins).
115
- const merged = {};
116
- for (let i = chain.length - 1; i >= 0; i--) {
117
- Object.assign(merged, chain[i]);
118
157
  }
119
- return merged;
120
158
  }
121
159
  /* -------------------------------------------------------------------------- */
122
160
  /* CLI post-processing */
@@ -181,6 +219,86 @@ function enforceMatchingNozzles(arr) {
181
219
  }
182
220
  }
183
221
  /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
222
+ /** BambuStudio's built-in filament_colour default. */
223
+ export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
224
+ const FILAMENT_COLOUR_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
225
+ /**
226
+ * No BBL filament profile defines `filament_colour`; the GUI fills it from
227
+ * the AMS. Without it the CLI keeps a single-entry colour vector however many
228
+ * filaments are loaded and crashes (access violation) as soon as a slice uses
229
+ * any filament after the first. Give every slot exactly one colour.
230
+ */
231
+ function ensureFilamentColour(flat, colour) {
232
+ if (colour !== undefined) {
233
+ if (!FILAMENT_COLOUR_RE.test(colour)) {
234
+ throw new Error(`Invalid filament colour "${colour}"; expected #RRGGBB.`);
235
+ }
236
+ flat["filament_colour"] = [colour];
237
+ return;
238
+ }
239
+ const own = flat["filament_colour"];
240
+ if (Array.isArray(own) && typeof own[0] === "string" && FILAMENT_COLOUR_RE.test(own[0])) {
241
+ flat["filament_colour"] = [own[0]];
242
+ return;
243
+ }
244
+ flat["filament_colour"] = [DEFAULT_FILAMENT_COLOUR];
245
+ }
246
+ // BambuStudio's defaults when a process sets no prime tower position/width.
247
+ const DEFAULT_WIPE_TOWER_X = 15;
248
+ const DEFAULT_WIPE_TOWER_Y = 220;
249
+ const DEFAULT_PRIME_TOWER_WIDTH = 35;
250
+ // Clearance inside the shared nozzle area for the auto-sized tower brim and
251
+ // the CLI's own safety margin (X2D fails at 5 mm from the edge, passes at 6).
252
+ const WIPE_TOWER_MARGIN = 15;
253
+ function parseArea(area) {
254
+ const points = area.split(",").map((p) => p.trim().split("x").map(Number));
255
+ if (points.length < 3 || points.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
256
+ throw new Error(`Malformed extruder_printable_area entry "${area}".`);
257
+ }
258
+ const xs = points.map((p) => p[0]);
259
+ const ys = points.map((p) => p[1]);
260
+ return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
261
+ }
262
+ /**
263
+ * On multi-nozzle machines (H2D, X2D) each nozzle reaches only part of the
264
+ * bed (`extruder_printable_area`), and the prime tower must be reachable by
265
+ * every nozzle. The CLI's default tower position (x=15) lies outside the
266
+ * X2D/H2D right-nozzle area, so slicing fails with "G-code outside of the
267
+ * printable area". When the process sets no position and the default does
268
+ * not fit, move the tower inside the area all nozzles share.
269
+ */
270
+ function placePrimeTowerForAllNozzles(machineFlat, processFlat) {
271
+ if (processFlat["wipe_tower_x"] !== undefined || processFlat["wipe_tower_y"] !== undefined)
272
+ return;
273
+ if (["0", "false"].includes(String(processFlat["enable_prime_tower"]).toLowerCase()))
274
+ return;
275
+ const areas = machineFlat["extruder_printable_area"];
276
+ if (!Array.isArray(areas) || areas.length < 2)
277
+ return;
278
+ const rects = areas.map((a) => {
279
+ if (typeof a !== "string")
280
+ throw new Error("Malformed extruder_printable_area in machine profile.");
281
+ return parseArea(a);
282
+ });
283
+ const shared = {
284
+ minX: Math.max(...rects.map((r) => r.minX)) + WIPE_TOWER_MARGIN,
285
+ maxX: Math.min(...rects.map((r) => r.maxX)) - WIPE_TOWER_MARGIN,
286
+ minY: Math.max(...rects.map((r) => r.minY)) + WIPE_TOWER_MARGIN,
287
+ maxY: Math.min(...rects.map((r) => r.maxY)) - WIPE_TOWER_MARGIN,
288
+ };
289
+ const width = Number(processFlat["prime_tower_width"] ?? DEFAULT_PRIME_TOWER_WIDTH);
290
+ const size = Number.isFinite(width) && width > 0 ? width : DEFAULT_PRIME_TOWER_WIDTH;
291
+ if (shared.maxX - shared.minX < size || shared.maxY - shared.minY < size) {
292
+ throw new Error("No bed area is reachable by every nozzle for the prime tower.");
293
+ }
294
+ const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
295
+ const x = clamp(DEFAULT_WIPE_TOWER_X, shared.minX, shared.maxX - size);
296
+ const y = clamp(DEFAULT_WIPE_TOWER_Y, shared.minY, shared.maxY - size);
297
+ if (x === DEFAULT_WIPE_TOWER_X && y === DEFAULT_WIPE_TOWER_Y)
298
+ return;
299
+ processFlat["wipe_tower_x"] = [String(x)];
300
+ processFlat["wipe_tower_y"] = [String(y)];
301
+ }
184
302
  function inferExtruderCount(flat) {
185
303
  for (const key of ["nozzle_diameter", "extruder_type", "extruder_variant_list"]) {
186
304
  const v = flat[key];
@@ -190,61 +308,86 @@ function inferExtruderCount(flat) {
190
308
  return 1;
191
309
  }
192
310
  /**
193
- * Bambu ships `BBL/cli_config.json` with per-printer overlays containing
194
- * machine_limits keys (cli_safe_acceleration_*, cli_safe_jerk_*,
195
- * cli_safe_speed_*). Without these the slicer can emit movements faster
196
- * than the printer's safe envelope -- dangerous on real hardware.
197
- *
198
- * The overlay keys are scoped under printer.<printer_name>.machine_limits.
199
- * We look up by the leaf machine's `printer_model` or `name` and merge
200
- * those keys into the flattened machine profile.
201
- *
202
- * Returns true if an overlay was found and applied, false otherwise.
311
+ * Validate the selected model's CLI config before making a bundled profile
312
+ * into a User profile. A1-family models require machine_limits overrides;
313
+ * other official models (including P1S and H2D) intentionally declare only
314
+ * downward_check. Missing config cannot tell us whether limits are required.
315
+ * Returns whether limits were applied, not whether validation succeeded.
203
316
  */
204
317
  async function applyCliOverlay(flat, profilesRoot, vendor) {
205
318
  const cliConfigPath = path.join(profilesRoot, vendor, "cli_config.json");
319
+ const fail = (reason) => {
320
+ throw new Error(`Cannot prepare machine profile "${String(flat["name"])}" ` +
321
+ `(model "${String(flat["printer_model"] ?? stripNozzleSuffix(String(flat["name"])))}"): ` +
322
+ `${cliConfigPath}: ${reason}. Check your BambuStudio installation or BAMBU_PROFILES_ROOT.`);
323
+ };
206
324
  let raw;
207
325
  try {
208
326
  raw = await fs.readFile(cliConfigPath, "utf8");
209
327
  }
210
328
  catch {
211
- return false;
329
+ return fail("required CLI configuration is missing or unreadable");
212
330
  }
213
331
  let cliConfig;
214
332
  try {
215
333
  cliConfig = JSON.parse(raw);
216
334
  }
217
335
  catch {
218
- return false;
336
+ return fail("invalid JSON in required CLI configuration");
219
337
  }
338
+ if (!isRecord(cliConfig))
339
+ return fail("expected a CLI configuration object");
220
340
  const printerSection = cliConfig["printer"];
221
- if (!printerSection || typeof printerSection !== "object")
222
- return false;
341
+ if (!isRecord(printerSection))
342
+ return fail("expected a printer configuration object");
223
343
  // Match key: cli_config.json keys are bare printer names like
224
- // "Bambu Lab H2D" / "Bambu Lab A1". Try, in order: explicit printer_model,
225
- // printer_settings_id with the " 0.4 nozzle" suffix stripped, raw name
226
- // with that suffix stripped, raw name as-is.
227
- const candidates = [
228
- flat["printer_model"],
229
- typeof flat["printer_settings_id"] === "string"
230
- ? stripNozzleSuffix(flat["printer_settings_id"])
231
- : undefined,
232
- typeof flat["name"] === "string" ? stripNozzleSuffix(flat["name"]) : undefined,
233
- flat["name"],
234
- ].filter((v) => typeof v === "string" && v.length > 0);
235
- for (const key of candidates) {
236
- const block = printerSection[key];
237
- if (!block || typeof block !== "object")
238
- continue;
239
- const mlimits = block["machine_limits"];
240
- if (!mlimits || typeof mlimits !== "object")
241
- continue;
242
- // Merge machine_limits into the flat profile. These are CLI-only safety
243
- // values; they should never be overridden by the leaf.
244
- Object.assign(flat, mlimits);
245
- return true;
344
+ // "Bambu Lab H2D" / "Bambu Lab A1". If no model is provided, try preset
345
+ // identity with the nozzle suffix stripped, then the name as-is.
346
+ // An explicit model is authoritative; do not silently use another model's
347
+ // limits because a stale preset name happens to match.
348
+ const candidates = typeof flat["printer_model"] === "string" && flat["printer_model"].trim()
349
+ ? [flat["printer_model"]]
350
+ : [
351
+ typeof flat["printer_settings_id"] === "string"
352
+ ? stripNozzleSuffix(flat["printer_settings_id"])
353
+ : undefined,
354
+ typeof flat["name"] === "string" ? stripNozzleSuffix(flat["name"]) : undefined,
355
+ flat["name"],
356
+ ].filter((v) => typeof v === "string" && v.length > 0);
357
+ const model = candidates.find(key => Object.prototype.hasOwnProperty.call(printerSection, key));
358
+ if (!model)
359
+ return fail(`selected model is absent (looked for ${candidates.join(", ")})`);
360
+ const block = printerSection[model];
361
+ if (!isRecord(block))
362
+ return fail(`printer.${model} must be an object`);
363
+ const downward = block["downward_check"];
364
+ if (Object.prototype.hasOwnProperty.call(block, "downward_check") && (!isRecord(downward) || Object.keys(downward).length === 0 ||
365
+ Object.entries(downward).some(([name, values]) => !name.trim() || !Array.isArray(values) ||
366
+ values.some(value => typeof value !== "string" || !value.trim())))) {
367
+ return fail(`printer.${model}.downward_check must map profile names to arrays of names`);
368
+ }
369
+ if (!Object.prototype.hasOwnProperty.call(block, "machine_limits")) {
370
+ if (!isRecord(downward))
371
+ return fail(`printer.${model} has neither machine_limits nor downward_check`);
372
+ return false;
373
+ }
374
+ const limits = block["machine_limits"];
375
+ if (!isRecord(limits) || Object.keys(limits).length === 0) {
376
+ return fail(`printer.${model}.machine_limits must be a nonempty object`);
246
377
  }
247
- return false;
378
+ const limitKey = /^cli_safe_(?:acceleration_(?:e|extruding|retracting|travel|x|y|z)|(?:jerk|speed)_(?:e|x|y|z))$/;
379
+ for (const [key, value] of Object.entries(limits)) {
380
+ if (!limitKey.test(key) || typeof value !== "string" ||
381
+ value.split(",").some(part => !part.trim() || !Number.isFinite(Number(part)) || Number(part) < 0)) {
382
+ return fail(`printer.${model}.machine_limits contains an invalid limit "${key}"`);
383
+ }
384
+ }
385
+ // CLI safety values override even explicit values in the selected leaf.
386
+ Object.assign(flat, limits);
387
+ return true;
388
+ }
389
+ function isRecord(value) {
390
+ return value !== null && typeof value === "object" && !Array.isArray(value);
248
391
  }
249
392
  /**
250
393
  * Ensure the chosen machine is in the process/filament's
@@ -373,18 +516,45 @@ export async function flattenForCli(opts) {
373
516
  }
374
517
  const index = await buildNameIndex(opts.profilesRoot, vendor);
375
518
  // Flatten each leaf.
376
- const machineFlat = flattenByName(opts.machineLeaf, index);
377
- const processFlat = flattenByName(opts.processLeaf, index);
378
- const filamentFlats = opts.filamentLeaves.map((n) => flattenByName(n, index));
519
+ if (opts.sourceProfiles?.filaments && opts.sourceProfiles.filaments.length !== opts.filamentLeaves.length) {
520
+ throw new Error("Every filament slot must have a source profile.");
521
+ }
522
+ const machineFlat = opts.sourceProfiles?.machine
523
+ ? flattenData(opts.sourceProfiles.machine, index)
524
+ : flattenByName(opts.machineLeaf, index);
525
+ const processFlat = opts.sourceProfiles?.process
526
+ ? flattenData(opts.sourceProfiles.process, index)
527
+ : flattenByName(opts.processLeaf, index);
528
+ const filamentFlats = opts.filamentLeaves.map((n, i) => {
529
+ const source = opts.sourceProfiles?.filaments?.[i];
530
+ return source ? flattenData(source, index) : flattenByName(n, index);
531
+ });
379
532
  // CLI-specific post-processing on machine profile only.
380
533
  deriveNozzleVolumeType(machineFlat, opts.nozzleVolumeType);
381
534
  applyMachineModelBedMetadata(machineFlat, index);
382
- const cliOverlayApplied = await applyCliOverlay(machineFlat, opts.profilesRoot, vendor);
535
+ // A standalone custom machine can accompany process/filament profiles that
536
+ // need BBL resolution; do not impose bundled model config on that machine.
537
+ const sourceMachine = opts.sourceProfiles?.machine;
538
+ const standaloneMachine = sourceMachine && String(sourceMachine.from).toLowerCase() !== "system" &&
539
+ !(typeof sourceMachine.inherits === "string" && sourceMachine.inherits.length > 0) &&
540
+ (sourceMachine.include === undefined || sourceMachine.include === null);
541
+ const cliConfigValidated = !standaloneMachine;
542
+ const cliOverlayApplied = cliConfigValidated
543
+ ? await applyCliOverlay(machineFlat, opts.profilesRoot, vendor)
544
+ : false;
383
545
  // Normalize each flattened profile for CLI consumption.
384
546
  normalizeForCli(machineFlat, "machine", opts.machineLeaf);
385
547
  normalizeForCli(processFlat, "process", opts.processLeaf);
386
548
  filamentFlats.forEach((f, i) => normalizeForCli(f, "filament", opts.filamentLeaves[i]));
549
+ if (opts.filamentColours && opts.filamentColours.length !== filamentFlats.length) {
550
+ throw new Error(`${opts.filamentColours.length} filament colours were supplied for ${filamentFlats.length} filament slots.`);
551
+ }
552
+ filamentFlats.forEach((f, i) => ensureFilamentColour(f, opts.filamentColours?.[i]));
387
553
  applyBedType(processFlat, opts.bedType);
554
+ if (processFlat["wipe_tower_x"] === undefined && processFlat["wipe_tower_y"] === undefined) {
555
+ Object.assign(processFlat, opts.projectTowerPosition);
556
+ }
557
+ placePrimeTowerForAllNozzles(machineFlat, processFlat);
388
558
  // Mirror the GUI's auto-extend behavior: when the caller explicitly
389
559
  // chose a process or filament that wasn't pre-declared compatible with
390
560
  // the chosen machine (e.g. "0.20mm Standard @BBL P1P" used on a P1S),
@@ -392,8 +562,8 @@ export async function flattenForCli(opts) {
392
562
  // does this implicitly when saving a project.
393
563
  ensureMachineInCompatList(processFlat, opts.machineLeaf);
394
564
  filamentFlats.forEach((f) => ensureMachineInCompatList(f, opts.machineLeaf));
395
- // Write temp files. Hash the leaf name into the filename so concurrent
396
- // slices for different printers don't collide.
565
+ // Hash the resolved content so concurrent jobs using the same preset with
566
+ // different overrides never overwrite each other's input files.
397
567
  await fs.mkdir(opts.tempDir, { recursive: true });
398
568
  const machinePath = await writeTemp(opts.tempDir, "machine", opts.machineLeaf, machineFlat);
399
569
  const processPath = await writeTemp(opts.tempDir, "process", opts.processLeaf, processFlat);
@@ -410,16 +580,18 @@ export async function flattenForCli(opts) {
410
580
  machineLeafName: opts.machineLeaf,
411
581
  processLeafName: opts.processLeaf,
412
582
  filamentLeafNames: opts.filamentLeaves,
583
+ cliConfigValidated,
413
584
  cliOverlayApplied,
414
585
  },
415
586
  };
416
587
  }
417
588
  async function writeTemp(tempDir, kind, leafName, data) {
418
- const hash = crypto.createHash("sha1").update(leafName).digest("hex").slice(0, 8);
589
+ const serialized = JSON.stringify(data, null, 2);
590
+ const hash = crypto.createHash("sha256").update(leafName).update("\0").update(serialized).digest("hex").slice(0, 16);
419
591
  const safe = leafName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
420
592
  const filename = `flat-${kind}-${safe}-${hash}.json`;
421
593
  const out = path.join(tempDir, filename);
422
- await fs.writeFile(out, JSON.stringify(data, null, 2), "utf8");
594
+ await fs.writeFile(out, serialized, "utf8");
423
595
  return out;
424
596
  }
425
597
  /* -------------------------------------------------------------------------- */
@@ -427,7 +599,7 @@ async function writeTemp(tempDir, kind, leafName, data) {
427
599
  /* -------------------------------------------------------------------------- */
428
600
  /**
429
601
  * Given the SLICER_PATH (path to BambuStudio executable), walk up to the
430
- * Resources/profiles directory. Falls back to common platform paths.
602
+ * profile directory for that installation (macOS, Windows, or Linux prefix).
431
603
  *
432
604
  * Override via BAMBU_PROFILES_ROOT env.
433
605
  */
@@ -436,10 +608,33 @@ export function detectProfilesRoot(slicerPath) {
436
608
  return process.env["BAMBU_PROFILES_ROOT"];
437
609
  }
438
610
  if (slicerPath) {
439
- // macOS: /Applications/BambuStudio.app/Contents/MacOS/BambuStudio
440
- // -> /Applications/BambuStudio.app/Contents/Resources/profiles
441
- const macGuess = path.resolve(path.dirname(slicerPath), "..", "Resources", "profiles");
442
- return macGuess;
611
+ let executable = slicerPath;
612
+ if (!slicerPath.includes('/') && !slicerPath.includes('\\')) {
613
+ const located = (process.env.PATH ?? '').split(path.delimiter)
614
+ .map(dir => path.join(dir, slicerPath))
615
+ .find(candidate => existsSync(candidate));
616
+ if (located)
617
+ executable = located;
618
+ }
619
+ // Package-manager launchers may be symlinks into the installation prefix.
620
+ try {
621
+ executable = realpathSync(executable);
622
+ }
623
+ catch { /* Probe the supplied path below. */ }
624
+ const bin = path.dirname(executable);
625
+ const candidates = path.basename(bin) === 'MacOS'
626
+ ? [path.resolve(bin, '..', 'Resources', 'profiles')]
627
+ : [
628
+ path.join(bin, 'resources', 'profiles'),
629
+ path.join(bin, 'Resources', 'profiles'),
630
+ path.resolve(bin, '..', 'share', 'BambuStudio', 'profiles'),
631
+ path.resolve(bin, '..', 'share', 'bambu-studio', 'profiles'),
632
+ path.resolve(bin, '..', 'share', 'BambuStudio', 'resources', 'profiles'),
633
+ ];
634
+ const found = candidates.find(root => existsSync(path.join(root, 'BBL', 'machine')));
635
+ // Do not select another installation if this executable has no profile tree.
636
+ // flattenForCli reports the missing tree and asks for BAMBU_PROFILES_ROOT.
637
+ return found ?? candidates[0];
443
638
  }
444
639
  // Default macOS install.
445
640
  return "/Applications/BambuStudio.app/Contents/Resources/profiles";
@@ -36,6 +36,7 @@ export interface BambuSliceOptions {
36
36
  skipObjects?: string;
37
37
  loadFilaments?: string;
38
38
  loadFilamentIds?: string;
39
+ filamentColours?: string[];
39
40
  bedType?: string;
40
41
  enableTimelapse?: boolean;
41
42
  allowMixTemp?: boolean;
@@ -63,25 +64,11 @@ export declare class STLManipulator extends EventEmitter {
63
64
  private sanitizeProcessForOrca;
64
65
  private writeTempJson;
65
66
  private resolveBambuLikeSettingsBundle;
66
- /**
67
- * Optionally rewrite a Bambu-like settings bundle so the paths point at
68
- * fully-flattened temp configs instead of the BBL-shipped leaf JSONs.
69
- *
70
- * BambuStudio's CLI does not resolve the `inherits` chain when loading
71
- * profiles via --load-settings / --load-filaments, which causes a
72
- * cluster of upstream bugs (see https://github.com/bambulab/BambuStudio/issues/9636
73
- * and #9968). Our flattener (src/slicer/profile-flatten.ts) reproduces
74
- * what the GUI does at slice time so the CLI accepts the configs.
75
- *
76
- * Opt-in via `BAMBU_CLI_FLATTEN=true`. When the env var is unset or
77
- * not "true"/"1", returns the bundle unchanged so behavior is
78
- * backward-compatible. When enabled, only BBL-shipped leaves get
79
- * flattened; user-provided custom configs pass through untouched.
80
- */
67
+ /** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
81
68
  private maybeFlattenBundle;
69
+ /** --load-filaments is positional; a single override must cover every project slot. */
70
+ private expandProjectFilaments;
82
71
  private resolveBambuStudioBedType;
83
- /** Read a profile JSON's top-level `name` field, or null if unreadable. */
84
- private readLeafName;
85
72
  /**
86
73
  * Load STL file and return geometry and bounding box
87
74
  */