bambu-printer-mcp 1.0.8 → 1.1.4

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/src/3mf_parser.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import JSZip from 'jszip';
2
2
  import { parseStringPromise } from 'xml2js';
3
- import { ThreeMFData, BambuSlicerConfig, ThreeMFMetadata, ThreeMFObject, ThreeMFBuildItem, AMSFilamentMapping } from './types.js';
3
+ import { ThreeMFData, BambuSlicerConfig, ThreeMFMetadata, ThreeMFObject, ThreeMFBuildItem, AMSFilamentMapping, CollarCharmAnalysis, CollarCharmRole, ThreeMFAmsRequirements, ThreeMFPlateObjects } from './types.js';
4
4
  import * as fs from 'fs/promises';
5
5
 
6
6
  // Parses Bambu Studio's JSON config data
@@ -72,7 +72,12 @@ function parseBambuJSONConfig(jsonData: any): Partial<BambuSlicerConfig> {
72
72
 
73
73
  async function parseBambuConfig(zip: JSZip): Promise<Partial<BambuSlicerConfig>> {
74
74
  // Look for common Bambu config file names
75
- const potentialFiles = ['Metadata/project_settings.config', 'Metadata/model_settings.config', 'Metadata/slice_info.config'];
75
+ const potentialFiles = [
76
+ 'Metadata/project_settings.config',
77
+ 'Metadata/Slic3r_PE.config',
78
+ 'Metadata/model_settings.config',
79
+ 'Metadata/slice_info.config'
80
+ ];
76
81
  let configFile = null;
77
82
  let configContent = '';
78
83
 
@@ -220,4 +225,297 @@ export async function parse3MF(filePath: string): Promise<ThreeMFData> {
220
225
  console.error(`Error parsing 3MF file ${filePath}:`, error);
221
226
  throw new Error(`Failed to parse 3MF file: ${error.message}`);
222
227
  }
223
- }
228
+ }
229
+
230
+ export async function extractBambuTemplateSettings(
231
+ filePath: string,
232
+ outputDir: string
233
+ ): Promise<string> {
234
+ const data = await fs.readFile(filePath);
235
+ const zip = await JSZip.loadAsync(data);
236
+ const potentialFiles = [
237
+ 'Metadata/project_settings.config',
238
+ 'Metadata/Slic3r_PE.config',
239
+ 'Metadata/model_settings.config',
240
+ 'Metadata/slice_info.config'
241
+ ];
242
+
243
+ for (const name of potentialFiles) {
244
+ const file = zip.file(name);
245
+ if (!file) continue;
246
+
247
+ const content = await file.async('string');
248
+ const outputPath = `${outputDir}/${name.replace(/\//g, '_')}`;
249
+ await fs.writeFile(outputPath, content, 'utf8');
250
+ return outputPath;
251
+ }
252
+
253
+ throw new Error(`No slicer settings config found in template 3MF: ${filePath}`);
254
+ }
255
+
256
+ const COLLAR_CHARM_ROLE_COLORS = {
257
+ inner: 'black',
258
+ outer: 'white',
259
+ } as const;
260
+
261
+ const COLLAR_CHARM_ROLE_AMS_SLOTS = {
262
+ // User-facing convention:
263
+ // - inner/smaller object -> AMS 1 slot 1 black
264
+ // - outer/larger object -> AMS 2 slot 1 white
265
+ // Internal absolute tray indices are 0-based:
266
+ // AMS 1 slot 1 -> absolute tray 0
267
+ // AMS 2 slot 1 -> absolute tray 4
268
+ inner: 0,
269
+ outer: 4,
270
+ } as const;
271
+
272
+ export async function analyzeCollarCharm3MF(
273
+ filePath: string,
274
+ plateIndex: number = 0
275
+ ): Promise<CollarCharmAnalysis> {
276
+ const data = await fs.readFile(filePath);
277
+ const zip = await JSZip.loadAsync(data);
278
+ const plateName = `Metadata/plate_${plateIndex + 1}.json`;
279
+ const plateFile = zip.file(plateName);
280
+
281
+ if (!plateFile) {
282
+ throw new Error(`Prepared collar charm 3MF is missing ${plateName}. Slice the project first or export a printable 3MF.`);
283
+ }
284
+
285
+ const plateJson = JSON.parse(await plateFile.async('string'));
286
+ const bboxObjects = Array.isArray(plateJson?.bbox_objects) ? plateJson.bbox_objects : [];
287
+ const usedFilamentPositions = Array.isArray(plateJson?.filament_ids)
288
+ ? plateJson.filament_ids.filter((value: unknown) => Number.isInteger(value)).map((value: number) => value)
289
+ : [];
290
+
291
+ if (bboxObjects.length !== 2) {
292
+ throw new Error(`Collar charm wrapper requires exactly 2 plate objects; found ${bboxObjects.length} in ${plateName}.`);
293
+ }
294
+ if (usedFilamentPositions.length !== 2) {
295
+ throw new Error(`Collar charm wrapper requires exactly 2 used filament positions; found ${usedFilamentPositions.length} in ${plateName}.`);
296
+ }
297
+ if (bboxObjects.length !== usedFilamentPositions.length) {
298
+ throw new Error(`Collar charm wrapper requires object count and used filament count to match; found ${bboxObjects.length} objects and ${usedFilamentPositions.length} filament positions in ${plateName}.`);
299
+ }
300
+
301
+ const normalizedObjects = bboxObjects.map((object: any, objectIndex: number) => {
302
+ const area = Number(object?.area);
303
+ if (!Number.isFinite(area) || area <= 0) {
304
+ throw new Error(`Collar charm object ${objectIndex} in ${plateName} has invalid area metadata.`);
305
+ }
306
+ const name = typeof object?.name === 'string' && object.name.trim().length > 0
307
+ ? object.name.trim()
308
+ : `object_${objectIndex + 1}`;
309
+ return {
310
+ objectIndex,
311
+ name,
312
+ area,
313
+ filamentPosition: usedFilamentPositions[objectIndex],
314
+ };
315
+ });
316
+
317
+ const sortedByArea = [...normalizedObjects].sort((a, b) => a.area - b.area);
318
+ if (sortedByArea[0].area === sortedByArea[1].area) {
319
+ throw new Error(`Collar charm wrapper could not distinguish inner vs outer object because both plate objects have the same reported area in ${plateName}.`);
320
+ }
321
+
322
+ const roles: CollarCharmRole[] = [
323
+ {
324
+ role: 'inner',
325
+ objectIndex: sortedByArea[0].objectIndex,
326
+ name: sortedByArea[0].name,
327
+ area: sortedByArea[0].area,
328
+ filamentPosition: sortedByArea[0].filamentPosition,
329
+ },
330
+ {
331
+ role: 'outer',
332
+ objectIndex: sortedByArea[1].objectIndex,
333
+ name: sortedByArea[1].name,
334
+ area: sortedByArea[1].area,
335
+ filamentPosition: sortedByArea[1].filamentPosition,
336
+ },
337
+ ];
338
+
339
+ const trayByFilamentPosition = new Map<number, number>();
340
+ for (const role of roles) {
341
+ trayByFilamentPosition.set(
342
+ role.filamentPosition,
343
+ COLLAR_CHARM_ROLE_AMS_SLOTS[role.role]
344
+ );
345
+ }
346
+
347
+ const amsSlots = usedFilamentPositions.map((position: number) => {
348
+ const tray = trayByFilamentPosition.get(position);
349
+ if (tray === undefined) {
350
+ throw new Error(`Collar charm wrapper could not assign a tray to project filament position ${position}.`);
351
+ }
352
+ return tray;
353
+ });
354
+
355
+ return {
356
+ plateIndex,
357
+ usedFilamentPositions,
358
+ amsSlots,
359
+ roles,
360
+ };
361
+ }
362
+
363
+ export function getCollarCharmRolePolicy() {
364
+ return {
365
+ colors: COLLAR_CHARM_ROLE_COLORS,
366
+ amsSlots: COLLAR_CHARM_ROLE_AMS_SLOTS,
367
+ };
368
+ }
369
+
370
+ export async function analyze3MFAmsRequirements(
371
+ filePath: string,
372
+ plateIndex: number = 0
373
+ ): Promise<ThreeMFAmsRequirements> {
374
+ const data = await fs.readFile(filePath);
375
+ const zip = await JSZip.loadAsync(data);
376
+ const plateNumber = plateIndex + 1;
377
+ const plateName = `Metadata/plate_${plateNumber}.json`;
378
+ const plateFile = zip.file(plateName);
379
+
380
+ if (!plateFile) {
381
+ throw new Error(`3MF is missing ${plateName}; cannot derive AMS filament order.`);
382
+ }
383
+
384
+ const plateJson = JSON.parse(await plateFile.async('string'));
385
+ const usedFilamentPositions = Array.isArray(plateJson?.filament_ids)
386
+ ? plateJson.filament_ids.filter((value: unknown) => Number.isInteger(value)).map((value: number) => value)
387
+ : [];
388
+
389
+ if (usedFilamentPositions.length === 0) {
390
+ throw new Error(`${plateName} does not declare filament_ids; cannot derive AMS filament order.`);
391
+ }
392
+
393
+ const sliceInfo = zip.file('Metadata/slice_info.config');
394
+ const filamentById = new Map<number, { tray_info_idx: string | null; type: string | null; color: string | null }>();
395
+
396
+ if (sliceInfo) {
397
+ try {
398
+ const parsed = await parseStringPromise(await sliceInfo.async('string'), {
399
+ explicitArray: false,
400
+ mergeAttrs: true,
401
+ });
402
+ const plateNodes = parsed?.config?.plate
403
+ ? Array.isArray(parsed.config.plate) ? parsed.config.plate : [parsed.config.plate]
404
+ : [];
405
+ const selectedPlate =
406
+ plateNodes.find((plate: any) => {
407
+ const metadata = Array.isArray(plate?.metadata)
408
+ ? plate.metadata
409
+ : plate?.metadata
410
+ ? [plate.metadata]
411
+ : [];
412
+ return metadata.some((item: any) => item?.key === 'index' && Number(item?.value) === plateNumber);
413
+ }) || plateNodes[plateIndex] || plateNodes[0];
414
+
415
+ const filamentNodes = selectedPlate?.filament
416
+ ? Array.isArray(selectedPlate.filament) ? selectedPlate.filament : [selectedPlate.filament]
417
+ : [];
418
+ for (const filament of filamentNodes) {
419
+ const id = Number.parseInt(String(filament?.id ?? ''), 10);
420
+ if (!Number.isFinite(id)) continue;
421
+ filamentById.set(id, {
422
+ tray_info_idx: typeof filament?.tray_info_idx === 'string' ? filament.tray_info_idx : null,
423
+ type: typeof filament?.type === 'string' ? filament.type : null,
424
+ color: typeof filament?.color === 'string' ? filament.color : null,
425
+ });
426
+ }
427
+ } catch {
428
+ // plate_N.json already gives the project filament order. Bad
429
+ // slice_info should degrade metadata only, not block AMS planning.
430
+ }
431
+ }
432
+
433
+ return {
434
+ plateIndex,
435
+ usedFilamentPositions,
436
+ filaments: usedFilamentPositions.map((position: number, order: number) => {
437
+ const filamentId = position + 1;
438
+ const byOneBasedId = filamentById.get(filamentId);
439
+ const byOrder = Array.from(filamentById.values())[order];
440
+ const filament = byOneBasedId || byOrder || null;
441
+ return {
442
+ filamentPosition: position,
443
+ filamentId,
444
+ tray_info_idx: filament?.tray_info_idx ?? null,
445
+ type: filament?.type ?? null,
446
+ color: filament?.color ?? null,
447
+ };
448
+ }),
449
+ };
450
+ }
451
+
452
+ export async function analyze3MFPlateObjects(
453
+ filePath: string,
454
+ plateIndex: number = 0
455
+ ): Promise<ThreeMFPlateObjects> {
456
+ const data = await fs.readFile(filePath);
457
+ const zip = await JSZip.loadAsync(data);
458
+ const plateName = `Metadata/plate_${plateIndex + 1}.json`;
459
+ const plateFile = zip.file(plateName);
460
+
461
+ if (!plateFile) {
462
+ throw new Error(`3MF is missing ${plateName}; cannot list plate objects.`);
463
+ }
464
+
465
+ const plateJson = JSON.parse(await plateFile.async('string'));
466
+ const bboxObjects = Array.isArray(plateJson?.bbox_objects) ? plateJson.bbox_objects : [];
467
+ const sliceInfoObjects: Array<{ id: number; name: string | null }> = [];
468
+ const sliceInfo = zip.file('Metadata/slice_info.config');
469
+
470
+ if (sliceInfo) {
471
+ try {
472
+ const parsed = await parseStringPromise(await sliceInfo.async('string'), {
473
+ explicitArray: false,
474
+ mergeAttrs: true,
475
+ });
476
+ const plateNodes = parsed?.config?.plate
477
+ ? Array.isArray(parsed.config.plate) ? parsed.config.plate : [parsed.config.plate]
478
+ : [];
479
+ const plateNumber = plateIndex + 1;
480
+ const selectedPlate =
481
+ plateNodes.find((plate: any) => {
482
+ const metadata = Array.isArray(plate?.metadata)
483
+ ? plate.metadata
484
+ : plate?.metadata
485
+ ? [plate.metadata]
486
+ : [];
487
+ return metadata.some((item: any) => item?.key === 'index' && Number(item?.value) === plateNumber);
488
+ }) || plateNodes[plateIndex] || plateNodes[0];
489
+ const objectNodes = selectedPlate?.object
490
+ ? Array.isArray(selectedPlate.object) ? selectedPlate.object : [selectedPlate.object]
491
+ : [];
492
+ for (const object of objectNodes) {
493
+ const id = Number.parseInt(String(object?.identify_id ?? ''), 10);
494
+ if (!Number.isInteger(id)) continue;
495
+ sliceInfoObjects.push({
496
+ id,
497
+ name: typeof object?.name === 'string' ? object.name : null,
498
+ });
499
+ }
500
+ } catch {
501
+ // Fall back to bbox IDs for older or malformed 3MFs.
502
+ }
503
+ }
504
+
505
+ return {
506
+ plateIndex,
507
+ objects: bboxObjects
508
+ .map((object: any, index: number) => {
509
+ const sliceObject = sliceInfoObjects[index];
510
+ const id = sliceObject?.id ?? Number(object?.id);
511
+ if (!Number.isInteger(id)) return null;
512
+ return {
513
+ id,
514
+ name: sliceObject?.name || (typeof object?.name === 'string' ? object.name : `object_${id}`),
515
+ area: Number.isFinite(Number(object?.area)) ? Number(object.area) : null,
516
+ bbox: object?.bbox ?? null,
517
+ };
518
+ })
519
+ .filter((object: unknown): object is NonNullable<typeof object> => object !== null),
520
+ };
521
+ }
@@ -0,0 +1,47 @@
1
+ export function normalizeBridgeAmsTrayValue(value: unknown, label: string): number {
2
+ let numeric: number;
3
+ if (typeof value === "number") {
4
+ numeric = value;
5
+ } else if (typeof value === "string" && value.trim() !== "") {
6
+ numeric = Number(value);
7
+ } else {
8
+ throw new Error(`${label} must be an integer tray index; got ${JSON.stringify(value)}`);
9
+ }
10
+
11
+ if (
12
+ !Number.isInteger(numeric) ||
13
+ numeric < -1 ||
14
+ (numeric > 15 && numeric < 128) ||
15
+ numeric > 254
16
+ ) {
17
+ throw new Error(
18
+ `${label} must be an integer in [-1, 15] (absolute tray) or 128-254 (HT/external); got ${JSON.stringify(value)}`
19
+ );
20
+ }
21
+
22
+ return numeric;
23
+ }
24
+
25
+ function isNumericMappingKey(key: string): boolean {
26
+ const numeric = Number(key);
27
+ return Number.isInteger(numeric) && numeric >= 0;
28
+ }
29
+
30
+ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping"): number[] {
31
+ return Object.entries(mapping)
32
+ .sort(([leftKey], [rightKey]) => {
33
+ const leftNumeric = isNumericMappingKey(leftKey);
34
+ const rightNumeric = isNumericMappingKey(rightKey);
35
+ if (leftNumeric && rightNumeric) return Number(leftKey) - Number(rightKey);
36
+ if (leftNumeric) return -1;
37
+ if (rightNumeric) return 1;
38
+ return 0;
39
+ })
40
+ .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`));
41
+ }
42
+
43
+ export function hasAmsMappingInput(value: unknown): boolean {
44
+ if (Array.isArray(value)) return value.length > 0;
45
+ if (value && typeof value === "object") return Object.keys(value as Record<string, unknown>).length > 0;
46
+ return value !== undefined && value !== null;
47
+ }