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