bambu-printer-mcp 1.1.31 → 1.1.33

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.
@@ -0,0 +1,460 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { profileJsonFiles } from './profile-files.js';
5
+ // OrcaSlicer 14da40e3a7b82aa8fc88593c2cf880e66560028f:
6
+ // PresetCacheFormat.{hpp,cpp}, Config.hpp, Config.cpp::save_to_json,
7
+ // Preset.hpp::VendorProfile and Semver.hpp. Only the named v2 format is read;
8
+ // the old ordinal-based cache must never be interpreted as this format.
9
+ const MAX_CACHE_BYTES = 64 * 1024 * 1024;
10
+ const MAX_ITEMS = 100000;
11
+ const VECTOR = 0x4000;
12
+ // Nullable declarations in the same pinned PrintConfig.cpp; unknown nil values fail closed.
13
+ const NULLABLE_KEYS = new Set([
14
+ "bridge_acceleration",
15
+ "bridge_speed",
16
+ "default_acceleration",
17
+ "default_jerk",
18
+ "default_junction_deviation",
19
+ "deretract_speed_extruder_change",
20
+ "enable_overhang_speed",
21
+ "extruder_max_nozzle_count",
22
+ "filament_adaptive_volumetric_speed",
23
+ "filament_cooling_before_tower",
24
+ "filament_deretraction_speed",
25
+ "filament_flow_ratio",
26
+ "filament_flush_temp",
27
+ "filament_flush_temp_fast",
28
+ "filament_flush_volumetric_speed",
29
+ "filament_ironing_flow",
30
+ "filament_ironing_inset",
31
+ "filament_ironing_spacing",
32
+ "filament_ironing_speed",
33
+ "filament_long_retractions_when_cut",
34
+ "filament_pre_cooling_temperature",
35
+ "filament_pre_cooling_temperature_nc",
36
+ "filament_preheat_temperature_delta",
37
+ "filament_ramming_travel_time",
38
+ "filament_ramming_travel_time_nc",
39
+ "filament_ramming_volumetric_speed",
40
+ "filament_ramming_volumetric_speed_nc",
41
+ "filament_retract_after_wipe",
42
+ "filament_retract_before_wipe",
43
+ "filament_retract_length_nc",
44
+ "filament_retract_length_toolchange",
45
+ "filament_retract_lift_above",
46
+ "filament_retract_lift_below",
47
+ "filament_retract_lift_enforce",
48
+ "filament_retract_restart_extra",
49
+ "filament_retract_restart_extra_toolchange",
50
+ "filament_retract_when_changing_layer",
51
+ "filament_retraction_distances_when_cut",
52
+ "filament_retraction_length",
53
+ "filament_retraction_minimum_travel",
54
+ "filament_retraction_speed",
55
+ "filament_wipe",
56
+ "filament_wipe_distance",
57
+ "filament_z_hop",
58
+ "filament_z_hop_types",
59
+ "gap_infill_speed",
60
+ "hotend_cooling_rate",
61
+ "hotend_heating_rate",
62
+ "infill_jerk",
63
+ "initial_layer_acceleration",
64
+ "initial_layer_infill_speed",
65
+ "initial_layer_jerk",
66
+ "initial_layer_speed",
67
+ "initial_layer_travel_acceleration",
68
+ "initial_layer_travel_jerk",
69
+ "initial_layer_travel_speed",
70
+ "inner_wall_acceleration",
71
+ "inner_wall_jerk",
72
+ "inner_wall_speed",
73
+ "internal_bridge_speed",
74
+ "internal_solid_infill_acceleration",
75
+ "internal_solid_infill_speed",
76
+ "long_retractions_when_ec",
77
+ "nozzle_flush_dataset",
78
+ "nozzle_type",
79
+ "nozzle_volume",
80
+ "outer_wall_acceleration",
81
+ "outer_wall_jerk",
82
+ "outer_wall_speed",
83
+ "overhang_1_4_speed",
84
+ "overhang_2_4_speed",
85
+ "overhang_3_4_speed",
86
+ "overhang_4_4_speed",
87
+ "retraction_distances_when_ec",
88
+ "slowdown_for_curled_perimeters",
89
+ "small_perimeter_speed",
90
+ "small_perimeter_threshold",
91
+ "sparse_infill_acceleration",
92
+ "sparse_infill_speed",
93
+ "support_interface_speed",
94
+ "support_speed",
95
+ "top_solid_infill_flow_ratio",
96
+ "top_surface_acceleration",
97
+ "top_surface_jerk",
98
+ "top_surface_speed",
99
+ "travel_acceleration",
100
+ "travel_jerk",
101
+ "travel_speed",
102
+ "travel_speed_z",
103
+ ]);
104
+ const fail = (message) => { throw new Error(`Orca profile cache: ${message}`); };
105
+ class Reader {
106
+ constructor(bytes) {
107
+ this.bytes = bytes;
108
+ this.offset = 0;
109
+ }
110
+ take(size) {
111
+ if (!Number.isSafeInteger(size) || size < 0 || size > this.bytes.length - this.offset)
112
+ fail('truncated or invalid length');
113
+ const result = this.bytes.subarray(this.offset, this.offset + size);
114
+ this.offset += size;
115
+ return result;
116
+ }
117
+ u8() { return this.take(1).readUInt8(); }
118
+ u16() { return this.take(2).readUInt16LE(); }
119
+ u32() { return this.take(4).readUInt32LE(); }
120
+ i32() { return this.take(4).readInt32LE(); }
121
+ u64() {
122
+ const value = this.take(8).readBigUInt64LE();
123
+ if (value > BigInt(Number.MAX_SAFE_INTEGER))
124
+ fail('length exceeds safe range');
125
+ return Number(value);
126
+ }
127
+ count() {
128
+ const n = this.u64();
129
+ if (n > MAX_ITEMS || n > this.bytes.length - this.offset)
130
+ fail('collection length out of bounds');
131
+ return n;
132
+ }
133
+ string() {
134
+ const raw = this.take(this.u64());
135
+ try {
136
+ return new TextDecoder('utf-8', { fatal: true }).decode(raw);
137
+ }
138
+ catch {
139
+ return fail('invalid UTF-8');
140
+ }
141
+ }
142
+ strings() { return this.array(() => this.string()); }
143
+ array(read) { return Array.from({ length: this.count() }, read); }
144
+ double() { return this.take(8).readDoubleLE(); }
145
+ }
146
+ function crc32(bytes) {
147
+ let crc = 0xffffffff;
148
+ for (const byte of bytes) {
149
+ crc ^= byte;
150
+ for (let bit = 0; bit < 8; bit++)
151
+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
152
+ }
153
+ return (crc ^ 0xffffffff) >>> 0;
154
+ }
155
+ /** Read raw source diffs, not merged profiles: inheritance/includes still use the shared resolver. */
156
+ export function decodeOrcaBblCache(bytes) {
157
+ if (bytes.length < 20 || bytes.length > MAX_CACHE_BYTES)
158
+ fail('file size out of bounds');
159
+ const header = new Reader(bytes);
160
+ if (header.u32() !== 0x4f52435a)
161
+ fail('unknown magic');
162
+ if (header.u32() !== 2)
163
+ fail('unsupported format version');
164
+ if (header.u64() !== bytes.length - 20)
165
+ fail('body size does not match file');
166
+ const expectedCrc = header.u32();
167
+ const body = bytes.subarray(20);
168
+ if (crc32(body) !== expectedCrc)
169
+ fail('CRC checksum mismatch');
170
+ const r = new Reader(body);
171
+ if (r.u32() !== 2)
172
+ fail('unsupported body version');
173
+ if (r.string() !== 'BBL')
174
+ fail('expected BBL vendor');
175
+ const version = r.string();
176
+ if (!/^\d+(?:\.\d+){2,3}(?:[-+][\w.-]+)?$/.test(version))
177
+ fail('invalid vendor version');
178
+ const keys = r.strings();
179
+ const types = r.array(() => r.u16());
180
+ const enums = r.strings();
181
+ if (keys.length !== types.length || keys.length > 65535 || enums.length > 65535 || enums[0] !== '')
182
+ fail('invalid option dictionary');
183
+ if (new Set(keys).size !== keys.length || keys.some(key => !key || ['__proto__', 'constructor', 'prototype'].includes(key)))
184
+ fail('invalid option name');
185
+ // VendorMap is std::map<string, VendorProfile>, serialized by cereal in declaration order.
186
+ const modelsMetadata = [];
187
+ const vendors = r.count();
188
+ let ownVendor = false;
189
+ let vendorProfileVersion = "";
190
+ for (let i = 0; i < vendors; i++) {
191
+ const mapKey = r.string();
192
+ r.string(); // display name
193
+ const id = r.string();
194
+ const profileVersion = r.string();
195
+ if (mapKey === 'BBL' && id === 'BBL' && profileVersion) {
196
+ ownVendor = true;
197
+ vendorProfileVersion = profileVersion;
198
+ }
199
+ r.string();
200
+ r.string(); // update/changelog URLs
201
+ const models = r.count();
202
+ for (let j = 0; j < models; j++) {
203
+ const modelId = r.string();
204
+ const modelName = r.string();
205
+ r.string();
206
+ r.u8();
207
+ r.string();
208
+ r.strings();
209
+ r.strings();
210
+ const unsupportedBeds = r.strings();
211
+ r.string();
212
+ r.string();
213
+ const imageBedType = r.string();
214
+ for (let k = 0; k < 6; k++)
215
+ r.string();
216
+ if (mapKey === 'BBL' && modelName) {
217
+ modelsMetadata.push({ subPath: `machine/__models__/${encodeURIComponent(modelId)}.json`,
218
+ data: { name: modelName, type: 'machine_model',
219
+ ...(imageBedType ? { image_bed_type: imageBedType } : {}),
220
+ ...(unsupportedBeds.length ? { not_support_bed_type: unsupportedBeds.join(';') } : {}) } });
221
+ }
222
+ }
223
+ r.strings();
224
+ r.strings(); // default filament/material sets
225
+ }
226
+ if (!ownVendor)
227
+ fail('cache does not carry its BBL vendor profile');
228
+ const number = (value, nullable = false) => {
229
+ if (nullable && Number.isNaN(value))
230
+ return 'nil';
231
+ if (!Number.isFinite(value))
232
+ return fail('non-finite numeric option');
233
+ return String(value);
234
+ };
235
+ const point = (dimension, separator) => Array.from({ length: dimension }, () => number(r.double())).join(separator);
236
+ const scalar = (type, vector) => {
237
+ switch (type) {
238
+ case 1: return number(r.double(), vector);
239
+ case 2: {
240
+ const n = r.i32();
241
+ return vector && n === 2147483647 ? 'nil' : String(n);
242
+ }
243
+ case 3: return r.string();
244
+ case 4: {
245
+ const n = r.double();
246
+ return Number.isNaN(n) && vector ? 'nil' : `${number(n)}%`;
247
+ }
248
+ case 5: {
249
+ const n = r.double();
250
+ const percent = r.u8();
251
+ if (percent > 1)
252
+ return fail('invalid percent flag');
253
+ return Number.isNaN(n) && vector ? 'nil' : number(n) + (percent ? '%' : '');
254
+ }
255
+ case 6: return point(2, vector ? 'x' : ',');
256
+ case 7: return point(3, ',');
257
+ case 8: {
258
+ const n = r.u8();
259
+ if (vector && n === 255)
260
+ return 'nil';
261
+ if (n > 1)
262
+ return fail('invalid boolean option');
263
+ return String(n);
264
+ }
265
+ default: return fail(`unsupported option type ${type}`);
266
+ }
267
+ };
268
+ const option = (type) => {
269
+ if (type === 9 || type === VECTOR + 9) {
270
+ const count = r.u32();
271
+ if (count > MAX_ITEMS || (type === 9 && count !== 1))
272
+ fail('invalid enum count');
273
+ const values = Array.from({ length: count }, () => {
274
+ const index = r.u16();
275
+ if (index >= enums.length)
276
+ return fail('enum index out of bounds');
277
+ if (index !== 0)
278
+ return enums[index];
279
+ const value = r.i32();
280
+ if (type === VECTOR + 9 && value === 2147483647)
281
+ return 'nil';
282
+ return fail('unnamed enum cannot be exported safely');
283
+ });
284
+ return type === 9 ? values[0] : values;
285
+ }
286
+ if (type === VECTOR + 10)
287
+ return r.array(() => r.array(() => point(2, 'x')).join(','));
288
+ if (type === VECTOR + 11)
289
+ return r.array(() => r.array(() => String(r.i32())).join(','));
290
+ if (type & VECTOR)
291
+ return r.array(() => scalar(type - VECTOR, true));
292
+ return scalar(type, false);
293
+ };
294
+ const profiles = [...modelsMetadata];
295
+ const names = new Set(profiles.map(p => String(p.data.name)));
296
+ const paths = new Set(profiles.map(p => p.subPath.toLowerCase()));
297
+ for (const kind of ['process', 'filament', 'machine']) {
298
+ const count = r.u32();
299
+ if (count > MAX_ITEMS)
300
+ fail('preset count out of bounds');
301
+ for (let i = 0; i < count; i++) {
302
+ const name = r.string();
303
+ const subPath = r.string();
304
+ if (!name || names.has(name))
305
+ fail('duplicate/empty preset name');
306
+ const normalized = subPath.replace(/\\/g, '/');
307
+ if (!normalized.startsWith(`${kind}/`) || !normalized.endsWith('.json') || normalized.split('/').some(p => !p || p === '.' || p === '..') || normalized.includes(':') || paths.has(normalized.toLowerCase()))
308
+ fail('invalid or colliding preset path');
309
+ names.add(name);
310
+ paths.add(normalized.toLowerCase());
311
+ const data = Object.create(null);
312
+ const configCount = r.u32();
313
+ const seen = new Set();
314
+ if (configCount > keys.length)
315
+ fail('option count out of bounds');
316
+ for (let j = 0; j < configCount; j++) {
317
+ const key = r.u16();
318
+ if (key >= keys.length || seen.has(key))
319
+ fail('invalid/duplicate option index');
320
+ seen.add(key);
321
+ const value = option(types[key]);
322
+ if (types[key] !== VECTOR + 3 && Array.isArray(value) && value.includes('nil') && !NULLABLE_KEYS.has(keys[key]))
323
+ fail(`unexpected nil for ${keys[key]}`);
324
+ data[keys[key]] = value;
325
+ }
326
+ const inherits = r.string();
327
+ const includes = r.strings();
328
+ const description = r.string();
329
+ const instantiation = r.string();
330
+ const settingId = r.string();
331
+ const filamentId = r.string();
332
+ const renamed = r.strings();
333
+ Object.assign(data, { name, type: kind, from: 'system', version: vendorProfileVersion, instantiation, inherits });
334
+ if (includes.length)
335
+ data.include = includes;
336
+ if (description)
337
+ data.description = description;
338
+ if (settingId)
339
+ data.setting_id = settingId;
340
+ if (filamentId)
341
+ data.filament_id = filamentId;
342
+ if (renamed.length)
343
+ data.renamed_from = renamed.map(value => JSON.stringify(value)).join(';');
344
+ profiles.push({ subPath: normalized, data });
345
+ }
346
+ }
347
+ if (r.u64() !== 0)
348
+ fail('vendor cache records profile parse errors');
349
+ if (r.offset !== body.length)
350
+ fail('unexpected trailing cache data');
351
+ return profiles;
352
+ }
353
+ /** Expand only the selected installation's cache in the owning server's private temp directory. */
354
+ export async function materializeOrcaProfilesRoot(profilesRoot, tempDir) {
355
+ if (profileJsonFiles(path.join(profilesRoot, 'BBL', 'machine')).length)
356
+ return profilesRoot;
357
+ const cachePath = path.join(profilesRoot, 'BBL.opc');
358
+ let stat;
359
+ try {
360
+ stat = await fs.stat(cachePath);
361
+ }
362
+ catch (error) {
363
+ if (error.code === 'ENOENT')
364
+ return profilesRoot;
365
+ throw error;
366
+ }
367
+ if (stat.size > MAX_CACHE_BYTES)
368
+ fail('file size out of bounds');
369
+ let cli;
370
+ try {
371
+ cli = await fs.readFile(path.join(profilesRoot, 'BBL', 'cli_config.json'));
372
+ }
373
+ catch {
374
+ return fail('selected installation is missing BBL/cli_config.json; update Orca or supply its exact matching profile tree');
375
+ }
376
+ const parsed = JSON.parse(cli.toString('utf8'));
377
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
378
+ fail('invalid BBL/cli_config.json');
379
+ const cache = await fs.readFile(cachePath);
380
+ const profiles = decodeOrcaBblCache(cache);
381
+ const digest = createHash('sha256').update(cache).update(cli).digest('hex');
382
+ const output = await fs.mkdtemp(path.join(tempDir, `orca-profiles-${digest.slice(0, 16)}-`));
383
+ try {
384
+ await fs.mkdir(path.join(output, 'BBL'), { recursive: true });
385
+ await fs.writeFile(path.join(output, 'BBL', 'cli_config.json'), cli, { flag: 'wx' });
386
+ await fs.writeFile(path.join(output, 'BBL.opc'), cache, { flag: 'wx' });
387
+ for (const profile of profiles) {
388
+ const file = path.join(output, 'BBL', profile.subPath);
389
+ await fs.mkdir(path.dirname(file), { recursive: true });
390
+ await fs.writeFile(file, JSON.stringify(profile.data), { flag: 'wx' });
391
+ }
392
+ return output;
393
+ }
394
+ catch (error) {
395
+ await fs.rm(output, { recursive: true, force: true });
396
+ throw error;
397
+ }
398
+ }
399
+ /** Orca v2 cache builds resolve User presets by loaded file identity, not just inherits. */
400
+ export async function stageOrcaCliProfiles(profilesRoot, tempDir, bundle) {
401
+ const settings = bundle.settingsArg?.split(';') ?? [];
402
+ if (settings.length !== 2)
403
+ fail('complete machine and process settings are required');
404
+ const root = await fs.mkdtemp(path.join(tempDir, 'orca-cli-'));
405
+ try {
406
+ await fs.mkdir(path.join(root, 'system'));
407
+ await fs.copyFile(path.join(profilesRoot, 'BBL.opc'), path.join(root, 'system', 'BBL.opc'));
408
+ const profiles = decodeOrcaBblCache(await fs.readFile(path.join(profilesRoot, 'BBL.opc')));
409
+ const parents = new Set(profiles.map(p => `${p.data.type}:${p.data.name}`));
410
+ const version = profiles.find(p => p.data.type === 'machine')?.data.version;
411
+ if (typeof version !== 'string')
412
+ fail('missing vendor profile version');
413
+ const output = [];
414
+ const colours = [];
415
+ const extras = [];
416
+ for (const [index, file] of [...settings, ...bundle.filamentPaths].entries()) {
417
+ const kind = index === 0 ? 'machine' : index === 1 ? 'process' : 'filament';
418
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
419
+ if (kind === 'filament') {
420
+ const colour = Array.isArray(data.filament_colour) ? data.filament_colour[0] : undefined;
421
+ if (typeof colour !== 'string' || !/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(colour))
422
+ fail('missing positional filament colour');
423
+ colours.push(colour);
424
+ }
425
+ if (kind === 'process') {
426
+ if (typeof data.curr_bed_type === 'string')
427
+ extras.push('--curr-bed-type', data.curr_bed_type);
428
+ for (const key of ['wipe_tower_x', 'wipe_tower_y']) {
429
+ if (data[key] === undefined)
430
+ continue;
431
+ const values = Array.isArray(data[key]) ? data[key] : [data[key]];
432
+ if (!values.length || values.some(v => !Number.isFinite(Number(v))))
433
+ fail('invalid saved tower position');
434
+ extras.push(`--${key.replace(/_/g, '-')}`, values.join(','));
435
+ }
436
+ }
437
+ // The selected machine's system identity remains the validated cache leaf.
438
+ // A fully resolved custom process/filament may be a standalone User root.
439
+ if (!parents.has(`${kind}:${data.inherits}`)) {
440
+ if (kind === 'machine')
441
+ fail('selected machine parent is absent from the exact cache');
442
+ delete data.inherits;
443
+ }
444
+ const digest = createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 16);
445
+ const name = `mcp_${kind}_${index}_${digest}`;
446
+ Object.assign(data, { name, from: 'User', version });
447
+ const directory = path.join(root, 'user', 'default', kind);
448
+ await fs.mkdir(directory, { recursive: true });
449
+ const staged = path.join(directory, `${name}.json`);
450
+ await fs.writeFile(staged, JSON.stringify(data), { flag: 'wx' });
451
+ output.push(staged);
452
+ }
453
+ return { settingsArg: output.slice(0, 2).join(';'), filamentPaths: output.slice(2),
454
+ extraArgs: ['--datadir', root, ...extras, '--filament-colour', colours.join(';')], dataDir: root };
455
+ }
456
+ catch (error) {
457
+ await fs.rm(root, { recursive: true, force: true });
458
+ throw error;
459
+ }
460
+ }
@@ -674,7 +674,7 @@ export function detectProfilesRoot(slicerPath, slicerType = 'bambustudio') {
674
674
  path.resolve(bin, '..', 'share', name, 'resources', 'profiles'),
675
675
  ]),
