mcp-3d-printer-server 1.2.9 → 1.2.12

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 (86) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/CONTRIBUTORS.md +56 -0
  3. package/README.md +528 -811
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +1 -0
  6. package/dist/blender-mcp-bridge.js +209 -21
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +614 -110
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -126
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +610 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +371 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +16 -9
  58. package/server.json +56 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +200 -19
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +686 -137
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -164
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +429 -0
  80. package/src/safety/printer-state.ts +344 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
@@ -0,0 +1,404 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readBoundedPrintFile } from "./archive.js";
3
+ import { materialNozzleCeiling, normalizeMaterial } from "./limits.js";
4
+
5
+ /**
6
+ * Thermal safety for printers without a Bambu model table (OctoPrint,
7
+ * Klipper/Moonraker, Duet/RepRapFirmware, Repetier, PrusaLink, Creality).
8
+ *
9
+ * There is no invented per-model table. Independent server-side ceilings apply
10
+ * to every heater and can only be changed through the server environment; a
11
+ * tool argument or a file can never raise them. Material ceilings are shared
12
+ * with the Bambu path (limits.ts) and positive nozzle heating always needs a
13
+ * declared material.
14
+ */
15
+ export type Heater = "nozzle" | "bed" | "chamber";
16
+ export interface HeaterCeilings { nozzle: number; bed: number; chamber: number }
17
+
18
+ export const DEFAULT_GENERIC_CEILINGS: Readonly<HeaterCeilings> = { nozzle: 300, bed: 120, chamber: 60 };
19
+ const CEILING_ENV: Readonly<Record<Heater, string>> = {
20
+ nozzle: "PRINTER_MAX_NOZZLE_TEMP",
21
+ bed: "PRINTER_MAX_BED_TEMP",
22
+ chamber: "PRINTER_MAX_CHAMBER_TEMP",
23
+ };
24
+ // Typo guard for the environment override, not a hardware claim.
25
+ const ENV_SANITY_MAX: Readonly<HeaterCeilings> = { nozzle: 500, bed: 200, chamber: 100 };
26
+
27
+ /** Resolve ceilings from the server environment; an invalid override refuses heating. */
28
+ export function genericCeilings(env: NodeJS.ProcessEnv = process.env): HeaterCeilings {
29
+ const ceilings: HeaterCeilings = { ...DEFAULT_GENERIC_CEILINGS };
30
+ for (const heater of ["nozzle", "bed", "chamber"] as const) {
31
+ const raw = env[CEILING_ENV[heater]]?.trim();
32
+ if (!raw) continue;
33
+ const value = Number(raw);
34
+ if (!/^\d+(?:\.\d+)?$/.test(raw) || !Number.isFinite(value) || value > ENV_SANITY_MAX[heater]) {
35
+ throw new Error(
36
+ `${CEILING_ENV[heater]} must be a number from 0 to ${ENV_SANITY_MAX[heater]} °C; received "${raw}". ` +
37
+ "Positive heating and printing are refused until the server configuration is corrected."
38
+ );
39
+ }
40
+ ceilings[heater] = value;
41
+ }
42
+ return ceilings;
43
+ }
44
+
45
+ export function finiteTemperature(value: unknown, label = "temperature"): number {
46
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
47
+ throw new Error(`${label} must be a finite, non-negative number in °C; received ${String(value)}.`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ /** Check one heater target against server ceilings and every candidate material. */
53
+ export function validateGenericTemperature(
54
+ heater: Heater,
55
+ value: unknown,
56
+ materials: readonly string[] | undefined,
57
+ ceilings: HeaterCeilings = genericCeilings()
58
+ ): number {
59
+ const temperature = finiteTemperature(value, `${heater} temperature`);
60
+ // Switching a heater off never needs a declaration.
61
+ if (temperature === 0) return 0;
62
+ if (temperature > ceilings[heater]) {
63
+ throw new Error(
64
+ `Print safety: ${heater} temperature ${temperature} C exceeds the ${ceilings[heater]} C server ceiling ` +
65
+ `(${CEILING_ENV[heater]}). Tool arguments and files cannot raise it.`
66
+ );
67
+ }
68
+ if (heater === "nozzle") {
69
+ if (!materials?.length) {
70
+ throw new Error(
71
+ "Print safety: positive nozzle heating requires a declared material. Slice with filament_type metadata " +
72
+ "(for example '; filament_type = PLA') or pass material (PLA, PETG, ABS, ASA, TPU, PA, PC, ...)."
73
+ );
74
+ }
75
+ for (const declared of materials) {
76
+ const ceiling = materialNozzleCeiling(declared);
77
+ if (ceiling === undefined) {
78
+ throw new Error(
79
+ `Print safety: unknown material '${declared}' cannot authorize nozzle heating. ` +
80
+ "Pass material with a known type such as PLA, PETG, ABS, ASA, TPU, PA or PC."
81
+ );
82
+ }
83
+ if (temperature > ceiling) {
84
+ throw new Error(
85
+ `Print safety: ${normalizeMaterial(declared)} nozzle temperature ${temperature} C exceeds the independent ${ceiling} C material policy limit.`
86
+ );
87
+ }
88
+ }
89
+ }
90
+ return temperature;
91
+ }
92
+
93
+ export interface ThermalTarget {
94
+ line: number;
95
+ command: string;
96
+ heater: Heater;
97
+ value: number;
98
+ /** Physical tool index when known; undefined means any tool may be affected. */
99
+ tool?: number;
100
+ }
101
+
102
+ export interface GcodeThermalScan {
103
+ targets: ThermalTarget[];
104
+ /** Raw slicer filament_type declarations, per tool, when present. */
105
+ declaredMaterials?: string[];
106
+ commandCount: number;
107
+ }
108
+
109
+ const NUMBER = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)`;
110
+ const PARAMETER = new RegExp(`^([A-Z])\\s*(${NUMBER}(?::${NUMBER})*)?`, "i");
111
+ const fail = (message: string): never => { throw new Error(`Print safety: ${message}`); };
112
+
113
+ // Commands whose trailing text is free-form display text, never parameters.
114
+ const DISPLAY_COMMANDS = new Set(["M0", "M1", "M117", "M118", "M291", "M292"]);
115
+ // Not permitted inside a print job: they change heater limits, tune or create
116
+ // heaters, switch temperature units, or run/write another uninspected program.
117
+ const REFUSED_COMMANDS: Readonly<Record<string, string>> = {
118
+ M143: "changes the firmware heater maximum",
119
+ M144: "switches the bed between stored standby/active temperatures the job cannot show",
120
+ M301: "changes hotend heater control",
121
+ M304: "changes bed heater control",
122
+ M306: "changes the heater model",
123
+ M307: "changes the heater model",
124
+ M303: "runs a heater autotune",
125
+ M570: "changes heater fault detection",
126
+ M950: "creates or remaps a heater",
127
+ M149: "changes temperature units",
128
+ M98: "runs a printer-side macro file",
129
+ M32: "starts another print file",
130
+ M23: "selects another print file",
131
+ M24: "starts another print file",
132
+ M28: "writes a new file to printer storage",
133
+ M29: "writes a new file to printer storage",
134
+ M30: "deletes or ends a printer-side file",
135
+ M997: "updates firmware",
136
+ };
137
+ const EMBEDDED_THERMAL = /(?:^|[\s;])(?:M0*(?:104|109|140|190|141|191|568|303|143)|G0*10)(?![\d.])/i;
138
+
139
+ function parseParameters(text: string, code: string, allowed: string, flags = ""): Map<string, number[]> {
140
+ const result = new Map<string, number[]>();
141
+ let rest = text.trim();
142
+ while (rest) {
143
+ const token = rest.match(PARAMETER);
144
+ if (!token) return fail(`unsupported ${code} parameter syntax '${rest.slice(0, 40)}'`);
145
+ const key = token[1].toUpperCase();
146
+ if (!allowed.includes(key)) return fail(`unsupported ${code} parameter ${key}; thermal inspection fails closed`);
147
+ if (result.has(key)) return fail(`duplicate ${code} parameter ${key}`);
148
+ if (token[2] === undefined && !flags.includes(key)) return fail(`missing numeric ${code} ${key} value`);
149
+ const values = token[2] === undefined ? [] : token[2].split(":").map(Number);
150
+ if (values.some((value) => !Number.isFinite(value))) return fail(`nonfinite ${code} ${key} value`);
151
+ result.set(key, values);
152
+ rest = rest.slice(token[0].length).trimStart();
153
+ }
154
+ return result;
155
+ }
156
+
157
+ function toolIndex(values: number[] | undefined, code: string): number | undefined {
158
+ if (values === undefined) return undefined;
159
+ if (values.length !== 1 || !Number.isInteger(values[0]) || values[0] < 0) return fail(`${code} tool index must be a nonnegative integer`);
160
+ return values[0];
161
+ }
162
+
163
+ function heaterForKlipperName(name: string): { heater: Heater; tool?: number } | undefined {
164
+ const normalized = name.trim().replace(/^"|"$/g, "").toLowerCase();
165
+ const extruder = normalized.match(/^extruder(\d*)$/);
166
+ if (extruder) return { heater: "nozzle", tool: extruder[1] ? Number(extruder[1]) : 0 };
167
+ if (normalized === "heater_bed" || normalized === "bed") return { heater: "bed" };
168
+ if (/(?:^|[\s_])chamber(?:$|[\s_])|^chamber/.test(normalized)) return { heater: "chamber" };
169
+ return undefined;
170
+ }
171
+
172
+ /** Best-effort classification of printer macro parameters that name a heater. */
173
+ function macroParameterHeater(key: string): Heater | "ambiguous" | undefined {
174
+ const upper = key.toUpperCase();
175
+ const namesHeater = /^(?:BED|HEATER_BED|EXTRUDER\d*|HOTEND|NOZZLE|CHAMBER)$/.test(upper);
176
+ if (!namesHeater && !/TEMP/.test(upper)) return undefined;
177
+ if (/BED/.test(upper)) return "bed";
178
+ if (/CHAMBER/.test(upper)) return "chamber";
179
+ if (/EXTRUDER|HOTEND|NOZZLE|TOOL/.test(upper)) return "nozzle";
180
+ return "ambiguous";
181
+ }
182
+
183
+ /**
184
+ * Parse every heater target in G-code text. This deliberately covers only
185
+ * commands that affect heat; unrelated vendor commands do not need a global
186
+ * allowlist. It cannot simulate printer-side macro bodies: Klipper macro
187
+ * parameters that name a heater are checked, but a macro's own body is printer
188
+ * configuration outside the uploaded file.
189
+ */
190
+ export function scanGcodeThermal(text: string): GcodeThermalScan {
191
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f�]/.test(text)) {
192
+ return fail("binary or malformed G-code cannot be inspected; export plain-text G-code (not .bgcode)");
193
+ }
194
+ const lines = text.split(/\r\n|\n|\r/);
195
+ const targets: ThermalTarget[] = [];
196
+ const declarations: string[][] = [];
197
+ let activeTool: number | undefined;
198
+ // RepRapFirmware tool temperatures this file has set (active S / standby R),
199
+ // so M568 A1/A2 activation never uses an uninspected printer-side value.
200
+ const toolTargetsSet = new Map<number, { active: boolean; standby: boolean }>();
201
+ const noteToolTargets = (tool: number | undefined, active: boolean, standby: boolean) => {
202
+ if (tool === undefined) return;
203
+ const current = toolTargetsSet.get(tool) ?? { active: false, standby: false };
204
+ toolTargetsSet.set(tool, { active: current.active || active, standby: current.standby || standby });
205
+ };
206
+ let commandCount = 0;
207
+
208
+ for (let index = 0; index < lines.length; index++) {
209
+ const lineNumber = index + 1;
210
+ try {
211
+ const raw = lines[index];
212
+ const metadata = raw.match(/^\s*;\s*filament_type\s*[=:]\s*(.*?)\s*$/i);
213
+ if (metadata) {
214
+ const values = metadata[1].split(/[;,]/).map((value) => value.trim().replace(/^"|"$/g, "")).filter(Boolean);
215
+ if (!values.length) fail("empty filament_type metadata");
216
+ declarations.push(values);
217
+ continue;
218
+ }
219
+ let line = raw;
220
+ const comment = line.indexOf(";");
221
+ if (comment >= 0) line = line.slice(0, comment);
222
+ line = line.trim().replace(/^N\d+\s*/i, "").replace(/\*\d+\s*$/, "").trim();
223
+ if (!line || line === "%") continue;
224
+
225
+ const classic = line.match(/^([GMT])\s*(-?\d+(?:\.\d+)?)/i);
226
+ if (!classic) {
227
+ commandCount++;
228
+ const word = line.match(/^([A-Za-z_][A-Za-z0-9_]*)/)?.[1]?.toUpperCase() ?? "";
229
+ if (/^T[XC?]$/.test(word) || /^T\?/.test(line)) { activeTool = undefined; continue; }
230
+ if (["RESPOND", "M117", "TEMPERATURE_WAIT", "SET_TEMPERATURE_FAN_TARGET"].includes(word)) continue;
231
+ const rest = line.slice(word.length);
232
+ const pairs = [...rest.matchAll(/([A-Za-z_][A-Za-z0-9_]*)\s*=\s*("[^"]*"|'[^']*'|\S+)/g)];
233
+ if (word === "SET_HEATER_TEMPERATURE") {
234
+ const params = new Map(pairs.map((pair) => [pair[1].toUpperCase(), pair[2]]));
235
+ for (const key of params.keys()) if (!["HEATER", "TARGET"].includes(key)) fail(`unsupported SET_HEATER_TEMPERATURE parameter ${key}`);
236
+ const heaterName = params.get("HEATER");
237
+ const heater = heaterName ? heaterForKlipperName(heaterName) : undefined;
238
+ if (!heater) fail(`SET_HEATER_TEMPERATURE heater '${heaterName ?? ""}' is unknown; cannot establish its ceiling`);
239
+ const target = params.get("TARGET");
240
+ if (target !== undefined) {
241
+ if (!new RegExp(`^${NUMBER}$`).test(target)) fail(`SET_HEATER_TEMPERATURE TARGET '${target}' is not a literal number`);
242
+ targets.push({ line: lineNumber, command: word, heater: heater!.heater, value: Number(target), tool: heater!.tool });
243
+ }
244
+ continue;
245
+ }
246
+ for (const [, key, value] of pairs) {
247
+ const heater = macroParameterHeater(key);
248
+ if (!heater) continue;
249
+ if (heater === "ambiguous") fail(`${word} parameter ${key} looks like a temperature but does not name its heater; use BED/EXTRUDER/CHAMBER names`);
250
+ if (!new RegExp(`^${NUMBER}$`).test(value)) fail(`${word} ${key}='${value}' is not a literal number (unresolved placeholder?)`);
251
+ targets.push({ line: lineNumber, command: word, heater: heater as Heater, value: Number(value), tool: heater === "nozzle" ? activeTool : undefined });
252
+ }
253
+ continue;
254
+ }
255
+
256
+ commandCount++;
257
+ const letter = classic[1].toUpperCase();
258
+ const [base, sub] = classic[2].split(".");
259
+ const code = `${letter}${Number(base)}${sub === undefined ? "" : `.${sub}`}`;
260
+ const argumentsText = line.slice(classic[0].length);
261
+ if (DISPLAY_COMMANDS.has(code)) continue;
262
+ if (letter === "T") {
263
+ const tool = Number(base);
264
+ activeTool = sub === undefined && Number.isInteger(tool) && tool >= 0 ? tool : undefined;
265
+ if (EMBEDDED_THERMAL.test(argumentsText)) fail("multiple commands on one line are unsupported");
266
+ continue;
267
+ }
268
+ if (REFUSED_COMMANDS[code]) fail(`${code} ${REFUSED_COMMANDS[code]} and is not permitted in a checked print job`);
269
+ if (/^M(?:104|109|140|190|141|191|568)\./.test(code) || /^G10\./.test(code)) {
270
+ fail(`unsupported thermal command variant ${code}; re-slice with standard heater commands`);
271
+ }
272
+ if (code === "M104" || code === "M109") {
273
+ // Marlin B is the AUTOTEMP maximum target; I would select an unknown preset.
274
+ const params = parseParameters(argumentsText, code, "SRTBF", "F");
275
+ const tool = toolIndex(params.get("T"), code) ?? activeTool;
276
+ for (const key of ["S", "R", "B"]) {
277
+ for (const value of params.get(key) ?? []) targets.push({ line: lineNumber, command: code, heater: "nozzle", value, tool });
278
+ }
279
+ // On RepRapFirmware, M104/M109 S set the tool's active temperature.
280
+ noteToolTargets(tool, params.has("S") || params.has("R"), false);
281
+ } else if (["M140", "M190", "M141", "M191"].includes(code)) {
282
+ const params = parseParameters(argumentsText, code, "SRPH");
283
+ const heater: Heater = code === "M140" || code === "M190" ? "bed" : "chamber";
284
+ for (const key of ["S", "R"]) {
285
+ for (const value of params.get(key) ?? []) targets.push({ line: lineNumber, command: code, heater, value });
286
+ }
287
+ } else if (code === "G10") {
288
+ // RepRapFirmware tool temperatures; with L it sets coordinates instead.
289
+ const params = parseParameters(argumentsText, code, "PSRLXYZUVWABCEIJK");
290
+ if (!params.has("L") && (params.has("S") || params.has("R"))) {
291
+ const tool = toolIndex(params.get("P"), code) ?? activeTool;
292
+ for (const key of ["S", "R"]) {
293
+ for (const value of params.get(key) ?? []) targets.push({ line: lineNumber, command: code, heater: "nozzle", value, tool });
294
+ }
295
+ noteToolTargets(tool, params.has("S"), params.has("R"));
296
+ }
297
+ } else if (code === "M568") {
298
+ const params = parseParameters(argumentsText, code, "PSRAF");
299
+ const tool = toolIndex(params.get("P"), code) ?? activeTool;
300
+ for (const key of ["S", "R"]) {
301
+ for (const value of params.get(key) ?? []) targets.push({ line: lineNumber, command: code, heater: "nozzle", value, tool });
302
+ }
303
+ noteToolTargets(tool, params.has("S"), params.has("R"));
304
+ // A1 (standby) / A2 (active) heat to the tool's stored temperature. Only
305
+ // allow that when this file set it, so the ceilings covered the value.
306
+ for (const state of params.get("A") ?? []) {
307
+ if (state !== 1 && state !== 2) continue;
308
+ const known = tool === undefined ? undefined : toolTargetsSet.get(tool);
309
+ if (!(state === 2 ? known?.active : known?.standby)) {
310
+ fail(`M568 A${state} activates ${tool === undefined ? "an unknown tool" : `tool ${tool}`} at a ${state === 2 ? "active (S)" : "standby (R)"} temperature this file does not set; add the literal target first`);
311
+ }
312
+ }
313
+ } else if (EMBEDDED_THERMAL.test(argumentsText)) {
314
+ fail("multiple commands on one line are unsupported");
315
+ }
316
+ } catch (error) {
317
+ throw new Error(`Print safety line ${lineNumber}: ${(error instanceof Error ? error.message : String(error)).replace(/^Print safety: /, "")}`);
318
+ }
319
+ }
320
+
321
+ let declaredMaterials: string[] | undefined;
322
+ if (declarations.length) {
323
+ const first = JSON.stringify(declarations[0]);
324
+ if (declarations.some((values) => JSON.stringify(values) !== first)) fail("contradictory filament_type metadata");
325
+ declaredMaterials = declarations[0];
326
+ }
327
+ return { targets, declaredMaterials, commandCount };
328
+ }
329
+
330
+ export interface GenericPrintInspection {
331
+ sha256: string;
332
+ /** Materials used for ceilings, per tool (normalized when known). */
333
+ materials: string[];
334
+ materialSource: "file" | "declared" | "none";
335
+ peaks: HeaterCeilings;
336
+ ceilings: HeaterCeilings;
337
+ targetCount: number;
338
+ }
339
+
340
+ /** Resolve the caller's declaration against the file's slicer metadata. */
341
+ function resolveMaterials(fileMaterials: string[] | undefined, declared: string | undefined): { materials: string[]; source: GenericPrintInspection["materialSource"] } {
342
+ if (declared !== undefined) {
343
+ const normalized = normalizeMaterial(declared);
344
+ if (!normalized) {
345
+ return fail(`unknown declared material '${declared}'; use a known type such as PLA, PETG, ABS, ASA, TPU, PA or PC`);
346
+ }
347
+ for (const fileMaterial of fileMaterials ?? []) {
348
+ const fileNormalized = normalizeMaterial(fileMaterial);
349
+ // A known file declaration can never be relabelled by an argument.
350
+ if (fileNormalized && fileNormalized !== normalized) {
351
+ return fail(`declared material ${declared} contradicts the G-code filament_type ${fileMaterial}`);
352
+ }
353
+ }
354
+ return { materials: [declared], source: "declared" };
355
+ }
356
+ if (fileMaterials?.length) return { materials: fileMaterials, source: "file" };
357
+ return { materials: [], source: "none" };
358
+ }
359
+
360
+ /** Inspect exact G-code bytes: every heater target against ceilings and materials. */
361
+ export function inspectGcodeThermal(bytes: Buffer | string, options: { material?: string; ceilings?: HeaterCeilings } = {}): GenericPrintInspection {
362
+ const buffer = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : bytes;
363
+ const ceilings = options.ceilings ?? genericCeilings();
364
+ const scan = scanGcodeThermal(buffer.toString("utf8"));
365
+ if (!scan.commandCount) return fail("the G-code contains no printable commands");
366
+ const { materials, source } = resolveMaterials(scan.declaredMaterials, options.material);
367
+ const peaks: HeaterCeilings = { nozzle: 0, bed: 0, chamber: 0 };
368
+ for (const target of scan.targets) {
369
+ let candidates: string[] | undefined;
370
+ if (target.heater === "nozzle") {
371
+ if (materials.length <= 1) candidates = materials;
372
+ else if (target.tool === undefined) candidates = materials; // unknown tool: every material must allow it
373
+ else if (target.tool < materials.length) candidates = [materials[target.tool]];
374
+ else fail(`line ${target.line}: tool ${target.tool} has no declared material`);
375
+ }
376
+ try {
377
+ validateGenericTemperature(target.heater, target.value, candidates, ceilings);
378
+ } catch (error) {
379
+ throw new Error(`Print safety line ${target.line} (${target.command}): ${(error instanceof Error ? error.message : String(error)).replace(/^Print safety: /, "")}`);
380
+ }
381
+ peaks[target.heater] = Math.max(peaks[target.heater], target.value);
382
+ }
383
+ return {
384
+ sha256: createHash("sha256").update(buffer).digest("hex"),
385
+ materials: materials.map((material) => normalizeMaterial(material) ?? material),
386
+ materialSource: source,
387
+ peaks,
388
+ ceilings,
389
+ targetCount: scan.targets.length,
390
+ };
391
+ }
392
+
393
+ const INSPECTABLE_GCODE = /\.(?:gcode|gco|g)$/i;
394
+
395
+ export function assertInspectableGcodeName(name: string): void {
396
+ if (!INSPECTABLE_GCODE.test(name)) {
397
+ fail(`'${name}' is not plain-text G-code (.gcode, .gco or .g). Binary .bgcode and project files cannot be inspected for heater targets`);
398
+ }
399
+ }
400
+
401
+ /** Read a bounded file and inspect it. */
402
+ export async function inspectGcodeFile(filePath: string, options: { material?: string; ceilings?: HeaterCeilings } = {}): Promise<GenericPrintInspection> {
403
+ return inspectGcodeThermal(await readBoundedPrintFile(filePath), options);
404
+ }
@@ -0,0 +1,98 @@
1
+ /** Independent hardware ceilings. Editable slicer profiles never raise these limits.
2
+ * Manufacturer references (verified 2026-09-27):
3
+ * P1P: https://public-cdn.bambulab.com/store/bambulab-P1P-tech-specs.pdf
4
+ * P1S: https://cdn1.bambulab.com/documentation/quick-start-59b0cefdc0fc4/P1S/English%20version-Quick%20Start%20Guide%20for%20P1S.pdf
5
+ * A1: https://bambulab.com/en/a1/tech-specs
6
+ * A1 mini: https://us.store.bambulab.com/products/a1-mini
7
+ * X1/X1C: https://cdn1.bambulab.com/documentation/Quick%20Start%20Guide%20for%20X1%20Combo%26X1-Carbon%20Combo-v1.pdf
8
+ * X1E: https://cdn1.bambulab.com/x1e/spec/Bambu%20Lab%20X1E%20Technical%20Specification.pdf
9
+ * H2D: https://cdn1.bambulab.com/documentation/h2d/en/H2D_Laser_Full_Combo_20250305.pdf
10
+ * H2D Pro: https://bambulab.com/en/h2d-pro/tech-specs
11
+ * H2C: https://csm.bblcdn.com/hub/eca403f48aee405393afc97adbbf6422.pdf
12
+ * H2S: https://bambulab.com/it/support/buying-guide
13
+ * P2S: https://blog.bambulab.com/the-icon-redefined-meet-the-p2s-a-completely-reengineered-version-of-the-ultra-productive-p1-series/
14
+ * X2D: https://csm.bblcdn.com/hub/7c58718aaa2e40edab56efb87419a96a.pdf
15
+ * X1-family bed caps use 110 C: 120 C requires verified 110 V supply, unavailable here.
16
+ * A zero chamber cap means no supported active chamber heater, not ambient temperature.
17
+ */
18
+ export const MACHINE_LIMITS: Readonly<Record<string, {nozzle: number; bed: number; chamber: number; volume: readonly number[]}>> = {
19
+ p1p: {nozzle:300,bed:100,chamber:0,volume:[256,256,256]},
20
+ p1s: {nozzle:300,bed:100,chamber:0,volume:[256,256,256]},
21
+ p2s: {nozzle:300,bed:110,chamber:0,volume:[256,256,256]},
22
+ a1: {nozzle:300,bed:100,chamber:0,volume:[256,256,256]},
23
+ a1mini: {nozzle:300,bed:80,chamber:0,volume:[180,180,180]},
24
+ x1: {nozzle:300,bed:110,chamber:0,volume:[256,256,256]},
25
+ x1c: {nozzle:300,bed:110,chamber:0,volume:[256,256,256]},
26
+ x1e: {nozzle:320,bed:110,chamber:60,volume:[256,256,256]},
27
+ h2d: {nozzle:350,bed:120,chamber:65,volume:[350,320,325]},
28
+ h2dpro: {nozzle:350,bed:120,chamber:65,volume:[350,320,325]},
29
+ h2c: {nozzle:350,bed:120,chamber:65,volume:[330,320,325]},
30
+ h2s: {nozzle:350,bed:120,chamber:65,volume:[340,320,340]},
31
+ x2d: {nozzle:300,bed:120,chamber:65,volume:[256,256,260]},
32
+ };
33
+
34
+ export function normalizeModel(value: unknown): string | undefined {
35
+ if (typeof value !== 'string') return undefined;
36
+ let model = value.trim().toLowerCase().replace(/^bambu\s*(?:lab\s*)?/, '').replace(/[\s_-]/g, '');
37
+ if (model === 'x1carbon') model = 'x1c';
38
+ return Object.prototype.hasOwnProperty.call(MACHINE_LIMITS, model) ? model : undefined;
39
+ }
40
+
41
+ /** Policy ceilings, NOT manufacturer-recommended print temperatures or decomposition
42
+ * thresholds. The normal PLA ceiling of 260 C includes Bambu PLA Aero's bundled
43
+ * range_high=260 (other bundled PLA ranges are lower). A separate 290 C startup
44
+ * allowance is only used by the inspector for the exact one-shot X1E common flush;
45
+ * it must never authorize manual heating or sustained deposition at that target.
46
+ * File/profile nozzle_temperature_range_high and RFID limits cannot raise this policy.
47
+ */
48
+ const MATERIAL_LIMITS: Readonly<Record<string, number>> = {
49
+ PLA:260, PETG:300, ABS:300, ASA:300, TPU:290, PVA:290, BVOH:290,
50
+ HIPS:300, PP:300, POM:250, PET:350, PA:350, PC:350, PPA:350, PPS:350,
51
+ 'SUPPORT-PLA':290, 'SUPPORT-PA':350,
52
+ };
53
+ /** Independent nozzle policy ceiling for a declared material, or undefined when unknown. */
54
+ export function materialNozzleCeiling(value: unknown): number | undefined {
55
+ const material = normalizeMaterial(value);
56
+ return material ? MATERIAL_LIMITS[material] : undefined;
57
+ }
58
+ export function normalizeMaterial(value: unknown): string | undefined {
59
+ if (typeof value !== 'string') return undefined;
60
+ const material = value.trim().toUpperCase().replace(/^BAMBU(?: LAB)?\s+/, '').replace(/^GENERIC\s+/, '');
61
+ if (material === 'SUPPORT-PLA' || material === 'SUPPORT-PA') return material;
62
+ if (/^SUPPORT(?: FOR)? (?:PLA(?:\/PETG)?|W)(?:\b|$)/.test(material)) return 'SUPPORT-PLA';
63
+ if (/^SUPPORT(?: FOR)? (?:PA(?:\/PET)?|G)(?:\b|$)/.test(material)) return 'SUPPORT-PA';
64
+ const match = material.match(/^(PPA|PPS|PETG|PLA|ABS|ASA|TPU|PVA|BVOH|HIPS|PP|POM|PET|PA(?:6|12|HT)?|PC)(?=$|[\s+_/-])/);
65
+ return match ? match[1].replace(/^PA(?:6|12|HT)$/, 'PA') : undefined;
66
+ }
67
+
68
+ function validate(component: 'nozzle'|'bed'|'chamber', value: unknown, model: string, materials: string[]|undefined, startupPurge=false): number {
69
+ if ((typeof value !== 'number' && (typeof value !== 'string' || !/^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim()))) || !Number.isFinite(Number(value)) || Number(value) < 0)
70
+ throw new Error(`${component} temperature must be a finite nonnegative number; received ${String(value)}`);
71
+ const temperature = Number(value);
72
+ // Switching a heater off needs no material or machine declaration.
73
+ if (temperature === 0) return 0;
74
+ const normalized = normalizeModel(model);
75
+ if (!normalized) throw new Error(`Unknown printer model '${model}'; cannot establish temperature limits`);
76
+ const maximum = MACHINE_LIMITS[normalized][component];
77
+ if (maximum === undefined || temperature > maximum) throw new Error(`${normalized} ${component} temperature ${temperature} C exceeds the ${maximum ?? 0} C hardware limit`);
78
+ if (component === 'nozzle') {
79
+ if (!materials?.length) throw new Error('Nozzle heating requires a declared material for every affected filament');
80
+ for (const declaration of materials) {
81
+ const material = normalizeMaterial(declaration);
82
+ if (!material) throw new Error(`Unknown material '${declaration}'; cannot authorize positive nozzle temperature`);
83
+ const ceiling = startupPurge && material === 'PLA' ? 290 : MATERIAL_LIMITS[material];
84
+ if (temperature > ceiling) throw new Error(`${material} nozzle temperature ${temperature} C exceeds the independent ${ceiling} C material policy limit`);
85
+ }
86
+ }
87
+ return temperature;
88
+ }
89
+
90
+ export function validateTemperature(component: 'nozzle'|'bed'|'chamber', value: unknown, model: string, materials?: string[]): number {
91
+ return validate(component,value,model,materials);
92
+ }
93
+
94
+ /** Inspector-only exception: the caller must have verified the bounded startup
95
+ * purge form and that no layer/deposition has begun. Never use for manual heat. */
96
+ export function validateStartupPurgeTemperature(value: unknown, model: string, materials: string[]): number {
97
+ return validate('nozzle',value,model,materials,true);
98
+ }