mcp-3d-printer-server 1.2.24 → 1.2.26

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +200 -0
  7. package/dist/bambu-network-bridge.js +881 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +528 -72
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +59 -8
  53. package/dist/safety/printer-state.js +174 -39
  54. package/dist/slicer/bambu-cli.d.ts +6 -2
  55. package/dist/slicer/bambu-cli.js +19 -12
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/slicer/slicer-error.d.ts +11 -1
  59. package/dist/slicer/slicer-error.js +16 -2
  60. package/dist/stl/stl-manipulator.d.ts +27 -1
  61. package/dist/stl/stl-manipulator.js +216 -76
  62. package/dist/studio-print-options.d.ts +57 -0
  63. package/dist/studio-print-options.js +179 -0
  64. package/package.json +25 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1075 -0
  68. package/src/bambu-network-certs.ts +154 -0
  69. package/src/control-reply.ts +41 -0
  70. package/src/counterpart/camera-sources.ts +102 -0
  71. package/src/counterpart/camera-stream.ts +195 -0
  72. package/src/counterpart/camera-tools.ts +35 -0
  73. package/src/counterpart/contract.ts +12 -4
  74. package/src/counterpart/route.ts +82 -0
  75. package/src/counterpart/service.ts +150 -16
  76. package/src/design-guidance.ts +188 -0
  77. package/src/fulu-orca.ts +18 -2
  78. package/src/index.ts +546 -87
  79. package/src/printers/bambu-filaments.ts +346 -0
  80. package/src/printers/bambu.ts +506 -81
  81. package/src/printers/creality.ts +8 -3
  82. package/src/printers/generic-base.ts +33 -40
  83. package/src/printers/klipper.ts +12 -8
  84. package/src/printers/octoprint.ts +16 -9
  85. package/src/printers/printer-factory.ts +3 -3
  86. package/src/printers/prusa.ts +0 -1
  87. package/src/safety/confirmation.ts +15 -38
  88. package/src/safety/hidden-hms.ts +406 -0
  89. package/src/safety/limits.ts +15 -5
  90. package/src/safety/print-file.ts +139 -17
  91. package/src/safety/printer-state.ts +179 -40
  92. package/src/slicer/bambu-cli.ts +22 -13
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/slicer/slicer-error.ts +21 -2
  95. package/src/stl/stl-manipulator.ts +254 -84
  96. package/src/studio-print-options.ts +201 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The print options Bambu Studio's Send dialog sends, resolved the way it
