bambu-printer-mcp 1.0.8 → 1.1.2
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/README.md +517 -477
- package/dist/3mf_parser.d.ts +15 -1
- package/dist/3mf_parser.js +258 -1
- package/dist/ams-mapping.d.ts +3 -0
- package/dist/ams-mapping.js +45 -0
- package/dist/index.js +1530 -80
- package/dist/printers/bambu.d.ts +133 -0
- package/dist/printers/bambu.js +1005 -53
- package/dist/slicer/profile-flatten.d.ts +75 -0
- package/dist/slicer/profile-flatten.js +446 -0
- package/dist/stl/stl-manipulator.d.ts +31 -3
- package/dist/stl/stl-manipulator.js +300 -52
- package/dist/types.d.ts +35 -0
- package/package.json +8 -3
- package/patches/bambu-node+3.22.21.patch +16 -0
- package/src/3mf_parser.ts +301 -3
- package/src/ams-mapping.ts +47 -0
- package/src/index.ts +1976 -81
- package/src/printers/bambu.ts +1216 -56
- package/src/slicer/profile-flatten.ts +562 -0
- package/src/stl/stl-manipulator.ts +384 -55
- package/src/types.ts +41 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BambuStudio CLI profile flattener.
|
|
3
|
+
*
|
|
4
|
+
* Background: BambuStudio's bundled profile JSONs (Resources/profiles/BBL/...)
|
|
5
|
+
* use an `inherits` chain that the GUI resolves at runtime but the CLI does
|
|
6
|
+
* not. Passing a leaf profile straight to `--load-settings` / `--load-filaments`
|
|
7
|
+
* yields a partial config and the slicer asserts (e.g. `nozzle_volume_type
|
|
8
|
+
* not found` -> SIGSEGV / assertion in MutablePolygon.cpp / Geometry.hpp).
|
|
9
|
+
* See https://github.com/bambulab/BambuStudio/issues/9636 and #9968.
|
|
10
|
+
*
|
|
11
|
+
* This module:
|
|
12
|
+
* 1. Indexes every BBL profile JSON by its `name` field.
|
|
13
|
+
* 2. Recursively walks `inherits`, deep-merging parent into child.
|
|
14
|
+
* 3. Derives `nozzle_volume_type` from `default_nozzle_volume_type[0]`
|
|
15
|
+
* (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
|
+
* 5. Writes the flattened JSON to a temp file the caller passes to
|
|
19
|
+
* BambuStudio CLI.
|
|
20
|
+
*
|
|
21
|
+
* BBL only. Other vendors are out of scope and rejected explicitly so we
|
|
22
|
+
* fail loud rather than producing dangerous gcode for hardware we don't own.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs/promises";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
|
|
29
|
+
export type ProfileKind = "machine" | "process" | "filament";
|
|
30
|
+
|
|
31
|
+
export interface FlattenedProfiles {
|
|
32
|
+
machinePath: string;
|
|
33
|
+
processPath: string;
|
|
34
|
+
filamentPaths: string[];
|
|
35
|
+
/** Diagnostics for callers / logs. Not part of the slicer invocation. */
|
|
36
|
+
meta: {
|
|
37
|
+
profilesRoot: string;
|
|
38
|
+
machineLeafName: string;
|
|
39
|
+
processLeafName: string;
|
|
40
|
+
filamentLeafNames: string[];
|
|
41
|
+
cliOverlayApplied: boolean;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FlattenOptions {
|
|
46
|
+
/** e.g. "Bambu Lab H2S 0.4 nozzle" */
|
|
47
|
+
machineLeaf: string;
|
|
48
|
+
/** e.g. "0.20mm Standard @BBL H2S" */
|
|
49
|
+
processLeaf: string;
|
|
50
|
+
/** e.g. ["Bambu PLA Basic @BBL H2S"] */
|
|
51
|
+
filamentLeaves: string[];
|
|
52
|
+
/** Absolute path to `.../Resources/profiles`. */
|
|
53
|
+
profilesRoot: string;
|
|
54
|
+
/** Where to write flattened temp files. */
|
|
55
|
+
tempDir: string;
|
|
56
|
+
/** Vendor subdir under profilesRoot. Currently only "BBL" supported. */
|
|
57
|
+
vendor?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Override for installed nozzle flow type. The printer reports this on
|
|
60
|
+
* boot from its physical nozzle scan; both nozzles always match (you
|
|
61
|
+
* can't mix Standard + High Flow, just like you can't mix 0.2 + 0.4).
|
|
62
|
+
* If omitted we use `default_nozzle_volume_type` from the profile tree
|
|
63
|
+
* (= "Standard" on stock BBL machines). Pass "High Flow" when HF
|
|
64
|
+
* nozzles are installed.
|
|
65
|
+
*/
|
|
66
|
+
nozzleVolumeType?: "Standard" | "High Flow";
|
|
67
|
+
/** BambuStudio display name, e.g. "Textured PEI Plate" or "Cool Plate". */
|
|
68
|
+
bedType?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface IndexedProfile {
|
|
72
|
+
filePath: string;
|
|
73
|
+
data: Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type NameIndex = Map<string, IndexedProfile>;
|
|
77
|
+
|
|
78
|
+
/* -------------------------------------------------------------------------- */
|
|
79
|
+
/* Indexing */
|
|
80
|
+
/* -------------------------------------------------------------------------- */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a name -> {filePath, data} map for every JSON in
|
|
84
|
+
* <profilesRoot>/<vendor>/{machine,process,filament}/.
|
|
85
|
+
*
|
|
86
|
+
* The `name` field inside each JSON is the lookup key (this is what
|
|
87
|
+
* `inherits` references). We index every file, including non-instantiable
|
|
88
|
+
* abstract bases like `fdm_machine_common` and `fdm_bbl_3dp_001_common`,
|
|
89
|
+
* because those are the parents we'll walk to.
|
|
90
|
+
*/
|
|
91
|
+
async function buildNameIndex(
|
|
92
|
+
profilesRoot: string,
|
|
93
|
+
vendor: string
|
|
94
|
+
): Promise<NameIndex> {
|
|
95
|
+
const index: NameIndex = new Map();
|
|
96
|
+
const subdirs: ProfileKind[] = ["machine", "process", "filament"];
|
|
97
|
+
|
|
98
|
+
for (const sub of subdirs) {
|
|
99
|
+
const dir = path.join(profilesRoot, vendor, sub);
|
|
100
|
+
let entries: string[];
|
|
101
|
+
try {
|
|
102
|
+
entries = await fs.readdir(dir);
|
|
103
|
+
} catch {
|
|
104
|
+
// Vendor or subdir missing -- skip silently; flatten will fail later
|
|
105
|
+
// with a precise "name not found" error.
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!entry.endsWith(".json")) continue;
|
|
110
|
+
const filePath = path.join(dir, entry);
|
|
111
|
+
let raw: string;
|
|
112
|
+
try {
|
|
113
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let data: Record<string, unknown>;
|
|
118
|
+
try {
|
|
119
|
+
data = JSON.parse(raw) as Record<string, unknown>;
|
|
120
|
+
} catch {
|
|
121
|
+
// Malformed profile -- skip, don't poison the index.
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const name = data["name"];
|
|
125
|
+
if (typeof name !== "string" || name.length === 0) continue;
|
|
126
|
+
// First-write wins. BBL doesn't have name collisions in practice;
|
|
127
|
+
// log if it ever does so we notice.
|
|
128
|
+
if (!index.has(name)) {
|
|
129
|
+
index.set(name, { filePath, data });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* -------------------------------------------------------------------------- */
|
|
138
|
+
/* Inheritance walk + merge */
|
|
139
|
+
/* -------------------------------------------------------------------------- */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the full inheritance chain for `leafName` and return a single
|
|
143
|
+
* deep-merged object. Child wins on key collision.
|
|
144
|
+
*
|
|
145
|
+
* Throws on:
|
|
146
|
+
* - Unknown name (broken `inherits` reference).
|
|
147
|
+
* - Cycles (A -> B -> A).
|
|
148
|
+
*/
|
|
149
|
+
function flattenByName(leafName: string, index: NameIndex): Record<string, unknown> {
|
|
150
|
+
const chain: Record<string, unknown>[] = [];
|
|
151
|
+
const visited = new Set<string>();
|
|
152
|
+
let cursor: string | undefined = leafName;
|
|
153
|
+
|
|
154
|
+
while (cursor) {
|
|
155
|
+
if (visited.has(cursor)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Profile inheritance cycle detected at "${cursor}" (chain: ${[...visited].join(" -> ")})`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
visited.add(cursor);
|
|
161
|
+
|
|
162
|
+
const entry = index.get(cursor);
|
|
163
|
+
if (!entry) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Profile "${cursor}" not found in index. ` +
|
|
166
|
+
`Inherits chain so far: ${[...visited].join(" -> ")}. ` +
|
|
167
|
+
`This usually means the leaf name is misspelled or the profile tree is incomplete.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
chain.push(entry.data);
|
|
172
|
+
const parent = entry.data["inherits"];
|
|
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]);
|
|
180
|
+
}
|
|
181
|
+
return merged;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* -------------------------------------------------------------------------- */
|
|
185
|
+
/* CLI post-processing */
|
|
186
|
+
/* -------------------------------------------------------------------------- */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Bambu's CLI looks up `nozzle_volume_type`, but the profile tree only
|
|
190
|
+
* defines `default_nozzle_volume_type`. The GUI propagates the latter
|
|
191
|
+
* (or the value reported by the printer's nozzle scan on boot) into the
|
|
192
|
+
* former at runtime. We do the same.
|
|
193
|
+
*
|
|
194
|
+
* Shape: array of strings, one entry per physical nozzle. Hardware
|
|
195
|
+
* invariant from Bambu: BOTH nozzles must match (same diameter, same
|
|
196
|
+
* flow type) -- you cannot install 0.2 + 0.4, and you cannot install
|
|
197
|
+
* Standard + High Flow. So the array always contains identical entries.
|
|
198
|
+
*
|
|
199
|
+
* Verified against a GUI-sliced H2D project_settings.config:
|
|
200
|
+
* ["High Flow", "High Flow"] (dual-nozzle, HF installed)
|
|
201
|
+
* ["Standard", "Standard"] (dual-nozzle, stock)
|
|
202
|
+
* ["Standard"] (single-extruder default)
|
|
203
|
+
*
|
|
204
|
+
* Override priority:
|
|
205
|
+
* 1. Caller-supplied `nozzleVolumeType` (e.g. printer reported HF).
|
|
206
|
+
* 2. Existing `nozzle_volume_type` already in the flattened profile.
|
|
207
|
+
* 3. `default_nozzle_volume_type` from the profile tree.
|
|
208
|
+
* 4. ["Standard"] x extruder count.
|
|
209
|
+
*/
|
|
210
|
+
function deriveNozzleVolumeType(
|
|
211
|
+
flat: Record<string, unknown>,
|
|
212
|
+
override?: "Standard" | "High Flow"
|
|
213
|
+
): void {
|
|
214
|
+
if (override) {
|
|
215
|
+
const count = inferExtruderCount(flat);
|
|
216
|
+
flat["nozzle_volume_type"] = Array(count).fill(override);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (Array.isArray(flat["nozzle_volume_type"])) {
|
|
221
|
+
enforceMatchingNozzles(flat["nozzle_volume_type"] as unknown[]);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const def = flat["default_nozzle_volume_type"];
|
|
226
|
+
if (Array.isArray(def) && def.every((v) => typeof v === "string")) {
|
|
227
|
+
enforceMatchingNozzles(def);
|
|
228
|
+
flat["nozzle_volume_type"] = [...def];
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const extruderCount = inferExtruderCount(flat);
|
|
233
|
+
flat["nozzle_volume_type"] = Array(extruderCount).fill("Standard");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Hardware invariant: all nozzles on a Bambu printer have identical flow
|
|
238
|
+
* type (and diameter). If a profile somehow declares mixed types it's
|
|
239
|
+
* either bad input or a future mistake -- fail loud so we don't ship
|
|
240
|
+
* gcode that could damage the printer.
|
|
241
|
+
*/
|
|
242
|
+
function enforceMatchingNozzles(arr: unknown[]): void {
|
|
243
|
+
if (arr.length <= 1) return;
|
|
244
|
+
const first = arr[0];
|
|
245
|
+
for (let i = 1; i < arr.length; i++) {
|
|
246
|
+
if (arr[i] !== first) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`nozzle_volume_type entries must all match (Bambu hardware invariant). ` +
|
|
249
|
+
`Got: ${JSON.stringify(arr)}. ` +
|
|
250
|
+
`Both nozzles on H2-series printers always have identical flow type.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Best-effort extruder count for fallback nozzle_volume_type sizing. */
|
|
257
|
+
function inferExtruderCount(flat: Record<string, unknown>): number {
|
|
258
|
+
for (const key of ["nozzle_diameter", "extruder_type", "extruder_variant_list"]) {
|
|
259
|
+
const v = flat[key];
|
|
260
|
+
if (Array.isArray(v) && v.length > 0) return v.length;
|
|
261
|
+
}
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Bambu ships `BBL/cli_config.json` with per-printer overlays containing
|
|
267
|
+
* machine_limits keys (cli_safe_acceleration_*, cli_safe_jerk_*,
|
|
268
|
+
* cli_safe_speed_*). Without these the slicer can emit movements faster
|
|
269
|
+
* than the printer's safe envelope -- dangerous on real hardware.
|
|
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.
|
|
276
|
+
*/
|
|
277
|
+
async function applyCliOverlay(
|
|
278
|
+
flat: Record<string, unknown>,
|
|
279
|
+
profilesRoot: string,
|
|
280
|
+
vendor: string
|
|
281
|
+
): Promise<boolean> {
|
|
282
|
+
const cliConfigPath = path.join(profilesRoot, vendor, "cli_config.json");
|
|
283
|
+
let raw: string;
|
|
284
|
+
try {
|
|
285
|
+
raw = await fs.readFile(cliConfigPath, "utf8");
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
let cliConfig: Record<string, unknown>;
|
|
290
|
+
try {
|
|
291
|
+
cliConfig = JSON.parse(raw) as Record<string, unknown>;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const printerSection = cliConfig["printer"];
|
|
297
|
+
if (!printerSection || typeof printerSection !== "object") return false;
|
|
298
|
+
|
|
299
|
+
// Match key: cli_config.json keys are bare printer names like
|
|
300
|
+
// "Bambu Lab H2D" / "Bambu Lab A1". Try, in order: explicit printer_model,
|
|
301
|
+
// printer_settings_id with the " 0.4 nozzle" suffix stripped, raw name
|
|
302
|
+
// with that suffix stripped, raw name as-is.
|
|
303
|
+
const candidates = [
|
|
304
|
+
flat["printer_model"],
|
|
305
|
+
typeof flat["printer_settings_id"] === "string"
|
|
306
|
+
? stripNozzleSuffix(flat["printer_settings_id"])
|
|
307
|
+
: undefined,
|
|
308
|
+
typeof flat["name"] === "string" ? stripNozzleSuffix(flat["name"]) : undefined,
|
|
309
|
+
flat["name"],
|
|
310
|
+
].filter((v): v is string => typeof v === "string" && v.length > 0);
|
|
311
|
+
|
|
312
|
+
for (const key of candidates) {
|
|
313
|
+
const block = (printerSection as Record<string, unknown>)[key];
|
|
314
|
+
if (!block || typeof block !== "object") continue;
|
|
315
|
+
const mlimits = (block as Record<string, unknown>)["machine_limits"];
|
|
316
|
+
if (!mlimits || typeof mlimits !== "object") continue;
|
|
317
|
+
// Merge machine_limits into the flat profile. These are CLI-only safety
|
|
318
|
+
// values; they should never be overridden by the leaf.
|
|
319
|
+
Object.assign(flat, mlimits as Record<string, unknown>);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Ensure the chosen machine is in the process/filament's
|
|
328
|
+
* `compatible_printers` list. This mirrors what the GUI does when you
|
|
329
|
+
* save a project with a printer/process combo that wasn't in the
|
|
330
|
+
* shipped compat list.
|
|
331
|
+
*/
|
|
332
|
+
function ensureMachineInCompatList(
|
|
333
|
+
flat: Record<string, unknown>,
|
|
334
|
+
machineLeaf: string
|
|
335
|
+
): void {
|
|
336
|
+
const list = flat["compatible_printers"];
|
|
337
|
+
if (!Array.isArray(list)) {
|
|
338
|
+
flat["compatible_printers"] = [machineLeaf];
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!list.includes(machineLeaf)) {
|
|
342
|
+
list.push(machineLeaf);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function stripNozzleSuffix(name: string): string {
|
|
347
|
+
return name.replace(/\s+\d+\.\d+\s+nozzle.*$/, "");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Adjust fields for CLI consumption.
|
|
352
|
+
*
|
|
353
|
+
* - Strip `inherits` and `instantiation` (chain marker, abstract-base flag).
|
|
354
|
+
* The GUI consumes them; the CLI treats anything still present as part of
|
|
355
|
+
* the merged config.
|
|
356
|
+
* - Rewrite `from`. Leaf BBL profiles ship with `"from": "system"`, which
|
|
357
|
+
* the CLI rejects when loaded via `--load-settings` (system presets
|
|
358
|
+
* aren't supposed to be loaded that way). The GUI rewrites this to
|
|
359
|
+
* `"from": "project"` when it embeds the merged config in a 3MF. We do
|
|
360
|
+
* the same so the CLI accepts our flattened temp file.
|
|
361
|
+
* - Strip `setting_id` / `is_custom_defined` (user-registry markers that
|
|
362
|
+
* make no sense on a transient flattened file).
|
|
363
|
+
*/
|
|
364
|
+
function normalizeForCli(
|
|
365
|
+
flat: Record<string, unknown>,
|
|
366
|
+
kind: ProfileKind,
|
|
367
|
+
leafName: string
|
|
368
|
+
): void {
|
|
369
|
+
// Set inherits to the leaf name BEFORE we delete it below, so the
|
|
370
|
+
// CLI's system_name resolution lands on the right value.
|
|
371
|
+
// (See note further down for why this matters.)
|
|
372
|
+
flat["inherits"] = leafName;
|
|
373
|
+
// Identity field. The CLI sets this internally, but we mirror it for
|
|
374
|
+
// consistency with what the GUI's project_settings.config emits.
|
|
375
|
+
switch (kind) {
|
|
376
|
+
case "machine":
|
|
377
|
+
flat["printer_settings_id"] = leafName;
|
|
378
|
+
break;
|
|
379
|
+
case "process":
|
|
380
|
+
flat["print_settings_id"] = leafName;
|
|
381
|
+
break;
|
|
382
|
+
case "filament":
|
|
383
|
+
flat["filament_settings_id"] = [leafName];
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// CRITICAL: when from=="User", the CLI computes `system_name = inherits`
|
|
388
|
+
// and the compatibility check compares the *other* configs'
|
|
389
|
+
// `compatible_printers` list against that system_name. The leaf
|
|
390
|
+
// profiles' compat lists contain the leaf machine name (e.g.
|
|
391
|
+
// "Bambu Lab H2S 0.4 nozzle"), so we must set `inherits` on the
|
|
392
|
+
// flattened machine to that same leaf name. For process/filament,
|
|
393
|
+
// `inherits` doesn't drive the compat check we hit, but we set it for
|
|
394
|
+
// symmetry so any future check succeeds the same way.
|
|
395
|
+
// Source: BambuStudio.cpp ~line 2222 (from!="system" -> system_name = inherits).
|
|
396
|
+
// NOTE: do NOT delete inherits -- we just set it above. The CLI uses
|
|
397
|
+
// its value (when from=="User") for the system_name compat check.
|
|
398
|
+
delete flat["instantiation"];
|
|
399
|
+
delete flat["setting_id"];
|
|
400
|
+
delete flat["is_custom_defined"];
|
|
401
|
+
// Always rewrite `from` to "User". "Project" is what the GUI uses when
|
|
402
|
+
// embedding in a 3MF, but the CLI accepts "User" for --load-settings
|
|
403
|
+
// paths and that's semantically what we are.
|
|
404
|
+
flat["from"] = "User";
|
|
405
|
+
// Mirror the GUI's behavior of clearing compatibility constraints in the
|
|
406
|
+
// merged project config. The leaf process/filament profiles ship with
|
|
407
|
+
// `compatible_printers: ["Bambu Lab H2S 0.4 nozzle"]` etc., and the CLI
|
|
408
|
+
// re-validates that list against our flattened machine config -- which
|
|
409
|
+
// has had `from` rewritten and inheritance collapsed, so the equality
|
|
410
|
+
// check fails. The GUI's project_settings.config has these set to null
|
|
411
|
+
// because at that point compatibility is already established by the
|
|
412
|
+
// user. We do the same.
|
|
413
|
+
// Leave compatible_printers / compatible_prints as the leaf declared
|
|
414
|
+
// them. The GUI nulls these in embedded project configs, but the CLI's
|
|
415
|
+
// --load-settings path requires a non-empty list and matches against
|
|
416
|
+
// the machine's `name` / `printer_settings_id`. The leaves ship with
|
|
417
|
+
// the correct list ([machine_leaf_name]) so just preserve.
|
|
418
|
+
// _condition fields can stay null (string-typed, tolerated as null).
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function applyBedType(processFlat: Record<string, unknown>, bedType?: string): void {
|
|
422
|
+
if (!bedType) return;
|
|
423
|
+
processFlat.curr_bed_type = bedType;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function applyMachineModelBedMetadata(
|
|
427
|
+
machineFlat: Record<string, unknown>,
|
|
428
|
+
index: NameIndex
|
|
429
|
+
): void {
|
|
430
|
+
const modelName = machineFlat.printer_model;
|
|
431
|
+
if (typeof modelName !== "string") return;
|
|
432
|
+
|
|
433
|
+
const model = index.get(modelName)?.data;
|
|
434
|
+
if (!model) return;
|
|
435
|
+
|
|
436
|
+
for (const key of ["default_bed_type", "image_bed_type", "not_support_bed_type"]) {
|
|
437
|
+
if (!(key in machineFlat) && key in model) {
|
|
438
|
+
machineFlat[key] = model[key];
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* -------------------------------------------------------------------------- */
|
|
444
|
+
/* Public entry point */
|
|
445
|
+
/* -------------------------------------------------------------------------- */
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Flatten the leaf profiles, post-process for CLI, and write to temp files.
|
|
449
|
+
*
|
|
450
|
+
* Throws on unknown leaf names, missing profilesRoot, or cycles.
|
|
451
|
+
*/
|
|
452
|
+
export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProfiles> {
|
|
453
|
+
const vendor = opts.vendor ?? "BBL";
|
|
454
|
+
if (vendor !== "BBL") {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`profile-flatten: only BBL vendor is supported (got "${vendor}"). ` +
|
|
457
|
+
`Other vendors are out of scope -- contributions welcome but untested.`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Confirm the profiles root looks plausible before doing real work.
|
|
462
|
+
const probe = path.join(opts.profilesRoot, vendor, "machine");
|
|
463
|
+
try {
|
|
464
|
+
await fs.access(probe);
|
|
465
|
+
} catch {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`profile-flatten: profilesRoot "${opts.profilesRoot}" does not contain "${vendor}/machine". ` +
|
|
468
|
+
`Set BAMBU_PROFILES_ROOT or check your BambuStudio install.`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const index = await buildNameIndex(opts.profilesRoot, vendor);
|
|
473
|
+
|
|
474
|
+
// Flatten each leaf.
|
|
475
|
+
const machineFlat = flattenByName(opts.machineLeaf, index);
|
|
476
|
+
const processFlat = flattenByName(opts.processLeaf, index);
|
|
477
|
+
const filamentFlats = opts.filamentLeaves.map((n) => flattenByName(n, index));
|
|
478
|
+
|
|
479
|
+
// CLI-specific post-processing on machine profile only.
|
|
480
|
+
deriveNozzleVolumeType(machineFlat, opts.nozzleVolumeType);
|
|
481
|
+
applyMachineModelBedMetadata(machineFlat, index);
|
|
482
|
+
const cliOverlayApplied = await applyCliOverlay(machineFlat, opts.profilesRoot, vendor);
|
|
483
|
+
|
|
484
|
+
// Normalize each flattened profile for CLI consumption.
|
|
485
|
+
normalizeForCli(machineFlat, "machine", opts.machineLeaf);
|
|
486
|
+
normalizeForCli(processFlat, "process", opts.processLeaf);
|
|
487
|
+
filamentFlats.forEach((f, i) => normalizeForCli(f, "filament", opts.filamentLeaves[i]));
|
|
488
|
+
applyBedType(processFlat, opts.bedType);
|
|
489
|
+
|
|
490
|
+
// Mirror the GUI's auto-extend behavior: when the caller explicitly
|
|
491
|
+
// chose a process or filament that wasn't pre-declared compatible with
|
|
492
|
+
// the chosen machine (e.g. "0.20mm Standard @BBL P1P" used on a P1S),
|
|
493
|
+
// add the machine name to the process/filament compat list. The GUI
|
|
494
|
+
// does this implicitly when saving a project.
|
|
495
|
+
ensureMachineInCompatList(processFlat, opts.machineLeaf);
|
|
496
|
+
filamentFlats.forEach((f) => ensureMachineInCompatList(f, opts.machineLeaf));
|
|
497
|
+
|
|
498
|
+
// Write temp files. Hash the leaf name into the filename so concurrent
|
|
499
|
+
// slices for different printers don't collide.
|
|
500
|
+
await fs.mkdir(opts.tempDir, { recursive: true });
|
|
501
|
+
const machinePath = await writeTemp(opts.tempDir, "machine", opts.machineLeaf, machineFlat);
|
|
502
|
+
const processPath = await writeTemp(opts.tempDir, "process", opts.processLeaf, processFlat);
|
|
503
|
+
const filamentPaths: string[] = [];
|
|
504
|
+
for (let i = 0; i < filamentFlats.length; i++) {
|
|
505
|
+
filamentPaths.push(
|
|
506
|
+
await writeTemp(opts.tempDir, `filament-${i}`, opts.filamentLeaves[i], filamentFlats[i])
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
machinePath,
|
|
512
|
+
processPath,
|
|
513
|
+
filamentPaths,
|
|
514
|
+
meta: {
|
|
515
|
+
profilesRoot: opts.profilesRoot,
|
|
516
|
+
machineLeafName: opts.machineLeaf,
|
|
517
|
+
processLeafName: opts.processLeaf,
|
|
518
|
+
filamentLeafNames: opts.filamentLeaves,
|
|
519
|
+
cliOverlayApplied,
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function writeTemp(
|
|
525
|
+
tempDir: string,
|
|
526
|
+
kind: string,
|
|
527
|
+
leafName: string,
|
|
528
|
+
data: Record<string, unknown>
|
|
529
|
+
): Promise<string> {
|
|
530
|
+
const hash = crypto.createHash("sha1").update(leafName).digest("hex").slice(0, 8);
|
|
531
|
+
const safe = leafName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
|
|
532
|
+
const filename = `flat-${kind}-${safe}-${hash}.json`;
|
|
533
|
+
const out = path.join(tempDir, filename);
|
|
534
|
+
await fs.writeFile(out, JSON.stringify(data, null, 2), "utf8");
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* -------------------------------------------------------------------------- */
|
|
539
|
+
/* Profile root detection */
|
|
540
|
+
/* -------------------------------------------------------------------------- */
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Given the SLICER_PATH (path to BambuStudio executable), walk up to the
|
|
544
|
+
* Resources/profiles directory. Falls back to common platform paths.
|
|
545
|
+
*
|
|
546
|
+
* Override via BAMBU_PROFILES_ROOT env.
|
|
547
|
+
*/
|
|
548
|
+
export function detectProfilesRoot(slicerPath?: string): string {
|
|
549
|
+
if (process.env["BAMBU_PROFILES_ROOT"]) {
|
|
550
|
+
return process.env["BAMBU_PROFILES_ROOT"];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (slicerPath) {
|
|
554
|
+
// macOS: /Applications/BambuStudio.app/Contents/MacOS/BambuStudio
|
|
555
|
+
// -> /Applications/BambuStudio.app/Contents/Resources/profiles
|
|
556
|
+
const macGuess = path.resolve(path.dirname(slicerPath), "..", "Resources", "profiles");
|
|
557
|
+
return macGuess;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Default macOS install.
|
|
561
|
+
return "/Applications/BambuStudio.app/Contents/Resources/profiles";
|
|
562
|
+
}
|