bambu-printer-mcp 1.1.32 → 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.
package/CONTRIBUTORS.md CHANGED
@@ -6,6 +6,8 @@ Thank you to everyone who builds, tests, reports problems, and shares real print
6
6
 
7
7
  | Contributor | Contribution |
8
8
  | --- | --- |
9
+ | [bwees](https://github.com/bwees) | Reports and fixes missing non-preset vendor data in [OrcaSlicer #16700](https://github.com/OrcaSlicer/OrcaSlicer/issues/16700) and [#16706](https://github.com/OrcaSlicer/OrcaSlicer/pull/16706), restoring the authoritative BBL CLI matrix required by cache-based MCP slicing. |
10
+ | [ExPikaPaka](https://github.com/ExPikaPaka), [raistlin7447](https://github.com/raistlin7447), [HanifKoh](https://github.com/HanifKoh), and [SoftFever](https://github.com/SoftFever) | Provide and maintain OrcaSlicer's published preset-cache format, source-profile inheritance and include handling used as the contract for this independent cache reader. Credit covers upstream source and format evidence; it does not claim they tested this MCP integration. |
9
11
  | [mygrexit](https://github.com/mygrexit) | Contributes pinned x86-64 Linux X2D native setup, emergency routing and one-shot cleanup in [#56](https://github.com/DMontgomery40/bambu-printer-mcp/pull/56), checked Orca/time-lapse G-code forms in [#57](https://github.com/DMontgomery40/bambu-printer-mcp/pull/57), and model-specific hidden-HMS notices in [#58](https://github.com/DMontgomery40/bambu-printer-mcp/pull/58). Supplies regressions, review corrections, and contributor-reported Debian/X2D read-only, heater-off and print-start evidence in [#55](https://github.com/DMontgomery40/bambu-printer-mcp/issues/55); maintainer validation is offline and mocked. Also reports and fixes hanging Linux X2D stop/heater-off calls in [#61](https://github.com/DMontgomery40/bambu-printer-mcp/pull/61), with never-connecting transport and helper-exit regressions; the release adds protection against expired commands dispatching after reconnection. In [#64](https://github.com/DMontgomery40/bambu-printer-mcp/pull/64), contributes ready-card native job storage, fresh dispatch rechecks and tests, and reports Linux X2D firmware 01.01.01.00 printing to external storage with plug-in 02.08.02.54 and a Bambuddy thumbnail; maintainer verification uses source and mocked transports. |
10
12
  | [maziggy](https://github.com/maziggy) | Proposes choosing ready external storage by default in [Bambu Studio #12146](https://github.com/bambulab/BambuStudio/pull/12146), a related upstream proposal supporting the storage choice in [#64](https://github.com/DMontgomery40/bambu-printer-mcp/pull/64). The upstream proposal remains unmerged; this credit does not claim it shipped in Bambu Studio. |
11
13
  | [patrikmadoran-cyber](https://github.com/patrikmadoran-cyber) | Adds explicit Standard/High Flow nozzle selection across slicing and print auto-slicing in [#54](https://github.com/DMontgomery40/bambu-printer-mcp/pull/54), with unsupported-variant regressions and real Windows BambuStudio 02.06.00.51 H2S 0.6 slicing evidence. No physical print is claimed. |
@@ -0,0 +1,18 @@
1
+ interface CachedProfile {
2
+ subPath: string;
3
+ data: Record<string, unknown>;
4
+ }
5
+ /** Read raw source diffs, not merged profiles: inheritance/includes still use the shared resolver. */
6
+ export declare function decodeOrcaBblCache(bytes: Buffer): CachedProfile[];
7
+ /** Expand only the selected installation's cache in the owning server's private temp directory. */
8
+ export declare function materializeOrcaProfilesRoot(profilesRoot: string, tempDir: string): Promise<string>;
9
+ interface CliProfiles {
10
+ settingsArg?: string;
11
+ filamentPaths: string[];
12
+ }
13
+ /** Orca v2 cache builds resolve User presets by loaded file identity, not just inherits. */
14
+ export declare function stageOrcaCliProfiles(profilesRoot: string, tempDir: string, bundle: CliProfiles): Promise<CliProfiles & {
15
+ extraArgs: string[];
16
+ dataDir: string;
17
+ }>;
18
+ export {};
@@ -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.32",
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": {
@@ -0,0 +1,379 @@
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
+
6
+ // OrcaSlicer 14da40e3a7b82aa8fc88593c2cf880e66560028f:
7
+ // PresetCacheFormat.{hpp,cpp}, Config.hpp, Config.cpp::save_to_json,
8
+ // Preset.hpp::VendorProfile and Semver.hpp. Only the named v2 format is read;
9
+ // the old ordinal-based cache must never be interpreted as this format.
10
+ const MAX_CACHE_BYTES = 64 * 1024 * 1024;
11
+ const MAX_ITEMS = 100_000;
12
+ const VECTOR = 0x4000;
13
+ // Nullable declarations in the same pinned PrintConfig.cpp; unknown nil values fail closed.
14
+ const NULLABLE_KEYS = new Set([
15
+ "bridge_acceleration",
16
+ "bridge_speed",
17
+ "default_acceleration",
18
+ "default_jerk",
19
+ "default_junction_deviation",
20
+ "deretract_speed_extruder_change",
21
+ "enable_overhang_speed",
22
+ "extruder_max_nozzle_count",
23
+ "filament_adaptive_volumetric_speed",
24
+ "filament_cooling_before_tower",
25
+ "filament_deretraction_speed",
26
+ "filament_flow_ratio",
27
+ "filament_flush_temp",
28
+ "filament_flush_temp_fast",
29
+ "filament_flush_volumetric_speed",
30
+ "filament_ironing_flow",
31
+ "filament_ironing_inset",
32
+ "filament_ironing_spacing",
33
+ "filament_ironing_speed",
34
+ "filament_long_retractions_when_cut",
35
+ "filament_pre_cooling_temperature",
36
+ "filament_pre_cooling_temperature_nc",
37
+ "filament_preheat_temperature_delta",
38
+ "filament_ramming_travel_time",
39
+ "filament_ramming_travel_time_nc",
40
+ "filament_ramming_volumetric_speed",
41
+ "filament_ramming_volumetric_speed_nc",
42
+ "filament_retract_after_wipe",
43
+ "filament_retract_before_wipe",
44
+ "filament_retract_length_nc",
45
+ "filament_retract_length_toolchange",
46
+ "filament_retract_lift_above",
47
+ "filament_retract_lift_below",
48
+ "filament_retract_lift_enforce",
49
+ "filament_retract_restart_extra",
50
+ "filament_retract_restart_extra_toolchange",
51
+ "filament_retract_when_changing_layer",
52
+ "filament_retraction_distances_when_cut",
53
+ "filament_retraction_length",
54
+ "filament_retraction_minimum_travel",
55
+ "filament_retraction_speed",
56
+ "filament_wipe",
57
+ "filament_wipe_distance",
58
+ "filament_z_hop",
59
+ "filament_z_hop_types",
60
+ "gap_infill_speed",
61
+ "hotend_cooling_rate",
62
+ "hotend_heating_rate",
63
+ "infill_jerk",
64
+ "initial_layer_acceleration",
65
+ "initial_layer_infill_speed",
66
+ "initial_layer_jerk",
67
+ "initial_layer_speed",
68
+ "initial_layer_travel_acceleration",
69
+ "initial_layer_travel_jerk",
70
+ "initial_layer_travel_speed",
71
+ "inner_wall_acceleration",
72
+ "inner_wall_jerk",
73
+ "inner_wall_speed",
74
+ "internal_bridge_speed",
75
+ "internal_solid_infill_acceleration",
76
+ "internal_solid_infill_speed",
77
+ "long_retractions_when_ec",
78
+ "nozzle_flush_dataset",
79
+ "nozzle_type",
80
+ "nozzle_volume",
81
+ "outer_wall_acceleration",
82
+ "outer_wall_jerk",
83
+ "outer_wall_speed",
84
+ "overhang_1_4_speed",
85
+ "overhang_2_4_speed",
86
+ "overhang_3_4_speed",
87
+ "overhang_4_4_speed",
88
+ "retraction_distances_when_ec",
89
+ "slowdown_for_curled_perimeters",
90
+ "small_perimeter_speed",
91
+ "small_perimeter_threshold",
92
+ "sparse_infill_acceleration",
93
+ "sparse_infill_speed",
94
+ "support_interface_speed",
95
+ "support_speed",
96
+ "top_solid_infill_flow_ratio",
97
+ "top_surface_acceleration",
98
+ "top_surface_jerk",
99
+ "top_surface_speed",
100
+ "travel_acceleration",
101
+ "travel_jerk",
102
+ "travel_speed",
103
+ "travel_speed_z",
104
+ ]);
105
+ const fail = (message: string): never => { throw new Error(`Orca profile cache: ${message}`); };
106
+
107
+ class Reader {
108
+ offset = 0;
109
+ constructor(readonly bytes: Buffer) {}
110
+ take(size: number): Buffer {
111
+ if (!Number.isSafeInteger(size) || size < 0 || size > this.bytes.length - this.offset) fail('truncated or invalid length');
112
+ const result = this.bytes.subarray(this.offset, this.offset + size);
113
+ this.offset += size;
114
+ return result;
115
+ }
116
+ u8(): number { return this.take(1).readUInt8(); }
117
+ u16(): number { return this.take(2).readUInt16LE(); }
118
+ u32(): number { return this.take(4).readUInt32LE(); }
119
+ i32(): number { return this.take(4).readInt32LE(); }
120
+ u64(): number {
121
+ const value = this.take(8).readBigUInt64LE();
122
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) fail('length exceeds safe range');
123
+ return Number(value);
124
+ }
125
+ count(): number {
126
+ const n = this.u64();
127
+ if (n > MAX_ITEMS || n > this.bytes.length - this.offset) fail('collection length out of bounds');
128
+ return n;
129
+ }
130
+ string(): string {
131
+ const raw = this.take(this.u64());
132
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(raw); }
133
+ catch { return fail('invalid UTF-8'); }
134
+ }
135
+ strings(): string[] { return this.array(() => this.string()); }
136
+ array<T>(read: () => T): T[] { return Array.from({ length: this.count() }, read); }
137
+ double(): number { return this.take(8).readDoubleLE(); }
138
+ }
139
+
140
+ function crc32(bytes: Buffer): number {
141
+ let crc = 0xffffffff;
142
+ for (const byte of bytes) {
143
+ crc ^= byte;
144
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
145
+ }
146
+ return (crc ^ 0xffffffff) >>> 0;
147
+ }
148
+
149
+ interface CachedProfile { subPath: string; data: Record<string, unknown>; }
150
+
151
+ /** Read raw source diffs, not merged profiles: inheritance/includes still use the shared resolver. */
152
+ export function decodeOrcaBblCache(bytes: Buffer): CachedProfile[] {
153
+ if (bytes.length < 20 || bytes.length > MAX_CACHE_BYTES) fail('file size out of bounds');
154
+ const header = new Reader(bytes);
155
+ if (header.u32() !== 0x4f52435a) fail('unknown magic');
156
+ if (header.u32() !== 2) fail('unsupported format version');
157
+ if (header.u64() !== bytes.length - 20) fail('body size does not match file');
158
+ const expectedCrc = header.u32();
159
+ const body = bytes.subarray(20);
160
+ if (crc32(body) !== expectedCrc) fail('CRC checksum mismatch');
161
+ const r = new Reader(body);
162
+ if (r.u32() !== 2) fail('unsupported body version');
163
+ if (r.string() !== 'BBL') fail('expected BBL vendor');
164
+ const version = r.string();
165
+ if (!/^\d+(?:\.\d+){2,3}(?:[-+][\w.-]+)?$/.test(version)) fail('invalid vendor version');
166
+ const keys = r.strings();
167
+ const types = r.array(() => r.u16());
168
+ const enums = r.strings();
169
+ if (keys.length !== types.length || keys.length > 65535 || enums.length > 65535 || enums[0] !== '') fail('invalid option dictionary');
170
+ if (new Set(keys).size !== keys.length || keys.some(key => !key || ['__proto__', 'constructor', 'prototype'].includes(key))) fail('invalid option name');
171
+
172
+ // VendorMap is std::map<string, VendorProfile>, serialized by cereal in declaration order.
173
+ const modelsMetadata: CachedProfile[] = [];
174
+ const vendors = r.count();
175
+ let ownVendor = false;
176
+ let vendorProfileVersion = "";
177
+ for (let i = 0; i < vendors; i++) {
178
+ const mapKey = r.string();
179
+ r.string(); // display name
180
+ const id = r.string();
181
+ const profileVersion = r.string();
182
+ if (mapKey === 'BBL' && id === 'BBL' && profileVersion) { ownVendor = true; vendorProfileVersion = profileVersion; }
183
+ r.string(); r.string(); // update/changelog URLs
184
+ const models = r.count();
185
+ for (let j = 0; j < models; j++) {
186
+ const modelId = r.string(); const modelName = r.string();
187
+ r.string(); r.u8(); r.string();
188
+ r.strings(); r.strings(); const unsupportedBeds = r.strings();
189
+ r.string(); r.string(); const imageBedType = r.string();
190
+ for (let k = 0; k < 6; k++) r.string();
191
+ if (mapKey === 'BBL' && modelName) {
192
+ modelsMetadata.push({ subPath: `machine/__models__/${encodeURIComponent(modelId)}.json`,
193
+ data: { name: modelName, type: 'machine_model',
194
+ ...(imageBedType ? { image_bed_type: imageBedType } : {}),
195
+ ...(unsupportedBeds.length ? { not_support_bed_type: unsupportedBeds.join(';') } : {}) } });
196
+ }
197
+ }
198
+ r.strings(); r.strings(); // default filament/material sets
199
+ }
200
+ if (!ownVendor) fail('cache does not carry its BBL vendor profile');
201
+
202
+ const number = (value: number, nullable = false): string => {
203
+ if (nullable && Number.isNaN(value)) return 'nil';
204
+ if (!Number.isFinite(value)) return fail('non-finite numeric option');
205
+ return String(value);
206
+ };
207
+ const point = (dimension: number, separator: string): string =>
208
+ Array.from({ length: dimension }, () => number(r.double())).join(separator);
209
+ const scalar = (type: number, vector: boolean): string => {
210
+ switch (type) {
211
+ case 1: return number(r.double(), vector);
212
+ case 2: { const n = r.i32(); return vector && n === 2147483647 ? 'nil' : String(n); }
213
+ case 3: return r.string();
214
+ case 4: { const n = r.double(); return Number.isNaN(n) && vector ? 'nil' : `${number(n)}%`; }
215
+ case 5: {
216
+ const n = r.double(); const percent = r.u8();
217
+ if (percent > 1) return fail('invalid percent flag');
218
+ return Number.isNaN(n) && vector ? 'nil' : number(n) + (percent ? '%' : '');
219
+ }
220
+ case 6: return point(2, vector ? 'x' : ',');
221
+ case 7: return point(3, ',');
222
+ case 8: {
223
+ const n = r.u8();
224
+ if (vector && n === 255) return 'nil';
225
+ if (n > 1) return fail('invalid boolean option');
226
+ return String(n);
227
+ }
228
+ default: return fail(`unsupported option type ${type}`);
229
+ }
230
+ };
231
+ const option = (type: number): string | string[] => {
232
+ if (type === 9 || type === VECTOR + 9) {
233
+ const count = r.u32();
234
+ if (count > MAX_ITEMS || (type === 9 && count !== 1)) fail('invalid enum count');
235
+ const values = Array.from({ length: count }, () => {
236
+ const index = r.u16();
237
+ if (index >= enums.length) return fail('enum index out of bounds');
238
+ if (index !== 0) return enums[index];
239
+ const value = r.i32();
240
+ if (type === VECTOR + 9 && value === 2147483647) return 'nil';
241
+ return fail('unnamed enum cannot be exported safely');
242
+ });
243
+ return type === 9 ? values[0] : values;
244
+ }
245
+ if (type === VECTOR + 10) return r.array(() => r.array(() => point(2, 'x')).join(','));
246
+ if (type === VECTOR + 11) return r.array(() => r.array(() => String(r.i32())).join(','));
247
+ if (type & VECTOR) return r.array(() => scalar(type - VECTOR, true));
248
+ return scalar(type, false);
249
+ };
250
+ const profiles: CachedProfile[] = [...modelsMetadata];
251
+ const names = new Set(profiles.map(p => String(p.data.name))); const paths = new Set(profiles.map(p => p.subPath.toLowerCase()));
252
+ for (const kind of ['process', 'filament', 'machine']) {
253
+ const count = r.u32();
254
+ if (count > MAX_ITEMS) fail('preset count out of bounds');
255
+ for (let i = 0; i < count; i++) {
256
+ const name = r.string(); const subPath = r.string();
257
+ if (!name || names.has(name)) fail('duplicate/empty preset name');
258
+ const normalized = subPath.replace(/\\/g, '/');
259
+ if (!normalized.startsWith(`${kind}/`) || !normalized.endsWith('.json') || normalized.split('/').some(p => !p || p === '.' || p === '..') || normalized.includes(':') || paths.has(normalized.toLowerCase())) fail('invalid or colliding preset path');
260
+ names.add(name); paths.add(normalized.toLowerCase());
261
+ const data: Record<string, unknown> = Object.create(null);
262
+ const configCount = r.u32(); const seen = new Set<number>();
263
+ if (configCount > keys.length) fail('option count out of bounds');
264
+ for (let j = 0; j < configCount; j++) {
265
+ const key = r.u16();
266
+ if (key >= keys.length || seen.has(key)) fail('invalid/duplicate option index');
267
+ seen.add(key);
268
+ const value = option(types[key]);
269
+ if (types[key] !== VECTOR + 3 && Array.isArray(value) && value.includes('nil') && !NULLABLE_KEYS.has(keys[key])) fail(`unexpected nil for ${keys[key]}`);
270
+ data[keys[key]] = value;
271
+ }
272
+ const inherits = r.string(); const includes = r.strings();
273
+ const description = r.string(); const instantiation = r.string();
274
+ const settingId = r.string(); const filamentId = r.string(); const renamed = r.strings();
275
+ Object.assign(data, { name, type: kind, from: 'system', version: vendorProfileVersion, instantiation, inherits });
276
+ if (includes.length) data.include = includes;
277
+ if (description) data.description = description;
278
+ if (settingId) data.setting_id = settingId;
279
+ if (filamentId) data.filament_id = filamentId;
280
+ if (renamed.length) data.renamed_from = renamed.map(value => JSON.stringify(value)).join(';');
281
+ profiles.push({ subPath: normalized, data });
282
+ }
283
+ }
284
+ if (r.u64() !== 0) fail('vendor cache records profile parse errors');
285
+ if (r.offset !== body.length) fail('unexpected trailing cache data');
286
+ return profiles;
287
+ }
288
+
289
+ /** Expand only the selected installation's cache in the owning server's private temp directory. */
290
+ export async function materializeOrcaProfilesRoot(profilesRoot: string, tempDir: string): Promise<string> {
291
+ if (profileJsonFiles(path.join(profilesRoot, 'BBL', 'machine')).length) return profilesRoot;
292
+ const cachePath = path.join(profilesRoot, 'BBL.opc');
293
+ let stat;
294
+ try { stat = await fs.stat(cachePath); }
295
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return profilesRoot; throw error; }
296
+ if (stat.size > MAX_CACHE_BYTES) fail('file size out of bounds');
297
+ let cli: Buffer;
298
+ try { cli = await fs.readFile(path.join(profilesRoot, 'BBL', 'cli_config.json')); }
299
+ catch { return fail('selected installation is missing BBL/cli_config.json; update Orca or supply its exact matching profile tree'); }
300
+ const parsed: unknown = JSON.parse(cli.toString('utf8'));
301
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) fail('invalid BBL/cli_config.json');
302
+ const cache = await fs.readFile(cachePath);
303
+ const profiles = decodeOrcaBblCache(cache);
304
+ const digest = createHash('sha256').update(cache).update(cli).digest('hex');
305
+ const output = await fs.mkdtemp(path.join(tempDir, `orca-profiles-${digest.slice(0, 16)}-`));
306
+ try {
307
+ await fs.mkdir(path.join(output, 'BBL'), { recursive: true });
308
+ await fs.writeFile(path.join(output, 'BBL', 'cli_config.json'), cli, { flag: 'wx' });
309
+ await fs.writeFile(path.join(output, 'BBL.opc'), cache, { flag: 'wx' });
310
+ for (const profile of profiles) {
311
+ const file = path.join(output, 'BBL', profile.subPath);
312
+ await fs.mkdir(path.dirname(file), { recursive: true });
313
+ await fs.writeFile(file, JSON.stringify(profile.data), { flag: 'wx' });
314
+ }
315
+ return output;
316
+ } catch (error) {
317
+ await fs.rm(output, { recursive: true, force: true });
318
+ throw error;
319
+ }
320
+ }
321
+
322
+ interface CliProfiles { settingsArg?: string; filamentPaths: string[]; }
323
+
324
+ /** Orca v2 cache builds resolve User presets by loaded file identity, not just inherits. */
325
+ export async function stageOrcaCliProfiles(
326
+ profilesRoot: string, tempDir: string, bundle: CliProfiles
327
+ ): Promise<CliProfiles & { extraArgs: string[]; dataDir: string }> {
328
+ const settings = bundle.settingsArg?.split(';') ?? [];
329
+ if (settings.length !== 2) fail('complete machine and process settings are required');
330
+ const root = await fs.mkdtemp(path.join(tempDir, 'orca-cli-'));
331
+ try {
332
+ await fs.mkdir(path.join(root, 'system'));
333
+ await fs.copyFile(path.join(profilesRoot, 'BBL.opc'), path.join(root, 'system', 'BBL.opc'));
334
+ const profiles = decodeOrcaBblCache(await fs.readFile(path.join(profilesRoot, 'BBL.opc')));
335
+ const parents = new Set(profiles.map(p => `${p.data.type}:${p.data.name}`));
336
+ const version = profiles.find(p => p.data.type === 'machine')?.data.version;
337
+ if (typeof version !== 'string') fail('missing vendor profile version');
338
+ const output: string[] = [];
339
+ const colours: string[] = [];
340
+ const extras: string[] = [];
341
+ for (const [index, file] of [...settings, ...bundle.filamentPaths].entries()) {
342
+ const kind = index === 0 ? 'machine' : index === 1 ? 'process' : 'filament';
343
+ const data = JSON.parse(await fs.readFile(file, 'utf8')) as Record<string, unknown>;
344
+ if (kind === 'filament') {
345
+ const colour = Array.isArray(data.filament_colour) ? data.filament_colour[0] : undefined;
346
+ if (typeof colour !== 'string' || !/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(colour)) fail('missing positional filament colour');
347
+ colours.push(colour);
348
+ }
349
+ if (kind === 'process') {
350
+ if (typeof data.curr_bed_type === 'string') extras.push('--curr-bed-type', data.curr_bed_type);
351
+ for (const key of ['wipe_tower_x', 'wipe_tower_y']) {
352
+ if (data[key] === undefined) continue;
353
+ const values = Array.isArray(data[key]) ? data[key] as unknown[] : [data[key]];
354
+ if (!values.length || values.some(v => !Number.isFinite(Number(v)))) fail('invalid saved tower position');
355
+ extras.push(`--${key.replace(/_/g, '-')}`, values.join(','));
356
+ }
357
+ }
358
+ // The selected machine's system identity remains the validated cache leaf.
359
+ // A fully resolved custom process/filament may be a standalone User root.
360
+ if (!parents.has(`${kind}:${data.inherits}`)) {
361
+ if (kind === 'machine') fail('selected machine parent is absent from the exact cache');
362
+ delete data.inherits;
363
+ }
364
+ const digest = createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 16);
365
+ const name = `mcp_${kind}_${index}_${digest}`;
366
+ Object.assign(data, { name, from: 'User', version });
367
+ const directory = path.join(root, 'user', 'default', kind);
368
+ await fs.mkdir(directory, { recursive: true });
369
+ const staged = path.join(directory, `${name}.json`);
370
+ await fs.writeFile(staged, JSON.stringify(data), { flag: 'wx' });
371
+ output.push(staged);
372
+ }
373
+ return { settingsArg: output.slice(0, 2).join(';'), filamentPaths: output.slice(2),
374
+ extraArgs: ['--datadir', root, ...extras, '--filament-colour', colours.join(';')], dataDir: root };
375
+ } catch (error) {
376
+ await fs.rm(root, { recursive: true, force: true });
377
+ throw error;
378
+ }
379
+ }
@@ -836,7 +836,7 @@ export function detectProfilesRoot(slicerPath?: string, slicerType = 'bambustudi
836
836
  path.resolve(bin, '..', 'share', name, 'resources', 'profiles'),
837
837
  ]),
