mcp-3d-printer-server 1.2.20 → 1.2.22
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 +27 -0
- package/README.md +112 -9
- package/dist/blender-mcp-bridge.d.ts +21 -0
- package/dist/blender-mcp-bridge.js +3 -3
- package/dist/counterpart/contract.d.ts +395 -0
- package/dist/counterpart/contract.js +220 -0
- package/dist/counterpart/progress.d.ts +32 -0
- package/dist/counterpart/progress.js +443 -0
- package/dist/counterpart/service.d.ts +56 -0
- package/dist/counterpart/service.js +232 -0
- package/dist/fusion-mcp-bridge.d.ts +166 -0
- package/dist/fusion-mcp-bridge.js +529 -0
- package/dist/index.js +272 -8
- package/dist/printers/creality.d.ts +26 -1
- package/dist/printers/creality.js +75 -2
- package/dist/printers/duet.d.ts +16 -0
- package/dist/printers/duet.js +64 -0
- package/dist/printers/generic-base.d.ts +52 -6
- package/dist/printers/generic-base.js +134 -7
- package/dist/printers/klipper.d.ts +33 -2
- package/dist/printers/klipper.js +106 -0
- package/dist/printers/octoprint.d.ts +23 -1
- package/dist/printers/octoprint.js +81 -0
- package/dist/printers/prusa.d.ts +13 -0
- package/dist/printers/prusa.js +59 -0
- package/dist/printers/repetier.d.ts +17 -1
- package/dist/printers/repetier.js +55 -0
- package/dist/slicer/bambu-cli.d.ts +2 -0
- package/dist/slicer/bambu-cli.js +11 -0
- package/dist/slicer/filament-profiles.d.ts +25 -0
- package/dist/slicer/filament-profiles.js +249 -0
- package/dist/stl/stl-manipulator.d.ts +21 -1
- package/dist/stl/stl-manipulator.js +86 -6
- package/package.json +7 -2
- package/server.json +2 -2
- package/src/blender-mcp-bridge.ts +3 -3
- package/src/counterpart/contract.ts +326 -0
- package/src/counterpart/progress.ts +470 -0
- package/src/counterpart/service.ts +253 -0
- package/src/fusion-mcp-bridge.ts +512 -0
- package/src/index.ts +314 -10
- package/src/printers/creality.ts +84 -1
- package/src/printers/duet.ts +67 -0
- package/src/printers/generic-base.ts +172 -1
- package/src/printers/klipper.ts +118 -2
- package/src/printers/octoprint.ts +86 -1
- package/src/printers/prusa.ts +60 -0
- package/src/printers/repetier.ts +63 -1
- package/src/slicer/bambu-cli.ts +13 -0
- package/src/slicer/filament-profiles.ts +249 -0
- package/src/stl/stl-manipulator.ts +100 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
|
|
1
|
+
import { GenericPrinterImplementation, type GenericPrinterState, type SnapshotCandidate } from "./generic-base.js";
|
|
2
|
+
import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
|
|
2
3
|
/**
|
|
3
4
|
* Note: Repetier-Server documents printer commands at /printer/api/<slug>;
|
|
4
5
|
* this adapter's slug-less routes are unverified. Remote starts are refused
|
|
@@ -7,6 +8,21 @@ import { GenericPrinterImplementation, type GenericPrinterState } from "./generi
|
|
|
7
8
|
export declare class RepetierImplementation extends GenericPrinterImplementation {
|
|
8
9
|
protected readonly printerLabel = "Repetier";
|
|
9
10
|
protected readonly uploadStartsPrint = true;
|
|
11
|
+
/**
|
|
12
|
+
* The printer the counterpart tools address: REPETIER_SLUG when set,
|
|
13
|
+
* otherwise the only printer listPrinter reports.
|
|
14
|
+
* REST form: /printer/api/<slug>?a=<command>&data=<json>&apikey=<key>
|
|
15
|
+
* (https://www.repetier-server.com/manuals/programming/API/index.html).
|
|
16
|
+
*/
|
|
17
|
+
private selectPrinter;
|
|
18
|
+
private printerUrl;
|
|
19
|
+
counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
|
|
20
|
+
counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity>;
|
|
21
|
+
/** getPrinterConfig webcams[].staticUrl (older servers: a single webcam object). */
|
|
22
|
+
snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]>;
|
|
23
|
+
/** Pause sends the @pause host command; resume is the documented continueJob. */
|
|
24
|
+
protected rawPauseJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
25
|
+
protected rawResumeJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
10
26
|
getStatus(host: string, port: string, apiKey: string): Promise<any>;
|
|
11
27
|
getFiles(host: string, port: string, apiKey: string): Promise<any>;
|
|
12
28
|
getFile(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import FormData from "form-data";
|
|
3
3
|
import { GenericPrinterImplementation } from "./generic-base.js";
|
|
4
|
+
import { nonEmptyText } from "../counterpart/contract.js";
|
|
5
|
+
import { mapRepetier } from "../counterpart/progress.js";
|
|
4
6
|
/**
|
|
5
7
|
* Note: Repetier-Server documents printer commands at /printer/api/<slug>;
|
|
6
8
|
* this adapter's slug-less routes are unverified. Remote starts are refused
|
|
@@ -13,6 +15,59 @@ export class RepetierImplementation extends GenericPrinterImplementation {
|
|
|
13
15
|
// The upload form's print=1 flag uploads and starts the same bytes.
|
|
14
16
|
this.uploadStartsPrint = true;
|
|
15
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* The printer the counterpart tools address: REPETIER_SLUG when set,
|
|
20
|
+
* otherwise the only printer listPrinter reports.
|
|
21
|
+
* REST form: /printer/api/<slug>?a=<command>&data=<json>&apikey=<key>
|
|
22
|
+
* (https://www.repetier-server.com/manuals/programming/API/index.html).
|
|
23
|
+
*/
|
|
24
|
+
async selectPrinter(host, port, apiKey) {
|
|
25
|
+
const wanted = process.env.REPETIER_SLUG?.trim();
|
|
26
|
+
const route = wanted ? encodeURIComponent(wanted) : "";
|
|
27
|
+
const data = (await this.apiClient.get(`http://${host}:${port}/printer/api/${route}?a=listPrinter&apikey=${encodeURIComponent(apiKey)}`)).data;
|
|
28
|
+
const printers = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
|
|
29
|
+
if (wanted) {
|
|
30
|
+
const entry = printers.find((printer) => printer?.slug === wanted);
|
|
31
|
+
if (!entry)
|
|
32
|
+
throw new Error(`Repetier lists no printer with slug "${wanted}" (REPETIER_SLUG).`);
|
|
33
|
+
return { slug: wanted, entry };
|
|
34
|
+
}
|
|
35
|
+
if (printers.length !== 1 || typeof printers[0]?.slug !== "string") {
|
|
36
|
+
throw new Error(`Repetier reports ${printers.length} printers; set REPETIER_SLUG to the printer's slug.`);
|
|
37
|
+
}
|
|
38
|
+
return { slug: printers[0].slug, entry: printers[0] };
|
|
39
|
+
}
|
|
40
|
+
printerUrl(host, port, apiKey, slug, action, data) {
|
|
41
|
+
const query = `a=${action}${data === undefined ? "" : `&data=${encodeURIComponent(JSON.stringify(data))}`}&apikey=${encodeURIComponent(apiKey)}`;
|
|
42
|
+
return `http://${host}:${port}/printer/api/${encodeURIComponent(slug)}?${query}`;
|
|
43
|
+
}
|
|
44
|
+
async counterpartProgress(host, port, apiKey) {
|
|
45
|
+
const { entry } = await this.selectPrinter(host, port, apiKey);
|
|
46
|
+
return mapRepetier(entry, new Date().toISOString());
|
|
47
|
+
}
|
|
48
|
+
async counterpartIdentity(host, port, apiKey) {
|
|
49
|
+
const { entry, slug } = await this.selectPrinter(host, port, apiKey);
|
|
50
|
+
return { name: nonEmptyText(entry?.name) ?? slug };
|
|
51
|
+
}
|
|
52
|
+
/** getPrinterConfig webcams[].staticUrl (older servers: a single webcam object). */
|
|
53
|
+
async snapshotCandidates(host, port, apiKey) {
|
|
54
|
+
const { slug } = await this.selectPrinter(host, port, apiKey);
|
|
55
|
+
const config = (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "getPrinterConfig"))).data;
|
|
56
|
+
const webcams = Array.isArray(config?.webcams) ? config.webcams : config?.webcam ? [config.webcam] : [];
|
|
57
|
+
return webcams
|
|
58
|
+
.map((webcam) => nonEmptyText(webcam?.staticUrl))
|
|
59
|
+
.filter((url) => url !== undefined)
|
|
60
|
+
.map((url) => ({ url, source: "repetier-webcam", configured: true }));
|
|
61
|
+
}
|
|
62
|
+
/** Pause sends the @pause host command; resume is the documented continueJob. */
|
|
63
|
+
async rawPauseJob(host, port, apiKey) {
|
|
64
|
+
const { slug } = await this.selectPrinter(host, port, apiKey);
|
|
65
|
+
return (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "send", { cmd: "@pause" }))).data;
|
|
66
|
+
}
|
|
67
|
+
async rawResumeJob(host, port, apiKey) {
|
|
68
|
+
const { slug } = await this.selectPrinter(host, port, apiKey);
|
|
69
|
+
return (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "continueJob"))).data;
|
|
70
|
+
}
|
|
16
71
|
async getStatus(host, port, apiKey) {
|
|
17
72
|
const url = `http://${host}:${port}/printer/api/?a=getPrinterInfo&apikey=${apiKey}`;
|
|
18
73
|
const response = await this.apiClient.get(url);
|
|
@@ -35,6 +35,8 @@ export interface BambuSliceOptions {
|
|
|
35
35
|
minSave?: boolean;
|
|
36
36
|
skipModifiedGcodes?: boolean;
|
|
37
37
|
slicePlate?: number;
|
|
38
|
+
/** Process keys written over the resolved process preset (layer_height, wall_loops, sparse_infill_density). */
|
|
39
|
+
processOverrides?: Record<string, string>;
|
|
38
40
|
}
|
|
39
41
|
export interface BambuSettingsBundle {
|
|
40
42
|
settingsArg?: string;
|
package/dist/slicer/bambu-cli.js
CHANGED
|
@@ -431,6 +431,17 @@ export class BambuCliProfilePreparer {
|
|
|
431
431
|
this.writeTempJson(outputBase, "process_orca_resolved", this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
|
|
432
432
|
].join(";");
|
|
433
433
|
}
|
|
434
|
+
// Print-sheet quality/strength choices win over the preset and any template.
|
|
435
|
+
if (options.processOverrides && Object.keys(options.processOverrides).length && bundle.settingsArg) {
|
|
436
|
+
const [machinePath, processPath, ...rest] = bundle.settingsArg.split(";");
|
|
437
|
+
if (processPath) {
|
|
438
|
+
bundle.settingsArg = [
|
|
439
|
+
machinePath,
|
|
440
|
+
this.writeTempJson(outputBase, "process_overrides", { ...this.readJsonFile(processPath), ...options.processOverrides }),
|
|
441
|
+
...rest,
|
|
442
|
+
].join(";");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
434
445
|
return this.isolateMachineSettings(bundle);
|
|
435
446
|
}
|
|
436
447
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface FilamentProfileContext {
|
|
2
|
+
/** The canonical slicer type slice_stl uses without a slicer_type argument. */
|
|
3
|
+
slicerType: string;
|
|
4
|
+
slicerPath?: string;
|
|
5
|
+
/** SLICER_PROFILE: Orca "machine.json;process.json[|filament.json]", or PrusaSlicer's exported config. */
|
|
6
|
+
slicerProfile?: string;
|
|
7
|
+
/** What slice_stl loads when no filament is passed, in slot order. */
|
|
8
|
+
configuredFilaments?: string[];
|
|
9
|
+
/** Tests replace the searched directories. */
|
|
10
|
+
roots?: {
|
|
11
|
+
system: string[];
|
|
12
|
+
user: string[];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare function resolveFilamentProfile(material: string | undefined, context: FilamentProfileContext): Promise<string | undefined>;
|
|
16
|
+
/**
|
|
17
|
+
* A generic OrcaSlicer filament preset with its `inherits` chain merged in,
|
|
18
|
+
* parent first, for --load-filaments: the CLI does not resolve inheritance,
|
|
19
|
+
* so a bundled preset such as "Generic PETG @System" would otherwise slice
|
|
20
|
+
* without its own temperatures. Undefined when the file inherits nothing, or
|
|
21
|
+
* when a parent is not installed (the file is then passed as it is).
|
|
22
|
+
*/
|
|
23
|
+
export declare function flattenOrcaFilament(file: string, context: Pick<FilamentProfileContext, "slicerPath" | "roots">): Promise<Record<string, unknown> | undefined>;
|
|
24
|
+
/** Tests only: forget cached profile indexes. */
|
|
25
|
+
export declare function clearFilamentProfileCache(): void;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The installed slicer filament profile for a material, as a path slice_stl's
|
|
3
|
+
* load_filaments accepts (get_printer_filaments slots[].profile).
|
|
4
|
+
*
|
|
5
|
+
* The existing resolvers do not fit: bambu-printer-mcp's inventory resolver
|
|
6
|
+
* and bambu-cli.ts look up Bambu (BBL) profiles by an AMS tray_info_idx, which
|
|
7
|
+
* other printers do not report. Here the key is the spool's material, matched
|
|
8
|
+
* exactly against each profile's filament_type, in the slicer slice_stl uses
|
|
9
|
+
* by default:
|
|
10
|
+
* - OrcaSlicer: the app's bundled profiles/<Vendor>/filament, the copies under
|
|
11
|
+
* OrcaSlicer/system, and the user's OrcaSlicer/user/<id>/filament presets.
|
|
12
|
+
* filament_type and compatible_printers are read through `inherits`.
|
|
13
|
+
* - PrusaSlicer and Slic3r: the data directory's filament/*.ini presets.
|
|
14
|
+
* Vendor bundle INIs hold many presets in one file and cannot be --load-ed.
|
|
15
|
+
* Profiles whose compatible_printers names other printers are excluded; ones
|
|
16
|
+
* that name the configured printer (SLICER_PROFILE's machine) come first,
|
|
17
|
+
* then universal ones. Any failure resolves to undefined: a missing profile
|
|
18
|
+
* never fails get_printer_filaments.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { detectProfilesRoot } from "./profile-flatten.js";
|
|
24
|
+
import { parseLooseSlicerConfig } from "./templates.js";
|
|
25
|
+
const ORCA = new Set(["orcaslicer"]);
|
|
26
|
+
const PRUSA = new Set(["prusaslicer", "slic3r"]);
|
|
27
|
+
const MAX_FILES = 20000;
|
|
28
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
29
|
+
const MAX_DEPTH = 5;
|
|
30
|
+
const INDEX_TTL_MS = 60000;
|
|
31
|
+
const indexes = new Map();
|
|
32
|
+
/** Per-user application data directories, in the order the slicers use them. */
|
|
33
|
+
function appDataDirs() {
|
|
34
|
+
const home = os.homedir();
|
|
35
|
+
return [
|
|
36
|
+
path.join(home, "Library", "Application Support"),
|
|
37
|
+
path.join(home, ".config"),
|
|
38
|
+
...(process.env.APPDATA ? [process.env.APPDATA] : []),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
function defaultRoots(context) {
|
|
42
|
+
if (ORCA.has(context.slicerType)) {
|
|
43
|
+
const system = [detectProfilesRoot(context.slicerPath, "orcaslicer"), ...appDataDirs().map((dir) => path.join(dir, "OrcaSlicer", "system"))];
|
|
44
|
+
const user = appDataDirs().map((dir) => path.join(dir, "OrcaSlicer", "user"));
|
|
45
|
+
return { system, user };
|
|
46
|
+
}
|
|
47
|
+
const home = os.homedir();
|
|
48
|
+
const name = context.slicerType === "slic3r" ? "Slic3r" : "PrusaSlicer";
|
|
49
|
+
const user = [
|
|
50
|
+
...appDataDirs().map((dir) => path.join(dir, name)),
|
|
51
|
+
...(name === "PrusaSlicer" ? [path.join(home, ".PrusaSlicer"), path.join(home, ".var", "app", "com.prusa3d.PrusaSlicer", "config", "PrusaSlicer")] : [path.join(home, ".Slic3r")]),
|
|
52
|
+
];
|
|
53
|
+
return { system: [], user };
|
|
54
|
+
}
|
|
55
|
+
async function listFiles(dir, extension, depth, budget) {
|
|
56
|
+
if (depth > MAX_DEPTH || budget.left <= 0)
|
|
57
|
+
return [];
|
|
58
|
+
const items = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
59
|
+
const files = [];
|
|
60
|
+
for (const item of items.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
61
|
+
if (budget.left <= 0)
|
|
62
|
+
break;
|
|
63
|
+
const full = path.join(dir, item.name);
|
|
64
|
+
if (item.isDirectory())
|
|
65
|
+
files.push(...await listFiles(full, extension, depth + 1, budget));
|
|
66
|
+
else if (item.isFile() && item.name.toLowerCase().endsWith(extension)) {
|
|
67
|
+
files.push(full);
|
|
68
|
+
budget.left -= 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return files;
|
|
72
|
+
}
|
|
73
|
+
async function readConfig(file) {
|
|
74
|
+
try {
|
|
75
|
+
const stat = await fs.stat(file);
|
|
76
|
+
if (!stat.isFile() || stat.size > MAX_BYTES)
|
|
77
|
+
return undefined;
|
|
78
|
+
const parsed = parseLooseSlicerConfig(await fs.readFile(file, "utf8"));
|
|
79
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function buildIndex(context, roots) {
|
|
86
|
+
const budget = { left: MAX_FILES };
|
|
87
|
+
const files = [];
|
|
88
|
+
if (ORCA.has(context.slicerType)) {
|
|
89
|
+
// <root>/<Vendor>/filament/** for system trees; <user>/<id>/filament/** for presets.
|
|
90
|
+
for (const [list, user] of [[roots.system, false], [roots.user, true]]) {
|
|
91
|
+
for (const root of list) {
|
|
92
|
+
const groups = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
93
|
+
for (const group of groups.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
94
|
+
for (const file of await listFiles(path.join(root, group.name, "filament"), ".json", 0, budget))
|
|
95
|
+
files.push({ file, user });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const root of roots.user) {
|
|
102
|
+
for (const file of await listFiles(path.join(root, "filament"), ".ini", MAX_DEPTH, budget))
|
|
103
|
+
files.push({ file, user: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const entries = [];
|
|
107
|
+
const byName = new Map();
|
|
108
|
+
for (const { file, user } of files) {
|
|
109
|
+
const config = await readConfig(file);
|
|
110
|
+
if (!config)
|
|
111
|
+
continue;
|
|
112
|
+
const name = typeof config.name === "string" && config.name.trim() ? config.name.trim() : path.basename(file, path.extname(file));
|
|
113
|
+
const entry = { file, name, config, user };
|
|
114
|
+
entries.push(entry);
|
|
115
|
+
// System names first: `inherits` points at system presets.
|
|
116
|
+
if (!byName.has(name))
|
|
117
|
+
byName.set(name, entry);
|
|
118
|
+
}
|
|
119
|
+
return { entries, byName };
|
|
120
|
+
}
|
|
121
|
+
function profileIndex(context) {
|
|
122
|
+
const roots = context.roots ?? defaultRoots(context);
|
|
123
|
+
const key = JSON.stringify([context.slicerType, roots]);
|
|
124
|
+
const cached = indexes.get(key);
|
|
125
|
+
if (cached && Date.now() - cached.at < INDEX_TTL_MS)
|
|
126
|
+
return cached.index;
|
|
127
|
+
const index = buildIndex(context, roots);
|
|
128
|
+
indexes.set(key, { at: Date.now(), index });
|
|
129
|
+
// A failed build is not cached.
|
|
130
|
+
index.catch(() => indexes.delete(key));
|
|
131
|
+
return index;
|
|
132
|
+
}
|
|
133
|
+
/** A key's value on the profile or the nearest preset it inherits from. */
|
|
134
|
+
function inherited(index, entry, key) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
let current = entry;
|
|
137
|
+
for (let depth = 0; current && depth < 12 && !seen.has(current); depth += 1) {
|
|
138
|
+
seen.add(current);
|
|
139
|
+
if (current.config[key] !== undefined)
|
|
140
|
+
return current.config[key];
|
|
141
|
+
const parent = current.config.inherits;
|
|
142
|
+
current = typeof parent === "string" && parent.trim() ? index.byName.get(parent.trim()) : undefined;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
function textValues(value) {
|
|
147
|
+
if (Array.isArray(value))
|
|
148
|
+
return value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
149
|
+
if (typeof value !== "string" || !value.trim())
|
|
150
|
+
return [];
|
|
151
|
+
// PrusaSlicer lists are "a";"b"; Orca's are JSON arrays.
|
|
152
|
+
const quoted = [...value.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((match) => match[1].trim()).filter(Boolean);
|
|
153
|
+
return quoted.length ? quoted : value.split(";").map((item) => item.trim()).filter(Boolean);
|
|
154
|
+
}
|
|
155
|
+
/** The configured printer's preset names, from SLICER_PROFILE. */
|
|
156
|
+
async function printerNames(context) {
|
|
157
|
+
const names = new Set();
|
|
158
|
+
const settings = (context.slicerProfile ?? "").split("|", 1)[0].split(";").map((item) => item.trim()).filter(Boolean);
|
|
159
|
+
for (const file of settings) {
|
|
160
|
+
const config = await readConfig(file);
|
|
161
|
+
if (!config)
|
|
162
|
+
continue;
|
|
163
|
+
const isMachine = config.type === "machine" || config.printer_settings_id !== undefined || config.printer_model !== undefined;
|
|
164
|
+
if (!isMachine)
|
|
165
|
+
continue;
|
|
166
|
+
for (const key of ["name", "inherits", "printer_settings_id"]) {
|
|
167
|
+
for (const value of textValues(config[key]))
|
|
168
|
+
names.add(value);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return names;
|
|
172
|
+
}
|
|
173
|
+
export async function resolveFilamentProfile(material, context) {
|
|
174
|
+
try {
|
|
175
|
+
const wanted = material?.trim().toUpperCase();
|
|
176
|
+
if (!wanted) {
|
|
177
|
+
// No reported material: the profile slice_stl loads by default, when it exists.
|
|
178
|
+
const configured = context.configuredFilaments?.map((item) => item.trim()).find(Boolean);
|
|
179
|
+
return configured && existsSync(configured) ? path.resolve(configured) : undefined;
|
|
180
|
+
}
|
|
181
|
+
if (!ORCA.has(context.slicerType) && !PRUSA.has(context.slicerType))
|
|
182
|
+
return undefined;
|
|
183
|
+
const [index, printers] = await Promise.all([profileIndex(context), printerNames(context)]);
|
|
184
|
+
const candidates = [];
|
|
185
|
+
for (const entry of index.entries) {
|
|
186
|
+
if (String(entry.config.instantiation ?? "true").toLowerCase() === "false" || /@base$/i.test(entry.name))
|
|
187
|
+
continue;
|
|
188
|
+
if (!textValues(inherited(index, entry, "filament_type")).some((type) => type.toUpperCase() === wanted))
|
|
189
|
+
continue;
|
|
190
|
+
const compatible = textValues(inherited(index, entry, "compatible_printers"));
|
|
191
|
+
const condition = inherited(index, entry, "compatible_printers_condition");
|
|
192
|
+
let rank;
|
|
193
|
+
if (compatible.length) {
|
|
194
|
+
if (!compatible.some((name) => printers.has(name)))
|
|
195
|
+
continue;
|
|
196
|
+
rank = 0;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
// A condition this server cannot evaluate ranks below a profile for every printer.
|
|
200
|
+
rank = typeof condition === "string" && condition.trim() ? 2 : 1;
|
|
201
|
+
}
|
|
202
|
+
candidates.push({ entry, rank });
|
|
203
|
+
}
|
|
204
|
+
candidates.sort((a, b) => a.rank - b.rank || Number(b.entry.user) - Number(a.entry.user) || a.entry.file.localeCompare(b.entry.file));
|
|
205
|
+
const best = candidates[0]?.entry.file;
|
|
206
|
+
return best ? path.resolve(best) : undefined;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A generic OrcaSlicer filament preset with its `inherits` chain merged in,
|
|
214
|
+
* parent first, for --load-filaments: the CLI does not resolve inheritance,
|
|
215
|
+
* so a bundled preset such as "Generic PETG @System" would otherwise slice
|
|
216
|
+
* without its own temperatures. Undefined when the file inherits nothing, or
|
|
217
|
+
* when a parent is not installed (the file is then passed as it is).
|
|
218
|
+
*/
|
|
219
|
+
export async function flattenOrcaFilament(file, context) {
|
|
220
|
+
const leaf = await readConfig(file);
|
|
221
|
+
const parentName = leaf?.inherits;
|
|
222
|
+
if (!leaf || typeof parentName !== "string" || !parentName.trim())
|
|
223
|
+
return undefined;
|
|
224
|
+
const index = await profileIndex({ slicerType: "orcaslicer", ...context });
|
|
225
|
+
const chain = [leaf];
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
for (let name = parentName.trim(); name;) {
|
|
228
|
+
const parent = index.byName.get(name);
|
|
229
|
+
if (!parent || seen.has(name) || chain.length > 12)
|
|
230
|
+
return undefined;
|
|
231
|
+
seen.add(name);
|
|
232
|
+
chain.push(parent.config);
|
|
233
|
+
name = typeof parent.config.inherits === "string" ? parent.config.inherits.trim() : "";
|
|
234
|
+
}
|
|
235
|
+
const merged = Object.assign({}, ...chain.reverse());
|
|
236
|
+
const name = typeof leaf.name === "string" && leaf.name.trim() ? leaf.name.trim() : path.basename(file, path.extname(file));
|
|
237
|
+
// As the Bambu CLI flattener does: a user-owned, self-contained preset.
|
|
238
|
+
merged.name = name;
|
|
239
|
+
merged.inherits = "";
|
|
240
|
+
merged.from = "User";
|
|
241
|
+
merged.filament_settings_id = [name];
|
|
242
|
+
delete merged.instantiation;
|
|
243
|
+
delete merged.setting_id;
|
|
244
|
+
return merged;
|
|
245
|
+
}
|
|
246
|
+
/** Tests only: forget cached profile indexes. */
|
|
247
|
+
export function clearFilamentProfileCache() {
|
|
248
|
+
indexes.clear();
|
|
249
|
+
}
|
|
@@ -2,6 +2,19 @@ import * as THREE from 'three';
|
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import { type BambuSliceOptions } from '../slicer/bambu-cli.js';
|
|
4
4
|
export type { BambuSliceOptions } from '../slicer/bambu-cli.js';
|
|
5
|
+
/** Print-sheet slicing overrides (PRD §8.4), applied on top of the selected profile. */
|
|
6
|
+
export interface ProcessOverrides {
|
|
7
|
+
/** mm */
|
|
8
|
+
layerHeight?: number;
|
|
9
|
+
/** wall (perimeter) count */
|
|
10
|
+
wallLoops?: number;
|
|
11
|
+
/** e.g. "15%" */
|
|
12
|
+
sparseInfillDensity?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Orca/Bambu Studio process keys; profile JSON values are strings. */
|
|
15
|
+
export declare function orcaProcessOverrides(overrides: ProcessOverrides | undefined): Record<string, string>;
|
|
16
|
+
/** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
|
|
17
|
+
export declare function prusaSlicerOverrideArgs(overrides: ProcessOverrides | undefined): string[];
|
|
5
18
|
export type ProgressCallback = (progress: number, message?: string) => void;
|
|
6
19
|
export type OperationResult = {
|
|
7
20
|
success: boolean;
|
|
@@ -43,6 +56,13 @@ export declare class STLManipulator extends EventEmitter {
|
|
|
43
56
|
private splitProfileList;
|
|
44
57
|
private parseOrcaProfileArgs;
|
|
45
58
|
private warnForMissingProfiles;
|
|
59
|
+
/**
|
|
60
|
+
* Generic OrcaSlicer: write the overrides over a copy of the process profile
|
|
61
|
+
* (the settings file whose type is "process", else the last of two or more).
|
|
62
|
+
*/
|
|
63
|
+
private withOrcaProcessOverrides;
|
|
64
|
+
/** Bundled and user Orca filament presets inherit their settings; the CLI needs them merged in. */
|
|
65
|
+
private withFlattenedOrcaFilaments;
|
|
46
66
|
private appendOrcaProfileArgs;
|
|
47
67
|
private resolveOrcaGcodeOutput;
|
|
48
68
|
private resolveSlicerTimeoutMs;
|
|
@@ -133,7 +153,7 @@ export declare class STLManipulator extends EventEmitter {
|
|
|
133
153
|
* @param bambuOptions Optional Bambu-compatible CLI options (added last for compatibility)
|
|
134
154
|
* @returns Path to the generated G-code or sliced 3MF file
|
|
135
155
|
*/
|
|
136
|
-
sliceSTL(stlFilePath: string, slicerType: SlicerType, slicerPath: string, slicerProfile?: string, progressCallback?: ProgressCallback, printerPreset?: string, filamentProfile?: string, bambuOptions?: BambuSliceOptions): Promise<string>;
|
|
156
|
+
sliceSTL(stlFilePath: string, slicerType: SlicerType, slicerPath: string, slicerProfile?: string, progressCallback?: ProgressCallback, printerPreset?: string, filamentProfile?: string, bambuOptions?: BambuSliceOptions, processOverrides?: ProcessOverrides): Promise<string>;
|
|
137
157
|
/**
|
|
138
158
|
* Enhanced version of confirmTemperatures with better error handling
|
|
139
159
|
* @param gcodePath Path to the G-code file
|
|
@@ -8,9 +8,32 @@ import { promisify } from 'util';
|
|
|
8
8
|
import { EventEmitter } from 'events';
|
|
9
9
|
import * as crypto from 'crypto';
|
|
10
10
|
import { detectProfilesRoot } from '../slicer/profile-flatten.js';
|
|
11
|
+
import { flattenOrcaFilament } from '../slicer/filament-profiles.js';
|
|
11
12
|
import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReason, buildBambuCliArgs, planBambuCliInput, } from '../slicer/bambu-cli.js';
|
|
12
13
|
import { runSlicerProcess } from '../slicer/run-slicer.js';
|
|
13
14
|
import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
|
|
15
|
+
/** Orca/Bambu Studio process keys; profile JSON values are strings. */
|
|
16
|
+
export function orcaProcessOverrides(overrides) {
|
|
17
|
+
const keys = {};
|
|
18
|
+
if (overrides?.layerHeight !== undefined)
|
|
19
|
+
keys.layer_height = String(overrides.layerHeight);
|
|
20
|
+
if (overrides?.wallLoops !== undefined)
|
|
21
|
+
keys.wall_loops = String(overrides.wallLoops);
|
|
22
|
+
if (overrides?.sparseInfillDensity !== undefined)
|
|
23
|
+
keys.sparse_infill_density = overrides.sparseInfillDensity;
|
|
24
|
+
return keys;
|
|
25
|
+
}
|
|
26
|
+
/** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
|
|
27
|
+
export function prusaSlicerOverrideArgs(overrides) {
|
|
28
|
+
const args = [];
|
|
29
|
+
if (overrides?.layerHeight !== undefined)
|
|
30
|
+
args.push('--layer-height', String(overrides.layerHeight));
|
|
31
|
+
if (overrides?.wallLoops !== undefined)
|
|
32
|
+
args.push('--perimeters', String(overrides.wallLoops));
|
|
33
|
+
if (overrides?.sparseInfillDensity !== undefined)
|
|
34
|
+
args.push('--fill-density', overrides.sparseInfillDensity);
|
|
35
|
+
return args;
|
|
36
|
+
}
|
|
14
37
|
import { scanGcodeThermal } from '../safety/gcode-thermal.js';
|
|
15
38
|
const readFileAsync = promisify(fs.readFile);
|
|
16
39
|
const writeFileAsync = promisify(fs.writeFile);
|
|
@@ -73,6 +96,47 @@ export class STLManipulator extends EventEmitter {
|
|
|
73
96
|
}
|
|
74
97
|
}
|
|
75
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Generic OrcaSlicer: write the overrides over a copy of the process profile
|
|
101
|
+
* (the settings file whose type is "process", else the last of two or more).
|
|
102
|
+
*/
|
|
103
|
+
withOrcaProcessOverrides(profiles, overrides, outputDir) {
|
|
104
|
+
const keys = orcaProcessOverrides(overrides);
|
|
105
|
+
if (!Object.keys(keys).length)
|
|
106
|
+
return profiles;
|
|
107
|
+
const parsed = profiles.settingsProfiles.map((profile) => {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fs.readFileSync(profile, 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
let index = parsed.findIndex((profile) => profile?.type === 'process');
|
|
116
|
+
if (index < 0 && profiles.settingsProfiles.length >= 2)
|
|
117
|
+
index = profiles.settingsProfiles.length - 1;
|
|
118
|
+
if (index < 0 || !parsed[index] || typeof parsed[index] !== 'object') {
|
|
119
|
+
throw new SlicerError('preparation', 'layer_height, wall_loops and sparse_infill_density need a readable process profile for generic OrcaSlicer. ' +
|
|
120
|
+
"Pass slicer_profile as 'machine.json;process.json', or slice with bambu_model.", { slicerType: 'orcaslicer' });
|
|
121
|
+
}
|
|
122
|
+
const merged = path.join(outputDir, `process-overrides-${path.basename(profiles.settingsProfiles[index])}`);
|
|
123
|
+
fs.writeFileSync(merged, JSON.stringify({ ...parsed[index], ...keys }, null, 2));
|
|
124
|
+
const settingsProfiles = [...profiles.settingsProfiles];
|
|
125
|
+
settingsProfiles[index] = merged;
|
|
126
|
+
return { ...profiles, settingsProfiles };
|
|
127
|
+
}
|
|
128
|
+
/** Bundled and user Orca filament presets inherit their settings; the CLI needs them merged in. */
|
|
129
|
+
async withFlattenedOrcaFilaments(profiles, slicerPath, outputDir) {
|
|
130
|
+
const filamentProfiles = await Promise.all(profiles.filamentProfiles.map(async (profile, index) => {
|
|
131
|
+
const flat = await flattenOrcaFilament(profile, { slicerPath }).catch(() => undefined);
|
|
132
|
+
if (!flat)
|
|
133
|
+
return profile;
|
|
134
|
+
const merged = path.join(outputDir, `filament-${index + 1}-${path.basename(profile)}`);
|
|
135
|
+
fs.writeFileSync(merged, JSON.stringify(flat, null, 2));
|
|
136
|
+
return merged;
|
|
137
|
+
}));
|
|
138
|
+
return { ...profiles, filamentProfiles };
|
|
139
|
+
}
|
|
76
140
|
appendOrcaProfileArgs(args, profiles) {
|
|
77
141
|
for (const profile of profiles.settingsProfiles) {
|
|
78
142
|
args.push('--load-settings', profile);
|
|
@@ -775,7 +839,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
775
839
|
* @param bambuOptions Optional Bambu-compatible CLI options (added last for compatibility)
|
|
776
840
|
* @returns Path to the generated G-code or sliced 3MF file
|
|
777
841
|
*/
|
|
778
|
-
async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions) {
|
|
842
|
+
async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
|
|
779
843
|
const operationId = this.generateOperationId();
|
|
780
844
|
this.activeOperations.set(operationId, true);
|
|
781
845
|
const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
|
|
@@ -823,6 +887,9 @@ export class STLManipulator extends EventEmitter {
|
|
|
823
887
|
'to keep the machine;process|filament format.');
|
|
824
888
|
}
|
|
825
889
|
const options = { ...bambuOptions };
|
|
890
|
+
const overrideKeys = orcaProcessOverrides(processOverrides);
|
|
891
|
+
if (Object.keys(overrideKeys).length)
|
|
892
|
+
options.processOverrides = overrideKeys;
|
|
826
893
|
if (orcaProfiles.filamentProfiles.length > 0) {
|
|
827
894
|
const legacyFilaments = orcaProfiles.filamentProfiles.join(';');
|
|
828
895
|
if (options.loadFilaments && this.splitProfileList(options.loadFilaments).join(';') !== legacyFilaments) {
|
|
@@ -853,16 +920,26 @@ export class STLManipulator extends EventEmitter {
|
|
|
853
920
|
else {
|
|
854
921
|
switch (slicerType) {
|
|
855
922
|
case 'prusaslicer':
|
|
856
|
-
case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
|
|
923
|
+
case 'slic3r': { // PrusaSlicer and Slic3r share similar CLI args
|
|
924
|
+
// A filament preset (.ini from the slicer's filament folder) is loaded after the main
|
|
925
|
+
// config: --load may repeat, and later files override earlier ones.
|
|
926
|
+
const filaments = this.splitProfileList(filamentProfile);
|
|
927
|
+
if (filaments.length > 1) {
|
|
928
|
+
throw new SlicerError('preparation', `${slicerType} takes one filament preset; filament_profile/FILAMENT_PROFILE lists ${filaments.length}.`, { slicerType, slicerPath });
|
|
929
|
+
}
|
|
930
|
+
if (filaments.length && !/\.ini$/i.test(filaments[0])) {
|
|
931
|
+
throw new SlicerError('preparation', `${slicerType} loads filament presets as .ini files (its filament folder, or File > Export > Export Config); ${path.basename(filaments[0])} is not one.`, { slicerType, slicerPath });
|
|
932
|
+
}
|
|
857
933
|
args = [
|
|
858
934
|
'--slice',
|
|
859
935
|
'--output', outputFilePath,
|
|
860
|
-
'--load', slicerProfile
|
|
936
|
+
...(slicerProfile ? ['--load', slicerProfile] : []),
|
|
937
|
+
...filaments.flatMap((filament) => ['--load', filament]),
|
|
938
|
+
...prusaSlicerOverrideArgs(processOverrides),
|
|
861
939
|
stlFilePath
|
|
862
940
|
];
|
|
863
|
-
// Remove empty profile arg if not provided
|
|
864
|
-
args = args.filter(arg => arg !== '');
|
|
865
941
|
break;
|
|
942
|
+
}
|
|
866
943
|
case 'orcaslicer':
|
|
867
944
|
orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
|
|
868
945
|
fs.mkdirSync(orcaOutputDir, { recursive: true });
|
|
@@ -870,10 +947,13 @@ export class STLManipulator extends EventEmitter {
|
|
|
870
947
|
'--slice', '0',
|
|
871
948
|
'--outputdir', orcaOutputDir,
|
|
872
949
|
];
|
|
873
|
-
this.appendOrcaProfileArgs(args, orcaProfiles);
|
|
950
|
+
this.appendOrcaProfileArgs(args, await this.withFlattenedOrcaFilaments(this.withOrcaProcessOverrides(orcaProfiles, processOverrides, orcaOutputDir), slicerPath, orcaOutputDir));
|
|
874
951
|
args.push(stlFilePath);
|
|
875
952
|
break;
|
|
876
953
|
case 'cura':
|
|
954
|
+
if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
|
|
955
|
+
throw new SlicerError('preparation', 'layer_height, wall_loops and sparse_infill_density are not mapped for Cura; set them in the Cura definition passed as slicer_profile.', { slicerType, slicerPath });
|
|
956
|
+
}
|
|
877
957
|
// CuraEngine CLI args are different, often requiring -s for settings
|
|
878
958
|
// Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
|
|
879
959
|
args = [
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-3d-printer-server",
|
|
3
3
|
"mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.22",
|
|
5
5
|
"description": "MCP server for connecting Claude with 3D printer management systems",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
|
@@ -65,7 +65,12 @@
|
|
|
65
65
|
"src/blender-mcp-bridge.ts",
|
|
66
66
|
"CHANGELOG.md",
|
|
67
67
|
"CONTRIBUTORS.md",
|
|
68
|
-
"server.json"
|
|
68
|
+
"server.json",
|
|
69
|
+
"dist/counterpart",
|
|
70
|
+
"src/counterpart",
|
|
71
|
+
"dist/fusion-mcp-bridge.js",
|
|
72
|
+
"dist/fusion-mcp-bridge.d.ts",
|
|
73
|
+
"src/fusion-mcp-bridge.ts"
|
|
69
74
|
],
|
|
70
75
|
"keywords": [
|
|
71
76
|
"mcp",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.2.
|
|
10
|
+
"version": "1.2.22",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "mcp-3d-printer-server",
|
|
15
|
-
"version": "1.2.
|
|
15
|
+
"version": "1.2.22",
|
|
16
16
|
"runtimeHint": "npx",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
@@ -26,7 +26,7 @@ type StlMetadata = { bytes: number; triangles: number; bounding_box: BoundingBox
|
|
|
26
26
|
|
|
27
27
|
class BlenderError extends Error {}
|
|
28
28
|
|
|
29
|
-
function editDeadline(timeout: number, signal?: AbortSignal): EditDeadline {
|
|
29
|
+
export function editDeadline(timeout: number, signal?: AbortSignal): EditDeadline {
|
|
30
30
|
const deadline = performance.now() + timeout;
|
|
31
31
|
const check = () => {
|
|
32
32
|
if (signal?.aborted) throw new BlenderError("Blender edit cancelled. If execution started, inspect Blender before retrying.");
|
|
@@ -35,7 +35,7 @@ function editDeadline(timeout: number, signal?: AbortSignal): EditDeadline {
|
|
|
35
35
|
return { check, remaining: () => { check(); return Math.max(1, Math.ceil(deadline - performance.now())); } };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
class ClosingStdioTransport extends StdioClientTransport {
|
|
38
|
+
export class ClosingStdioTransport extends StdioClientTransport {
|
|
39
39
|
private closing?: Promise<void>;
|
|
40
40
|
|
|
41
41
|
override close(): Promise<void> {
|
|
@@ -120,7 +120,7 @@ function boundingBox(min: number[], max: number[]): BoundingBox {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
async function regularStl(filePath: string, deadline: EditDeadline): Promise<StlMetadata> {
|
|
123
|
+
export async function regularStl(filePath: string, deadline: EditDeadline): Promise<StlMetadata> {
|
|
124
124
|
deadline.check();
|
|
125
125
|
const stat = await fs.lstat(filePath).catch(() => { throw new BlenderError("STL file does not exist or is not readable."); });
|
|
126
126
|
deadline.check();
|