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
|
@@ -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
|
+
|
|
26
|
+
export interface FilamentProfileContext {
|
|
27
|
+
/** The canonical slicer type slice_stl uses without a slicer_type argument. */
|
|
28
|
+
slicerType: string;
|
|
29
|
+
slicerPath?: string;
|
|
30
|
+
/** SLICER_PROFILE: Orca "machine.json;process.json[|filament.json]", or PrusaSlicer's exported config. */
|
|
31
|
+
slicerProfile?: string;
|
|
32
|
+
/** What slice_stl loads when no filament is passed, in slot order. */
|
|
33
|
+
configuredFilaments?: string[];
|
|
34
|
+
/** Tests replace the searched directories. */
|
|
35
|
+
roots?: { system: string[]; user: string[] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Entry = { file: string; name: string; config: Record<string, unknown>; user: boolean };
|
|
39
|
+
type Index = { entries: Entry[]; byName: Map<string, Entry> };
|
|
40
|
+
|
|
41
|
+
const ORCA = new Set(["orcaslicer"]);
|
|
42
|
+
const PRUSA = new Set(["prusaslicer", "slic3r"]);
|
|
43
|
+
const MAX_FILES = 20_000;
|
|
44
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
45
|
+
const MAX_DEPTH = 5;
|
|
46
|
+
const INDEX_TTL_MS = 60_000;
|
|
47
|
+
const indexes = new Map<string, { at: number; index: Promise<Index> }>();
|
|
48
|
+
|
|
49
|
+
/** Per-user application data directories, in the order the slicers use them. */
|
|
50
|
+
function appDataDirs(): string[] {
|
|
51
|
+
const home = os.homedir();
|
|
52
|
+
return [
|
|
53
|
+
path.join(home, "Library", "Application Support"),
|
|
54
|
+
path.join(home, ".config"),
|
|
55
|
+
...(process.env.APPDATA ? [process.env.APPDATA] : []),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function defaultRoots(context: FilamentProfileContext): { system: string[]; user: string[] } {
|
|
60
|
+
if (ORCA.has(context.slicerType)) {
|
|
61
|
+
const system = [detectProfilesRoot(context.slicerPath, "orcaslicer"), ...appDataDirs().map((dir) => path.join(dir, "OrcaSlicer", "system"))];
|
|
62
|
+
const user = appDataDirs().map((dir) => path.join(dir, "OrcaSlicer", "user"));
|
|
63
|
+
return { system, user };
|
|
64
|
+
}
|
|
65
|
+
const home = os.homedir();
|
|
66
|
+
const name = context.slicerType === "slic3r" ? "Slic3r" : "PrusaSlicer";
|
|
67
|
+
const user = [
|
|
68
|
+
...appDataDirs().map((dir) => path.join(dir, name)),
|
|
69
|
+
...(name === "PrusaSlicer" ? [path.join(home, ".PrusaSlicer"), path.join(home, ".var", "app", "com.prusa3d.PrusaSlicer", "config", "PrusaSlicer")] : [path.join(home, ".Slic3r")]),
|
|
70
|
+
];
|
|
71
|
+
return { system: [], user };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function listFiles(dir: string, extension: string, depth: number, budget: { left: number }): Promise<string[]> {
|
|
75
|
+
if (depth > MAX_DEPTH || budget.left <= 0) return [];
|
|
76
|
+
const items = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
77
|
+
const files: string[] = [];
|
|
78
|
+
for (const item of items.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
79
|
+
if (budget.left <= 0) break;
|
|
80
|
+
const full = path.join(dir, item.name);
|
|
81
|
+
if (item.isDirectory()) files.push(...await listFiles(full, extension, depth + 1, budget));
|
|
82
|
+
else if (item.isFile() && item.name.toLowerCase().endsWith(extension)) { files.push(full); budget.left -= 1; }
|
|
83
|
+
}
|
|
84
|
+
return files;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readConfig(file: string): Promise<Record<string, unknown> | undefined> {
|
|
88
|
+
try {
|
|
89
|
+
const stat = await fs.stat(file);
|
|
90
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) return undefined;
|
|
91
|
+
const parsed = parseLooseSlicerConfig(await fs.readFile(file, "utf8"));
|
|
92
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function buildIndex(context: FilamentProfileContext, roots: { system: string[]; user: string[] }): Promise<Index> {
|
|
99
|
+
const budget = { left: MAX_FILES };
|
|
100
|
+
const files: { file: string; user: boolean }[] = [];
|
|
101
|
+
if (ORCA.has(context.slicerType)) {
|
|
102
|
+
// <root>/<Vendor>/filament/** for system trees; <user>/<id>/filament/** for presets.
|
|
103
|
+
for (const [list, user] of [[roots.system, false], [roots.user, true]] as const) {
|
|
104
|
+
for (const root of list) {
|
|
105
|
+
const groups = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
106
|
+
for (const group of groups.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
107
|
+
for (const file of await listFiles(path.join(root, group.name, "filament"), ".json", 0, budget)) files.push({ file, user });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
for (const root of roots.user) {
|
|
113
|
+
for (const file of await listFiles(path.join(root, "filament"), ".ini", MAX_DEPTH, budget)) files.push({ file, user: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const entries: Entry[] = [];
|
|
117
|
+
const byName = new Map<string, Entry>();
|
|
118
|
+
for (const { file, user } of files) {
|
|
119
|
+
const config = await readConfig(file);
|
|
120
|
+
if (!config) continue;
|
|
121
|
+
const name = typeof config.name === "string" && config.name.trim() ? config.name.trim() : path.basename(file, path.extname(file));
|
|
122
|
+
const entry = { file, name, config, user };
|
|
123
|
+
entries.push(entry);
|
|
124
|
+
// System names first: `inherits` points at system presets.
|
|
125
|
+
if (!byName.has(name)) byName.set(name, entry);
|
|
126
|
+
}
|
|
127
|
+
return { entries, byName };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function profileIndex(context: FilamentProfileContext): Promise<Index> {
|
|
131
|
+
const roots = context.roots ?? defaultRoots(context);
|
|
132
|
+
const key = JSON.stringify([context.slicerType, roots]);
|
|
133
|
+
const cached = indexes.get(key);
|
|
134
|
+
if (cached && Date.now() - cached.at < INDEX_TTL_MS) return cached.index;
|
|
135
|
+
const index = buildIndex(context, roots);
|
|
136
|
+
indexes.set(key, { at: Date.now(), index });
|
|
137
|
+
// A failed build is not cached.
|
|
138
|
+
index.catch(() => indexes.delete(key));
|
|
139
|
+
return index;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A key's value on the profile or the nearest preset it inherits from. */
|
|
143
|
+
function inherited(index: Index, entry: Entry, key: string): unknown {
|
|
144
|
+
const seen = new Set<Entry>();
|
|
145
|
+
let current: Entry | undefined = entry;
|
|
146
|
+
for (let depth = 0; current && depth < 12 && !seen.has(current); depth += 1) {
|
|
147
|
+
seen.add(current);
|
|
148
|
+
if (current.config[key] !== undefined) return current.config[key];
|
|
149
|
+
const parent: unknown = current.config.inherits;
|
|
150
|
+
current = typeof parent === "string" && parent.trim() ? index.byName.get(parent.trim()) : undefined;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function textValues(value: unknown): string[] {
|
|
156
|
+
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
157
|
+
if (typeof value !== "string" || !value.trim()) return [];
|
|
158
|
+
// PrusaSlicer lists are "a";"b"; Orca's are JSON arrays.
|
|
159
|
+
const quoted = [...value.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((match) => match[1].trim()).filter(Boolean);
|
|
160
|
+
return quoted.length ? quoted : value.split(";").map((item) => item.trim()).filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The configured printer's preset names, from SLICER_PROFILE. */
|
|
164
|
+
async function printerNames(context: FilamentProfileContext): Promise<Set<string>> {
|
|
165
|
+
const names = new Set<string>();
|
|
166
|
+
const settings = (context.slicerProfile ?? "").split("|", 1)[0].split(";").map((item) => item.trim()).filter(Boolean);
|
|
167
|
+
for (const file of settings) {
|
|
168
|
+
const config = await readConfig(file);
|
|
169
|
+
if (!config) continue;
|
|
170
|
+
const isMachine = config.type === "machine" || config.printer_settings_id !== undefined || config.printer_model !== undefined;
|
|
171
|
+
if (!isMachine) continue;
|
|
172
|
+
for (const key of ["name", "inherits", "printer_settings_id"]) {
|
|
173
|
+
for (const value of textValues(config[key])) names.add(value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return names;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function resolveFilamentProfile(material: string | undefined, context: FilamentProfileContext): Promise<string | undefined> {
|
|
180
|
+
try {
|
|
181
|
+
const wanted = material?.trim().toUpperCase();
|
|
182
|
+
if (!wanted) {
|
|
183
|
+
// No reported material: the profile slice_stl loads by default, when it exists.
|
|
184
|
+
const configured = context.configuredFilaments?.map((item) => item.trim()).find(Boolean);
|
|
185
|
+
return configured && existsSync(configured) ? path.resolve(configured) : undefined;
|
|
186
|
+
}
|
|
187
|
+
if (!ORCA.has(context.slicerType) && !PRUSA.has(context.slicerType)) return undefined;
|
|
188
|
+
const [index, printers] = await Promise.all([profileIndex(context), printerNames(context)]);
|
|
189
|
+
const candidates: { entry: Entry; rank: number }[] = [];
|
|
190
|
+
for (const entry of index.entries) {
|
|
191
|
+
if (String(entry.config.instantiation ?? "true").toLowerCase() === "false" || /@base$/i.test(entry.name)) continue;
|
|
192
|
+
if (!textValues(inherited(index, entry, "filament_type")).some((type) => type.toUpperCase() === wanted)) continue;
|
|
193
|
+
const compatible = textValues(inherited(index, entry, "compatible_printers"));
|
|
194
|
+
const condition = inherited(index, entry, "compatible_printers_condition");
|
|
195
|
+
let rank: number;
|
|
196
|
+
if (compatible.length) {
|
|
197
|
+
if (!compatible.some((name) => printers.has(name))) continue;
|
|
198
|
+
rank = 0;
|
|
199
|
+
} else {
|
|
200
|
+
// A condition this server cannot evaluate ranks below a profile for every printer.
|
|
201
|
+
rank = typeof condition === "string" && condition.trim() ? 2 : 1;
|
|
202
|
+
}
|
|
203
|
+
candidates.push({ entry, rank });
|
|
204
|
+
}
|
|
205
|
+
candidates.sort((a, b) => a.rank - b.rank || Number(b.entry.user) - Number(a.entry.user) || a.entry.file.localeCompare(b.entry.file));
|
|
206
|
+
const best = candidates[0]?.entry.file;
|
|
207
|
+
return best ? path.resolve(best) : undefined;
|
|
208
|
+
} catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A generic OrcaSlicer filament preset with its `inherits` chain merged in,
|
|
215
|
+
* parent first, for --load-filaments: the CLI does not resolve inheritance,
|
|
216
|
+
* so a bundled preset such as "Generic PETG @System" would otherwise slice
|
|
217
|
+
* without its own temperatures. Undefined when the file inherits nothing, or
|
|
218
|
+
* when a parent is not installed (the file is then passed as it is).
|
|
219
|
+
*/
|
|
220
|
+
export async function flattenOrcaFilament(file: string, context: Pick<FilamentProfileContext, "slicerPath" | "roots">): Promise<Record<string, unknown> | undefined> {
|
|
221
|
+
const leaf = await readConfig(file);
|
|
222
|
+
const parentName = leaf?.inherits;
|
|
223
|
+
if (!leaf || typeof parentName !== "string" || !parentName.trim()) return undefined;
|
|
224
|
+
const index = await profileIndex({ slicerType: "orcaslicer", ...context });
|
|
225
|
+
const chain: Record<string, unknown>[] = [leaf];
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
for (let name = parentName.trim(); name; ) {
|
|
228
|
+
const parent = index.byName.get(name);
|
|
229
|
+
if (!parent || seen.has(name) || chain.length > 12) return undefined;
|
|
230
|
+
seen.add(name);
|
|
231
|
+
chain.push(parent.config);
|
|
232
|
+
name = typeof parent.config.inherits === "string" ? parent.config.inherits.trim() : "";
|
|
233
|
+
}
|
|
234
|
+
const merged: Record<string, unknown> = Object.assign({}, ...chain.reverse());
|
|
235
|
+
const name = typeof leaf.name === "string" && leaf.name.trim() ? leaf.name.trim() : path.basename(file, path.extname(file));
|
|
236
|
+
// As the Bambu CLI flattener does: a user-owned, self-contained preset.
|
|
237
|
+
merged.name = name;
|
|
238
|
+
merged.inherits = "";
|
|
239
|
+
merged.from = "User";
|
|
240
|
+
merged.filament_settings_id = [name];
|
|
241
|
+
delete merged.instantiation;
|
|
242
|
+
delete merged.setting_id;
|
|
243
|
+
return merged;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Tests only: forget cached profile indexes. */
|
|
247
|
+
export function clearFilamentProfileCache(): void {
|
|
248
|
+
indexes.clear();
|
|
249
|
+
}
|
|
@@ -8,6 +8,7 @@ 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 {
|
|
12
13
|
BambuCliProfilePreparer,
|
|
13
14
|
assertSlicedProjectOutput,
|
|
@@ -21,6 +22,34 @@ import { runSlicerProcess } from '../slicer/run-slicer.js';
|
|
|
21
22
|
import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
|
|
22
23
|
|
|
23
24
|
export type { BambuSliceOptions } from '../slicer/bambu-cli.js';
|
|
25
|
+
|
|
26
|
+
/** Print-sheet slicing overrides (PRD §8.4), applied on top of the selected profile. */
|
|
27
|
+
export interface ProcessOverrides {
|
|
28
|
+
/** mm */
|
|
29
|
+
layerHeight?: number;
|
|
30
|
+
/** wall (perimeter) count */
|
|
31
|
+
wallLoops?: number;
|
|
32
|
+
/** e.g. "15%" */
|
|
33
|
+
sparseInfillDensity?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Orca/Bambu Studio process keys; profile JSON values are strings. */
|
|
37
|
+
export function orcaProcessOverrides(overrides: ProcessOverrides | undefined): Record<string, string> {
|
|
38
|
+
const keys: Record<string, string> = {};
|
|
39
|
+
if (overrides?.layerHeight !== undefined) keys.layer_height = String(overrides.layerHeight);
|
|
40
|
+
if (overrides?.wallLoops !== undefined) keys.wall_loops = String(overrides.wallLoops);
|
|
41
|
+
if (overrides?.sparseInfillDensity !== undefined) keys.sparse_infill_density = overrides.sparseInfillDensity;
|
|
42
|
+
return keys;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
|
|
46
|
+
export function prusaSlicerOverrideArgs(overrides: ProcessOverrides | undefined): string[] {
|
|
47
|
+
const args: string[] = [];
|
|
48
|
+
if (overrides?.layerHeight !== undefined) args.push('--layer-height', String(overrides.layerHeight));
|
|
49
|
+
if (overrides?.wallLoops !== undefined) args.push('--perimeters', String(overrides.wallLoops));
|
|
50
|
+
if (overrides?.sparseInfillDensity !== undefined) args.push('--fill-density', overrides.sparseInfillDensity);
|
|
51
|
+
return args;
|
|
52
|
+
}
|
|
24
53
|
import { execFile } from 'child_process';
|
|
25
54
|
import { scanGcodeThermal } from '../safety/gcode-thermal.js';
|
|
26
55
|
|
|
@@ -136,6 +165,49 @@ export class STLManipulator extends EventEmitter {
|
|
|
136
165
|
}
|
|
137
166
|
}
|
|
138
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Generic OrcaSlicer: write the overrides over a copy of the process profile
|
|
170
|
+
* (the settings file whose type is "process", else the last of two or more).
|
|
171
|
+
*/
|
|
172
|
+
private withOrcaProcessOverrides(
|
|
173
|
+
profiles: { settingsProfiles: string[]; filamentProfiles: string[] },
|
|
174
|
+
overrides: ProcessOverrides | undefined,
|
|
175
|
+
outputDir: string
|
|
176
|
+
): { settingsProfiles: string[]; filamentProfiles: string[] } {
|
|
177
|
+
const keys = orcaProcessOverrides(overrides);
|
|
178
|
+
if (!Object.keys(keys).length) return profiles;
|
|
179
|
+
const parsed = profiles.settingsProfiles.map((profile) => {
|
|
180
|
+
try { return JSON.parse(fs.readFileSync(profile, 'utf8')); } catch { return undefined; }
|
|
181
|
+
});
|
|
182
|
+
let index = parsed.findIndex((profile) => profile?.type === 'process');
|
|
183
|
+
if (index < 0 && profiles.settingsProfiles.length >= 2) index = profiles.settingsProfiles.length - 1;
|
|
184
|
+
if (index < 0 || !parsed[index] || typeof parsed[index] !== 'object') {
|
|
185
|
+
throw new SlicerError('preparation',
|
|
186
|
+
'layer_height, wall_loops and sparse_infill_density need a readable process profile for generic OrcaSlicer. ' +
|
|
187
|
+
"Pass slicer_profile as 'machine.json;process.json', or slice with bambu_model.",
|
|
188
|
+
{ slicerType: 'orcaslicer' });
|
|
189
|
+
}
|
|
190
|
+
const merged = path.join(outputDir, `process-overrides-${path.basename(profiles.settingsProfiles[index])}`);
|
|
191
|
+
fs.writeFileSync(merged, JSON.stringify({ ...parsed[index], ...keys }, null, 2));
|
|
192
|
+
const settingsProfiles = [...profiles.settingsProfiles];
|
|
193
|
+
settingsProfiles[index] = merged;
|
|
194
|
+
return { ...profiles, settingsProfiles };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Bundled and user Orca filament presets inherit their settings; the CLI needs them merged in. */
|
|
198
|
+
private async withFlattenedOrcaFilaments<T extends { settingsProfiles: string[]; filamentProfiles: string[] }>(
|
|
199
|
+
profiles: T, slicerPath: string, outputDir: string
|
|
200
|
+
): Promise<T> {
|
|
201
|
+
const filamentProfiles = await Promise.all(profiles.filamentProfiles.map(async (profile, index) => {
|
|
202
|
+
const flat = await flattenOrcaFilament(profile, { slicerPath }).catch(() => undefined);
|
|
203
|
+
if (!flat) return profile;
|
|
204
|
+
const merged = path.join(outputDir, `filament-${index + 1}-${path.basename(profile)}`);
|
|
205
|
+
fs.writeFileSync(merged, JSON.stringify(flat, null, 2));
|
|
206
|
+
return merged;
|
|
207
|
+
}));
|
|
208
|
+
return { ...profiles, filamentProfiles };
|
|
209
|
+
}
|
|
210
|
+
|
|
139
211
|
private appendOrcaProfileArgs(
|
|
140
212
|
args: string[],
|
|
141
213
|
profiles: { settingsProfiles: string[]; filamentProfiles: string[] }
|
|
@@ -1022,7 +1094,8 @@ export class STLManipulator extends EventEmitter {
|
|
|
1022
1094
|
progressCallback?: ProgressCallback,
|
|
1023
1095
|
printerPreset?: string,
|
|
1024
1096
|
filamentProfile?: string,
|
|
1025
|
-
bambuOptions?: BambuSliceOptions
|
|
1097
|
+
bambuOptions?: BambuSliceOptions,
|
|
1098
|
+
processOverrides?: ProcessOverrides
|
|
1026
1099
|
): Promise<string> {
|
|
1027
1100
|
const operationId = this.generateOperationId();
|
|
1028
1101
|
this.activeOperations.set(operationId, true);
|
|
@@ -1076,6 +1149,8 @@ export class STLManipulator extends EventEmitter {
|
|
|
1076
1149
|
);
|
|
1077
1150
|
}
|
|
1078
1151
|
const options: BambuSliceOptions = { ...bambuOptions };
|
|
1152
|
+
const overrideKeys = orcaProcessOverrides(processOverrides);
|
|
1153
|
+
if (Object.keys(overrideKeys).length) options.processOverrides = overrideKeys;
|
|
1079
1154
|
if (orcaProfiles.filamentProfiles.length > 0) {
|
|
1080
1155
|
const legacyFilaments = orcaProfiles.filamentProfiles.join(';');
|
|
1081
1156
|
if (options.loadFilaments && this.splitProfileList(options.loadFilaments).join(';') !== legacyFilaments) {
|
|
@@ -1112,16 +1187,29 @@ export class STLManipulator extends EventEmitter {
|
|
|
1112
1187
|
} else {
|
|
1113
1188
|
switch (slicerType) {
|
|
1114
1189
|
case 'prusaslicer':
|
|
1115
|
-
case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
|
|
1190
|
+
case 'slic3r': { // PrusaSlicer and Slic3r share similar CLI args
|
|
1191
|
+
// A filament preset (.ini from the slicer's filament folder) is loaded after the main
|
|
1192
|
+
// config: --load may repeat, and later files override earlier ones.
|
|
1193
|
+
const filaments = this.splitProfileList(filamentProfile);
|
|
1194
|
+
if (filaments.length > 1) {
|
|
1195
|
+
throw new SlicerError('preparation',
|
|
1196
|
+
`${slicerType} takes one filament preset; filament_profile/FILAMENT_PROFILE lists ${filaments.length}.`, { slicerType, slicerPath });
|
|
1197
|
+
}
|
|
1198
|
+
if (filaments.length && !/\.ini$/i.test(filaments[0])) {
|
|
1199
|
+
throw new SlicerError('preparation',
|
|
1200
|
+
`${slicerType} loads filament presets as .ini files (its filament folder, or File > Export > Export Config); ${path.basename(filaments[0])} is not one.`,
|
|
1201
|
+
{ slicerType, slicerPath });
|
|
1202
|
+
}
|
|
1116
1203
|
args = [
|
|
1117
1204
|
'--slice',
|
|
1118
1205
|
'--output', outputFilePath,
|
|
1119
|
-
'--load', slicerProfile
|
|
1206
|
+
...(slicerProfile ? ['--load', slicerProfile] : []),
|
|
1207
|
+
...filaments.flatMap((filament) => ['--load', filament]),
|
|
1208
|
+
...prusaSlicerOverrideArgs(processOverrides),
|
|
1120
1209
|
stlFilePath
|
|
1121
1210
|
];
|
|
1122
|
-
// Remove empty profile arg if not provided
|
|
1123
|
-
args = args.filter(arg => arg !== '');
|
|
1124
1211
|
break;
|
|
1212
|
+
}
|
|
1125
1213
|
|
|
1126
1214
|
case 'orcaslicer':
|
|
1127
1215
|
orcaOutputDir = path.join(this.tempDir, `${operationId}-orca-output`);
|
|
@@ -1130,11 +1218,17 @@ export class STLManipulator extends EventEmitter {
|
|
|
1130
1218
|
'--slice', '0',
|
|
1131
1219
|
'--outputdir', orcaOutputDir,
|
|
1132
1220
|
];
|
|
1133
|
-
this.appendOrcaProfileArgs(args,
|
|
1221
|
+
this.appendOrcaProfileArgs(args, await this.withFlattenedOrcaFilaments(
|
|
1222
|
+
this.withOrcaProcessOverrides(orcaProfiles, processOverrides, orcaOutputDir), slicerPath, orcaOutputDir));
|
|
1134
1223
|
args.push(stlFilePath);
|
|
1135
1224
|
break;
|
|
1136
1225
|
|
|
1137
1226
|
case 'cura':
|
|
1227
|
+
if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
|
|
1228
|
+
throw new SlicerError('preparation',
|
|
1229
|
+
'layer_height, wall_loops and sparse_infill_density are not mapped for Cura; set them in the Cura definition passed as slicer_profile.',
|
|
1230
|
+
{ slicerType, slicerPath });
|
|
1231
|
+
}
|
|
1138
1232
|
// CuraEngine CLI args are different, often requiring -s for settings
|
|
1139
1233
|
// Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
|
|
1140
1234
|
args = [
|