676
676
  ];
677
- const found = candidates.find(root => existsSync(path.join(root, 'BBL', 'machine')));
677
+ const found = candidates.find(root => (existsSync(path.join(root, 'BBL', 'machine')) || existsSync(path.join(root, 'BBL.opc'))));
678
678
  // Do not select another installation if this executable has no profile tree.
679
679
  // flattenForCli reports the missing tree and asks for BAMBU_PROFILES_ROOT.
680
680
  return found ?? candidates[0];
@@ -11,6 +11,7 @@ import * as crypto from 'crypto';
11
11
  import { execFile } from 'child_process';
12
12
  import { flattenForCli, detectProfilesRoot, resolveBblMachineProfile } from '../slicer/profile-flatten.js';
13
13
  import { profileJsonFiles } from '../slicer/profile-files.js';
14
+ import { materializeOrcaProfilesRoot, stageOrcaCliProfiles } from '../slicer/orca-profile-cache.js';
14
15
  import { sliceWithOrcaApi, validateRemoteSliceOptions } from '../slicer/remote-slicer.js';
15
16
  const readFileAsync = promisify(fs.readFile);
16
17
  const writeFileAsync = promisify(fs.writeFile);
@@ -184,7 +185,7 @@ export class STLManipulator extends EventEmitter {
184
185
  }
