bambu-printer-mcp 1.1.5 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTORS.md +26 -0
- package/README.md +100 -27
- package/dist/blender-mcp-bridge.d.ts +10 -0
- package/dist/blender-mcp-bridge.js +389 -0
- package/dist/index.js +166 -102
- package/dist/printers/bambu.js +31 -13
- package/dist/slicer/profile-flatten.d.ts +15 -4
- package/dist/slicer/profile-flatten.js +181 -74
- package/dist/stl/stl-manipulator.d.ts +3 -17
- package/dist/stl/stl-manipulator.js +142 -85
- package/package.json +8 -4
- package/patches/bambu-node+3.22.21.patch +18 -2
- package/scripts/install-patches.mjs +64 -0
- package/src/blender-mcp-bridge.ts +344 -0
- package/src/index.ts +237 -123
- package/src/printers/bambu.ts +29 -13
- package/src/slicer/profile-flatten.ts +202 -80
- package/src/stl/stl-manipulator.ts +161 -98
package/src/printers/bambu.ts
CHANGED
|
@@ -62,6 +62,10 @@ function isH2ModelName(model: unknown): boolean {
|
|
|
62
62
|
return H2_MODEL_NAMES.has(String(model ?? "").trim().toLowerCase().replace(/\s+/g, " "));
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function isP2SModelName(model: unknown): boolean {
|
|
66
|
+
return String(model ?? "").trim().toLowerCase() === "p2s";
|
|
67
|
+
}
|
|
68
|
+
|
|
65
69
|
interface BambuPrintOptionsInternal {
|
|
66
70
|
projectName: string;
|
|
67
71
|
filePath: string;
|
|
@@ -145,11 +149,14 @@ class TolerantBambuClient extends BambuClient {
|
|
|
145
149
|
const sn = this.config.serialNumber;
|
|
146
150
|
if (sn.startsWith("093")) return "H2S";
|
|
147
151
|
if (sn.startsWith("094")) return "H2D";
|
|
152
|
+
if (sn.startsWith("239")) return "H2C";
|
|
153
|
+
if (sn.startsWith("31B")) return "H2DPRO";
|
|
148
154
|
if (sn.startsWith("00M")) return "X1C";
|
|
149
155
|
if (sn.startsWith("00W")) return "X1";
|
|
150
156
|
if (sn.startsWith("03W")) return "X1E";
|
|
151
157
|
if (sn.startsWith("01S")) return "P1P";
|
|
152
158
|
if (sn.startsWith("01P")) return "P1S";
|
|
159
|
+
if (sn.startsWith("22E")) return "P2S";
|
|
153
160
|
if (sn.startsWith("030")) return "A1";
|
|
154
161
|
if (sn.startsWith("039")) return "A1M";
|
|
155
162
|
return undefined;
|
|
@@ -572,27 +579,36 @@ export class BambuImplementation {
|
|
|
572
579
|
remoteFileName = remoteFileName.replace(/\.gcode\.3mf\.gcode\.3mf$/i, ".gcode.3mf");
|
|
573
580
|
|
|
574
581
|
// H2-series printers land files at the FTP root and reference them via ftp:///<name>.
|
|
575
|
-
//
|
|
582
|
+
// Full-size A1 uses SD root/project_file (reported on firmware 01.08.01.00).
|
|
583
|
+
// P1/X1/A1 mini retain their legacy cache/gcode_file route.
|
|
584
|
+
// P2S keeps the /cache/<name> upload but only accepts the H2-style
|
|
585
|
+
// project_file command, referenced via ftp:///cache/<name> (verified on
|
|
586
|
+
// P2S firmware 01.02.00.00; file:///sdcard/... fails with ERROR STATE).
|
|
576
587
|
const isH2 =
|
|
577
588
|
serial.startsWith("093") ||
|
|
578
589
|
serial.startsWith("094") ||
|
|
579
590
|
isH2ModelName(options.bambuModel);
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
591
|
+
const isP2S = serial.startsWith("22E") || isP2SModelName(options.bambuModel);
|
|
592
|
+
const isA1 = String(options.bambuModel ?? "").trim().toLowerCase() === "a1" ||
|
|
593
|
+
(!options.bambuModel && serial.startsWith("030"));
|
|
594
|
+
const usesH2ProjectFile = isH2 || isP2S;
|
|
595
|
+
const remoteProjectPath = isH2 || isA1 ? remoteFileName : `cache/${remoteFileName}`;
|
|
596
|
+
const remoteUploadPath = `/${remoteProjectPath}`;
|
|
597
|
+
const projectUrl = usesH2ProjectFile
|
|
598
|
+
? `ftp:///${remoteProjectPath}`
|
|
584
599
|
: `file:///sdcard/${remoteProjectPath}`;
|
|
585
600
|
|
|
586
601
|
// Upload via basic-ftp directly (bypasses bambu-js double-path bug)
|
|
587
602
|
await this.ftpUpload(host, token, options.filePath, remoteUploadPath);
|
|
588
603
|
|
|
589
604
|
// Pre-sliced .gcode.3mf files: routing depends on firmware generation.
|
|
590
|
-
// P1/A1
|
|
605
|
+
// Legacy P1/X1/A1 mini: project_file returns 405004002 for .gcode.3mf (firmware
|
|
591
606
|
// doesn't recognise the container), so use gcode_file instead.
|
|
592
|
-
// H2-series: gcode_file is not supported
|
|
607
|
+
// H2-series and P2S: gcode_file is not supported (P2S answers 0500-4002
|
|
608
|
+
// "Unsupported file path or name"); project_file works because the
|
|
593
609
|
// firmware can open the zip and find Metadata/plate_<n>.gcode directly.
|
|
594
610
|
if (options.filePath.toLowerCase().endsWith(".gcode.3mf")) {
|
|
595
|
-
if (!
|
|
611
|
+
if (!usesH2ProjectFile && !isA1) {
|
|
596
612
|
const printer = await this.getPrinter(host, serial, token);
|
|
597
613
|
await invokeWithoutAck(printer, new GCodeFileCommand({ fileName: remoteProjectPath }));
|
|
598
614
|
return {
|
|
@@ -601,7 +617,7 @@ export class BambuImplementation {
|
|
|
601
617
|
remoteProjectPath,
|
|
602
618
|
};
|
|
603
619
|
}
|
|
604
|
-
// H2-series: fall through to project_file path below
|
|
620
|
+
// H2-series and P2S: fall through to project_file path below
|
|
605
621
|
}
|
|
606
622
|
|
|
607
623
|
const projectMetadata = await this.resolveProjectFileMetadata(
|
|
@@ -687,7 +703,7 @@ export class BambuImplementation {
|
|
|
687
703
|
|
|
688
704
|
let amsMapping: number[];
|
|
689
705
|
let amsMapping2: Array<{ ams_id: number; slot_id: number }>;
|
|
690
|
-
if (
|
|
706
|
+
if (usesH2ProjectFile) {
|
|
691
707
|
const projLen = Math.max(projectMetadata.projectFilamentCount, baseMapping.length, 1);
|
|
692
708
|
amsMapping = Array.from({ length: projLen }, (_, i) =>
|
|
693
709
|
i < baseMapping.length ? baseMapping[i] : -1
|
|
@@ -699,7 +715,7 @@ export class BambuImplementation {
|
|
|
699
715
|
return { ams_id: Math.floor(v / 4), slot_id: v % 4 };
|
|
700
716
|
});
|
|
701
717
|
} else {
|
|
702
|
-
amsMapping = Array.from({ length: 5 }, (_, i) =>
|
|
718
|
+
amsMapping = Array.from({ length: Math.max(5, baseMapping.length, projectMetadata.projectFilamentCount) }, (_, i) =>
|
|
703
719
|
i < baseMapping.length ? baseMapping[i] : -1
|
|
704
720
|
);
|
|
705
721
|
amsMapping2 = [];
|
|
@@ -707,7 +723,7 @@ export class BambuImplementation {
|
|
|
707
723
|
|
|
708
724
|
const b = (v: any) => (v ? 1 : 0);
|
|
709
725
|
let projectFileCmd: Record<string, any>;
|
|
710
|
-
if (
|
|
726
|
+
if (usesH2ProjectFile) {
|
|
711
727
|
const submissionId = String(Date.now() & 0x7fffffff);
|
|
712
728
|
projectFileCmd = {
|
|
713
729
|
print: {
|
|
@@ -1270,7 +1286,7 @@ export class BambuImplementation {
|
|
|
1270
1286
|
// integration's models.py shows the printer reports its own
|
|
1271
1287
|
// `ipcam.rtsp_url` for these models, and Parker (H2S) rejects the
|
|
1272
1288
|
// A1/P1 80-byte auth packet on port 6000 (verified 2026-04-27 --
|
|
1273
|
-
//
|
|
1289
|
+
// confirmed by local H2 camera transport probes).
|
|
1274
1290
|
const RTSP_MODELS = new Set([
|
|
1275
1291
|
"x1", "x1c", "x1carbon", "x1e", "p2s",
|
|
1276
1292
|
"h2", "h2s", "h2d", "h2c", "h2dpro",
|
|
@@ -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.
|
|
17
|
-
*
|
|
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
|
*
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
import fs from "node:fs/promises";
|
|
28
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
26
29
|
import path from "node:path";
|
|
27
30
|
import crypto from "node:crypto";
|
|
28
31
|
|
|
@@ -38,6 +41,9 @@ export interface FlattenedProfiles {
|
|
|
38
41
|
machineLeafName: string;
|
|
39
42
|
processLeafName: string;
|
|
40
43
|
filamentLeafNames: string[];
|
|
44
|
+
/** False only for an explicit standalone custom machine. */
|
|
45
|
+
cliConfigValidated: boolean;
|
|
46
|
+
/** Some validated official model configs intentionally have no limits. */
|
|
41
47
|
cliOverlayApplied: boolean;
|
|
42
48
|
};
|
|
43
49
|
}
|
|
@@ -66,6 +72,12 @@ export interface FlattenOptions {
|
|
|
66
72
|
nozzleVolumeType?: "Standard" | "High Flow";
|
|
67
73
|
/** BambuStudio display name, e.g. "Textured PEI Plate" or "Cool Plate". */
|
|
68
74
|
bedType?: string;
|
|
75
|
+
/** Actual input configs, including user overrides on top of BBL parents. */
|
|
76
|
+
sourceProfiles?: {
|
|
77
|
+
machine?: Record<string, unknown>;
|
|
78
|
+
process?: Record<string, unknown>;
|
|
79
|
+
filaments?: (Record<string, unknown> | undefined)[];
|
|
80
|
+
};
|
|
69
81
|
}
|
|
70
82
|
|
|
71
83
|
interface IndexedProfile {
|
|
@@ -146,39 +158,81 @@ async function buildNameIndex(
|
|
|
146
158
|
* - Unknown name (broken `inherits` reference).
|
|
147
159
|
* - Cycles (A -> B -> A).
|
|
148
160
|
*/
|
|
149
|
-
function flattenByName(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
function flattenByName(
|
|
162
|
+
leafName: string,
|
|
163
|
+
index: NameIndex,
|
|
164
|
+
visiting = new Set<string>()
|
|
165
|
+
): Record<string, unknown> {
|
|
166
|
+
if (visiting.has(leafName)) {
|
|
167
|
+
throw new Error(`Profile inheritance cycle detected (inherits/include): ${[...visiting, leafName].join(" -> ")}`);
|
|
168
|
+
}
|
|
169
|
+
const entry = index.get(leafName);
|
|
170
|
+
if (!entry) {
|
|
171
|
+
throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.`);
|
|
172
|
+
}
|
|
173
|
+
visiting.add(leafName);
|
|
174
|
+
try {
|
|
175
|
+
return flattenData(entry.data, index, visiting);
|
|
176
|
+
} finally {
|
|
177
|
+
visiting.delete(leafName);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
153
180
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
181
|
+
function flattenData(
|
|
182
|
+
data: Record<string, unknown>,
|
|
183
|
+
index: NameIndex,
|
|
184
|
+
visiting = new Set<string>()
|
|
185
|
+
): Record<string, unknown> {
|
|
186
|
+
const parent = data["inherits"];
|
|
187
|
+
const merged = typeof parent === "string" && parent.length > 0
|
|
188
|
+
? flattenByName(parent, index, visiting)
|
|
189
|
+
: {};
|
|
190
|
+
// Includes may themselves inherit or include templates. Each level wins
|
|
191
|
+
// over its parent, then the including profile's own settings win last.
|
|
192
|
+
applyIncludes(merged, data, index, visiting);
|
|
193
|
+
Object.assign(merged, data);
|
|
194
|
+
delete merged["include"];
|
|
195
|
+
return merged;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Keys of an include template that describe the template, not the config. */
|
|
199
|
+
const INCLUDE_METADATA_KEYS = new Set(["name", "type", "from", "instantiation", "inherits", "include", "setting_id"]);
|
|
161
200
|
|
|
162
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Recent BBL profiles (e.g. "Bambu Lab P2S 0.4 nozzle") no longer carry
|
|
203
|
+
* their G-code inline: `machine_start_gcode`, `machine_end_gcode`,
|
|
204
|
+
* `change_filament_gcode`, ... live in separate "... template <key>"
|
|
205
|
+
* profiles listed under `include`. Skipping them silently falls back to
|
|
206
|
+
* the generic G-code inherited from `fdm_machine_common` & co, which is
|
|
207
|
+
* wrong for the printer (no AMS filament load, other printer's moves).
|
|
208
|
+
*/
|
|
209
|
+
function applyIncludes(
|
|
210
|
+
target: Record<string, unknown>,
|
|
211
|
+
profile: Record<string, unknown>,
|
|
212
|
+
index: NameIndex,
|
|
213
|
+
visiting: Set<string>
|
|
214
|
+
): void {
|
|
215
|
+
const raw = profile["include"];
|
|
216
|
+
if (raw === undefined || raw === null) return;
|
|
217
|
+
if (typeof raw !== "string" && !Array.isArray(raw)) {
|
|
218
|
+
throw new Error(`Profile "${String(profile["name"])}" has an invalid include; expected a name or array of names.`);
|
|
219
|
+
}
|
|
220
|
+
const names = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
|
|
221
|
+
for (const name of names) {
|
|
222
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
223
|
+
throw new Error(`Profile "${String(profile["name"])}" has an invalid include reference; every entry must be a nonempty name.`);
|
|
224
|
+
}
|
|
225
|
+
const entry = index.get(name);
|
|
163
226
|
if (!entry) {
|
|
164
227
|
throw new Error(
|
|
165
|
-
`Profile "${
|
|
166
|
-
`
|
|
167
|
-
`This usually means the leaf name is misspelled or the profile tree is incomplete.`
|
|
228
|
+
`Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
|
|
229
|
+
`Refusing to fall back to inherited defaults (wrong G-code for this printer).`
|
|
168
230
|
);
|
|
169
231
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
cursor = typeof parent === "string" && parent.length > 0 ? parent : undefined;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Merge root-most parent first, leaf last (so leaf wins).
|
|
177
|
-
const merged: Record<string, unknown> = {};
|
|
178
|
-
for (let i = chain.length - 1; i >= 0; i--) {
|
|
179
|
-
Object.assign(merged, chain[i]);
|
|
232
|
+
for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
|
|
233
|
+
if (!INCLUDE_METADATA_KEYS.has(key)) target[key] = value;
|
|
234
|
+
}
|
|
180
235
|
}
|
|
181
|
-
return merged;
|
|
182
236
|
}
|
|
183
237
|
|
|
184
238
|
/* -------------------------------------------------------------------------- */
|
|
@@ -263,16 +317,11 @@ function inferExtruderCount(flat: Record<string, unknown>): number {
|
|
|
263
317
|
}
|
|
264
318
|
|
|
265
319
|
/**
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* The overlay keys are scoped under printer.<printer_name>.machine_limits.
|
|
272
|
-
* We look up by the leaf machine's `printer_model` or `name` and merge
|
|
273
|
-
* those keys into the flattened machine profile.
|
|
274
|
-
*
|
|
275
|
-
* Returns true if an overlay was found and applied, false otherwise.
|
|
320
|
+
* Validate the selected model's CLI config before making a bundled profile
|
|
321
|
+
* into a User profile. A1-family models require machine_limits overrides;
|
|
322
|
+
* other official models (including P1S and H2D) intentionally declare only
|
|
323
|
+
* downward_check. Missing config cannot tell us whether limits are required.
|
|
324
|
+
* Returns whether limits were applied, not whether validation succeeded.
|
|
276
325
|
*/
|
|
277
326
|
async function applyCliOverlay(
|
|
278
327
|
flat: Record<string, unknown>,
|
|
@@ -280,47 +329,80 @@ async function applyCliOverlay(
|
|
|
280
329
|
vendor: string
|
|
281
330
|
): Promise<boolean> {
|
|
282
331
|
const cliConfigPath = path.join(profilesRoot, vendor, "cli_config.json");
|
|
332
|
+
const fail = (reason: string): never => {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Cannot prepare machine profile "${String(flat["name"])}" ` +
|
|
335
|
+
`(model "${String(flat["printer_model"] ?? stripNozzleSuffix(String(flat["name"])))}"): ` +
|
|
336
|
+
`${cliConfigPath}: ${reason}. Check your BambuStudio installation or BAMBU_PROFILES_ROOT.`
|
|
337
|
+
);
|
|
338
|
+
};
|
|
283
339
|
let raw: string;
|
|
284
340
|
try {
|
|
285
341
|
raw = await fs.readFile(cliConfigPath, "utf8");
|
|
286
342
|
} catch {
|
|
287
|
-
return
|
|
343
|
+
return fail("required CLI configuration is missing or unreadable");
|
|
288
344
|
}
|
|
289
|
-
let cliConfig:
|
|
345
|
+
let cliConfig: unknown;
|
|
290
346
|
try {
|
|
291
|
-
cliConfig = JSON.parse(raw)
|
|
347
|
+
cliConfig = JSON.parse(raw);
|
|
292
348
|
} catch {
|
|
293
|
-
return
|
|
349
|
+
return fail("invalid JSON in required CLI configuration");
|
|
294
350
|
}
|
|
295
|
-
|
|
351
|
+
if (!isRecord(cliConfig)) return fail("expected a CLI configuration object");
|
|
296
352
|
const printerSection = cliConfig["printer"];
|
|
297
|
-
if (!printerSection
|
|
353
|
+
if (!isRecord(printerSection)) return fail("expected a printer configuration object");
|
|
298
354
|
|
|
299
355
|
// Match key: cli_config.json keys are bare printer names like
|
|
300
|
-
// "Bambu Lab H2D" / "Bambu Lab A1".
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
356
|
+
// "Bambu Lab H2D" / "Bambu Lab A1". If no model is provided, try preset
|
|
357
|
+
// identity with the nozzle suffix stripped, then the name as-is.
|
|
358
|
+
// An explicit model is authoritative; do not silently use another model's
|
|
359
|
+
// limits because a stale preset name happens to match.
|
|
360
|
+
const candidates = typeof flat["printer_model"] === "string" && flat["printer_model"].trim()
|
|
361
|
+
? [flat["printer_model"]]
|
|
362
|
+
: [
|
|
363
|
+
typeof flat["printer_settings_id"] === "string"
|
|
364
|
+
? stripNozzleSuffix(flat["printer_settings_id"])
|
|
365
|
+
: undefined,
|
|
366
|
+
typeof flat["name"] === "string" ? stripNozzleSuffix(flat["name"]) : undefined,
|
|
367
|
+
flat["name"],
|
|
368
|
+
].filter((v): v is string => typeof v === "string" && v.length > 0);
|
|
369
|
+
|
|
370
|
+
const model = candidates.find(key => Object.prototype.hasOwnProperty.call(printerSection, key));
|
|
371
|
+
if (!model) return fail(`selected model is absent (looked for ${candidates.join(", ")})`);
|
|
372
|
+
const block = printerSection[model];
|
|
373
|
+
if (!isRecord(block)) return fail(`printer.${model} must be an object`);
|
|
374
|
+
|
|
375
|
+
const downward = block["downward_check"];
|
|
376
|
+
if (Object.prototype.hasOwnProperty.call(block, "downward_check") && (
|
|
377
|
+
!isRecord(downward) || Object.keys(downward).length === 0 ||
|
|
378
|
+
Object.entries(downward).some(([name, values]) => !name.trim() || !Array.isArray(values) ||
|
|
379
|
+
values.some(value => typeof value !== "string" || !value.trim()))
|
|
380
|
+
)) {
|
|
381
|
+
return fail(`printer.${model}.downward_check must map profile names to arrays of names`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!Object.prototype.hasOwnProperty.call(block, "machine_limits")) {
|
|
385
|
+
if (!isRecord(downward)) return fail(`printer.${model} has neither machine_limits nor downward_check`);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
const limits = block["machine_limits"];
|
|
389
|
+
if (!isRecord(limits) || Object.keys(limits).length === 0) {
|
|
390
|
+
return fail(`printer.${model}.machine_limits must be a nonempty object`);
|
|
321
391
|
}
|
|
392
|
+
const limitKey = /^cli_safe_(?:acceleration_(?:e|extruding|retracting|travel|x|y|z)|(?:jerk|speed)_(?:e|x|y|z))$/;
|
|
393
|
+
for (const [key, value] of Object.entries(limits)) {
|
|
394
|
+
if (!limitKey.test(key) || typeof value !== "string" ||
|
|
395
|
+
value.split(",").some(part => !part.trim() || !Number.isFinite(Number(part)) || Number(part) < 0)) {
|
|
396
|
+
return fail(`printer.${model}.machine_limits contains an invalid limit "${key}"`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// CLI safety values override even explicit values in the selected leaf.
|
|
400
|
+
Object.assign(flat, limits);
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
322
403
|
|
|
323
|
-
|
|
404
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
405
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
324
406
|
}
|
|
325
407
|
|
|
326
408
|
/**
|
|
@@ -472,14 +554,33 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
|
|
|
472
554
|
const index = await buildNameIndex(opts.profilesRoot, vendor);
|
|
473
555
|
|
|
474
556
|
// Flatten each leaf.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
557
|
+
if (opts.sourceProfiles?.filaments && opts.sourceProfiles.filaments.length !== opts.filamentLeaves.length) {
|
|
558
|
+
throw new Error("Every filament slot must have a source profile.");
|
|
559
|
+
}
|
|
560
|
+
const machineFlat = opts.sourceProfiles?.machine
|
|
561
|
+
? flattenData(opts.sourceProfiles.machine, index)
|
|
562
|
+
: flattenByName(opts.machineLeaf, index);
|
|
563
|
+
const processFlat = opts.sourceProfiles?.process
|
|
564
|
+
? flattenData(opts.sourceProfiles.process, index)
|
|
565
|
+
: flattenByName(opts.processLeaf, index);
|
|
566
|
+
const filamentFlats = opts.filamentLeaves.map((n, i) => {
|
|
567
|
+
const source = opts.sourceProfiles?.filaments?.[i];
|
|
568
|
+
return source ? flattenData(source, index) : flattenByName(n, index);
|
|
569
|
+
});
|
|
478
570
|
|
|
479
571
|
// CLI-specific post-processing on machine profile only.
|
|
480
572
|
deriveNozzleVolumeType(machineFlat, opts.nozzleVolumeType);
|
|
481
573
|
applyMachineModelBedMetadata(machineFlat, index);
|
|
482
|
-
|
|
574
|
+
// A standalone custom machine can accompany process/filament profiles that
|
|
575
|
+
// need BBL resolution; do not impose bundled model config on that machine.
|
|
576
|
+
const sourceMachine = opts.sourceProfiles?.machine;
|
|
577
|
+
const standaloneMachine = sourceMachine && String(sourceMachine.from).toLowerCase() !== "system" &&
|
|
578
|
+
!(typeof sourceMachine.inherits === "string" && sourceMachine.inherits.length > 0) &&
|
|
579
|
+
(sourceMachine.include === undefined || sourceMachine.include === null);
|
|
580
|
+
const cliConfigValidated = !standaloneMachine;
|
|
581
|
+
const cliOverlayApplied = cliConfigValidated
|
|
582
|
+
? await applyCliOverlay(machineFlat, opts.profilesRoot, vendor)
|
|
583
|
+
: false;
|
|
483
584
|
|
|
484
585
|
// Normalize each flattened profile for CLI consumption.
|
|
485
586
|
normalizeForCli(machineFlat, "machine", opts.machineLeaf);
|
|
@@ -495,8 +596,8 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
|
|
|
495
596
|
ensureMachineInCompatList(processFlat, opts.machineLeaf);
|
|
496
597
|
filamentFlats.forEach((f) => ensureMachineInCompatList(f, opts.machineLeaf));
|
|
497
598
|
|
|
498
|
-
//
|
|
499
|
-
//
|
|
599
|
+
// Hash the resolved content so concurrent jobs using the same preset with
|
|
600
|
+
// different overrides never overwrite each other's input files.
|
|
500
601
|
await fs.mkdir(opts.tempDir, { recursive: true });
|
|
501
602
|
const machinePath = await writeTemp(opts.tempDir, "machine", opts.machineLeaf, machineFlat);
|
|
502
603
|
const processPath = await writeTemp(opts.tempDir, "process", opts.processLeaf, processFlat);
|
|
@@ -516,6 +617,7 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
|
|
|
516
617
|
machineLeafName: opts.machineLeaf,
|
|
517
618
|
processLeafName: opts.processLeaf,
|
|
518
619
|
filamentLeafNames: opts.filamentLeaves,
|
|
620
|
+
cliConfigValidated,
|
|
519
621
|
cliOverlayApplied,
|
|
520
622
|
},
|
|
521
623
|
};
|
|
@@ -527,11 +629,12 @@ async function writeTemp(
|
|
|
527
629
|
leafName: string,
|
|
528
630
|
data: Record<string, unknown>
|
|
529
631
|
): Promise<string> {
|
|
530
|
-
const
|
|
632
|
+
const serialized = JSON.stringify(data, null, 2);
|
|
633
|
+
const hash = crypto.createHash("sha256").update(leafName).update("\0").update(serialized).digest("hex").slice(0, 16);
|
|
531
634
|
const safe = leafName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
|
|
532
635
|
const filename = `flat-${kind}-${safe}-${hash}.json`;
|
|
533
636
|
const out = path.join(tempDir, filename);
|
|
534
|
-
await fs.writeFile(out,
|
|
637
|
+
await fs.writeFile(out, serialized, "utf8");
|
|
535
638
|
return out;
|
|
536
639
|
}
|
|
537
640
|
|
|
@@ -541,7 +644,7 @@ async function writeTemp(
|
|
|
541
644
|
|
|
542
645
|
/**
|
|
543
646
|
* Given the SLICER_PATH (path to BambuStudio executable), walk up to the
|
|
544
|
-
*
|
|
647
|
+
* profile directory for that installation (macOS, Windows, or Linux prefix).
|
|
545
648
|
*
|
|
546
649
|
* Override via BAMBU_PROFILES_ROOT env.
|
|
547
650
|
*/
|
|
@@ -551,10 +654,29 @@ export function detectProfilesRoot(slicerPath?: string): string {
|
|
|
551
654
|
}
|
|
552
655
|
|
|
553
656
|
if (slicerPath) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
657
|
+
let executable = slicerPath;
|
|
658
|
+
if (!slicerPath.includes('/') && !slicerPath.includes('\\')) {
|
|
659
|
+
const located = (process.env.PATH ?? '').split(path.delimiter)
|
|
660
|
+
.map(dir => path.join(dir, slicerPath))
|
|
661
|
+
.find(candidate => existsSync(candidate));
|
|
662
|
+
if (located) executable = located;
|
|
663
|
+
}
|
|
664
|
+
// Package-manager launchers may be symlinks into the installation prefix.
|
|
665
|
+
try { executable = realpathSync(executable); } catch { /* Probe the supplied path below. */ }
|
|
666
|
+
const bin = path.dirname(executable);
|
|
667
|
+
const candidates = path.basename(bin) === 'MacOS'
|
|
668
|
+
? [path.resolve(bin, '..', 'Resources', 'profiles')]
|
|
669
|
+
: [
|
|
670
|
+
path.join(bin, 'resources', 'profiles'),
|
|
671
|
+
path.join(bin, 'Resources', 'profiles'),
|
|
672
|
+
path.resolve(bin, '..', 'share', 'BambuStudio', 'profiles'),
|
|
673
|
+
path.resolve(bin, '..', 'share', 'bambu-studio', 'profiles'),
|
|
674
|
+
path.resolve(bin, '..', 'share', 'BambuStudio', 'resources', 'profiles'),
|
|
675
|
+
];
|
|
676
|
+
const found = candidates.find(root => existsSync(path.join(root, 'BBL', 'machine')));
|
|
677
|
+
// Do not select another installation if this executable has no profile tree.
|
|
678
|
+
// flattenForCli reports the missing tree and asks for BAMBU_PROFILES_ROOT.
|
|
679
|
+
return found ?? candidates[0];
|
|
558
680
|
}
|
|
559
681
|
|
|
560
682
|
// Default macOS install.
|