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
@@ -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 || '', // Pass profile path
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, orcaProfiles);
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 = [
@@ -1503,24 +1597,25 @@ export class STLManipulator extends EventEmitter {
1503
1597
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1504
1598
  if (progressCallback) progressCallback(75, "Finding largest flat surface...");
1505
1599
 
1506
- // Find the group with the largest total area (excluding near-vertical faces)
1600
+ // Include already-horizontal faces: excluding Z-aligned normals can
1601
+ // stand a flat model on its edge, including on a repeated layFlat call.
1507
1602
  let largestArea = 0;
1508
1603
  let targetNormal: THREE.Vector3 | null = null;
1509
- const upVector = new THREE.Vector3(0, 1, 0); // Assuming Y is up for most models initially?
1510
1604
  const downVector = new THREE.Vector3(0, 0, -1); // Target normal (pointing down)
1511
1605
 
1606
+ // Faces are grouped by direction, so a part whose flat faces are all
1607
+ // horizontal has equal up- and down-facing areas. On a tie, keep the
1608
+ // face that already points down so a seated part is not flipped.
1512
1609
  for (const group of normalGroups.values()) {
1513
- // Check if the normal is reasonably horizontal (not too close to vertical Z)
1514
- // Angle threshold could be adjusted (e.g., > 5 degrees from Z axis)
1515
- if (Math.abs(group.representativeNormal.z) < 0.99 && group.totalArea > largestArea) {
1610
+ const tied = targetNormal !== null && Math.abs(group.totalArea - largestArea) <= largestArea * 1e-6;
1611
+ if (tied ? group.representativeNormal.z < targetNormal!.z : group.totalArea > largestArea) {
1516
1612
  largestArea = group.totalArea;
1517
1613
  targetNormal = group.representativeNormal;
1518
1614
  }
1519
1615
  }
1520
1616
 
1521
1617
  if (!targetNormal) {
1522
- // If no suitable horizontal face found, maybe pick the lowest Z face?
1523
- // For now, throw an error or return unchanged.
1618
+ // Only a mesh without any face area reaches this.
1524
1619
  console.warn("Could not find a suitable large flat face to lay down. No changes made.");
1525
1620
  this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
1526
1621
  return stlFilePath;