838
838
  ];
839
- const found = candidates.find(root => existsSync(path.join(root, 'BBL', 'machine')));
839
+ const found = candidates.find(root => (existsSync(path.join(root, 'BBL', 'machine')) || existsSync(path.join(root, 'BBL.opc'))));
840
840
  // Do not select another installation if this executable has no profile tree.
841
841
  // flattenForCli reports the missing tree and asks for BAMBU_PROFILES_ROOT.
842
842
  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
 
16
17
  const readFileAsync = promisify(fs.readFile);
@@ -291,7 +292,7 @@ export class STLManipulator extends EventEmitter {
291
292
  return value;
292
293
  }
293
294
 
294
- private sanitizeProcessForOrca(processConfig: any, printerPreset?: string): any {
295
+ private sanitizeProcessForOrca(processConfig: any, printerPreset?: string, preserveExtrusionMode = false): any {
295
296
  const sanitized = { ...processConfig };
296
297
 
297
298
  sanitized.type = 'process';
@@ -312,7 +313,7 @@ export class STLManipulator extends EventEmitter {
312
313
  sanitized.prime_tower_brim_width = String(sanitized.prime_tower_brim_width);
313
314
  }
314
315
 
315
- return this.useAbsoluteExtrusionForOrca(sanitized);
316
+ return preserveExtrusionMode ? sanitized : this.useAbsoluteExtrusionForOrca(sanitized);
316
317
  }
317
318
 
318
319
  /** Orca slices with absolute E distances; per-layer `G92 E0` resets would break them. */
@@ -344,7 +345,8 @@ export class STLManipulator extends EventEmitter {
344
345
  slicerProfile?: string,
345
346
  printerPreset?: string,
346
347
  bambuOptions?: BambuSliceOptions,
347
- activeProfilesRoot?: string
348
+ activeProfilesRoot?: string,
349
+ preserveOrcaExtrusionMode = false
348
350
  ): Promise<BambuSettingsBundle> {
349
351
  const roots = activeProfilesRoot
350
352
  ? [path.join(activeProfilesRoot, 'BBL')]
@@ -417,7 +419,7 @@ export class STLManipulator extends EventEmitter {
417
419
  outputBase,
418
420
  slicerType === 'orcaslicer' ? 'process_orca' : 'process',
419
421
  slicerType === 'orcaslicer'
420
- ? this.sanitizeProcessForOrca(mergedProcess, printerPreset)
422
+ ? this.sanitizeProcessForOrca(mergedProcess, printerPreset, preserveOrcaExtrusionMode)
421
423
  : mergedProcess
422
424
  );
423
425
 
@@ -460,7 +462,7 @@ export class STLManipulator extends EventEmitter {
460
462
  ? this.writeTempJson(
461
463
  outputBase,
462
464
  'process_default_orca',
463
- this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset)
465
+ this.sanitizeProcessForOrca(this.readJsonFile(defaultProcessPath), printerPreset, preserveOrcaExtrusionMode)
464
466
  )
465
467
  : defaultProcessPath;
466
468
  }
@@ -1608,6 +1610,7 @@ export class STLManipulator extends EventEmitter {
1608
1610
  let outputFilePath = path.join(this.tempDir, outputFileName);
1609
1611
 
1610
1612
  let args: string[] = [];
1613
+ const orcaTemporaryDirectories: string[] = [];
1611
1614
 
1612
1615
  try {
1613
1616
  if (progressCallback) progressCallback(0, `Starting slicing with ${slicerType}...`);
@@ -1660,14 +1663,19 @@ export class STLManipulator extends EventEmitter {
1660
1663
  const profileSlicerType = remote ? 'orcaslicer' : slicerType;
1661
1664
  const bambuOutputPath = path.join(this.tempDir, outputBase + (remote ? `_${operationId}` : '') + '_sliced.3mf');
1662
1665
  const outputDir = path.dirname(bambuOutputPath);
1663
- const profilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1666
+ const selectedProfilesRoot = detectProfilesRoot(remote ? undefined : slicerPath, profileSlicerType);
1667
+ const profilesRoot = !remote && profileSlicerType === 'orcaslicer'
1668
+ ? await materializeOrcaProfilesRoot(selectedProfilesRoot, this.tempDir)
1669
+ : selectedProfilesRoot;
1670
+ if (profilesRoot !== selectedProfilesRoot) orcaTemporaryDirectories.push(profilesRoot);
1664
1671
  const rawBundle = await this.resolveBambuLikeSettingsBundle(
1665
1672
  outputBase,
1666
1673
  profileSlicerType,
1667
1674
  slicerProfile,
1668
1675
  printerPreset,
1669
1676
  bambuOptions,
1670
- profilesRoot
1677
+ profilesRoot,
1678
+ profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot
1671
1679
  );
1672
1680
  // Resolve every BBL dependency before launching the slicer.
1673
1681
  const settingsBundle = await this.maybeFlattenBundle(
@@ -1689,7 +1697,7 @@ export class STLManipulator extends EventEmitter {
1689
1697
  // absolute-extrusion normalization after resolving those ancestors.
1690
1698
  // The machine owns the extrusion mode and layer G-code once process
1691
1699
  // files lose machine keys, so normalize it there as well.
1692
- if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg) {
1700
+ if (profileSlicerType === 'orcaslicer' && settingsBundle.settingsArg && profilesRoot === selectedProfilesRoot) {
1693
1701
  const [machinePath, processPath] = settingsBundle.settingsArg.split(';');
1694
1702
  settingsBundle.settingsArg = [
1695
1703
  this.writeTempJson(outputBase, 'machine_orca_resolved',
@@ -1698,7 +1706,14 @@ export class STLManipulator extends EventEmitter {
1698
1706
  this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
1699
1707
  ].join(';');
1700
1708
  }
1701
- const cliBundle = this.isolateMachineSettings(settingsBundle);
1709
+ let cliBundle = this.isolateMachineSettings(settingsBundle);
1710
+ let orcaExtraArgs: string[] = [];
1711
+ if (!remote && profileSlicerType === 'orcaslicer' && profilesRoot !== selectedProfilesRoot) {
1712
+ const staged = await stageOrcaCliProfiles(profilesRoot, this.tempDir, cliBundle);
1713
+ cliBundle = { ...cliBundle, settingsArg: staged.settingsArg, filamentPaths: staged.filamentPaths };
1714
+ orcaExtraArgs = staged.extraArgs;
1715
+ orcaTemporaryDirectories.push(staged.dataDir);
1716
+ }
1702
1717
  if (remote) {
1703
1718
  const [machinePath, processPath] = cliBundle.settingsArg?.split(';') ?? [];
1704
1719
  if (!machinePath || !processPath) throw new Error('Remote slicing requires complete prepared machine and process profiles.');
@@ -1710,6 +1725,7 @@ export class STLManipulator extends EventEmitter {
1710
1725
  break;
1711
1726
  }
1712
1727
  args = [
1728
+ ...orcaExtraArgs,
1713
1729
  '--slice', String(bambuOptions?.slicePlate ?? 0),
1714
1730
  '--outputdir', outputDir,
1715
1731
  '--export-3mf', path.basename(bambuOutputPath),
@@ -1825,6 +1841,11 @@ export class STLManipulator extends EventEmitter {
1825
1841
  throw error;
1826
1842
  } finally {
1827
1843
  this.activeOperations.delete(operationId);
1844
+ for (const directory of orcaTemporaryDirectories.reverse()) {
1845
+ await fs.promises.rm(directory, { recursive: true, force: true }).catch(error => {
1846
+ console.error('Could not remove temporary Orca profiles:', error);
1847
+ });
1848
+ }
1828
1849
  }
1829
1850
  }
1830
1851