mcp-3d-printer-server 1.2.19 → 1.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +112 -9
  3. package/dist/blender-mcp-bridge.d.ts +21 -0
  4. package/dist/blender-mcp-bridge.js +3 -3
  5. package/dist/counterpart/contract.d.ts +395 -0
  6. package/dist/counterpart/contract.js +220 -0
  7. package/dist/counterpart/progress.d.ts +32 -0
  8. package/dist/counterpart/progress.js +443 -0
  9. package/dist/counterpart/service.d.ts +56 -0
  10. package/dist/counterpart/service.js +224 -0
  11. package/dist/fusion-mcp-bridge.d.ts +166 -0
  12. package/dist/fusion-mcp-bridge.js +529 -0
  13. package/dist/index.js +272 -8
  14. package/dist/printers/creality.d.ts +26 -1
  15. package/dist/printers/creality.js +75 -2
  16. package/dist/printers/duet.d.ts +16 -0
  17. package/dist/printers/duet.js +64 -0
  18. package/dist/printers/generic-base.d.ts +52 -6
  19. package/dist/printers/generic-base.js +134 -7
  20. package/dist/printers/klipper.d.ts +33 -2
  21. package/dist/printers/klipper.js +106 -0
  22. package/dist/printers/octoprint.d.ts +23 -1
  23. package/dist/printers/octoprint.js +81 -0
  24. package/dist/printers/prusa.d.ts +13 -0
  25. package/dist/printers/prusa.js +59 -0
  26. package/dist/printers/repetier.d.ts +17 -1
  27. package/dist/printers/repetier.js +55 -0
  28. package/dist/slicer/bambu-cli.d.ts +2 -0
  29. package/dist/slicer/bambu-cli.js +11 -0
  30. package/dist/slicer/filament-profiles.d.ts +25 -0
  31. package/dist/slicer/filament-profiles.js +249 -0
  32. package/dist/stl/stl-manipulator.d.ts +21 -1
  33. package/dist/stl/stl-manipulator.js +94 -13
  34. package/package.json +7 -2
  35. package/server.json +2 -2
  36. package/src/blender-mcp-bridge.ts +3 -3
  37. package/src/counterpart/contract.ts +326 -0
  38. package/src/counterpart/progress.ts +470 -0
  39. package/src/counterpart/service.ts +247 -0
  40. package/src/fusion-mcp-bridge.ts +512 -0
  41. package/src/index.ts +314 -10
  42. package/src/printers/creality.ts +84 -1
  43. package/src/printers/duet.ts +67 -0
  44. package/src/printers/generic-base.ts +172 -1
  45. package/src/printers/klipper.ts +118 -2
  46. package/src/printers/octoprint.ts +86 -1
  47. package/src/printers/prusa.ts +60 -0
  48. package/src/printers/repetier.ts +63 -1
  49. package/src/slicer/bambu-cli.ts +13 -0
  50. package/src/slicer/filament-profiles.ts +249 -0
  51. package/src/stl/stl-manipulator.ts +108 -13
@@ -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;
@@ -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 || '', // Pass profile path
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 = [
@@ -1194,22 +1274,23 @@ export class STLManipulator extends EventEmitter {
1194
1274
  throw new Error("Operation cancelled");
1195
1275
  if (progressCallback)
1196
1276
  progressCallback(75, "Finding largest flat surface...");
1197
- // Find the group with the largest total area (excluding near-vertical faces)
1277
+ // Include already-horizontal faces: excluding Z-aligned normals can
1278
+ // stand a flat model on its edge, including on a repeated layFlat call.
1198
1279
  let largestArea = 0;
1199
1280
  let targetNormal = null;
1200
- const upVector = new THREE.Vector3(0, 1, 0); // Assuming Y is up for most models initially?
1201
1281
  const downVector = new THREE.Vector3(0, 0, -1); // Target normal (pointing down)
1282
+ // Faces are grouped by direction, so a part whose flat faces are all
1283
+ // horizontal has equal up- and down-facing areas. On a tie, keep the
1284
+ // face that already points down so a seated part is not flipped.
1202
1285
  for (const group of normalGroups.values()) {
1203
- // Check if the normal is reasonably horizontal (not too close to vertical Z)
1204
- // Angle threshold could be adjusted (e.g., > 5 degrees from Z axis)
1205
- if (Math.abs(group.representativeNormal.z) < 0.99 && group.totalArea > largestArea) {
1286
+ const tied = targetNormal !== null && Math.abs(group.totalArea - largestArea) <= largestArea * 1e-6;
1287
+ if (tied ? group.representativeNormal.z < targetNormal.z : group.totalArea > largestArea) {
1206
1288
  largestArea = group.totalArea;
1207
1289
  targetNormal = group.representativeNormal;
1208
1290
  }
1209
1291
  }
1210
1292
  if (!targetNormal) {
1211
- // If no suitable horizontal face found, maybe pick the lowest Z face?
1212
- // For now, throw an error or return unchanged.
1293
+ // Only a mesh without any face area reaches this.
1213
1294
  console.warn("Could not find a suitable large flat face to lay down. No changes made.");
1214
1295
  this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
1215
1296
  return stlFilePath;
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.19",
4
+ "version": "1.2.21",
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",