185
186
  return value;
186
187
  }
187
- sanitizeProcessForOrca(processConfig, printerPreset) {
188
+ sanitizeProcessForOrca(processConfig, printerPreset, preserveExtrusionMode = false) {
188
189
  const sanitized = { ...processConfig };
189
190
  sanitized.type = 'process';
190
191
  if (!sanitized.from || sanitized.from === 'project') {
@@ -200,7 +201,7 @@ export class STLManipulator extends EventEmitter {
200
201
  else if (sanitized.prime_tower_brim_width !== undefined) {
201
202
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
202
203
  }
203
- return this.useAbsoluteExtrusionForOrca(sanitized);
204
+ return preserveExtrusionMode ? sanitized : this.useAbsoluteExtrusionForOrca(sanitized);
204
205
  }
205
206
  /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
206
207
  useAbsoluteExtrusionForOrca(config) {
@@ -218,7 +219,7 @@ export class STLManipulator extends EventEmitter {
218
219
  fs.writeFileSync(outPath, serialized);
219
220
  return outPath;
220
221
  }
221
- async resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, activeProfilesRoot) {
222
+ async resolveBambuLikeSettingsBundle(outputBase, slicerType, slicerProfile, printerPreset, bambuOptions, activeProfilesRoot, preserveOrcaExtrusionMode = false) {
222
223
  const roots = activeProfilesRoot
223
224
  ? [path.join(activeProfilesRoot, 'BBL')]
224
225
  : this.getAvailableProfileRoots();
@@ -279,7 +280,7 @@ export class STLManipulator extends EventEmitter {
279
280
  type: 'process',
280
281
  };
281
282
  processPath = this.writeTempJson(outputBase, slicerType === 'orcaslicer' ? 'process_orca' : 'process', slicerType === 'orcaslicer'
282
- ? this.sanitizeProcessForOrca(mergedProcess, printerPreset)
283
+ ? this.sanitizeProcessForOrca(mergedProcess, printerPreset, preserveOrcaExtrusionMode)
283
284
  : mergedProcess);
284
285
  const defaultFilamentProfiles = !bambuOptions?.loadFilaments && Array.isArray(parsedProfile.default_filament_profile)
285
286
  ? parsedProfile.default_filament_profile
@@ -317,7 +318,7 @@ export class STLManipulator extends EventEmitter {
317
318
  if (defaultProcessPath) {
318
319
  processPath =
319
320
  slicerType === 'orcaslicer'
320
- ? this.writeTempJson(outputBase, 'process_default_orca', this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset))
321
+ ? this.writeTempJson(outputBase, 'process_default_orca', this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset, preserveOrcaExtrusionMode))
321
322
  : defaultProcessPath;
322
323
  }
323
324
  }
@@ -1240,6 +1241,7 @@ export class STLManipulator extends EventEmitter {
1240
1241
  const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
1241
1242
  let outputFilePath = path.join(this.tempDir, outputFileName);
1242
1243
  let args = [];
1244
+ const orcaTemporaryDirectories = [];
1243
1245
  try {
1244
1246
  if (progressCallback)
1245
1247
  progressCallback(0, `Starting slicing with ${slicerType}...`);
@@ -1291,8 +1293,13 @@ export class STLManipulator extends EventEmitter {
1291
1293
  const profileSlicerType = remote ? 'orcaslicer' : slicerType;
1292
1294
  const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
1293
1295
  const outputDir = path.dirname(bambuOutputPath);
1294
- const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1295
- const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, profileSlicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot);
1296
+ const selectedProfilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1297
+ const profilesRoot = !remote && profileSlicerType === 'orcaslicer'
1298
+ ? await materializeOrcaProfilesRoot(selectedProfilesRoot, this.tempDir)
1299
+ : selectedProfilesRoot;
1300
+ if (profilesRoot !== selectedProfilesRoot)
1301
+ orcaTemporaryDirectories.push(profilesRoot);
1302
+ const rawBundle = await this.resolveBambuLikeSettingsBundle(outputBase, profileSlicerType, slicerProfile, printerPreset, bambuOptions, profilesRoot, profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot);
1296
1303
  // Resolve every BBL dependency before launching the slicer.
1297
1304
  const settingsBundle = await this.maybeFlattenBundle(await this.expandProjectFilaments(stlFilePath, rawBundle), bambuOptions, profilesRoot);
1298
1305
  // The machine preset assumes the stock hotend; record the installed one so
@@ -1312,14 +1319,21 @@ export class STLManipulator extends EventEmitter {
1312
1319
  // absolute-extrusion normalization after resolving those ancestors.
1313
1320
  // The machine owns the extrusion mode and layer G-code once process
1314
1321
  // files lose machine keys, so normalize it there as well.
1315
- if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1322
+ if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg && profilesRoot === selectedProfilesRoot) {
1316
1323
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1317
1324
  settingsBundle.settingsArg = [
1318
1325
  this.writeTempJson(outputBase, 'machine_orca_resolved', this.useAbsoluteExtrusionForOrca(this.readJsonFile(machinePath))),
1319
1326
  this.writeTempJson(outputBase, 'process_orca_resolved', this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1320
1327
  ].join(';');
1321
1328
  }
1322
- const cliBundle = this.isolateMachineSettings(settingsBundle);
1329
+ let cliBundle = this.isolateMachineSettings(settingsBundle);
1330
+ let orcaExtraArgs = [];
1331
+ if (!remote && profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot) {
1332
+ const staged = await stageOrcaCliProfiles(profilesRoot, this.tempDir, cliBundle);
1333
+ cliBundle = { ...cliBundle, settingsArg: staged.settingsArg, filamentPaths: staged.filamentPaths };
1334
+ orcaExtraArgs = staged.extraArgs;
1335
+ orcaTemporaryDirectories.push(staged.dataDir);
1336
+ }
1323
1337
  if (remote) {
1324
1338
  const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
1325
1339
  if (!machinePath || !processPath)
@@ -1332,6 +1346,7 @@ export class STLManipulator extends EventEmitter {
1332
1346
  break;
1333
1347
  }
1334
1348
  args = [
1349
+ ...orcaExtraArgs,
1335
1350
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1336
1351
  '--outputdir', outputDir,
1337
1352
  '--export-3mf', path.basename(bambuOutputPath),
@@ -1455,6 +1470,11 @@ export class STLManipulator extends EventEmitter {
1455
1470
  }
1456
1471
  finally {
1457
1472
  this.activeOperations.delete(operationId);
1473
+ for (const directory of orcaTemporaryDirectories.reverse()) {
1474
+ await fs.promises.rm(directory, { recursive: true, force: true }).catch(error => {
1475
+ console.error('Could not remove temporary Orca profiles:', error);
1476
+ });
1477
+ }
1458
1478
  }
1459
1479
  }
1460
1480
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bambu-printer-mcp",
3
- "version": "1.1.31",
3
+ "version": "1.1.33",
4
4
  "description": "MCP server for Bambu Lab 3D printers with STL manipulation, slicing, and print management",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -26,6 +26,8 @@ export type BambuNativePrintOptions = {
26
26
  vibrationCalibration?: boolean;
27
27
  layerInspect?: boolean;
28
28
  timelapse?: boolean;
29
+ /** true: the SD card or USB stick; false: internal storage (eMMC); unset: BAMBU_NATIVE_TRY_EMMC_PRINT or internal. Reported as the requested storage. */
30
+ externalStorage?: boolean;
29
31
  };
30
32
 
31
33
  export type BambuNativeControlOptions = {
@@ -864,6 +866,7 @@ export async function printWithBambuNative(
864
866
  BAMBU_NATIVE_VIBRATION_CALIBRATION: boolEnv(options.vibrationCalibration, true),
865
867
  BAMBU_NATIVE_LAYER_INSPECT: boolEnv(options.layerInspect, false),
866
868
  BAMBU_NATIVE_TIMELAPSE: boolEnv(options.timelapse, false),
869
+ ...(options.externalStorage === undefined ? {} : { BAMBU_NATIVE_TRY_EMMC_PRINT: String(!options.externalStorage) }),
867
870
  },
868
871
  300_000,
869
872
  onUpdate,
@@ -885,6 +888,7 @@ export async function printWithBambuNative(
885
888
  return {
886
889
  status: "success",
887
890
  route: "bambu:///local",
891
+ ...(options.externalStorage === undefined ? {} : { storage: options.externalStorage ? "external" : "internal" }),
888
892
  updates: result.updates,
889
893
  };
890
894
  }