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.
- package/CHANGELOG.md +222 -0
- package/CONTRIBUTORS.md +56 -0
- package/README.md +528 -811
- package/dist/3mf_parser.js +8 -8
- package/dist/blender-mcp-bridge.d.ts +1 -0
- package/dist/blender-mcp-bridge.js +209 -21
- package/dist/fulu-orca.js +29 -5
- package/dist/index.js +614 -110
- package/dist/printers/bambu-node-guard.d.ts +2 -0
- package/dist/printers/bambu-node-guard.js +38 -0
- package/dist/printers/bambu.d.ts +53 -7
- package/dist/printers/bambu.js +420 -126
- package/dist/printers/creality.d.ts +15 -6
- package/dist/printers/creality.js +42 -9
- package/dist/printers/duet.d.ts +11 -6
- package/dist/printers/duet.js +57 -34
- package/dist/printers/generic-base.d.ts +69 -0
- package/dist/printers/generic-base.js +209 -0
- package/dist/printers/klipper.d.ts +17 -6
- package/dist/printers/klipper.js +63 -16
- package/dist/printers/octoprint.d.ts +18 -6
- package/dist/printers/octoprint.js +64 -9
- package/dist/printers/printer-factory.d.ts +11 -2
- package/dist/printers/printer-factory.js +9 -8
- package/dist/printers/prusa.d.ts +14 -6
- package/dist/printers/prusa.js +56 -9
- package/dist/printers/repetier.d.ts +18 -6
- package/dist/printers/repetier.js +48 -6
- package/dist/safety/archive.d.ts +11 -0
- package/dist/safety/archive.js +214 -0
- package/dist/safety/artifact.d.ts +6 -0
- package/dist/safety/artifact.js +66 -0
- package/dist/safety/confirmation.d.ts +22 -0
- package/dist/safety/confirmation.js +43 -0
- package/dist/safety/expected-peaks.d.ts +24 -0
- package/dist/safety/expected-peaks.js +24 -0
- package/dist/safety/gcode-thermal.d.ts +64 -0
- package/dist/safety/gcode-thermal.js +382 -0
- package/dist/safety/limits.d.ts +31 -0
- package/dist/safety/limits.js +105 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +610 -0
- package/dist/safety/printer-state.d.ts +51 -0
- package/dist/safety/printer-state.js +371 -0
- package/dist/slicer/bambu-cli.d.ts +110 -0
- package/dist/slicer/bambu-cli.js +547 -0
- package/dist/slicer/profile-flatten.d.ts +104 -0
- package/dist/slicer/profile-flatten.js +676 -0
- package/dist/slicer/run-slicer.d.ts +18 -0
- package/dist/slicer/run-slicer.js +102 -0
- package/dist/slicer/slicer-error.d.ts +27 -0
- package/dist/slicer/slicer-error.js +53 -0
- package/dist/slicer/templates.d.ts +126 -0
- package/dist/slicer/templates.js +255 -0
- package/dist/stl/stl-manipulator.d.ts +25 -3
- package/dist/stl/stl-manipulator.js +214 -232
- package/package.json +16 -9
- package/server.json +56 -0
- package/src/3mf_parser.ts +8 -8
- package/src/blender-mcp-bridge.ts +200 -19
- package/src/fulu-orca.ts +36 -6
- package/src/index.ts +686 -137
- package/src/printers/bambu-node-guard.ts +44 -0
- package/src/printers/bambu.ts +458 -164
- package/src/printers/creality.ts +40 -9
- package/src/printers/duet.ts +64 -53
- package/src/printers/generic-base.ts +286 -0
- package/src/printers/klipper.ts +83 -28
- package/src/printers/octoprint.ts +74 -21
- package/src/printers/printer-factory.ts +21 -11
- package/src/printers/prusa.ts +53 -10
- package/src/printers/repetier.ts +55 -17
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/confirmation.ts +56 -0
- package/src/safety/expected-peaks.ts +47 -0
- package/src/safety/gcode-thermal.ts +404 -0
- package/src/safety/limits.ts +98 -0
- package/src/safety/print-file.ts +429 -0
- package/src/safety/printer-state.ts +344 -0
- package/src/slicer/bambu-cli.ts +633 -0
- package/src/slicer/profile-flatten.ts +830 -0
- package/src/slicer/run-slicer.ts +126 -0
- package/src/slicer/slicer-error.ts +72 -0
- package/src/slicer/templates.ts +288 -0
- 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
|
+
}
|