mcp-3d-printer-server 1.2.5 → 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.
- package/CHANGELOG.md +228 -0
- package/CONTRIBUTORS.md +49 -0
- package/README.md +529 -739
- package/dist/3mf_parser.js +8 -8
- package/dist/blender-mcp-bridge.d.ts +11 -0
- package/dist/blender-mcp-bridge.js +661 -0
- package/dist/fulu-orca.js +29 -5
- package/dist/index.js +752 -212
- 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 -120
- 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 +517 -0
- package/dist/safety/printer-state.d.ts +51 -0
- package/dist/safety/printer-state.js +365 -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 +30 -14
- package/server.json +29 -0
- package/src/3mf_parser.ts +8 -8
- package/src/blender-mcp-bridge.ts +611 -0
- package/src/fulu-orca.ts +36 -6
- package/src/index.ts +888 -255
- package/src/printers/bambu-node-guard.ts +44 -0
- package/src/printers/bambu.ts +458 -158
- 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 +354 -0
- package/src/safety/printer-state.ts +339 -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
- package/dist/index.js.map +0 -1
|
@@ -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
|
|
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;
|