3
+ * resolves them, so every print route sends what Bambu's own Print button
4
+ * would. Source: Bambu Studio v02.08.02.61 (commit 926a7192574b), the version
5
+ * the hidden-HMS and stage tables are pinned to:
6
+ *
7
+ * - SelectMachine.cpp:3344-3356 `set_print_config(bed_type,
8
+ * bed_leveling == "on", flow_cali == "on", false, timelapse, true,
9
+ * ext_change_assist, bed_leveling.getValueInt(), flow_cali.getValueInt(),
10
+ * nozzle_offset_cali.getValueInt(), pa_value_switch ? 0 : 1)`. Vibration
11
+ * calibration is always false and layer inspection always true; the
12
+ * parameter order is PrintJob.hpp:98-116.
13
+ * - SelectMachine.cpp:7318-7329 `getValueInt`: off 0, on 1, auto 2.
14
+ * - SelectMachine.cpp:1117-1123 `update_select_layout`: update_option_opts
15
+ * (which choices each option offers on this printer) runs before
16
+ * load_option_vals.
17
+ * - SelectMachine.cpp:2993-3027 `load_option_vals`: the value saved in
18
+ * BambuStudio.conf under the printer type, if the option offers it; else
19
+ * "auto" if offered; else "on". Dual-nozzle printers then set nozzle offset
20
+ * calibration to "auto" for a two-nozzle job and, without a filament
21
+ * switcher, "off" for a one-nozzle job.
22
+ * - SelectMachine.cpp:2586-2615 `update_option_opts`: bed leveling offers
23
+ * auto/on/off when the printer's support_bed_leveling is 2 and on/off when it
24
+ * is 1; with 0 it is hidden and keeps the auto/on/off it was built with
25
+ * (SelectMachine.cpp:676-691), as does flow calibration when the printer has
26
+ * no PA calibration. DeviceManager.cpp:1100/4441 give P-series printers
27
+ * (series_p1p: P1P, P1S) no PA calibration, so their flow calibration is
28
+ * hidden and resolves to "auto". Nozzle offset calibration always offers auto.
29
+ * - SelectMachine.cpp:705-707: the PA value switch starts on and is not saved,
30
+ * so extruder_cali_manual_mode is 0.
31
+ *
32
+ * Approximations, outside the P1P/P1S: flow calibration on other printers is
33
+ * shown when the printer's push status reports PA calibration (assumed here),
34
+ * and offers auto when support_auto_flow_calibration is true and the mapped
35
+ * filaments allow it (can_support_pa_auto_cali, assumed true). A filament
36
+ * switcher on a dual-nozzle printer is assumed absent.
37
+ */
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+ const AUTO_ON_OFF = ["auto", "on", "off"];
42
+ const ON_OFF = ["on", "off"];
43
+ /**
44
+ * Studio's printer types (resources/printers/<type>.json at the pinned commit:
45
+ * model_id/printer_type, printer_series, support_bed_leveling,
46
+ * support_auto_flow_calibration), keyed by this server's model names.
47
+ */
48
+ const STUDIO_PRINTERS = {
49
+ p1p: { types: ["C11"], pSeries: true, bedLeveling: 1, autoFlow: false, dualNozzle: false },
50
+ p1s: { types: ["C12"], pSeries: true, bedLeveling: 1, autoFlow: false, dualNozzle: false },
51
+ x1c: { types: ["3DPrinter-X1-Carbon"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
52
+ x1: { types: ["3DPrinter-X1"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
53
+ x1e: { types: ["C13"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
54
+ a1mini: { types: ["N1"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
55
+ a1: { types: ["N2S"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
56
+ p2s: { types: ["N7"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: false },
57
+ x2d: { types: ["N6"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
58
+ h2s: { types: ["O1S"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: false },
59
+ h2d: { types: ["O1D"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
60
+ h2dpro: { types: ["O1E"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
61
+ h2c: { types: ["O1C", "O1C2"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
62
+ };
63
+ /** Where Studio keeps BambuStudio.conf; BAMBU_STUDIO_CONFIG overrides it (empty: none). */
64
+ export function studioConfigPath() {
65
+ const override = process.env.BAMBU_STUDIO_CONFIG;
66
+ if (override !== undefined)
67
+ return override.trim() || undefined;
68
+ if (process.platform === "darwin")
69
+ return path.join(os.homedir(), "Library", "Application Support", "BambuStudio", "BambuStudio.conf");
70
+ if (process.platform === "win32")
71
+ return process.env.APPDATA ? path.join(process.env.APPDATA, "BambuStudio", "BambuStudio.conf") : undefined;
72
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "BambuStudio", "BambuStudio.conf");
73
+ }
74
+ /**
75
+ * The Send dialog options Studio saved for a printer type, and nothing else:
76
+ * BambuStudio.conf also holds access codes, so only that one section is kept,
77
+ * and a parse failure is reported without any of the file's text.
78
+ */
79
+ export function readStudioSavedOptions(printerTypes, configPath) {
80
+ if (!configPath)
81
+ return undefined;
82
+ let text;
83
+ try {
84
+ const stat = fs.statSync(configPath);
85
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024)
86
+ return undefined;
87
+ text = fs.readFileSync(configPath, "utf8");
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ let config;
93
+ // Studio appends "# MD5 checksum <hex>" after the JSON.
94
+ try {
95
+ config = JSON.parse(text.split("\n").filter(line => !line.trimStart().startsWith("#")).join("\n"));
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ if (!config || typeof config !== "object")
101
+ return undefined;
102
+ for (const type of printerTypes) {
103
+ const section = config[type];
104
+ if (!section || typeof section !== "object" || Array.isArray(section))
105
+ continue;
106
+ const saved = {};
107
+ for (const key of ["bed_leveling", "flow_cali", "nozzle_offset_cali", "timelapse"]) {
108
+ const value = section[key];
109
+ if (typeof value === "string")
110
+ saved[key] = value;
111
+ }
112
+ return saved;
113
+ }
114
+ return undefined;
115
+ }
116
+ function fromInt(value, label) {
117
+ if (value === 0)
118
+ return "off";
119
+ if (value === 1)
120
+ return "on";
121
+ if (value === 2)
122
+ return "auto";
123
+ throw new Error(`${label} must be 0 (off), 1 (on) or 2 (auto).`);
124
+ }
125
+ const toInt = (value) => value === "off" ? 0 : value === "on" ? 1 : 2;
126
+ /** load_option_vals for one option: saved if offered, else auto if offered, else on. */
127
+ function loaded(saved, offers) {
128
+ if (saved !== undefined && offers.includes(saved))
129
+ return { value: saved, source: "studio-config" };
130
+ return { value: offers.includes("auto") ? "auto" : "on", source: "studio-default" };
131
+ }
132
+ /**
133
+ * What Studio's Send dialog would send for this model, with the caller's
134
+ * explicit choices taking precedence. usedNozzles is the number of nozzles the
135
+ * job uses, for dual-nozzle printers.
136
+ */
137
+ export function resolveStudioPrintOptions(model, caller = {}, options = {}) {
138
+ const printer = STUDIO_PRINTERS[model];
139
+ const saved = printer ? readStudioSavedOptions(printer.types, "configPath" in options ? options.configPath : studioConfigPath()) ?? {} : {};
140
+ const printerType = printer?.types[0];
141
+ const bedOffers = !printer || printer.bedLeveling !== 1 ? AUTO_ON_OFF : ON_OFF;
142
+ const flowOffers = !printer || printer.pSeries || printer.autoFlow ? AUTO_ON_OFF : ON_OFF;
143
+ const bed = loaded(saved.bed_leveling, bedOffers);
144
+ const flow = loaded(saved.flow_cali, flowOffers);
145
+ let offset = loaded(saved.nozzle_offset_cali, AUTO_ON_OFF);
146
+ if (printer?.dualNozzle && options.usedNozzles !== undefined) {
147
+ if (options.usedNozzles >= 2)
148
+ offset = { value: "auto", source: "studio-default" };
149
+ else if (options.usedNozzles === 1)
150
+ offset = { value: "off", source: "studio-default" };
151
+ }
152
+ const choose = (current, auto, flag, label) => auto !== undefined ? { value: fromInt(auto, label), source: "caller" }
153
+ : flag !== undefined ? { value: flag ? "on" : "off", source: "caller" }
154
+ : current;
155
+ const bedLeveling = choose(bed, caller.autoBedLeveling, caller.bedLeveling, "auto_bed_leveling");
156
+ const flowCali = choose(flow, caller.autoFlowCali, caller.flowCalibration, "auto_flow_cali");
157
+ const nozzleOffset = choose(offset, caller.autoOffsetCali, undefined, "auto_offset_cali");
158
+ const manualMode = caller.extruderCaliManualMode ?? 0;
159
+ if (!Number.isInteger(manualMode) || manualMode < -1 || manualMode > 1)
160
+ throw new Error("extruder_cali_manual_mode must be -1, 0 or 1.");
161
+ return {
162
+ ...(printerType ? { printerType } : {}),
163
+ values: { bed_leveling: bedLeveling.value, flow_cali: flowCali.value, nozzle_offset_cali: nozzleOffset.value },
164
+ sources: { bed_leveling: bedLeveling.source, flow_cali: flowCali.source, nozzle_offset_cali: nozzleOffset.source },
165
+ task_bed_leveling: bedLeveling.value === "on",
166
+ task_flow_cali: flowCali.value === "on",
167
+ task_vibration_cali: caller.vibrationCalibration ?? false,
168
+ task_layer_inspect: caller.layerInspect ?? true,
169
+ auto_bed_leveling: toInt(bedLeveling.value),
170
+ auto_flow_cali: toInt(flowCali.value),
171
+ auto_offset_cali: toInt(nozzleOffset.value),
172
+ extruder_cali_manual_mode: manualMode,
173
+ };
174
+ }
175
+ /** The number of nozzles a job's used filaments print from, when the inspection knows. */
176
+ export function usedNozzleCount(inspection) {
177
+ const nozzles = inspection.usedFilamentPositions.map(position => inspection.filamentNozzleIndices?.[position]).filter((value) => Number.isInteger(value));
178
+ return nozzles.length === inspection.usedFilamentPositions.length && nozzles.length ? new Set(nozzles).size : undefined;
179
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-3d-printer-server",
3
3
  "mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
4
- "version": "1.2.24",
4
+ "version": "1.2.26",
5
5
  "description": "MCP server for connecting Claude with 3D printer management systems",
6
6
  "main": "dist/index.js",
7
7
  "repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
@@ -12,7 +12,7 @@
12
12
  "start:http": "MCP_TRANSPORT=streamable-http node dist/index.js",
13
13
  "dev": "nodemon src/index.ts --exec \"npm run build && npm start\"",
14
14
  "build": "tsc",
15
- "test": "npm run build && node dist/test/stl-manipulator-test.js && node --test tests/*.test.mjs tests/slicer/*.test.mjs tests/safety/*.test.mjs",
15
+ "test": "npm run build && node dist/test/stl-manipulator-test.js && node --test --import ./tests/support/isolate-resume-records.mjs tests/*.test.mjs tests/slicer/*.test.mjs tests/safety/*.test.mjs",
16
16
  "inspect": "npm run build && npx @modelcontextprotocol/inspector dist/index.js",
17
17
  "prepack": "npm run build",
18
18
  "test:package": "node scripts/test-package.mjs",
@@ -68,9 +68,27 @@
68
68
  "server.json",
69
69
  "dist/counterpart",
70
70
  "src/counterpart",
71
+ "dist/design-guidance.js",
72
+ "dist/design-guidance.d.ts",
73
+ "src/design-guidance.ts",
71
74
  "dist/fusion-mcp-bridge.js",
72
75
  "dist/fusion-mcp-bridge.d.ts",
73
- "src/fusion-mcp-bridge.ts"
76
+ "src/fusion-mcp-bridge.ts",
77
+ "dist/ams-mapping.js",
78
+ "dist/ams-mapping.d.ts",
79
+ "src/ams-mapping.ts",
80
+ "dist/bambu-network-bridge.js",
81
+ "dist/bambu-network-bridge.d.ts",
82
+ "src/bambu-network-bridge.ts",
83
+ "dist/control-reply.js",
84
+ "dist/control-reply.d.ts",
85
+ "src/control-reply.ts",
86
+ "dist/bambu-network-certs.js",
87
+ "dist/bambu-network-certs.d.ts",
88
+ "src/bambu-network-certs.ts",
89
+ "dist/studio-print-options.js",
90
+ "dist/studio-print-options.d.ts",
91
+ "src/studio-print-options.ts"
74
92
  ],
75
93
  "keywords": [
76
94
  "mcp",
@@ -90,7 +108,7 @@
90
108
  "@modelcontextprotocol/sdk": "^1.32.2",
91
109
  "axios": "^1.21.0",
92
110
  "bambu-node": "3.22.21",
93
- "basic-ftp": "^6.2.3",
111
+ "basic-ftp": "^6.2.5",
94
112
  "dotenv": "^16.6.1",
95
113
  "form-data": "^4.0.6",
96
114
  "jszip": "^3.10.2",
@@ -108,5 +126,8 @@
108
126
  },
109
127
  "engines": {
110
128
  "node": ">=18"
129
+ },
130
+ "optionalDependencies": {
131
+ "sharp": "0.35.5"
111
132
  }
112
133
  }
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.2.24",
10
+ "version": "1.2.26",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "mcp-3d-printer-server",
15
- "version": "1.2.24",
15
+ "version": "1.2.26",
16
16
  "runtimeHint": "npx",
17
17
  "transport": {
18
18
  "type": "stdio"
@@ -0,0 +1,132 @@
1
+ /** Tray-value options. `dualNozzle` admits 255, the right/main nozzle external spool that
2
+ * dual-nozzle printers report beside the left/deputy external spool 254 (Bambu Studio
3
+ * VIRTUAL_TRAY_MAIN_ID / VIRTUAL_TRAY_DEPUTY_ID). Single-nozzle printers keep 254 only. */
4
+ export interface AmsTrayValueOptions { dualNozzle?: boolean }
5
+
6
+ export function describeAmsTrayRange(options: AmsTrayValueOptions = {}): string {
7
+ return options.dualNozzle
8
+ ? "[-1, 15] (absolute tray), 128-253 (AMS-HT), 254 (left/deputy external) or 255 (right/main external)"
9
+ : "[-1, 15] (absolute tray) or 128-254 (HT/external)";
10
+ }
11
+
12
+ export function normalizeBridgeAmsTrayValue(value: unknown, label: string, options: AmsTrayValueOptions = {}): number {
13
+ let numeric: number;
14
+ if (typeof value === "number") {
15
+ numeric = value;
16
+ } else if (typeof value === "string" && value.trim() !== "") {
17
+ numeric = Number(value);
18
+ } else {
19
+ throw new Error(`${label} must be an integer tray index; got ${JSON.stringify(value)}`);
20
+ }
21
+
22
+ if (
23
+ !Number.isInteger(numeric) ||
24
+ numeric < -1 ||
25
+ (numeric > 15 && numeric < 128) ||
26
+ numeric > (options.dualNozzle ? 255 : 254)
27
+ ) {
28
+ throw new Error(
29
+ `${label} must be an integer in ${describeAmsTrayRange(options)}; got ${JSON.stringify(value)}`
30
+ );
31
+ }
32
+
33
+ return numeric;
34
+ }
35
+
36
+ function isNumericMappingKey(key: string): boolean {
37
+ const numeric = Number(key);
38
+ return Number.isInteger(numeric) && numeric >= 0;
39
+ }
40
+
41
+ export function normalizeAmsMappingObject(mapping: Record<string, unknown>, label = "ams_mapping", options: AmsTrayValueOptions = {}): number[] {
42
+ return Object.entries(mapping)
43
+ .sort(([leftKey], [rightKey]) => {
44
+ const leftNumeric = isNumericMappingKey(leftKey);
45
+ const rightNumeric = isNumericMappingKey(rightKey);
46
+ if (leftNumeric && rightNumeric) return Number(leftKey) - Number(rightKey);
47
+ if (leftNumeric) return -1;
48
+ if (rightNumeric) return 1;
49
+ return 0;
50
+ })
51
+ .map(([key, value]) => normalizeBridgeAmsTrayValue(value, `${label}[${key}]`, options));
52
+ }
53
+
54
+ export function hasAmsMappingInput(value: unknown): boolean {
55
+ if (Array.isArray(value)) return value.length > 0;
56
+ if (value && typeof value === "object") return Object.keys(value as Record<string, unknown>).length > 0;
57
+ return value !== undefined && value !== null;
58
+ }
59
+
60
+ /** Flat wire entries use -1 for dual-nozzle virtual trays; ams_mapping2 identifies their spool. */
61
+ export function bridgeAmsMappingValue(value: number, dualNozzle = false): number {
62
+ return dualNozzle && (value === 254 || value === 255) ? -1 : value;
63
+ }
64
+
65
+ /** Select an external spool only from a verified physical nozzle on dual-nozzle printers. */
66
+ export function externalTrayForNozzle(nozzle: number | undefined, dualNozzle = false): number {
67
+ if (!dualNozzle) return 254;
68
+ if (nozzle === 0) return 255;
69
+ if (nozzle === 1) return 254;
70
+ throw new Error("External-spool printing requires inspected filament_map and physical_extruder_map nozzle bindings.");
71
+ }
72
+
73
+ /** Virtual trays use slot zero on dual-nozzle printers; retain the historical single-nozzle form. */
74
+ export function bridgeAmsMapping2Entry(value: number, dualNozzle = false): { ams_id: number; slot_id: number } {
75
+ if (value < 0) return { ams_id: 255, slot_id: 255 };
76
+ if (value === 255) return dualNozzle ? { ams_id: 255, slot_id: 0 } : { ams_id: 255, slot_id: 255 };
77
+ if (value === 254) return { ams_id: 254, slot_id: dualNozzle ? 0 : 254 };
78
+ if (value >= 128) return { ams_id: 128, slot_id: value - 128 };
79
+ return { ams_id: Math.floor(value / 4), slot_id: value % 4 };
80
+ }
81
+
82
+ /** A project filament as the sliced 3MF's Metadata/project_settings.config declares it. */
83
+ export interface ProjectFilament { type: string; filamentId: string; colour: string; nozzle?: number }
84
+
85
+ /** Read every project filament (not just the plate's used ones) from project_settings.config. */
86
+ export function projectFilaments(projectSettings: Record<string, unknown>): ProjectFilament[] {
87
+ const list = (key: string): string[] => Array.isArray(projectSettings[key]) ? (projectSettings[key] as unknown[]).map(String) : [];
88
+ const types = list("filament_type"), ids = list("filament_ids"), colours = list("filament_colour"), nozzles = list("filament_map");
89
+ return types.map((type, index) => ({
90
+ type, filamentId: ids[index] ?? "", colour: colours[index] ?? "",
91
+ ...(nozzles[index] !== undefined && Number.isInteger(Number(nozzles[index])) ? { nozzle: Number(nozzles[index]) } : {}),
92
+ }));
93
+ }
94
+
95
+ /** The colour the printer reports for an absolute tray value (AMS, AMS-HT or external spool). */
96
+ export function trayColour(rawStatus: any, tray: number): string | undefined {
97
+ const units: any[] = Array.isArray(rawStatus?.ams?.ams) ? rawStatus.ams.ams : [];
98
+ let colour: unknown;
99
+ if (tray === 254 || tray === 255) {
100
+ const slots: any[] = Array.isArray(rawStatus?.vir_slot) ? rawStatus.vir_slot : [];
101
+ colour = slots.find(slot => Number(slot?.id) === tray)?.tray_color ?? (tray === 254 ? rawStatus?.vt_tray?.tray_color : undefined);
102
+ } else if (tray >= 128) {
103
+ colour = units.find(unit => Number(unit?.id) === tray)?.tray?.[0]?.tray_color;
104
+ } else if (tray >= 0) {
105
+ const unit = units.find(candidate => Number(candidate?.id) === Math.floor(tray / 4));
106
+ colour = (Array.isArray(unit?.tray) ? unit.tray : []).find((slot: any) => Number(slot?.id) === tray % 4)?.tray_color;
107
+ }
108
+ return typeof colour === "string" ? colour : undefined;
109
+ }
110
+
111
+ /**
112
+ * PrintParams.ams_mapping_info exactly as Bambu Studio and FULU's slicer build it
113
+ * (SelectMachineDialog::get_ams_mapping_result) and send it with every print: one entry per
114
+ * project filament. A mapped filament names its tray, its filament id and type, its colour as
115
+ * #RRGGBBAA and the tray's colour as the printer reports it; an unmapped one carries ams -1.
116
+ * Filament-map nozzle 1 (left) is cloud nozzle 1 and 2 (right) is 0 (CloudTaskNozzleId).
117
+ */
118
+ export function studioAmsMappingInfo(filaments: ProjectFilament[], mapping: number[], colourOfTray: (tray: number) => string | undefined): Array<Record<string, unknown>> {
119
+ return filaments.map((filament, position) => {
120
+ const tray = mapping[position];
121
+ if (tray === undefined || tray < 0) return { ams: -1, targetColor: "", filamentId: "", filamentType: "" };
122
+ const hex = filament.colour.replace(/^#/, "").toUpperCase();
123
+ return {
124
+ ams: tray,
125
+ targetColor: colourOfTray(tray) ?? "",
126
+ filamentId: filament.filamentId,
127
+ filamentType: filament.type,
128
+ ...(filament.nozzle === 1 ? { nozzleId: 1 } : filament.nozzle === 2 ? { nozzleId: 0 } : {}),
129
+ sourceColor: /^[0-9A-F]{6}$/.test(hex) ? `#${hex}FF` : /^[0-9A-F]{8}$/.test(hex) ? `#${hex}` : "",
130
+ };
131
+ });
132
+ }