mcp-3d-printer-server 1.2.9 → 1.2.11

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 +207 -0
  2. package/CONTRIBUTORS.md +49 -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 +517 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +365 -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 +29 -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 +354 -0
  80. package/src/safety/printer-state.ts +339 -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,126 @@
1
+ import { spawn } from "node:child_process";
2
+ import { SlicerError, tailText } from "./slicer-error.js";
3
+
4
+ /** Bytes of each stream kept for diagnostics; verbose slicers are never truncated into a failure. */
5
+ const OUTPUT_TAIL_BYTES = 64 * 1024;
6
+
7
+ export interface SlicerRunOptions {
8
+ command: string;
9
+ args: string[];
10
+ timeoutMs: number;
11
+ slicerType: string;
12
+ /** Polled while the slicer runs; returning true kills it. */
13
+ isCancelled?: () => boolean;
14
+ }
15
+
16
+ export interface SlicerRunResult {
17
+ stdoutTail: string;
18
+ stderrTail: string;
19
+ }
20
+
21
+ class TailBuffer {
22
+ private chunks: Buffer[] = [];
23
+ private size = 0;
24
+
25
+ push(chunk: Buffer): void {
26
+ this.chunks.push(chunk);
27
+ this.size += chunk.length;
28
+ while (this.size > OUTPUT_TAIL_BYTES && this.chunks.length > 1) {
29
+ this.size -= this.chunks.shift()!.length;
30
+ }
31
+ }
32
+
33
+ text(): string {
34
+ const all = Buffer.concat(this.chunks);
35
+ return all.subarray(Math.max(0, all.length - OUTPUT_TAIL_BYTES)).toString("utf8");
36
+ }
37
+ }
38
+
39
+ function describeOutput(stdoutTail: string, stderrTail: string): string {
40
+ const stdout = tailText(stdoutTail);
41
+ const stderr = tailText(stderrTail);
42
+ return [
43
+ stdout ? `Slicer stdout (tail):\n${stdout}` : "Slicer stdout: (empty)",
44
+ stderr ? `Slicer stderr (tail):\n${stderr}` : "Slicer stderr: (empty)",
45
+ ].join("\n");
46
+ }
47
+
48
+ /**
49
+ * Run a slicer executable with a timeout and cancellation, keeping bounded
50
+ * stdout/stderr tails. A nonzero exit, signal, spawn failure, timeout, or
51
+ * cancellation rejects with a SlicerError that includes the evidence.
52
+ */
53
+ export function runSlicerProcess(options: SlicerRunOptions): Promise<SlicerRunResult> {
54
+ const { command, args, timeoutMs, slicerType, isCancelled } = options;
55
+ return new Promise((resolve, reject) => {
56
+ const stdout = new TailBuffer();
57
+ const stderr = new TailBuffer();
58
+ let settled = false;
59
+ let timedOut = false;
60
+ let cancelled = false;
61
+
62
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
63
+ child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
64
+ child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
65
+
66
+ const cleanup = () => {
67
+ clearTimeout(timeoutHandle);
68
+ clearInterval(cancelHandle);
69
+ };
70
+ const settle = (callback: () => void) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ cleanup();
74
+ callback();
75
+ };
76
+
77
+ const timeoutHandle = setTimeout(() => {
78
+ timedOut = true;
79
+ try { child.kill("SIGKILL"); } catch { /* reported through close/error */ }
80
+ }, timeoutMs);
81
+ const cancelHandle = setInterval(() => {
82
+ if (isCancelled?.()) {
83
+ cancelled = true;
84
+ try { child.kill("SIGKILL"); } catch { /* reported through close/error */ }
85
+ }
86
+ }, 500);
87
+
88
+ child.on("error", (error) => {
89
+ settle(() => reject(new SlicerError(
90
+ "execution",
91
+ `Could not start slicer "${command}": ${error.message}. Check SLICER_PATH/slicer_path.`,
92
+ { slicerType, slicerPath: command, stdoutTail: stdout.text(), stderrTail: stderr.text() }
93
+ )));
94
+ });
95
+
96
+ child.on("close", (code, signal) => {
97
+ const stdoutTail = stdout.text();
98
+ const stderrTail = stderr.text();
99
+ if (!timedOut && !cancelled && code === 0 && !signal) {
100
+ settle(() => resolve({ stdoutTail, stderrTail }));
101
+ return;
102
+ }
103
+ const reason = timedOut
104
+ ? `Slicer exceeded timeout ${timeoutMs}ms and was killed`
105
+ : cancelled
106
+ ? "Slicing operation cancelled"
107
+ : signal
108
+ ? `Slicer was terminated by signal ${signal}`
109
+ : `Slicer exited with code ${code}`;
110
+ settle(() => reject(new SlicerError(
111
+ "execution",
112
+ `${reason} (${slicerType}: ${command}).\n${describeOutput(stdoutTail, stderrTail)}`,
113
+ {
114
+ slicerType,
115
+ slicerPath: command,
116
+ exitCode: code,
117
+ signal: signal ?? null,
118
+ timedOut,
119
+ cancelled,
120
+ stdoutTail: tailText(stdoutTail),
121
+ stderrTail: tailText(stderrTail),
122
+ }
123
+ )));
124
+ });
125
+ });
126
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Slicer failures carry their own phase and actionable suggestion so the MCP
3
+ * error handler never falls back to printer-connectivity advice for a local
4
+ * profile or slicer problem.
5
+ */
6
+
7
+ export type SlicerFailurePhase = "preparation" | "execution" | "output";
8
+
9
+ export interface SlicerFailureDetails {
10
+ slicerType?: string;
11
+ slicerPath?: string;
12
+ exitCode?: number | null;
13
+ signal?: string | null;
14
+ timedOut?: boolean;
15
+ cancelled?: boolean;
16
+ stdoutTail?: string;
17
+ stderrTail?: string;
18
+ }
19
+
20
+ const BAMBU_COMPATIBLE = new Set(["bambustudio", "orcaslicer", "orcaslicer-bambulab"]);
21
+
22
+ export function slicerSuggestion(phase: SlicerFailurePhase, slicerType?: string): string {
23
+ const bambu = slicerType !== undefined && BAMBU_COMPATIBLE.has(slicerType);
24
+ switch (phase) {
25
+ case "preparation":
26
+ return bambu
27
+ ? "Slicer profile preparation failed before the slicer ran. Check that bambu_model and nozzle_diameter " +
28
+ "match an installed machine preset in the selected slicer's BBL profile tree (set BAMBU_PROFILES_ROOT " +
29
+ "for non-standard installs), that slicer_profile/filament paths exist and are valid JSON, and that " +
30
+ "BAMBU_SLICER_PROFILE_DIRS points at directories containing any custom parents. Otherwise slice in the " +
31
+ "GUI and print the exported .gcode.3mf."
32
+ : "Slicer setup failed before the slicer ran. Check slicer_path, slicer_profile, and filament_profile.";
33
+ case "execution":
34
+ return "The slicer process failed. Read the exit code and slicer output above: fix the reported profile, " +
35
+ "model, or flag problem (or raise SLICER_TIMEOUT_MS for a timeout), or slice in the GUI and print the " +
36
+ "exported file. Nothing was uploaded or printed.";
37
+ case "output":
38
+ return "The slicer exited without producing a printable result. Check the slicer output above, confirm the " +
39
+ "model fits the selected printer's plate, or slice in the GUI and export the sliced plate. Nothing was " +
40
+ "uploaded or printed.";
41
+ }
42
+ }
43
+
44
+ export class SlicerError extends Error {
45
+ readonly phase: SlicerFailurePhase;
46
+ readonly suggestion: string;
47
+ readonly details: SlicerFailureDetails;
48
+
49
+ constructor(phase: SlicerFailurePhase, message: string, details: SlicerFailureDetails = {}) {
50
+ super(message);
51
+ this.name = "SlicerError";
52
+ this.phase = phase;
53
+ this.details = details;
54
+ this.suggestion = slicerSuggestion(phase, details.slicerType);
55
+ }
56
+ }
57
+
58
+ /** Keep the last lines of slicer output; the actionable line is usually at the end. */
59
+ export function tailText(text: string, maxLines = 25, maxChars = 4000): string {
60
+ const trimmed = text.replace(/\s+$/, "");
61
+ if (!trimmed) return "";
62
+ let tail = trimmed.split(/\r?\n/).slice(-maxLines).join("\n");
63
+ if (tail.length > maxChars) tail = `...${tail.slice(-maxChars)}`;
64
+ return tail;
65
+ }
66
+
67
+ /** Wrap a preparation failure (profile lookup, flattening, arguments) without losing its message. */
68
+ export function asPreparationError(error: unknown, details: SlicerFailureDetails): SlicerError {
69
+ if (error instanceof SlicerError) return error;
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ return new SlicerError("preparation", `Slicer preparation failed: ${message}`, details);
72
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Local slicing-template registry (ported from bambu-printer-mcp).
3
+ *
4
+ * A template is a saved .3mf project whose embedded slicer settings are
5
+ * reused as the process profile, or a .json/.config process profile. Machine
6
+ * presets still come from the selected bambu_model/nozzle; a template never
7
+ * replaces the machine safety gate.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import JSZip from "jszip";
14
+ import { parse3MF } from "../3mf_parser.js";
15
+
16
+ export type TemplateSourceType = "3mf" | "json" | "config";
17
+
18
+ export interface TemplateEntry {
19
+ name: string;
20
+ path: string;
21
+ source_type: TemplateSourceType;
22
+ relative_path: string;
23
+ }
24
+
25
+ export function defaultTemplateDir(): string {
26
+ return process.env.BAMBU_TEMPLATE_DIR?.trim() ||
27
+ path.join(process.env.HOME || os.homedir(), "Sync", "bambu", "templates");
28
+ }
29
+
30
+ function resolveTemplateDir(templateDir?: string): string {
31
+ return templateDir && templateDir.trim().length > 0 ? templateDir : defaultTemplateDir();
32
+ }
33
+
34
+ export function sanitizeTemplateName(templateName: string): string {
35
+ const sanitized = templateName
36
+ .trim()
37
+ .replace(/\\/g, "/")
38
+ .replace(/^\/+|\/+$/g, "")
39
+ .replace(/\s+/g, "_")
40
+ .replace(/[^A-Za-z0-9/_-]/g, "_");
41
+ // Nested names are allowed; parent-directory segments are not.
42
+ if (!sanitized || sanitized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
43
+ throw new Error(`Invalid template name "${templateName}".`);
44
+ }
45
+ return sanitized;
46
+ }
47
+
48
+ export function scanTemplateRegistry(templateDir: string): TemplateEntry[] {
49
+ if (!fs.existsSync(templateDir)) return [];
50
+
51
+ const allowedExtensions = new Set([".3mf", ".json", ".config"]);
52
+ const entries: TemplateEntry[] = [];
53
+ const stack = [templateDir];
54
+
55
+ while (stack.length > 0) {
56
+ const currentDir = stack.pop()!;
57
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
58
+ const fullPath = path.join(currentDir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ stack.push(fullPath);
61
+ continue;
62
+ }
63
+ const extension = path.extname(entry.name).toLowerCase();
64
+ if (!entry.isFile() || !allowedExtensions.has(extension)) continue;
65
+
66
+ const relativePath = path.relative(templateDir, fullPath);
67
+ const templateName = relativePath
68
+ .replace(/\\/g, "/")
69
+ .replace(/(\.gcode)?\.3mf$/i, "")
70
+ .replace(/\.(json|config)$/i, "");
71
+ entries.push({
72
+ name: templateName,
73
+ path: fullPath,
74
+ source_type: extension === ".3mf" ? "3mf" : extension === ".json" ? "json" : "config",
75
+ relative_path: relativePath,
76
+ });
77
+ }
78
+ }
79
+
80
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
81
+ }
82
+
83
+ export function listTemplateRegistry(templateDir?: string): { template_dir: string; templates: TemplateEntry[] } {
84
+ const resolved = resolveTemplateDir(templateDir);
85
+ return { template_dir: resolved, templates: scanTemplateRegistry(resolved) };
86
+ }
87
+
88
+ export function resolveTemplatePath(
89
+ templateName: string | undefined,
90
+ templateDir?: string,
91
+ sourceTypes?: TemplateSourceType[]
92
+ ): string | undefined {
93
+ if (!templateName || templateName.trim().length === 0) return undefined;
94
+
95
+ const registry = listTemplateRegistry(templateDir);
96
+ const normalizedName = sanitizeTemplateName(templateName).toLowerCase();
97
+ const requestedName = templateName.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").toLowerCase();
98
+ const nameMatches = registry.templates.filter((entry) =>
99
+ entry.name.toLowerCase() === requestedName ||
100
+ entry.name.replace(/\s+/g, "_").replace(/[^A-Za-z0-9/_-]/g, "_").toLowerCase() === normalizedName
101
+ );
102
+ const searchTypes: TemplateSourceType[] = sourceTypes && sourceTypes.length > 0 ? sourceTypes : ["3mf", "json", "config"];
103
+ const match = searchTypes
104
+ .map((sourceType) => nameMatches.find((entry) => entry.source_type === sourceType))
105
+ .find((entry): entry is TemplateEntry => Boolean(entry));
106
+ if (!match) {
107
+ const typeHint = sourceTypes && sourceTypes.length > 0 ? ` with source type ${sourceTypes.join("/")}` : "";
108
+ const availableTypes = nameMatches.length > 0
109
+ ? ` Available source types: ${Array.from(new Set(nameMatches.map((entry) => entry.source_type))).join(", ")}.`
110
+ : "";
111
+ throw new Error(`Template "${templateName}"${typeHint} not found in ${registry.template_dir}.${availableTypes}`);
112
+ }
113
+ return match.path;
114
+ }
115
+
116
+ export function saveTemplate(sourcePath: string, templateName?: string, templateDir?: string) {
117
+ if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
118
+ throw new Error(`Template source not found: ${sourcePath}`);
119
+ }
120
+
121
+ const resolvedTemplateDir = resolveTemplateDir(templateDir);
122
+ const sourceBaseName = path.basename(sourcePath);
123
+ const extension = path.extname(sourceBaseName).toLowerCase();
124
+ if (![".3mf", ".json", ".config"].includes(extension)) {
125
+ throw new Error("Templates must be .3mf, .json, or .config files.");
126
+ }
127
+
128
+ const baseName = templateName && templateName.trim().length > 0
129
+ ? sanitizeTemplateName(templateName)
130
+ : sanitizeTemplateName(sourceBaseName.replace(/(\.gcode)?\.3mf$/i, "").replace(/\.(json|config)$/i, ""));
131
+
132
+ const destinationPath = path.join(resolvedTemplateDir, `${baseName}${extension}`);
133
+ const relative = path.relative(resolvedTemplateDir, destinationPath);
134
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
135
+ throw new Error(`Template name "${templateName}" escapes the template directory.`);
136
+ }
137
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
138
+ fs.copyFileSync(sourcePath, destinationPath);
139
+
140
+ const sourceType: TemplateSourceType = extension === ".3mf" ? "3mf" : extension === ".json" ? "json" : "config";
141
+ return {
142
+ saved: true,
143
+ template_name: baseName,
144
+ source_path: sourcePath,
145
+ destination_path: destinationPath,
146
+ template_dir: resolvedTemplateDir,
147
+ resolved_path: resolveTemplatePath(baseName, resolvedTemplateDir, [sourceType]),
148
+ };
149
+ }
150
+
151
+ /** Extract the embedded slicer settings from a Bambu/Orca project 3MF. */
152
+ export async function extractBambuTemplateSettings(filePath: string, outputDir: string): Promise<string> {
153
+ const zip = await JSZip.loadAsync(await fs.promises.readFile(filePath));
154
+ const potentialFiles = [
155
+ "Metadata/project_settings.config",
156
+ "Metadata/Slic3r_PE.config",
157
+ "Metadata/model_settings.config",
158
+ "Metadata/slice_info.config",
159
+ ];
160
+ for (const name of potentialFiles) {
161
+ const file = zip.file(name);
162
+ if (!file) continue;
163
+ const content = await file.async("string");
164
+ await fs.promises.mkdir(outputDir, { recursive: true });
165
+ const stem = path.basename(filePath).replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 64);
166
+ const outputPath = path.join(outputDir, `${stem}_${name.replace(/\//g, "_")}`);
167
+ await fs.promises.writeFile(outputPath, content, "utf8");
168
+ return outputPath;
169
+ }
170
+ throw new Error(`No slicer settings config found in template 3MF: ${filePath}`);
171
+ }
172
+
173
+ /** A template/profile path becomes the process profile passed to sliceSTL. */
174
+ export async function resolveTemplateSlicerProfilePath(templatePath: string, tempDir: string): Promise<string> {
175
+ const extension = path.extname(templatePath).toLowerCase();
176
+ if (extension === ".3mf") return extractBambuTemplateSettings(templatePath, tempDir);
177
+ if (extension === ".json" || extension === ".config") return templatePath;
178
+ throw new Error(`Template profile must be a .3mf, .json, or .config file: ${templatePath}`);
179
+ }
180
+
181
+ export function parseLooseSlicerConfig(content: string): Record<string, any> {
182
+ try {
183
+ return JSON.parse(content);
184
+ } catch {
185
+ const config: Record<string, any> = {};
186
+ for (const line of content.split(/\r?\n/)) {
187
+ const trimmed = line.trim();
188
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
189
+ const separatorIndex = trimmed.indexOf("=");
190
+ if (separatorIndex === -1) continue;
191
+ const key = trimmed.slice(0, separatorIndex).trim();
192
+ const value = trimmed.slice(separatorIndex + 1).trim();
193
+ if (key) config[key] = value;
194
+ }
195
+ return config;
196
+ }
197
+ }
198
+
199
+ const numberOrNull = (value: unknown) => (value !== undefined && value !== null ? Number(value) : null);
200
+
201
+ export function summarizeSliceSettings(config: Record<string, any>) {
202
+ const stringList = (value: unknown) =>
203
+ Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.length > 0) : [];
204
+ return {
205
+ printer_settings_id: typeof config.printer_settings_id === "string" ? config.printer_settings_id : null,
206
+ printer_model: typeof config.printer_model === "string" ? config.printer_model : null,
207
+ default_print_profile: typeof config.default_print_profile === "string" ? config.default_print_profile : null,
208
+ default_filament_profile: Array.isArray(config.default_filament_profile)
209
+ ? config.default_filament_profile
210
+ : typeof config.default_filament_profile === "string"
211
+ ? [config.default_filament_profile]
212
+ : [],
213
+ filament_settings_id: stringList(config.filament_settings_id),
214
+ filament_type: stringList(config.filament_type),
215
+ inherits: typeof config.inherits === "string" ? config.inherits : null,
216
+ print_settings_id: typeof config.print_settings_id === "string" ? config.print_settings_id : null,
217
+ compatible_printers: stringList(config.compatible_printers),
218
+ nozzle_diameter: Array.isArray(config.nozzle_diameter)
219
+ ? config.nozzle_diameter
220
+ : config.nozzle_diameter !== undefined
221
+ ? [config.nozzle_diameter]
222
+ : [],
223
+ layer_height: numberOrNull(config.layer_height),
224
+ first_layer_height: config.initial_layer_print_height !== undefined && config.initial_layer_print_height !== null
225
+ ? Number(config.initial_layer_print_height)
226
+ : numberOrNull(config.first_layer_height),
227
+ sparse_infill_density:
228
+ config.sparse_infill_density !== undefined && config.sparse_infill_density !== null
229
+ ? String(config.sparse_infill_density)
230
+ : null,
231
+ sparse_infill_pattern: typeof config.sparse_infill_pattern === "string" ? config.sparse_infill_pattern : null,
232
+ wall_loops: numberOrNull(config.wall_loops),
233
+ top_shell_layers: numberOrNull(config.top_shell_layers),
234
+ bottom_shell_layers: numberOrNull(config.bottom_shell_layers),
235
+ brim_width: numberOrNull(config.brim_width),
236
+ support_enabled:
237
+ config.enable_support !== undefined
238
+ ? String(config.enable_support) === "1" || String(config.enable_support).toLowerCase() === "true"
239
+ : config.support_enabled !== undefined
240
+ ? Boolean(config.support_enabled)
241
+ : null,
242
+ support_type: typeof config.support_type === "string" ? config.support_type : null,
243
+ bed_type: typeof config.curr_bed_type === "string"
244
+ ? config.curr_bed_type
245
+ : typeof config.bed_type === "string"
246
+ ? config.bed_type
247
+ : null,
248
+ nozzle_temperature: Array.isArray(config.nozzle_temperature)
249
+ ? config.nozzle_temperature
250
+ : config.nozzle_temperature !== undefined
251
+ ? [config.nozzle_temperature]
252
+ : [],
253
+ };
254
+ }
255
+
256
+ /** Inspect settings in a template 3MF or a profile file without slicing. */
257
+ export async function inspectSliceSettings(sourcePath: string, tempDir: string) {
258
+ if (!fs.existsSync(sourcePath)) {
259
+ throw new Error(`Slice settings source not found: ${sourcePath}`);
260
+ }
261
+ const extension = path.extname(sourcePath).toLowerCase();
262
+ const settingsDir = path.join(tempDir, "slice-settings");
263
+
264
+ if (extension === ".3mf") {
265
+ const parsed = await parse3MF(sourcePath);
266
+ const extractedSettingsPath = await extractBambuTemplateSettings(sourcePath, settingsDir);
267
+ const config = (parsed.slicerConfig || {}) as Record<string, any>;
268
+ return {
269
+ source_path: sourcePath,
270
+ source_type: "3mf",
271
+ extracted_settings_path: extractedSettingsPath,
272
+ object_count: parsed.objects.length,
273
+ build_item_count: parsed.build.items.length,
274
+ metadata_keys: Object.keys(parsed.metadata),
275
+ summary: summarizeSliceSettings(config),
276
+ raw_key_count: Object.keys(config).length,
277
+ };
278
+ }
279
+
280
+ const config = parseLooseSlicerConfig(fs.readFileSync(sourcePath, "utf8"));
281
+ return {
282
+ source_path: sourcePath,
283
+ source_type: extension === ".json" ? "json" : extension === ".config" ? "config" : "text",
284
+ extracted_settings_path: sourcePath,
285
+ summary: summarizeSliceSettings(config),
286
+ raw_key_count: Object.keys(config).length,
287
+ };
288
+ }