mcp-3d-printer-server 1.2.5 → 1.2.11

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/CONTRIBUTORS.md +49 -0
  3. package/README.md +529 -739
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +11 -0
  6. package/dist/blender-mcp-bridge.js +661 -0
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +752 -212
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -120
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +517 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +365 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +30 -14
  58. package/server.json +29 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +611 -0
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +888 -255
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -158
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +354 -0
  80. package/src/safety/printer-state.ts +339 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
  87. package/dist/index.js.map +0 -1
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Bambu-compatible CLI (BambuStudio, OrcaSlicer, FULU OrcaSlicer-bambulab)
3
+ * profile preparation. Ported from bambu-printer-mcp's STLManipulator so both
4
+ * servers share the same safety semantics:
5
+ *
6
+ * - The exact model/nozzle machine preset must exist in the selected
7
+ * installation's BBL tree; it is never borrowed from another tree.
8
+ * - `inherits` and `include` are resolved recursively (profile-flatten.ts);
9
+ * missing references, cycles, and malformed profiles stop preparation.
10
+ * - Custom process/filament overrides keep their own values on top of their
11
+ * resolved parents; filament slots keep their positional order.
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import crypto from "node:crypto";
18
+ import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from "./profile-flatten.js";
19
+
20
+ export type BambuCliSlicerType = "bambustudio" | "orcaslicer" | "orcaslicer-bambulab";
21
+
22
+ export const BAMBU_CLI_BED_TYPES: Record<string, string> = {
23
+ textured_plate: "Textured PEI Plate",
24
+ cool_plate: "Cool Plate",
25
+ engineering_plate: "Engineering Plate",
26
+ hot_plate: "High Temp Plate",
27
+ };
28
+
29
+ /** Bambu-compatible slicer CLI options exposed through slice_stl. */
30
+ export interface BambuSliceOptions {
31
+ uptodate?: boolean; // --uptodate: update 3MF configs to latest presets
32
+ repetitions?: number; // --repetitions N: print N copies
33
+ orient?: boolean; // --orient 1: auto-orient for printability
34
+ arrange?: boolean; // --arrange 1: auto-arrange objects on plate
35
+ ensureOnBed?: boolean; // --ensure-on-bed: lift floating models onto bed
36
+ cloneObjects?: string; // --clone-objects "1,3,1,10": clone counts per object
37
+ skipObjects?: string; // --skip-objects "3,5,10": skip specific objects
38
+ loadFilaments?: string; // "f1.json;f2.json": positional filament profiles
39
+ loadFilamentIds?: string; // --load-filament-ids "1,2,3,1": filament-to-object mapping
40
+ filamentColours?: string[]; // positional #RRGGBB per loaded filament slot
41
+ bedType?: string; // textured_plate, cool_plate, engineering_plate, hot_plate
42
+ nozzleType?: string; // installed hotend material written into the machine preset
43
+ enableTimelapse?: boolean; // --enable-timelapse
44
+ allowMixTemp?: boolean; // --allow-mix-temp
45
+ scale?: number; // --scale factor
46
+ rotate?: number; // --rotate degrees (Z)
47
+ rotateX?: number; // --rotate-x degrees
48
+ rotateY?: number; // --rotate-y degrees
49
+ minSave?: boolean; // --min-save
50
+ skipModifiedGcodes?: boolean;// --skip-modified-gcodes
51
+ slicePlate?: number; // --slice N (0 = all plates)
52
+ }
53
+
54
+ export interface BambuSettingsBundle {
55
+ settingsArg?: string;
56
+ filamentPaths: string[];
57
+ /** Positional slot colours declared by the input 3MF project. */
58
+ filamentColours?: string[];
59
+ /** Saved per-plate tower coordinates must survive default process loading. */
60
+ projectTowerPosition?: { wipe_tower_x?: unknown; wipe_tower_y?: unknown };
61
+ /** Identity provenance before the process is merged into a generated file. */
62
+ processSource?: { filePath: string; profile: Record<string, unknown> };
63
+ }
64
+
65
+ /**
66
+ * User/system profile directories searched for custom process and filament
67
+ * dependencies (never for machine presets). BAMBU_SLICER_PROFILE_DIRS
68
+ * (path-delimiter separated) replaces the defaults; an empty value disables them.
69
+ */
70
+ export function configuredBambuProfileDirs(): string[] {
71
+ const override = process.env.BAMBU_SLICER_PROFILE_DIRS;
72
+ if (override !== undefined) {
73
+ return override.split(path.delimiter).filter(Boolean);
74
+ }
75
+
76
+ const home = os.homedir();
77
+ const roots = [
78
+ path.join(home, "Library", "Application Support"),
79
+ path.join(home, ".config"),
80
+ ...(process.env.APPDATA ? [process.env.APPDATA] : []),
81
+ ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA] : []),
82
+ ];
83
+ const appNames = ["BambuStudio", "Bambu Studio", "OrcaSlicer", "Orca Slicer", "OrcaStudio", "Orca Studio"];
84
+ return roots.flatMap((root) => appNames.map((appName) => path.join(root, appName, "system", "BBL")));
85
+ }
86
+
87
+ export function resolveBambuCliBedType(bedType?: string): string | undefined {
88
+ if (!bedType) return undefined;
89
+ const normalized = bedType.trim().toLowerCase();
90
+ if (normalized === "supertack_plate") {
91
+ throw new Error(
92
+ "The Bambu CLI SuperTack bed type is not verified; use a pre-sliced 3MF or choose textured_plate, cool_plate, engineering_plate, or hot_plate."
93
+ );
94
+ }
95
+ return BAMBU_CLI_BED_TYPES[normalized] || bedType;
96
+ }
97
+
98
+ export class BambuCliProfilePreparer {
99
+ constructor(private readonly tempDir: string) {}
100
+
101
+ private readJsonFile(filePath: string): any {
102
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
103
+ }
104
+
105
+ writeTempJson(outputBase: string, suffix: string, value: unknown): string {
106
+ const serialized = JSON.stringify(value, null, 2);
107
+ const hash = crypto.createHash("sha256").update(serialized).digest("hex").slice(0, 16);
108
+ fs.mkdirSync(this.tempDir, { recursive: true });
109
+ const safeBase = outputBase.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
110
+ const outPath = path.join(this.tempDir, `${safeBase}_${suffix}_${hash}.json`);
111
+ // Concurrent slices can prepare the same content-addressed file; write a
112
+ // private temp file and rename it so no reader ever sees a partial file.
113
+ const staging = `${outPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
114
+ fs.writeFileSync(staging, serialized);
115
+ fs.renameSync(staging, outPath);
116
+ return outPath;
117
+ }
118
+
119
+ private findProfileFile(
120
+ category: "machine" | "process" | "filament",
121
+ profileName: string | undefined,
122
+ roots: string[]
123
+ ): string | undefined {
124
+ if (!profileName) return undefined;
125
+ // Names are looked up as files; never let a name escape the profile tree.
126
+ if (profileName.includes("/") || profileName.includes("\\") || profileName.includes("\0")) return undefined;
127
+ for (const root of roots) {
128
+ const candidate = path.join(root, category, `${profileName}.json`);
129
+ if (fs.existsSync(candidate)) return candidate;
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ private buildFilamentIdIndex(roots: string[]): Map<string, string> {
135
+ const index = new Map<string, string>();
136
+ for (const root of roots) {
137
+ const filamentDir = path.join(root, "filament");
138
+ if (!fs.existsSync(filamentDir)) continue;
139
+ for (const entry of fs.readdirSync(filamentDir)) {
140
+ if (!entry.endsWith(".json")) continue;
141
+ const filePath = path.join(filamentDir, entry);
142
+ try {
143
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
144
+ const filamentId = typeof parsed?.filament_id === "string" ? parsed.filament_id.trim() : "";
145
+ if (filamentId && !index.has(filamentId)) index.set(filamentId, filePath);
146
+ } catch {
147
+ // A malformed unrelated profile cannot satisfy an ID lookup; a
148
+ // requested ID that stays unresolved is rejected by the caller.
149
+ }
150
+ }
151
+ }
152
+ return index;
153
+ }
154
+
155
+ private stripAbsoluteExtruderResets(value: unknown): unknown {
156
+ if (typeof value === "string") {
157
+ return value.split(/\r?\n/).filter((line) => line.trim().toUpperCase() !== "G92 E0").join("\n");
158
+ }
159
+ if (Array.isArray(value)) {
160
+ return value.filter((line) => String(line).trim().toUpperCase() !== "G92 E0");
161
+ }
162
+ return value;
163
+ }
164
+
165
+ /** Orca's existing absolute-extrusion normalization for process G-code. */
166
+ sanitizeProcessForOrca(processConfig: any, printerPreset?: string): any {
167
+ const sanitized = { ...processConfig };
168
+ sanitized.type = "process";
169
+ if (!sanitized.from || sanitized.from === "project") sanitized.from = "User";
170
+ if (printerPreset) sanitized.compatible_printers = [printerPreset];
171
+ if (sanitized.prime_tower_brim_width !== undefined && Number(sanitized.prime_tower_brim_width) < 0) {
172
+ sanitized.prime_tower_brim_width = "0";
173
+ } else if (sanitized.prime_tower_brim_width !== undefined) {
174
+ sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
175
+ }
176
+ sanitized.use_relative_e_distances = "0";
177
+ sanitized.before_layer_change_gcode = this.stripAbsoluteExtruderResets(sanitized.before_layer_change_gcode);
178
+ sanitized.layer_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_gcode);
179
+ sanitized.layer_change_gcode = this.stripAbsoluteExtruderResets(sanitized.layer_change_gcode);
180
+ return sanitized;
181
+ }
182
+
183
+ /**
184
+ * The same normalization for the machine file. Machine isolation drops these
185
+ * keys from the process when the machine defines them, so the machine copy
186
+ * must carry the absolute-extrusion values or Orca would pair absolute E with
187
+ * the machine's G92 E0 resets.
188
+ */
189
+ sanitizeMachineForOrca(machineConfig: any): any {
190
+ const sanitized = { ...machineConfig };
191
+ if (sanitized.use_relative_e_distances !== undefined) sanitized.use_relative_e_distances = "0";
192
+ for (const key of ["before_layer_change_gcode", "layer_gcode", "layer_change_gcode"]) {
193
+ if (sanitized[key] !== undefined) sanitized[key] = this.stripAbsoluteExtruderResets(sanitized[key]);
194
+ }
195
+ return sanitized;
196
+ }
197
+
198
+ /**
199
+ * Select the machine preset, process, and positional filament profiles
200
+ * from the active installation. Throws before any slicer runs when the
201
+ * exact machine preset or a named dependency is missing.
202
+ */
203
+ async resolveSettingsBundle(
204
+ outputBase: string,
205
+ slicerType: BambuCliSlicerType,
206
+ activeProfilesRoot: string,
207
+ printerPreset: string | undefined,
208
+ slicerProfile: string | undefined,
209
+ options: BambuSliceOptions = {}
210
+ ): Promise<BambuSettingsBundle> {
211
+ const roots = [path.join(activeProfilesRoot, "BBL")];
212
+ const userRoots = configuredBambuProfileDirs();
213
+ const findProfile = (kind: "machine" | "process" | "filament", name?: string) =>
214
+ this.findProfileFile(kind, name, kind === "machine" ? roots : [...roots, ...userRoots]);
215
+
216
+ if (!printerPreset) {
217
+ throw new Error(
218
+ "Printer preset is required for Bambu-compatible CLI slicing; select the exact bambu_model and nozzle_diameter."
219
+ );
220
+ }
221
+ const machinePath = findProfile("machine", printerPreset);
222
+ if (!machinePath) {
223
+ throw new Error(
224
+ `Printer profile "${printerPreset}" was not found in ${path.join(roots[0], "machine")}. ` +
225
+ `Check bambu_model/nozzle_diameter against the installed ${slicerType} version, or set BAMBU_PROFILES_ROOT ` +
226
+ `to the matching profile tree; refusing to use printer defaults or another installation's preset.`
227
+ );
228
+ }
229
+ let machineConfig: any;
230
+ try {
231
+ machineConfig = this.readJsonFile(machinePath);
232
+ if (!machineConfig || Array.isArray(machineConfig) || machineConfig.name !== printerPreset) {
233
+ throw new Error("expected a JSON machine profile with the selected preset name");
234
+ }
235
+ } catch (error: any) {
236
+ throw new Error(`Printer profile "${printerPreset}" is invalid: ${error?.message ?? error}`);
237
+ }
238
+ // Some FULU/Orca machine leaves inherit their default process/filaments.
239
+ // Resolve those selections before building the positional CLI bundle.
240
+ machineConfig = await resolveBblMachineProfile(activeProfilesRoot, printerPreset);
241
+
242
+ const filamentPaths: string[] = [];
243
+ let processPath: string | undefined;
244
+ let processSource: BambuSettingsBundle["processSource"];
245
+
246
+ if (slicerProfile) {
247
+ if (!fs.existsSync(slicerProfile)) {
248
+ throw new Error(
249
+ `Slicer process profile not found: ${slicerProfile} (from slicer_profile, a template, or SLICER_PROFILE). ` +
250
+ "Fix the path, or leave slicer_profile and SLICER_PROFILE empty to use the machine preset's default process."
251
+ );
252
+ }
253
+ let parsedProfile: any = null;
254
+ try {
255
+ parsedProfile = this.readJsonFile(slicerProfile);
256
+ } catch {
257
+ parsedProfile = null;
258
+ }
259
+
260
+ if (parsedProfile && typeof parsedProfile === "object" && !Array.isArray(parsedProfile)) {
261
+ processSource = { filePath: slicerProfile, profile: parsedProfile };
262
+ const inheritedProcessName =
263
+ (typeof parsedProfile.inherits === "string" && parsedProfile.inherits) ||
264
+ (typeof parsedProfile.print_settings_id === "string" && parsedProfile.print_settings_id !== parsedProfile.name
265
+ ? parsedProfile.print_settings_id
266
+ : undefined) ||
267
+ (typeof parsedProfile.default_print_profile === "string" ? parsedProfile.default_print_profile : undefined) ||
268
+ (typeof machineConfig?.default_print_profile === "string" ? machineConfig.default_print_profile : undefined);
269
+
270
+ const inheritedProcessPath = findProfile("process", inheritedProcessName);
271
+ const inheritedProcess = inheritedProcessPath ? this.readJsonFile(inheritedProcessPath) : {};
272
+ const mergedProcess = { ...inheritedProcess, ...parsedProfile, type: "process" };
273
+
274
+ processPath = this.writeTempJson(
275
+ outputBase,
276
+ slicerType === "orcaslicer" ? "process_orca" : "process",
277
+ slicerType === "orcaslicer" ? this.sanitizeProcessForOrca(mergedProcess, printerPreset) : mergedProcess
278
+ );
279
+
280
+ const defaultFilamentProfiles =
281
+ !options.loadFilaments && Array.isArray(parsedProfile.default_filament_profile)
282
+ ? parsedProfile.default_filament_profile
283
+ : [];
284
+ for (const profileName of defaultFilamentProfiles) {
285
+ const filamentPath = findProfile("filament", String(profileName));
286
+ if (!filamentPath) {
287
+ throw new Error(`Filament profile "${String(profileName)}" was not found; refusing to omit its slot.`);
288
+ }
289
+ filamentPaths.push(filamentPath);
290
+ }
291
+
292
+ if (!options.loadFilaments && filamentPaths.length === 0 && Array.isArray(parsedProfile.filament_ids)) {
293
+ const filamentIdIndex = this.buildFilamentIdIndex([...roots, ...userRoots]);
294
+ for (const filamentId of parsedProfile.filament_ids) {
295
+ const filamentPath = filamentIdIndex.get(String(filamentId));
296
+ if (!filamentPath) {
297
+ throw new Error(`Filament ID "${String(filamentId)}" could not be resolved; refusing to omit its slot.`);
298
+ }
299
+ filamentPaths.push(filamentPath);
300
+ }
301
+ }
302
+ } else {
303
+ // Non-JSON configs are read again during resolution and rejected there.
304
+ processPath = slicerProfile;
305
+ }
306
+ }
307
+
308
+ if (!processPath) {
309
+ const defaultProcessName =
310
+ typeof machineConfig?.default_print_profile === "string" ? machineConfig.default_print_profile : undefined;
311
+ const defaultProcessPath = findProfile("process", defaultProcessName);
312
+ if (!defaultProcessPath) {
313
+ throw new Error(
314
+ `Default process profile "${defaultProcessName ?? "(none declared)"}" for "${printerPreset}" was not found; ` +
315
+ "pass slicer_profile with a process profile JSON."
316
+ );
317
+ }
318
+ processPath =
319
+ slicerType === "orcaslicer"
320
+ ? this.writeTempJson(
321
+ outputBase,
322
+ "process_default_orca",
323
+ this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset)
324
+ )
325
+ : defaultProcessPath;
326
+ }
327
+
328
+ if (!options.loadFilaments && filamentPaths.length === 0 && Array.isArray(machineConfig?.default_filament_profile)) {
329
+ for (const profileName of machineConfig.default_filament_profile) {
330
+ const filamentPath = findProfile("filament", String(profileName));
331
+ if (!filamentPath) {
332
+ throw new Error(`Filament profile "${String(profileName)}" was not found; refusing to omit its slot.`);
333
+ }
334
+ filamentPaths.push(filamentPath);
335
+ }
336
+ }
337
+
338
+ if (options.loadFilaments) {
339
+ filamentPaths.length = 0;
340
+ for (const filamentPath of options.loadFilaments.split(";")) {
341
+ const trimmed = filamentPath.trim();
342
+ if (!trimmed) continue;
343
+ if (!fs.existsSync(trimmed)) {
344
+ throw new Error(`Filament profile not found: ${trimmed}; refusing to omit its slot.`);
345
+ }
346
+ filamentPaths.push(trimmed);
347
+ }
348
+ }
349
+
350
+ return {
351
+ settingsArg: [machinePath, processPath].join(";"),
352
+ filamentPaths,
353
+ processSource,
354
+ };
355
+ }
356
+
357
+ /** --load-filaments is positional; a single override must cover every project slot. */
358
+ async expandProjectFilaments(inputPath: string, bundle: BambuSettingsBundle): Promise<BambuSettingsBundle> {
359
+ if (!inputPath.toLowerCase().endsWith(".3mf") || bundle.filamentPaths.length === 0) return bundle;
360
+ const JSZip = (await import("jszip")).default;
361
+ const zip = await JSZip.loadAsync(await fs.promises.readFile(inputPath));
362
+ const file = zip.file("Metadata/project_settings.config");
363
+ // Geometry-only 3MFs have no embedded filament slots.
364
+ if (!file) return bundle;
365
+ let config: unknown;
366
+ try {
367
+ config = JSON.parse(await file.async("string"));
368
+ } catch {
369
+ throw new Error("Cannot determine project filament slots: project_settings.config is not valid JSON.");
370
+ }
371
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
372
+ throw new Error("Cannot determine project filament slots: invalid project_settings.config.");
373
+ }
374
+ const data = config as Record<string, unknown>;
375
+ const count = Math.max(0, ...["filament_settings_id", "filament_type", "filament_colour", "filament_diameter"]
376
+ .map((key) => (Array.isArray(data[key]) ? (data[key] as unknown[]).length : 0)));
377
+ if (count === 0) throw new Error("Cannot determine project filament slots from project_settings.config.");
378
+ // Keep the project's slot colours; loaded profiles carry none of their own.
379
+ const colours = Array.isArray(data.filament_colour) && data.filament_colour.length === count &&
380
+ data.filament_colour.every((c) => typeof c === "string" && /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(c))
381
+ ? (data.filament_colour as string[])
382
+ : undefined;
383
+ const projectTowerPosition = Object.fromEntries(["wipe_tower_x", "wipe_tower_y"]
384
+ .filter((key) => data[key] !== undefined).map((key) => [key, data[key]]));
385
+ if (bundle.filamentPaths.length === 1) {
386
+ return { ...bundle, filamentPaths: Array(count).fill(bundle.filamentPaths[0]), filamentColours: colours, projectTowerPosition };
387
+ }
388
+ if (bundle.filamentPaths.length !== count) {
389
+ throw new Error(
390
+ `Project declares ${count} filament slots, but ${bundle.filamentPaths.length} profiles were supplied. ` +
391
+ "Provide one profile for all slots or one per slot."
392
+ );
393
+ }
394
+ return { ...bundle, filamentColours: colours, projectTowerPosition };
395
+ }
396
+
397
+ /** Resolve BBL dependencies before invoking the CLI; failures stop the slice. */
398
+ async flattenBundle(
399
+ bundle: BambuSettingsBundle,
400
+ options: BambuSliceOptions,
401
+ activeProfilesRoot?: string
402
+ ): Promise<BambuSettingsBundle> {
403
+ const parts = bundle.settingsArg?.split(";").filter(Boolean) ?? [];
404
+ const readProfile = (filePath: string): Record<string, unknown> => {
405
+ try {
406
+ const data: unknown = this.readJsonFile(filePath);
407
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
408
+ throw new Error("expected a JSON profile object");
409
+ }
410
+ return data as Record<string, unknown>;
411
+ } catch (err: any) {
412
+ throw new Error(`Cannot read slicer profile "${filePath}": ${err?.message ?? err}`);
413
+ }
414
+ };
415
+ const settings = parts.map(readProfile);
416
+ // Read every slot: filtering unreadable names would change positional mapping.
417
+ const filaments = bundle.filamentPaths.map(readProfile);
418
+ const profilesRoot = activeProfilesRoot ?? detectProfilesRoot(process.env.SLICER_PATH);
419
+ const profileRoots = [path.join(profilesRoot, "BBL")];
420
+ const hasReferences = (profile: Record<string, unknown>) =>
421
+ (typeof profile.inherits === "string" && profile.inherits.length > 0) ||
422
+ (profile.include !== undefined && profile.include !== null);
423
+ const isBundledFile = (filePath: string) =>
424
+ profileRoots.some((root) => {
425
+ const relative = path.relative(root, filePath);
426
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
427
+ });
428
+ const isBundled = (filePath: string, profile: Record<string, unknown>) =>
429
+ String(profile.from).toLowerCase() === "system" || isBundledFile(filePath);
430
+ const needsResolution = (filePath: string, profile: Record<string, unknown>) =>
431
+ hasReferences(profile) || isBundled(filePath, profile);
432
+ if (![...settings, ...filaments].some((profile, i) =>
433
+ needsResolution([...parts, ...bundle.filamentPaths][i], profile))) return bundle;
434
+ if (parts.length !== 2) {
435
+ throw new Error("Resolving BBL profiles requires both a machine and process profile; refusing to slice with unresolved settings.");
436
+ }
437
+
438
+ const [machinePath, processPath] = parts;
439
+ const [machine, processProfile] = settings;
440
+ const leafName = (filePath: string, profile: Record<string, unknown>): string => {
441
+ // A user preset inherits its system identity, while keeping its own values.
442
+ const name = !isBundled(filePath, profile) && typeof profile.inherits === "string" && profile.inherits
443
+ ? profile.inherits
444
+ : profile.name;
445
+ if (typeof name !== "string" || !name) {
446
+ if (needsResolution(filePath, profile)) throw new Error(`Cannot determine the profile name for "${filePath}".`);
447
+ return path.basename(filePath, ".json");
448
+ }
449
+ return name;
450
+ };
451
+ const flat = await flattenForCli({
452
+ machineLeaf: leafName(machinePath, machine),
453
+ // A generated path loses bundled provenance; inherited metadata can also
454
+ // make a custom preset look like a system profile. Use the original source
455
+ // only for identity and retain the prepared values in sourceProfiles below.
456
+ processLeaf: bundle.processSource
457
+ ? leafName(bundle.processSource.filePath, bundle.processSource.profile)
458
+ : leafName(processPath, processProfile),
459
+ filamentLeaves: filaments.map((profile, i) => leafName(bundle.filamentPaths[i], profile)),
460
+ profilesRoot,
461
+ userProfileRoots: configuredBambuProfileDirs(),
462
+ tempDir: this.tempDir,
463
+ bedType: resolveBambuCliBedType(options.bedType),
464
+ filamentColours: options.filamentColours ?? bundle.filamentColours,
465
+ projectTowerPosition: bundle.projectTowerPosition,
466
+ sourceProfiles: {
467
+ machine: isBundledFile(machinePath) ? undefined : machine,
468
+ process: isBundledFile(processPath) ? undefined : processProfile,
469
+ filaments: filaments.map((profile, i) => (isBundledFile(bundle.filamentPaths[i]) ? undefined : profile)),
470
+ },
471
+ });
472
+ // Standalone files retain their own identity and settings, but still need
473
+ // the CLI colour/tower fields. Do not discard those required overlays when
474
+ // choosing the original profile instead of the normalized BBL output.
475
+ const withCliFields = (filePath: string, profile: Record<string, unknown>, flatPath: string, keys: string[]): string => {
476
+ const flattened = readProfile(flatPath);
477
+ const fields = Object.fromEntries(keys.filter((key) => flattened[key] !== undefined).map((key) => [key, flattened[key]]));
478
+ if (Object.entries(fields).every(([key, value]) => JSON.stringify(profile[key]) === JSON.stringify(value))) return filePath;
479
+ return this.writeTempJson(path.basename(filePath, ".json"), "cli", { ...profile, ...fields });
480
+ };
481
+ return {
482
+ settingsArg: [
483
+ needsResolution(machinePath, machine) ? flat.machinePath : machinePath,
484
+ needsResolution(processPath, processProfile)
485
+ ? flat.processPath
486
+ : withCliFields(processPath, processProfile, flat.processPath, ["wipe_tower_x", "wipe_tower_y"]),
487
+ ].join(";"),
488
+ filamentPaths: filaments.map((profile, i) =>
489
+ needsResolution(bundle.filamentPaths[i], profile)
490
+ ? flat.filamentPaths[i]
491
+ : withCliFields(bundle.filamentPaths[i], profile, flat.filamentPaths[i], ["filament_colour"])),
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Full preparation: select profiles, expand project slots, resolve every
497
+ * BBL dependency, and apply Orca's post-resolution normalization.
498
+ */
499
+ async prepare(
500
+ inputPath: string,
501
+ outputBase: string,
502
+ slicerType: BambuCliSlicerType,
503
+ activeProfilesRoot: string,
504
+ printerPreset: string | undefined,
505
+ slicerProfile: string | undefined,
506
+ options: BambuSliceOptions
507
+ ): Promise<BambuSettingsBundle> {
508
+ const rawBundle = await this.resolveSettingsBundle(
509
+ outputBase, slicerType, activeProfilesRoot, printerPreset, slicerProfile, options);
510
+ const bundle = await this.flattenBundle(
511
+ await this.expandProjectFilaments(inputPath, rawBundle), options, activeProfilesRoot);
512
+ // The machine preset assumes the stock hotend; record the installed one so
513
+ // the sliced job's nozzle_type matches what the printer reports.
514
+ if (options.nozzleType && bundle.settingsArg) {
515
+ const [machinePath, ...rest] = bundle.settingsArg.split(";");
516
+ const machine = this.readJsonFile(machinePath);
517
+ const current = machine.nozzle_type;
518
+ machine.nozzle_type = Array.isArray(current) && current.length > 0 ? current.map(() => options.nozzleType) : [options.nozzleType];
519
+ bundle.settingsArg = [this.writeTempJson(outputBase, `machine_${options.nozzleType}`, machine), ...rest].join(";");
520
+ }
521
+ // Inherited process G-code is now present; apply Orca's existing
522
+ // absolute-extrusion normalization after resolving those ancestors.
523
+ if (slicerType === "orcaslicer" && bundle.settingsArg) {
524
+ const [machinePath, processPath] = bundle.settingsArg.split(";");
525
+ bundle.settingsArg = [
526
+ this.writeTempJson(outputBase, "machine_orca_resolved", this.sanitizeMachineForOrca(this.readJsonFile(machinePath))),
527
+ this.writeTempJson(outputBase, "process_orca_resolved", this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
528
+ ].join(";");
529
+ }
530
+ return this.isolateMachineSettings(bundle);
531
+ }
532
+
533
+ /**
534
+ * The CLI applies every key in every --load-settings/--load-filaments file,
535
+ * so machine settings carried by a process or filament file (for example a
536
+ * template 3MF's project_settings from another printer) would replace the
537
+ * selected preset's start G-code, model, and bed geometry. The machine
538
+ * preset owns those keys; drop them from the other profiles.
539
+ */
540
+ private isolateMachineSettings(bundle: BambuSettingsBundle): BambuSettingsBundle {
541
+ const [machinePath, processPath] = bundle.settingsArg?.split(";") ?? [];
542
+ if (!machinePath || !processPath) return bundle;
543
+ const machineKeys = Object.keys(this.readJsonFile(machinePath)).filter((key) => !PROFILE_METADATA_KEYS.has(key));
544
+ const isolate = (filePath: string, label: string): string => {
545
+ const profile = this.readJsonFile(filePath);
546
+ const foreign = machineKeys.filter((key) => Object.prototype.hasOwnProperty.call(profile, key));
547
+ if (foreign.length === 0) return filePath;
548
+ const isolated = { ...profile };
549
+ for (const key of foreign) delete isolated[key];
550
+ console.error(
551
+ `Ignoring ${foreign.length} machine setting(s) in ${label} profile ${filePath} ` +
552
+ `(the selected machine preset owns them): ${foreign.slice(0, 8).join(", ")}${foreign.length > 8 ? ", ..." : ""}`
553
+ );
554
+ return this.writeTempJson(path.basename(filePath, ".json"), `${label}_without_machine`, isolated);
555
+ };
556
+ return {
557
+ ...bundle,
558
+ settingsArg: [machinePath, isolate(processPath, "process")].join(";"),
559
+ filamentPaths: bundle.filamentPaths.map((filePath) => isolate(filePath, "filament")),
560
+ };
561
+ }
562
+ }
563
+
564
+ /** Keys every profile kind carries; they describe the file, not machine hardware. */
565
+ const PROFILE_METADATA_KEYS = new Set([
566
+ "name", "type", "from", "inherits", "instantiation", "setting_id", "version", "is_custom_defined",
567
+ "compatible_printers", "compatible_printers_condition", "compatible_prints", "compatible_prints_condition",
568
+ ]);
569
+
570
+ /** CLI flags for the prepared bundle and caller options (input path appended by the caller). */
571
+ export function buildBambuCliArgs(
572
+ bundle: BambuSettingsBundle,
573
+ outputDir: string,
574
+ exportName: string,
575
+ options: BambuSliceOptions
576
+ ): string[] {
577
+ const args = [
578
+ "--slice", String(options.slicePlate ?? 0),
579
+ "--outputdir", outputDir,
580
+ "--export-3mf", exportName,
581
+ ];
582
+ if (bundle.settingsArg) args.push("--load-settings", bundle.settingsArg);
583
+ // Always allow newer-version 3MF files (the CLI rejects them by default).
584
+ args.push("--allow-newer-file");
585
+ if (options.uptodate) args.push("--uptodate");
586
+ if (options.ensureOnBed) args.push("--ensure-on-bed");
587
+ if (options.enableTimelapse) args.push("--enable-timelapse");
588
+ if (options.allowMixTemp) args.push("--allow-mix-temp");
589
+ if (options.minSave) args.push("--min-save");
590
+ if (options.skipModifiedGcodes) args.push("--skip-modified-gcodes");
591
+ if (options.orient !== undefined) args.push("--orient", options.orient ? "1" : "0");
592
+ if (options.arrange !== undefined) args.push("--arrange", options.arrange ? "1" : "0");
593
+ if (options.repetitions !== undefined) args.push("--repetitions", String(options.repetitions));
594
+ if (options.scale !== undefined) args.push("--scale", String(options.scale));
595
+ if (options.rotate !== undefined) args.push("--rotate", String(options.rotate));
596
+ if (options.rotateX !== undefined) args.push("--rotate-x", String(options.rotateX));
597
+ if (options.rotateY !== undefined) args.push("--rotate-y", String(options.rotateY));
598
+ if (options.cloneObjects) args.push("--clone-objects", options.cloneObjects);
599
+ if (options.skipObjects) args.push("--skip-objects", options.skipObjects);
600
+ if (bundle.filamentPaths.length > 0) {
601
+ args.push("--load-filaments", bundle.filamentPaths.join(";"));
602
+ args.push("--load-defaultfila");
603
+ }
604
+ if (options.loadFilamentIds) args.push("--load-filament-ids", options.loadFilamentIds);
605
+ return args;
606
+ }
607
+
608
+ /**
609
+ * A sliced Bambu project must contain at least one nonempty
610
+ * Metadata/plate_<n>.gcode entry. A .gcode.md5 checksum or the unsliced
611
+ * input geometry is not printable output.
612
+ */
613
+ export async function assertSlicedProjectOutput(outputPath: string): Promise<string[]> {
614
+ const JSZip = (await import("jszip")).default;
615
+ let zip: InstanceType<typeof JSZip>;
616
+ try {
617
+ zip = await JSZip.loadAsync(await fs.promises.readFile(outputPath));
618
+ } catch (error: any) {
619
+ throw new Error(`Slicer output ${outputPath} is not a readable 3MF archive: ${error?.message ?? error}`);
620
+ }
621
+ const plates: string[] = [];
622
+ for (const entry of Object.values(zip.files)) {
623
+ if (entry.dir || !/^Metadata\/plate_\d+\.gcode$/i.test(entry.name)) continue;
624
+ const content = await entry.async("uint8array");
625
+ if (content.length > 0) plates.push(entry.name);
626
+ }
627
+ if (plates.length === 0) {
628
+ throw new Error(
629
+ `Slicer output ${outputPath} does not contain any nonempty Metadata/plate_<n>.gcode entry; it is not a printable sliced project.`
630
+ );
631
+ }
632
+ return plates.sort();
633
+ }