mcp-3d-printer-server 1.2.23 → 1.2.25

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.25] – 2026-11-02
4
+
5
+ ### Fixed
6
+
7
+ - `lay_flat` rests a model on a face that is its real bottom. Before, it turned the face-normal group with the largest area to face down and moved the lowest point to Z=0, without checking that those faces were the lowest. The stove gap filler from Scan to Make (six T strips) printed upside down: its cap undersides' 23,838 mm² face down but sit 12 mm above the fin tips, so it stood on its fins.
8
+ - A pose qualifies only when some face, turned down, lies on the rotated model's lowest plane, within 0.5% of its height and at most 0.2 mm. The candidate poses are the 64 largest face groups and the model's six axes, so a domed part can flip exactly.
9
+ - Among qualifying poses, `lay_flat` picks the one with the least part and support volume per mm² of bed contact. Support is the overhang more than 45° from vertical, times its height above the bed. Ties go to the larger contact, then to the face that already points down.
10
+ - When no pose qualifies, the model is unchanged. The text result is still the STL path, and the new `structuredContent` `{path, laid_flat, message}` says why.
11
+ - The gap filler now comes out cap-down, fin tips 14.4 mm up. This is checked from the output vertices only. The same rule in bambu-printer-mcp was also checked by slicing with Bambu Studio 02.08.02.61 for a P1S, which gave no warning; that slice was not repeated here.
12
+ - `slice_stl` returns Bambu Studio's own checks on the Bambu-compatible CLI route, and OrcaSlicer's where it writes the same `result.json`. When the CLI writes `result.json` (Bambu Studio 02.08.02.61 writes it on macOS on success too), `structuredContent.slicer_result` carries it parsed and unchanged: `error_string`, `return_code` and each `sliced_plates[].warning_message`. Before, the run's output directory was deleted with `result.json` in it, and warnings such as "It seems object … has floating cantilever. Please re-orient the object or enable support generation." were lost.
13
+ - The text result stays the sliced file's path.
14
+ - A failed slice is an error result whose text starts with Studio's `error_string` (unless it reports success), then each plate's `warning_message`, then the exit code, slicer output and suggestion, with no `Error: ` prefix. Its `structuredContent` carries `slicer_result` beside the existing `slicer` details.
15
+ - A `result.json` reporting a nonzero `return_code` fails the slice even when the CLI exits 0.
16
+ - The G-code slicers (PrusaSlicer, Cura, Slic3r, generic OrcaSlicer without a Bambu preset) write no `result.json` and return `structuredContent` `{path}` only.
17
+ - Tested with real `result.json` files from Bambu Studio 02.08.02.61 slicing the Scan to Make stove gap filler for a P1S on macOS, replayed by a fake CLI. The optional installed-Studio test also checks `slicer_result` on macOS; CI skips it. OrcaSlicer's `result.json` follows the same libslic3r format but was not run here.
18
+
19
+ ## [1.2.24] – 2026-11-02
20
+
21
+ ### Fixed
22
+
23
+ - `fusion_mcp_status` reports the Fusion360MCP add-in as reachable. The pinned add-in answers a ping on its fast path with `{"ok":true,"status":"pong"}`, and the bridge accepted only `{"pong":true}`, so it reported `connected: false` with a running add-in. Verified against a live Fusion with the add-in at `19b9f1e95192` through bambu-printer-mcp's identical bridge, and with socket fixtures here.
24
+
3
25
  ## [1.2.23] – 2026-11-02
4
26
 
5
27
  ### Removed
@@ -130,8 +130,10 @@ const NO_ACTIVE_DESIGN_HELP = "Fusion has no open design, and at the pinned faus
130
130
  * True only when the Fusion360MCP add-in answers its ping. A bare TCP connect
131
131
  * is not enough: Blender's MCP addon listens on the same default port (9876)
132
132
  * and answers "unknown command" to this harmless request.
133
- * Protocol: newline-delimited JSON, {"type","params"} in and
134
- * {"status":"success","result":{"pong":true}} out (add-in server/socket_server.py).
133
+ * Protocol: newline-delimited JSON, {"type","params"} in (add-in server/socket_server.py).
134
+ * The pinned add-in answers ping on its fast path (event_bridge.py) with
135
+ * {"status":"success","result":{"ok":true,"status":"pong"}}, and through its
136
+ * command handler with {"pong":true}.
135
137
  */
136
138
  function addinAnswers(host, port, timeoutMs) {
137
139
  return new Promise((resolve) => {
@@ -150,7 +152,7 @@ function addinAnswers(host, port, timeoutMs) {
150
152
  const line = buffer.includes("\n") ? buffer.slice(0, buffer.indexOf("\n")) : buffer;
151
153
  try {
152
154
  const reply = JSON.parse(line);
153
- done(reply?.status === "success" && reply?.result?.pong === true);
155
+ done(reply?.status === "success" && (reply?.result?.status === "pong" || reply?.result?.pong === true));
154
156
  }
155
157
  catch {
156
158
  if (buffer.includes("\n"))
package/dist/index.js CHANGED
@@ -692,7 +692,7 @@ class ThreeDPrinterMCPServer {
692
692
  slicerProfile = slicerProfile.split("|", 1)[0];
693
693
  }
694
694
  const slicerPath = this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
695
- return this.stlManipulator.sliceSTL(String(args.stl_path), slicerType, slicerPath, slicerProfile, undefined, // progressCallback
695
+ return this.stlManipulator.sliceSTLWithResult(String(args.stl_path), slicerType, slicerPath, slicerProfile, undefined, // progressCallback
696
696
  printerPreset, filamentProfile, options, processOverrides);
697
697
  }
698
698
  setupErrorHandling() {
@@ -1723,7 +1723,7 @@ class ThreeDPrinterMCPServer {
1723
1723
  },
1724
1724
  {
1725
1725
  name: "lay_flat",
1726
- description: "Attempt to rotate the model so its largest flat face lies on the XY plane (Z=0).",
1726
+ description: "Rotate the model so a face rests on the bed (Z=0), choosing the orientation with the least part and support volume per mm² of bed contact. A face counts only if nothing hangs below it. Returns the new STL path; structuredContent.laid_flat is false, with the input path, when no orientation qualifies.",
1727
1727
  inputSchema: {
1728
1728
  type: "object",
1729
1729
  properties: {
@@ -1968,14 +1968,16 @@ class ThreeDPrinterMCPServer {
1968
1968
  }
1969
1969
  result = await this.stlManipulator.extendBase(String(args.stl_path), Number(args.extension_inches));
1970
1970
  break;
1971
- case "slice_stl":
1972
- result = await this.runSliceTool("slice_stl", args);
1973
- break;
1971
+ case "slice_stl": {
1972
+ const sliced = await this.runSliceTool("slice_stl", args);
1973
+ // The text stays the sliced file's path; slicer_result is the slicer's own result.json, verbatim.
1974
+ return { content: [{ type: "text", text: sliced.path }], structuredContent: sliced };
1975
+ }
1974
1976
  case "slice_with_template":
1975
1977
  if (!args?.template_name || !String(args.template_name).trim()) {
1976
1978
  throw new Error("Missing required parameter: template_name");
1977
1979
  }
1978
- result = await this.runSliceTool("slice_with_template", args);
1980
+ result = (await this.runSliceTool("slice_with_template", args)).path;
1979
1981
  break;
1980
1982
  case "list_templates":
1981
1983
  result = listTemplateRegistry(typeof args?.template_dir === "string" ? args.template_dir : undefined);
@@ -2384,12 +2386,14 @@ class ThreeDPrinterMCPServer {
2384
2386
  }
2385
2387
  result = await this.stlManipulator.centerModel(String(args.stl_path));
2386
2388
  break;
2387
- case "lay_flat":
2389
+ case "lay_flat": {
2388
2390
  if (!args?.stl_path) {
2389
2391
  throw new Error("Missing required parameter: stl_path");
2390
2392
  }
2391
- result = await this.stlManipulator.layFlat(String(args.stl_path));
2392
- break;
2393
+ const layFlatResult = await this.stlManipulator.layFlatWithResult(String(args.stl_path));
2394
+ // The text stays the STL path clients read; structuredContent says whether it was laid flat.
2395
+ return { content: [{ type: "text", text: layFlatResult.path }], structuredContent: layFlatResult };
2396
+ }
2393
2397
  case "fusion_mcp_status":
2394
2398
  return structured(await this.fusion.status(args ?? {}, extra.signal));
2395
2399
  case "fusion_mcp_call":
@@ -2429,7 +2433,8 @@ class ThreeDPrinterMCPServer {
2429
2433
  content: [
2430
2434
  {
2431
2435
  type: "text",
2432
- text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2436
+ // A slicer failure leads with the slicer's own words.
2437
+ text: `${error instanceof SlicerError && error.slicerText ? "" : "Error: "}${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2433
2438
  }
2434
2439
  ],
2435
2440
  // Clients validate structuredContent against a declared outputSchema even on errors.
@@ -2523,6 +2528,7 @@ class ThreeDPrinterMCPServer {
2523
2528
  message,
2524
2529
  tool,
2525
2530
  slicer: { phase: error.phase, ...error.details },
2531
+ ...(error.slicerResult ? { slicer_result: error.slicerResult } : {}),
2526
2532
  };
2527
2533
  }
2528
2534
  if (SLICING_TOOLS.has(tool)) {
@@ -118,9 +118,11 @@ export interface BambuCliInput {
118
118
  */
119
119
  export declare function planBambuCliInput(slicerType: BambuCliSlicerType, is3mf: boolean, options: BambuSliceOptions): BambuCliInput;
120
120
  /**
121
- * The CLI's own reason for a failed run: result.json in the run's fresh output
122
- * directory (written on Linux for handled errors), else the exit-code table.
121
+ * The result.json in the run's fresh output directory, parsed verbatim. Bambu
122
+ * Studio 02.08.02.61 writes it on macOS for successes and failures alike.
123
123
  */
124
+ export declare function readCliResult(outputDir: string): Record<string, unknown> | undefined;
125
+ /** The CLI's own reason for a failed run: its result.json, else the exit-code table. */
124
126
  export declare function bambuCliFailureReason(exitCode: number | null, outputDir: string): string | undefined;
125
127
  /** CLI flags for the prepared bundle and caller options (input path appended by the caller). */
126
128
  export declare function buildBambuCliArgs(bundle: BambuSettingsBundle, outputDir: string, exportName: string, options: BambuSliceOptions, input: BambuCliInput): string[];
@@ -530,18 +530,23 @@ const BAMBU_CLI_ERRORS = {
530
530
  [-102]: "G-code in an unprintable area",
531
531
  };
532
532
  /**
533
- * The CLI's own reason for a failed run: result.json in the run's fresh output
534
- * directory (written on Linux for handled errors), else the exit-code table.
533
+ * The result.json in the run's fresh output directory, parsed verbatim. Bambu
534
+ * Studio 02.08.02.61 writes it on macOS for successes and failures alike.
535
535
  */
536
- export function bambuCliFailureReason(exitCode, outputDir) {
536
+ export function readCliResult(outputDir) {
537
537
  try {
538
538
  const result = JSON.parse(fs.readFileSync(path.join(outputDir, "result.json"), "utf8"));
539
- if (typeof result?.error_string === "string" && result.error_string.trim()) {
540
- return `${result.error_string.trim()}${result.return_code !== undefined ? ` [${result.return_code}]` : ""}`;
541
- }
539
+ return result && typeof result === "object" && !Array.isArray(result) ? result : undefined;
542
540
  }
543
541
  catch {
544
- // result.json is written only on Linux and only for handled errors.
542
+ return undefined;
543
+ }
544
+ }
545
+ /** The CLI's own reason for a failed run: its result.json, else the exit-code table. */
546
+ export function bambuCliFailureReason(exitCode, outputDir) {
547
+ const result = readCliResult(outputDir);
548
+ if (typeof result?.error_string === "string" && result.error_string.trim()) {
549
+ return `${result.error_string.trim()}${result.return_code !== undefined ? ` [${result.return_code}]` : ""}`;
545
550
  }
546
551
  if (exitCode === null)
547
552
  return undefined;
@@ -15,11 +15,21 @@ export interface SlicerFailureDetails {
15
15
  stderrTail?: string;
16
16
  }
17
17
  export declare function slicerSuggestion(phase: SlicerFailurePhase, slicerType?: string): string;
18
+ /** Bambu Studio's or OrcaSlicer's own result.json, parsed verbatim. */
19
+ export type SlicerResult = Record<string, unknown>;
20
+ /**
21
+ * The slicer's own words: its error_string when it reports a failing
22
+ * return_code (not "Success."), then each plate's warning_message.
23
+ */
24
+ export declare function slicerResultText(result: SlicerResult): string;
18
25
  export declare class SlicerError extends Error {
26
+ readonly slicerResult?: SlicerResult | undefined;
19
27
  readonly phase: SlicerFailurePhase;
20
28
  readonly suggestion: string;
21
29
  readonly details: SlicerFailureDetails;
22
- constructor(phase: SlicerFailurePhase, message: string, details?: SlicerFailureDetails);
30
+ /** The slicer's own words from `slicerResult`; the message starts with them. */
31
+ readonly slicerText: string;
32
+ constructor(phase: SlicerFailurePhase, message: string, details?: SlicerFailureDetails, slicerResult?: SlicerResult | undefined);
23
33
  }
24
34
  /** Keep the last lines of slicer output; the actionable line is usually at the end. */
25
35
  export declare function tailText(text: string, maxLines?: number, maxChars?: number): string;
@@ -25,12 +25,26 @@ export function slicerSuggestion(phase, slicerType) {
25
25
  "uploaded or printed.";
26
26
  }
27
27
  }
28
+ /**
29
+ * The slicer's own words: its error_string when it reports a failing
30
+ * return_code (not "Success."), then each plate's warning_message.
31
+ */
32
+ export function slicerResultText(result) {
33
+ const plates = Array.isArray(result.sliced_plates) ? result.sliced_plates : [];
34
+ return [
35
+ result.return_code === 0 ? undefined : result.error_string,
36
+ ...plates.map((plate) => plate && typeof plate === "object" ? plate.warning_message : undefined),
37
+ ].filter((text) => typeof text === "string" && text.trim() !== "").map((text) => text.trim()).join("\n");
38
+ }
28
39
  export class SlicerError extends Error {
29
- constructor(phase, message, details = {}) {
30
- super(message);
40
+ constructor(phase, message, details = {}, slicerResult) {
41
+ const slicerText = slicerResult ? slicerResultText(slicerResult) : "";
42
+ super(slicerText ? `${slicerText}\n${message}` : message);
43
+ this.slicerResult = slicerResult;
31
44
  this.name = "SlicerError";
32
45
  this.phase = phase;
33
46
  this.details = details;
47
+ this.slicerText = slicerText;
34
48
  this.suggestion = slicerSuggestion(phase, details.slicerType);
35
49
  }
36
50
  }
@@ -2,6 +2,11 @@ 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
+ /** A sliced file, with the slicer's own result.json when it wrote one. */
6
+ export type SliceOutput = {
7
+ path: string;
8
+ slicer_result?: Record<string, unknown>;
9
+ };
5
10
  /** Print-sheet slicing overrides (PRD §8.4), applied on top of the selected profile. */
6
11
  export interface ProcessOverrides {
7
12
  /** mm */
@@ -154,6 +159,12 @@ export declare class STLManipulator extends EventEmitter {
154
159
  * @returns Path to the generated G-code or sliced 3MF file
155
160
  */
156
161
  sliceSTL(stlFilePath: string, slicerType: SlicerType, slicerPath: string, slicerProfile?: string, progressCallback?: ProgressCallback, printerPreset?: string, filamentProfile?: string, bambuOptions?: BambuSliceOptions, processOverrides?: ProcessOverrides): Promise<string>;
162
+ /**
163
+ * sliceSTL with the Bambu-compatible CLI's own result.json, parsed verbatim.
164
+ * A failure's SlicerError carries it too, and its message starts with the
165
+ * slicer's error_string and plate warnings.
166
+ */
167
+ sliceSTLWithResult(stlFilePath: string, slicerType: SlicerType, slicerPath: string, slicerProfile?: string, progressCallback?: ProgressCallback, printerPreset?: string, filamentProfile?: string, bambuOptions?: BambuSliceOptions, processOverrides?: ProcessOverrides): Promise<SliceOutput>;
157
168
  /**
158
169
  * Enhanced version of confirmTemperatures with better error handling
159
170
  * @param gcodePath Path to the G-code file
@@ -193,7 +204,21 @@ export declare class STLManipulator extends EventEmitter {
193
204
  */
194
205
  centerModel(stlFilePath: string, progressCallback?: ProgressCallback): Promise<string>;
195
206
  /**
196
- * Rotate the model so its largest flat face lies on the XY plane (Z=0).
207
+ * Rotate the model so a face rests on the bed, in the pose with the least
208
+ * part and support material per mm² of bed contact, then move it down to
209
+ * Z=0. Returns the new file's path, or the input path when no pose qualifies.
197
210
  */
198
211
  layFlat(stlFilePath: string, progressCallback?: ProgressCallback): Promise<string>;
212
+ /**
213
+ * layFlat with its outcome. A pose qualifies only when some face, turned to
214
+ * face down, lies on the rotated mesh's lowest plane; `laid_flat` is false
215
+ * and the model is unchanged when none does.
216
+ */
217
+ layFlatWithResult(stlFilePath: string, progressCallback?: ProgressCallback): Promise<LayFlatResult>;
199
218
  }
219
+ export type LayFlatResult = {
220
+ /** The laid-flat STL, or the unchanged input when `laid_flat` is false. */
221
+ path: string;
222
+ laid_flat: boolean;
223
+ message: string;
224
+ };
@@ -9,7 +9,7 @@ import { EventEmitter } from 'events';
9
9
  import * as crypto from 'crypto';
10
10
  import { detectProfilesRoot } from '../slicer/profile-flatten.js';
11
11
  import { flattenOrcaFilament } from '../slicer/filament-profiles.js';
12
- import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReason, buildBambuCliArgs, planBambuCliInput, } from '../slicer/bambu-cli.js';
12
+ import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReason, readCliResult, buildBambuCliArgs, planBambuCliInput, } from '../slicer/bambu-cli.js';
13
13
  import { runSlicerProcess } from '../slicer/run-slicer.js';
14
14
  import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
15
15
  /** Orca/Bambu Studio process keys; profile JSON values are strings. */
@@ -840,6 +840,14 @@ export class STLManipulator extends EventEmitter {
840
840
  * @returns Path to the generated G-code or sliced 3MF file
841
841
  */
842
842
  async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
843
+ return (await this.sliceSTLWithResult(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides)).path;
844
+ }
845
+ /**
846
+ * sliceSTL with the Bambu-compatible CLI's own result.json, parsed verbatim.
847
+ * A failure's SlicerError carries it too, and its message starts with the
848
+ * slicer's error_string and plate warnings.
849
+ */
850
+ async sliceSTLWithResult(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
843
851
  const operationId = this.generateOperationId();
844
852
  this.activeOperations.set(operationId, true);
845
853
  const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
@@ -979,14 +987,31 @@ export class STLManipulator extends EventEmitter {
979
987
  progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
980
988
  console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
981
989
  const cliOutputDir = bambuOutputDir;
982
- await runSlicerProcess({
983
- command: slicerPath,
984
- args,
985
- timeoutMs: this.resolveSlicerTimeoutMs(),
986
- slicerType,
987
- isCancelled: () => !this.activeOperations.get(operationId),
988
- failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
989
- });
990
+ try {
991
+ await runSlicerProcess({
992
+ command: slicerPath,
993
+ args,
994
+ timeoutMs: this.resolveSlicerTimeoutMs(),
995
+ slicerType,
996
+ isCancelled: () => !this.activeOperations.get(operationId),
997
+ failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
998
+ });
999
+ }
1000
+ catch (error) {
1001
+ // Keep the CLI's own result.json before the output directory is removed.
1002
+ const failedResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
1003
+ if (failedResult && error instanceof SlicerError)
1004
+ throw new SlicerError(error.phase, error.message, error.details, failedResult);
1005
+ throw error;
1006
+ }
1007
+ const slicerResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
1008
+ if (cliOutputDir && !slicerResult) {
1009
+ // Older or Linux CLIs may write result.json only for handled errors.
1010
+ console.error(`${slicerType} wrote no result.json for this slice, so its own checks are not available.`);
1011
+ }
1012
+ if (typeof slicerResult?.return_code === "number" && slicerResult.return_code !== 0) {
1013
+ throw new SlicerError('execution', `Slicer exited with code 0, but its result.json reports return_code ${slicerResult.return_code}.`, { slicerType, slicerPath, exitCode: 0 }, slicerResult);
1014
+ }
990
1015
  if (!this.activeOperations.get(operationId)) {
991
1016
  throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
992
1017
  }
@@ -1013,7 +1038,7 @@ export class STLManipulator extends EventEmitter {
1013
1038
  catch (error) {
1014
1039
  if (error instanceof SlicerError)
1015
1040
  throw error;
1016
- throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath });
1041
+ throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath }, slicerResult);
1017
1042
  }
1018
1043
  if (progressCallback)
1019
1044
  progressCallback(100, "Slicing completed successfully");
@@ -1023,7 +1048,7 @@ export class STLManipulator extends EventEmitter {
1023
1048
  success: true,
1024
1049
  output: outputFilePath
1025
1050
  });
1026
- return outputFilePath;
1051
+ return slicerResult ? { path: outputFilePath, slicer_result: slicerResult } : { path: outputFilePath };
1027
1052
  }
1028
1053
  catch (error) {
1029
1054
  console.error(`Slicing failed for ${stlFilePath}:`, error instanceof Error ? error.message : error);
@@ -1202,9 +1227,19 @@ export class STLManipulator extends EventEmitter {
1202
1227
  }
1203
1228
  }
1204
1229
  /**
1205
- * Rotate the model so its largest flat face lies on the XY plane (Z=0).
1230
+ * Rotate the model so a face rests on the bed, in the pose with the least
1231
+ * part and support material per mm² of bed contact, then move it down to
1232
+ * Z=0. Returns the new file's path, or the input path when no pose qualifies.
1206
1233
  */
1207
1234
  async layFlat(stlFilePath, progressCallback) {
1235
+ return (await this.layFlatWithResult(stlFilePath, progressCallback)).path;
1236
+ }
1237
+ /**
1238
+ * layFlat with its outcome. A pose qualifies only when some face, turned to
1239
+ * face down, lies on the rotated mesh's lowest plane; `laid_flat` is false
1240
+ * and the model is unchanged when none does.
1241
+ */
1242
+ async layFlatWithResult(stlFilePath, progressCallback) {
1208
1243
  const operationId = this.generateOperationId();
1209
1244
  this.activeOperations.set(operationId, true);
1210
1245
  try {
@@ -1218,91 +1253,96 @@ export class STLManipulator extends EventEmitter {
1218
1253
  const positionAttribute = geometry.getAttribute('position');
1219
1254
  if (!positionAttribute)
1220
1255
  throw new Error("Geometry has no position attribute.");
1221
- const faces = [];
1256
+ const faceCount = Math.floor(positionAttribute.count / 3);
1257
+ if (faceCount === 0)
1258
+ throw new Error("No faces found in geometry.");
1259
+ const mesh = {
1260
+ positions: new Float64Array(faceCount * 9),
1261
+ normals: new Float64Array(faceCount * 3),
1262
+ areas: new Float64Array(faceCount),
1263
+ volume: 0,
1264
+ };
1265
+ const center = new THREE.Vector3();
1266
+ boundingBox.getCenter(center);
1222
1267
  const vA = new THREE.Vector3();
1223
1268
  const vB = new THREE.Vector3();
1224
1269
  const vC = new THREE.Vector3();
1225
- const faceNormal = new THREE.Vector3();
1226
1270
  const cb = new THREE.Vector3(), ab = new THREE.Vector3();
1227
- // Iterate through faces (triangles)
1228
- for (let i = 0; i < positionAttribute.count; i += 3) {
1229
- vA.fromBufferAttribute(positionAttribute, i);
1230
- vB.fromBufferAttribute(positionAttribute, i + 1);
1231
- vC.fromBufferAttribute(positionAttribute, i + 2);
1232
- // Calculate face normal
1271
+ const fromA = new THREE.Vector3(), fromB = new THREE.Vector3(), fromC = new THREE.Vector3();
1272
+ for (let face = 0; face < faceCount; face++) {
1273
+ vA.fromBufferAttribute(positionAttribute, face * 3);
1274
+ vB.fromBufferAttribute(positionAttribute, face * 3 + 1);
1275
+ vC.fromBufferAttribute(positionAttribute, face * 3 + 2);
1276
+ vA.toArray(mesh.positions, face * 9);
1277
+ vB.toArray(mesh.positions, face * 9 + 3);
1278
+ vC.toArray(mesh.positions, face * 9 + 6);
1279
+ // Signed tetrahedron volumes about the center sum to the enclosed volume.
1280
+ fromA.subVectors(vA, center);
1281
+ fromB.subVectors(vB, center);
1282
+ fromC.subVectors(vC, center);
1283
+ mesh.volume += fromA.dot(fromB.cross(fromC)) / 6;
1284
+ // The cross product's direction is the outward normal; half its length is the area.
1233
1285
  cb.subVectors(vC, vB);
1234
1286
  ab.subVectors(vA, vB);
1235
1287
  cb.cross(ab);
1236
- faceNormal.copy(cb).normalize();
1237
- // Calculate face area (using cross product magnitude / 2)
1238
- const area = cb.length() / 2;
1239
- faces.push({
1240
- normal: faceNormal.clone(),
1241
- area: area,
1242
- vertices: [vA.clone(), vB.clone(), vC.clone()] // Store vertices if needed for center calculation
1243
- });
1288
+ mesh.areas[face] = cb.length() / 2;
1289
+ cb.normalize().toArray(mesh.normals, face * 3);
1244
1290
  }
1245
- if (faces.length === 0)
1246
- throw new Error("No faces found in geometry.");
1291
+ mesh.volume = Math.abs(mesh.volume);
1247
1292
  if (!this.activeOperations.get(operationId))
1248
1293
  throw new Error("Operation cancelled");
1249
1294
  if (progressCallback)
1250
1295
  progressCallback(65, "Grouping faces by normal...");
1251
1296
  // Group faces by similar normal vectors (tolerance for floating point errors)
1252
- const normalGroups = new Map();
1297
+ const normalGroups = [];
1253
1298
  const tolerance = 1e-5;
1254
- for (const face of faces) {
1255
- let foundGroup = false;
1256
- for (const [key, group] of normalGroups.entries()) {
1257
- if (face.normal.distanceToSquared(group.representativeNormal) < tolerance) {
1258
- group.totalArea += face.area;
1259
- // Optional: Average the normal? For now, just use the first one.
1260
- foundGroup = true;
1261
- break;
1262
- }
1263
- }
1264
- if (!foundGroup) {
1265
- const key = `${face.normal.x.toFixed(5)},${face.normal.y.toFixed(5)},${face.normal.z.toFixed(5)}`;
1266
- if (!normalGroups.has(key)) { // Ensure unique key even if floats slightly differ after toFixed
1267
- normalGroups.set(key, { totalArea: face.area, representativeNormal: face.normal });
1268
- }
1269
- }
1299
+ const faceNormal = new THREE.Vector3();
1300
+ for (let face = 0; face < faceCount; face++) {
1301
+ if (!(mesh.areas[face] > 0))
1302
+ continue;
1303
+ faceNormal.fromArray(mesh.normals, face * 3);
1304
+ const group = normalGroups.find(candidate => faceNormal.distanceToSquared(candidate.representativeNormal) < tolerance);
1305
+ if (group)
1306
+ group.totalArea += mesh.areas[face];
1307
+ else
1308
+ normalGroups.push({ totalArea: mesh.areas[face], representativeNormal: faceNormal.clone() });
1270
1309
  }
1271
- if (normalGroups.size === 0)
1272
- throw new Error("Could not group faces by normal.");
1273
1310
  if (!this.activeOperations.get(operationId))
1274
1311
  throw new Error("Operation cancelled");
1275
1312
  if (progressCallback)
1276
- progressCallback(75, "Finding largest flat surface...");
1277
- // Include already-horizontal faces: excluding Z-aligned normals can
1278
- // stand a flat model on its edge, including on a repeated layFlat call.
1279
- let largestArea = 0;
1280
- let targetNormal = null;
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.
1285
- for (const group of normalGroups.values()) {
1286
- const tied = targetNormal !== null && Math.abs(group.totalArea - largestArea) <= largestArea * 1e-6;
1287
- if (tied ? group.representativeNormal.z < targetNormal.z : group.totalArea > largestArea) {
1288
- largestArea = group.totalArea;
1289
- targetNormal = group.representativeNormal;
1290
- }
1313
+ progressCallback(75, "Finding the pose that rests on the bed...");
1314
+ // Candidate directions to turn down: the largest face groups, plus the
1315
+ // model's own axes so a part with a domed side can be flipped exactly.
1316
+ const candidates = normalGroups
1317
+ .sort((a, b) => b.totalArea - a.totalArea)
1318
+ .slice(0, LAY_FLAT_MAX_GROUP_CANDIDATES)
1319
+ .map(group => group.representativeNormal);
1320
+ for (const axis of [[0, 0, -1], [0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) {
1321
+ const normal = new THREE.Vector3(axis[0], axis[1], axis[2]);
1322
+ if (!candidates.some(candidate => candidate.distanceToSquared(normal) < tolerance))
1323
+ candidates.push(normal);
1291
1324
  }
1292
- if (!targetNormal) {
1293
- // Only a mesh without any face area reaches this.
1294
- console.warn("Could not find a suitable large flat face to lay down. No changes made.");
1295
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
1296
- return stlFilePath;
1325
+ let best = null;
1326
+ for (const normal of candidates) {
1327
+ const pose = evaluateLayFlatPose(mesh, normal);
1328
+ if (pose && (!best || isBetterLayFlatPose(pose, best)))
1329
+ best = pose;
1330
+ }
1331
+ if (!best) {
1332
+ const message = "No face lies on the model's lowest plane in any candidate orientation, so the model is unchanged.";
1333
+ console.error(`lay_flat: ${message}`);
1334
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message });
1335
+ return { path: stlFilePath, laid_flat: false, message };
1297
1336
  }
1298
1337
  if (progressCallback)
1299
1338
  progressCallback(80, "Calculating rotation...");
1300
- // Calculate the rotation needed to align targetNormal with downVector
1301
- const quaternion = new THREE.Quaternion().setFromUnitVectors(targetNormal, downVector);
1302
- const rotationMatrix = new THREE.Matrix4().makeRotationFromQuaternion(quaternion);
1339
+ // Turn the chosen normal to face straight down. Turning +Z down rotates
1340
+ // half a turn about X, which keeps the model's X layout.
1341
+ const downVector = new THREE.Vector3(0, 0, -1);
1342
+ const rotationMatrix = best.normal.dot(downVector) < -1 + 1e-9
1343
+ ? new THREE.Matrix4().makeRotationX(Math.PI)
1344
+ : new THREE.Matrix4().makeRotationFromQuaternion(new THREE.Quaternion().setFromUnitVectors(best.normal, downVector));
1303
1345
  // Rotate around the model's center
1304
- const center = new THREE.Vector3();
1305
- boundingBox.getCenter(center);
1306
1346
  const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
1307
1347
  const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
1308
1348
  // Apply rotation
@@ -1320,8 +1360,9 @@ export class STLManipulator extends EventEmitter {
1320
1360
  const outputFileName = path.basename(stlFilePath, '.stl') + '_flat.stl';
1321
1361
  const outputFilePath = this.operationOutputPath(operationId, outputFileName);
1322
1362
  await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
1323
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath });
1324
- return outputFilePath;
1363
+ const message = `Rests on ${best.contactArea.toFixed(1)} mm² of bed contact; overhangs more than 45° from vertical need about ${best.supportVolume.toFixed(0)} mm³ of support.`;
1364
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath, message });
1365
+ return { path: outputFilePath, laid_flat: true, message };
1325
1366
  }
1326
1367
  catch (error) {
1327
1368
  this.emit('operationError', { operationId, type: 'layFlat', error: error instanceof Error ? error.message : String(error) });
@@ -1332,3 +1373,62 @@ export class STLManipulator extends EventEmitter {
1332
1373
  }
1333
1374
  }
1334
1375
  }
1376
+ const LAY_FLAT_MAX_GROUP_CANDIDATES = 64;
1377
+ /** Faces tilted more than 45° from vertical overhang and need support. */
1378
+ const LAY_FLAT_OVERHANG_COS = Math.SQRT1_2 + 1e-6;
1379
+ /**
1380
+ * Score turning `normal` to face the bed. The bed is the mesh's lowest plane in
1381
+ * that pose, its support plane. Faces lying on it, within 0.5% of the model's
1382
+ * height there (at most 0.2 mm), are bed contact. Overhanging faces above it
1383
+ * need support up from the bed: their projected area times their height.
1384
+ * Returns null when no face lies on the bed: turning that normal down would
1385
+ * rest the model on an edge or a point, not a face.
1386
+ */
1387
+ function evaluateLayFlatPose(mesh, normal) {
1388
+ const { positions, normals, areas } = mesh;
1389
+ const nx = normal.x, ny = normal.y, nz = normal.z;
1390
+ // Distance along the normal grows toward the bed: the bed is at its maximum.
1391
+ let bed = -Infinity, top = Infinity;
1392
+ for (let index = 0; index < positions.length; index += 3) {
1393
+ const along = positions[index] * nx + positions[index + 1] * ny + positions[index + 2] * nz;
1394
+ if (along > bed)
1395
+ bed = along;
1396
+ if (along < top)
1397
+ top = along;
1398
+ }
1399
+ const tolerance = Math.min(0.2, Math.max(0.005 * (bed - top), 1e-4));
1400
+ let contactArea = 0;
1401
+ let supportVolume = 0;
1402
+ for (let face = 0; face < areas.length; face++) {
1403
+ const facing = normals[face * 3] * nx + normals[face * 3 + 1] * ny + normals[face * 3 + 2] * nz;
1404
+ if (!(facing > 0))
1405
+ continue;
1406
+ const base = face * 9;
1407
+ let heightSum = 0, heightMax = 0;
1408
+ for (let corner = base; corner < base + 9; corner += 3) {
1409
+ const height = bed - (positions[corner] * nx + positions[corner + 1] * ny + positions[corner + 2] * nz);
1410
+ heightSum += height;
1411
+ if (height > heightMax)
1412
+ heightMax = height;
1413
+ }
1414
+ if (heightMax <= tolerance)
1415
+ contactArea += areas[face];
1416
+ else if (facing > LAY_FLAT_OVERHANG_COS)
1417
+ supportVolume += areas[face] * facing * (heightSum / 3);
1418
+ }
1419
+ if (!(contactArea > 0))
1420
+ return null;
1421
+ return { normal, contactArea, supportVolume, stackHeight: (mesh.volume + supportVolume) / contactArea };
1422
+ }
1423
+ /**
1424
+ * Prefer the pose with the lowest stack height, then the larger contact. On a
1425
+ * tie, keep the face that already points down so a seated part is not flipped.
1426
+ */
1427
+ function isBetterLayFlatPose(pose, best) {
1428
+ const same = (a, b) => Math.abs(a - b) <= 1e-9 + 1e-6 * Math.max(Math.abs(a), Math.abs(b));
1429
+ if (!same(pose.stackHeight, best.stackHeight))
1430
+ return pose.stackHeight < best.stackHeight;
1431
+ if (!same(pose.contactArea, best.contactArea))
1432
+ return pose.contactArea > best.contactArea;
1433
+ return pose.normal.z < best.normal.z;
1434
+ }
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.23",
4
+ "version": "1.2.25",
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",
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.23",
10
+ "version": "1.2.25",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "mcp-3d-printer-server",
15
- "version": "1.2.23",
15
+ "version": "1.2.25",
16
16
  "runtimeHint": "npx",
17
17
  "transport": {
18
18
  "type": "stdio"
@@ -136,8 +136,10 @@ const NO_ACTIVE_DESIGN_HELP =
136
136
  * True only when the Fusion360MCP add-in answers its ping. A bare TCP connect
137
137
  * is not enough: Blender's MCP addon listens on the same default port (9876)
138
138
  * and answers "unknown command" to this harmless request.
139
- * Protocol: newline-delimited JSON, {"type","params"} in and
140
- * {"status":"success","result":{"pong":true}} out (add-in server/socket_server.py).
139
+ * Protocol: newline-delimited JSON, {"type","params"} in (add-in server/socket_server.py).
140
+ * The pinned add-in answers ping on its fast path (event_bridge.py) with
141
+ * {"status":"success","result":{"ok":true,"status":"pong"}}, and through its
142
+ * command handler with {"pong":true}.
141
143
  */
142
144
  function addinAnswers(host: string, port: number, timeoutMs: number): Promise<boolean> {
143
145
  return new Promise((resolve) => {
@@ -154,7 +156,7 @@ function addinAnswers(host: string, port: number, timeoutMs: number): Promise<bo
154
156
  const line = buffer.includes("\n") ? buffer.slice(0, buffer.indexOf("\n")) : buffer;
155
157
  try {
156
158
  const reply = JSON.parse(line);
157
- done(reply?.status === "success" && reply?.result?.pong === true);
159
+ done(reply?.status === "success" && (reply?.result?.status === "pong" || reply?.result?.pong === true));
158
160
  } catch {
159
161
  if (buffer.includes("\n")) done(false);
160
162
  }
package/src/index.ts CHANGED
@@ -32,7 +32,7 @@ import { createServer as createHttpServer, type IncomingMessage, type Server as
32
32
  import { randomUUID } from "node:crypto";
33
33
  import * as THREE from 'three';
34
34
  import { PrinterFactory } from "./printers/printer-factory.js";
35
- import { STLManipulator, type BambuSliceOptions, type ProcessOverrides } from "./stl/stl-manipulator.js";
35
+ import { STLManipulator, type BambuSliceOptions, type ProcessOverrides, type SliceOutput } from "./stl/stl-manipulator.js";
36
36
  import { CounterpartService, type PrinterTarget } from "./counterpart/service.js";
37
37
  import {
38
38
  CAMERA_SNAPSHOT_OUTPUT_SCHEMA,
@@ -626,6 +626,8 @@ type StructuredToolError = {
626
626
  message: string;
627
627
  tool: string;
628
628
  slicer?: Record<string, unknown>;
629
+ /** The slicer's own result.json, verbatim. */
630
+ slicer_result?: Record<string, unknown>;
629
631
  };
630
632
 
631
633
  const SLICING_TOOLS = new Set(["slice_stl", "slice_with_template", "list_templates", "save_template", "get_slice_settings"]);
@@ -784,7 +786,7 @@ class ThreeDPrinterMCPServer {
784
786
  }
785
787
 
786
788
  /** Shared slice_stl / slice_with_template implementation. */
787
- private async runSliceTool(toolName: string, args: Record<string, unknown> | undefined): Promise<string> {
789
+ private async runSliceTool(toolName: string, args: Record<string, unknown> | undefined): Promise<SliceOutput> {
788
790
  if (!args?.stl_path) {
789
791
  throw new Error("Missing required parameter: stl_path");
790
792
  }
@@ -847,7 +849,7 @@ class ThreeDPrinterMCPServer {
847
849
  const slicerPath =
848
850
  this.resolveExecutableSelectorArg(args.slicer_path, toolName, "slicer_path") ?? defaultSlicerPathFor(slicerType);
849
851
 
850
- return this.stlManipulator.sliceSTL(
852
+ return this.stlManipulator.sliceSTLWithResult(
851
853
  String(args.stl_path),
852
854
  slicerType,
853
855
  slicerPath,
@@ -1919,7 +1921,7 @@ class ThreeDPrinterMCPServer {
1919
1921
  },
1920
1922
  {
1921
1923
  name: "lay_flat",
1922
- description: "Attempt to rotate the model so its largest flat face lies on the XY plane (Z=0).",
1924
+ description: "Rotate the model so a face rests on the bed (Z=0), choosing the orientation with the least part and support volume per mm² of bed contact. A face counts only if nothing hangs below it. Returns the new STL path; structuredContent.laid_flat is false, with the input path, when no orientation qualifies.",
1923
1925
  inputSchema: {
1924
1926
  type: "object",
1925
1927
  properties: {
@@ -2197,15 +2199,17 @@ class ThreeDPrinterMCPServer {
2197
2199
  );
2198
2200
  break;
2199
2201
 
2200
- case "slice_stl":
2201
- result = await this.runSliceTool("slice_stl", args as Record<string, unknown> | undefined);
2202
- break;
2202
+ case "slice_stl": {
2203
+ const sliced = await this.runSliceTool("slice_stl", args as Record<string, unknown> | undefined);
2204
+ // The text stays the sliced file's path; slicer_result is the slicer's own result.json, verbatim.
2205
+ return { content: [{ type: "text", text: sliced.path }], structuredContent: sliced };
2206
+ }
2203
2207
 
2204
2208
  case "slice_with_template":
2205
2209
  if (!args?.template_name || !String(args.template_name).trim()) {
2206
2210
  throw new Error("Missing required parameter: template_name");
2207
2211
  }
2208
- result = await this.runSliceTool("slice_with_template", args as Record<string, unknown> | undefined);
2212
+ result = (await this.runSliceTool("slice_with_template", args as Record<string, unknown> | undefined)).path;
2209
2213
  break;
2210
2214
 
2211
2215
  case "list_templates":
@@ -2795,12 +2799,14 @@ class ThreeDPrinterMCPServer {
2795
2799
  result = await this.stlManipulator.centerModel(String(args.stl_path));
2796
2800
  break;
2797
2801
 
2798
- case "lay_flat":
2802
+ case "lay_flat": {
2799
2803
  if (!args?.stl_path) {
2800
2804
  throw new Error("Missing required parameter: stl_path");
2801
2805
  }
2802
- result = await this.stlManipulator.layFlat(String(args.stl_path));
2803
- break;
2806
+ const layFlatResult = await this.stlManipulator.layFlatWithResult(String(args.stl_path));
2807
+ // The text stays the STL path clients read; structuredContent says whether it was laid flat.
2808
+ return { content: [{ type: "text", text: layFlatResult.path }], structuredContent: layFlatResult };
2809
+ }
2804
2810
 
2805
2811
  case "fusion_mcp_status":
2806
2812
  return structured(await this.fusion.status(args ?? {}, extra.signal));
@@ -2861,7 +2867,8 @@ class ThreeDPrinterMCPServer {
2861
2867
  content: [
2862
2868
  {
2863
2869
  type: "text",
2864
- text: `Error: ${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2870
+ // A slicer failure leads with the slicer's own words.
2871
+ text: `${error instanceof SlicerError && error.slicerText ? "" : "Error: "}${errorMessage}\nSuggestion: ${structuredError.suggestion}`
2865
2872
  }
2866
2873
  ],
2867
2874
  // Clients validate structuredContent against a declared outputSchema even on errors.
@@ -3035,6 +3042,7 @@ class ThreeDPrinterMCPServer {
3035
3042
  message,
3036
3043
  tool,
3037
3044
  slicer: { phase: error.phase, ...error.details },
3045
+ ...(error.slicerResult ? { slicer_result: error.slicerResult } : {}),
3038
3046
  };
3039
3047
  }
3040
3048
  if (SLICING_TOOLS.has(tool)) {
@@ -641,17 +641,23 @@ const BAMBU_CLI_ERRORS: Record<number, string> = {
641
641
  };
642
642
 
643
643
  /**
644
- * The CLI's own reason for a failed run: result.json in the run's fresh output
645
- * directory (written on Linux for handled errors), else the exit-code table.
644
+ * The result.json in the run's fresh output directory, parsed verbatim. Bambu
645
+ * Studio 02.08.02.61 writes it on macOS for successes and failures alike.
646
646
  */
647
- export function bambuCliFailureReason(exitCode: number | null, outputDir: string): string | undefined {
647
+ export function readCliResult(outputDir: string): Record<string, unknown> | undefined {
648
648
  try {
649
- const result = JSON.parse(fs.readFileSync(path.join(outputDir, "result.json"), "utf8"));
650
- if (typeof result?.error_string === "string" && result.error_string.trim()) {
651
- return `${result.error_string.trim()}${result.return_code !== undefined ? ` [${result.return_code}]` : ""}`;
652
- }
649
+ const result: unknown = JSON.parse(fs.readFileSync(path.join(outputDir, "result.json"), "utf8"));
650
+ return result && typeof result === "object" && !Array.isArray(result) ? result as Record<string, unknown> : undefined;
653
651
  } catch {
654
- // result.json is written only on Linux and only for handled errors.
652
+ return undefined;
653
+ }
654
+ }
655
+
656
+ /** The CLI's own reason for a failed run: its result.json, else the exit-code table. */
657
+ export function bambuCliFailureReason(exitCode: number | null, outputDir: string): string | undefined {
658
+ const result = readCliResult(outputDir);
659
+ if (typeof result?.error_string === "string" && result.error_string.trim()) {
660
+ return `${result.error_string.trim()}${result.return_code !== undefined ? ` [${result.return_code}]` : ""}`;
655
661
  }
656
662
  if (exitCode === null) return undefined;
657
663
  const code = Object.keys(BAMBU_CLI_ERRORS).map(Number).find((value) => value === exitCode || (value & 0xff) === exitCode);
@@ -41,16 +41,35 @@ export function slicerSuggestion(phase: SlicerFailurePhase, slicerType?: string)
41
41
  }
42
42
  }
43
43
 
44
+ /** Bambu Studio's or OrcaSlicer's own result.json, parsed verbatim. */
45
+ export type SlicerResult = Record<string, unknown>;
46
+
47
+ /**
48
+ * The slicer's own words: its error_string when it reports a failing
49
+ * return_code (not "Success."), then each plate's warning_message.
50
+ */
51
+ export function slicerResultText(result: SlicerResult): string {
52
+ const plates = Array.isArray(result.sliced_plates) ? result.sliced_plates : [];
53
+ return [
54
+ result.return_code === 0 ? undefined : result.error_string,
55
+ ...plates.map((plate) => plate && typeof plate === "object" ? (plate as Record<string, unknown>).warning_message : undefined),
56
+ ].filter((text): text is string => typeof text === "string" && text.trim() !== "").map((text) => text.trim()).join("\n");
57
+ }
58
+
44
59
  export class SlicerError extends Error {
45
60
  readonly phase: SlicerFailurePhase;
46
61
  readonly suggestion: string;
47
62
  readonly details: SlicerFailureDetails;
63
+ /** The slicer's own words from `slicerResult`; the message starts with them. */
64
+ readonly slicerText: string;
48
65
 
49
- constructor(phase: SlicerFailurePhase, message: string, details: SlicerFailureDetails = {}) {
50
- super(message);
66
+ constructor(phase: SlicerFailurePhase, message: string, details: SlicerFailureDetails = {}, readonly slicerResult?: SlicerResult) {
67
+ const slicerText = slicerResult ? slicerResultText(slicerResult) : "";
68
+ super(slicerText ? `${slicerText}\n${message}` : message);
51
69
  this.name = "SlicerError";
52
70
  this.phase = phase;
53
71
  this.details = details;
72
+ this.slicerText = slicerText;
54
73
  this.suggestion = slicerSuggestion(phase, details.slicerType);
55
74
  }
56
75
  }
@@ -13,6 +13,7 @@ import {
13
13
  BambuCliProfilePreparer,
14
14
  assertSlicedProjectOutput,
15
15
  bambuCliFailureReason,
16
+ readCliResult,
16
17
  buildBambuCliArgs,
17
18
  planBambuCliInput,
18
19
  type BambuCliSlicerType,
@@ -23,6 +24,9 @@ import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
23
24
 
24
25
  export type { BambuSliceOptions } from '../slicer/bambu-cli.js';
25
26
 
27
+ /** A sliced file, with the slicer's own result.json when it wrote one. */
28
+ export type SliceOutput = { path: string; slicer_result?: Record<string, unknown> };
29
+
26
30
  /** Print-sheet slicing overrides (PRD §8.4), applied on top of the selected profile. */
27
31
  export interface ProcessOverrides {
28
32
  /** mm */
@@ -1097,6 +1101,26 @@ export class STLManipulator extends EventEmitter {
1097
1101
  bambuOptions?: BambuSliceOptions,
1098
1102
  processOverrides?: ProcessOverrides
1099
1103
  ): Promise<string> {
1104
+ return (await this.sliceSTLWithResult(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback,
1105
+ printerPreset, filamentProfile, bambuOptions, processOverrides)).path;
1106
+ }
1107
+
1108
+ /**
1109
+ * sliceSTL with the Bambu-compatible CLI's own result.json, parsed verbatim.
1110
+ * A failure's SlicerError carries it too, and its message starts with the
1111
+ * slicer's error_string and plate warnings.
1112
+ */
1113
+ async sliceSTLWithResult(
1114
+ stlFilePath: string,
1115
+ slicerType: SlicerType,
1116
+ slicerPath: string,
1117
+ slicerProfile?: string,
1118
+ progressCallback?: ProgressCallback,
1119
+ printerPreset?: string,
1120
+ filamentProfile?: string,
1121
+ bambuOptions?: BambuSliceOptions,
1122
+ processOverrides?: ProcessOverrides
1123
+ ): Promise<SliceOutput> {
1100
1124
  const operationId = this.generateOperationId();
1101
1125
  this.activeOperations.set(operationId, true);
1102
1126
  const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
@@ -1256,14 +1280,30 @@ export class STLManipulator extends EventEmitter {
1256
1280
  console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1257
1281
 
1258
1282
  const cliOutputDir = bambuOutputDir;
1259
- await runSlicerProcess({
1260
- command: slicerPath,
1261
- args,
1262
- timeoutMs: this.resolveSlicerTimeoutMs(),
1263
- slicerType,
1264
- isCancelled: () => !this.activeOperations.get(operationId),
1265
- failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
1266
- });
1283
+ try {
1284
+ await runSlicerProcess({
1285
+ command: slicerPath,
1286
+ args,
1287
+ timeoutMs: this.resolveSlicerTimeoutMs(),
1288
+ slicerType,
1289
+ isCancelled: () => !this.activeOperations.get(operationId),
1290
+ failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
1291
+ });
1292
+ } catch (error) {
1293
+ // Keep the CLI's own result.json before the output directory is removed.
1294
+ const failedResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
1295
+ if (failedResult && error instanceof SlicerError) throw new SlicerError(error.phase, error.message, error.details, failedResult);
1296
+ throw error;
1297
+ }
1298
+ const slicerResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
1299
+ if (cliOutputDir && !slicerResult) {
1300
+ // Older or Linux CLIs may write result.json only for handled errors.
1301
+ console.error(`${slicerType} wrote no result.json for this slice, so its own checks are not available.`);
1302
+ }
1303
+ if (typeof slicerResult?.return_code === "number" && slicerResult.return_code !== 0) {
1304
+ throw new SlicerError('execution', `Slicer exited with code 0, but its result.json reports return_code ${slicerResult.return_code}.`,
1305
+ { slicerType, slicerPath, exitCode: 0 }, slicerResult);
1306
+ }
1267
1307
 
1268
1308
  if (!this.activeOperations.get(operationId)) {
1269
1309
  throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
@@ -1290,7 +1330,7 @@ export class STLManipulator extends EventEmitter {
1290
1330
  }
1291
1331
  } catch (error) {
1292
1332
  if (error instanceof SlicerError) throw error;
1293
- throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath });
1333
+ throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath }, slicerResult);
1294
1334
  }
1295
1335
 
1296
1336
  if (progressCallback) progressCallback(100, "Slicing completed successfully");
@@ -1302,7 +1342,7 @@ export class STLManipulator extends EventEmitter {
1302
1342
  output: outputFilePath
1303
1343
  });
1304
1344
 
1305
- return outputFilePath;
1345
+ return slicerResult ? { path: outputFilePath, slicer_result: slicerResult } : { path: outputFilePath };
1306
1346
  } catch (error) {
1307
1347
  console.error(`Slicing failed for ${stlFilePath}:`, error instanceof Error ? error.message : error);
1308
1348
  this.emit('operationError', {
@@ -1516,12 +1556,26 @@ export class STLManipulator extends EventEmitter {
1516
1556
  }
1517
1557
 
1518
1558
  /**
1519
- * Rotate the model so its largest flat face lies on the XY plane (Z=0).
1559
+ * Rotate the model so a face rests on the bed, in the pose with the least
1560
+ * part and support material per mm² of bed contact, then move it down to
1561
+ * Z=0. Returns the new file's path, or the input path when no pose qualifies.
1520
1562
  */
1521
1563
  async layFlat(
1522
1564
  stlFilePath: string,
1523
1565
  progressCallback?: ProgressCallback
1524
1566
  ): Promise<string> {
1567
+ return (await this.layFlatWithResult(stlFilePath, progressCallback)).path;
1568
+ }
1569
+
1570
+ /**
1571
+ * layFlat with its outcome. A pose qualifies only when some face, turned to
1572
+ * face down, lies on the rotated mesh's lowest plane; `laid_flat` is false
1573
+ * and the model is unchanged when none does.
1574
+ */
1575
+ async layFlatWithResult(
1576
+ stlFilePath: string,
1577
+ progressCallback?: ProgressCallback
1578
+ ): Promise<LayFlatResult> {
1525
1579
  const operationId = this.generateOperationId();
1526
1580
  this.activeOperations.set(operationId, true);
1527
1581
 
@@ -1536,100 +1590,98 @@ export class STLManipulator extends EventEmitter {
1536
1590
  const positionAttribute = geometry.getAttribute('position');
1537
1591
  if (!positionAttribute) throw new Error("Geometry has no position attribute.");
1538
1592
 
1539
- const faces: { normal: THREE.Vector3; area: number; vertices: THREE.Vector3[] }[] = [];
1593
+ const faceCount = Math.floor(positionAttribute.count / 3);
1594
+ if (faceCount === 0) throw new Error("No faces found in geometry.");
1595
+ const mesh: LayFlatMesh = {
1596
+ positions: new Float64Array(faceCount * 9),
1597
+ normals: new Float64Array(faceCount * 3),
1598
+ areas: new Float64Array(faceCount),
1599
+ volume: 0,
1600
+ };
1601
+ const center = new THREE.Vector3();
1602
+ boundingBox.getCenter(center);
1540
1603
  const vA = new THREE.Vector3();
1541
1604
  const vB = new THREE.Vector3();
1542
1605
  const vC = new THREE.Vector3();
1543
- const faceNormal = new THREE.Vector3();
1544
1606
  const cb = new THREE.Vector3(), ab = new THREE.Vector3();
1545
-
1546
- // Iterate through faces (triangles)
1547
- for (let i = 0; i < positionAttribute.count; i += 3) {
1548
- vA.fromBufferAttribute(positionAttribute, i);
1549
- vB.fromBufferAttribute(positionAttribute, i + 1);
1550
- vC.fromBufferAttribute(positionAttribute, i + 2);
1551
-
1552
- // Calculate face normal
1607
+ const fromA = new THREE.Vector3(), fromB = new THREE.Vector3(), fromC = new THREE.Vector3();
1608
+
1609
+ for (let face = 0; face < faceCount; face++) {
1610
+ vA.fromBufferAttribute(positionAttribute, face * 3);
1611
+ vB.fromBufferAttribute(positionAttribute, face * 3 + 1);
1612
+ vC.fromBufferAttribute(positionAttribute, face * 3 + 2);
1613
+ vA.toArray(mesh.positions, face * 9);
1614
+ vB.toArray(mesh.positions, face * 9 + 3);
1615
+ vC.toArray(mesh.positions, face * 9 + 6);
1616
+ // Signed tetrahedron volumes about the center sum to the enclosed volume.
1617
+ fromA.subVectors(vA, center);
1618
+ fromB.subVectors(vB, center);
1619
+ fromC.subVectors(vC, center);
1620
+ mesh.volume += fromA.dot(fromB.cross(fromC)) / 6;
1621
+
1622
+ // The cross product's direction is the outward normal; half its length is the area.
1553
1623
  cb.subVectors(vC, vB);
1554
1624
  ab.subVectors(vA, vB);
1555
1625
  cb.cross(ab);
1556
- faceNormal.copy(cb).normalize();
1557
-
1558
- // Calculate face area (using cross product magnitude / 2)
1559
- const area = cb.length() / 2;
1560
-
1561
- faces.push({
1562
- normal: faceNormal.clone(),
1563
- area: area,
1564
- vertices: [vA.clone(), vB.clone(), vC.clone()] // Store vertices if needed for center calculation
1565
- });
1626
+ mesh.areas[face] = cb.length() / 2;
1627
+ cb.normalize().toArray(mesh.normals, face * 3);
1566
1628
  }
1567
1629
 
1568
- if (faces.length === 0) throw new Error("No faces found in geometry.");
1630
+ mesh.volume = Math.abs(mesh.volume);
1569
1631
 
1570
1632
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1571
1633
  if (progressCallback) progressCallback(65, "Grouping faces by normal...");
1572
1634
 
1573
1635
  // Group faces by similar normal vectors (tolerance for floating point errors)
1574
- const normalGroups: Map<string, { totalArea: number; representativeNormal: THREE.Vector3 }> = new Map();
1575
- const tolerance = 1e-5;
1576
-
1577
- for (const face of faces) {
1578
- let foundGroup = false;
1579
- for (const [key, group] of normalGroups.entries()) {
1580
- if (face.normal.distanceToSquared(group.representativeNormal) < tolerance) {
1581
- group.totalArea += face.area;
1582
- // Optional: Average the normal? For now, just use the first one.
1583
- foundGroup = true;
1584
- break;
1585
- }
1586
- }
1587
- if (!foundGroup) {
1588
- const key = `${face.normal.x.toFixed(5)},${face.normal.y.toFixed(5)},${face.normal.z.toFixed(5)}`;
1589
- if (!normalGroups.has(key)) { // Ensure unique key even if floats slightly differ after toFixed
1590
- normalGroups.set(key, { totalArea: face.area, representativeNormal: face.normal });
1591
- }
1592
- }
1636
+ const normalGroups: { totalArea: number; representativeNormal: THREE.Vector3 }[] = [];
1637
+ const tolerance = 1e-5;
1638
+ const faceNormal = new THREE.Vector3();
1639
+
1640
+ for (let face = 0; face < faceCount; face++) {
1641
+ if (!(mesh.areas[face] > 0)) continue;
1642
+ faceNormal.fromArray(mesh.normals, face * 3);
1643
+ const group = normalGroups.find(candidate => faceNormal.distanceToSquared(candidate.representativeNormal) < tolerance);
1644
+ if (group) group.totalArea += mesh.areas[face];
1645
+ else normalGroups.push({ totalArea: mesh.areas[face], representativeNormal: faceNormal.clone() });
1593
1646
  }
1594
-
1595
- if (normalGroups.size === 0) throw new Error("Could not group faces by normal.");
1596
1647
 
1597
1648
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1598
- if (progressCallback) progressCallback(75, "Finding largest flat surface...");
1599
-
1600
- // Include already-horizontal faces: excluding Z-aligned normals can
1601
- // stand a flat model on its edge, including on a repeated layFlat call.
1602
- let largestArea = 0;
1603
- let targetNormal: THREE.Vector3 | null = null;
1604
- const downVector = new THREE.Vector3(0, 0, -1); // Target normal (pointing down)
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.
1609
- for (const group of normalGroups.values()) {
1610
- const tied = targetNormal !== null && Math.abs(group.totalArea - largestArea) <= largestArea * 1e-6;
1611
- if (tied ? group.representativeNormal.z < targetNormal!.z : group.totalArea > largestArea) {
1612
- largestArea = group.totalArea;
1613
- targetNormal = group.representativeNormal;
1614
- }
1649
+ if (progressCallback) progressCallback(75, "Finding the pose that rests on the bed...");
1650
+
1651
+ // Candidate directions to turn down: the largest face groups, plus the
1652
+ // model's own axes so a part with a domed side can be flipped exactly.
1653
+ const candidates = normalGroups
1654
+ .sort((a, b) => b.totalArea - a.totalArea)
1655
+ .slice(0, LAY_FLAT_MAX_GROUP_CANDIDATES)
1656
+ .map(group => group.representativeNormal);
1657
+ for (const axis of [[0, 0, -1], [0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) {
1658
+ const normal = new THREE.Vector3(axis[0], axis[1], axis[2]);
1659
+ if (!candidates.some(candidate => candidate.distanceToSquared(normal) < tolerance)) candidates.push(normal);
1615
1660
  }
1616
1661
 
1617
- if (!targetNormal) {
1618
- // Only a mesh without any face area reaches this.
1619
- console.warn("Could not find a suitable large flat face to lay down. No changes made.");
1620
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
1621
- return stlFilePath;
1662
+ let best: LayFlatPose | null = null;
1663
+ for (const normal of candidates) {
1664
+ const pose = evaluateLayFlatPose(mesh, normal);
1665
+ if (pose && (!best || isBetterLayFlatPose(pose, best))) best = pose;
1622
1666
  }
1623
-
1667
+
1668
+ if (!best) {
1669
+ const message = "No face lies on the model's lowest plane in any candidate orientation, so the model is unchanged.";
1670
+ console.error(`lay_flat: ${message}`);
1671
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message });
1672
+ return { path: stlFilePath, laid_flat: false, message };
1673
+ }
1674
+
1624
1675
  if (progressCallback) progressCallback(80, "Calculating rotation...");
1625
-
1626
- // Calculate the rotation needed to align targetNormal with downVector
1627
- const quaternion = new THREE.Quaternion().setFromUnitVectors(targetNormal, downVector);
1628
- const rotationMatrix = new THREE.Matrix4().makeRotationFromQuaternion(quaternion);
1676
+
1677
+ // Turn the chosen normal to face straight down. Turning +Z down rotates
1678
+ // half a turn about X, which keeps the model's X layout.
1679
+ const downVector = new THREE.Vector3(0, 0, -1);
1680
+ const rotationMatrix = best.normal.dot(downVector) < -1 + 1e-9
1681
+ ? new THREE.Matrix4().makeRotationX(Math.PI)
1682
+ : new THREE.Matrix4().makeRotationFromQuaternion(new THREE.Quaternion().setFromUnitVectors(best.normal, downVector));
1629
1683
 
1630
1684
  // Rotate around the model's center
1631
- const center = new THREE.Vector3();
1632
- boundingBox.getCenter(center);
1633
1685
  const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
1634
1686
  const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
1635
1687
 
@@ -1652,8 +1704,9 @@ export class STLManipulator extends EventEmitter {
1652
1704
 
1653
1705
  await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
1654
1706
 
1655
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath });
1656
- return outputFilePath;
1707
+ const message = `Rests on ${best.contactArea.toFixed(1)} mm² of bed contact; overhangs more than 45° from vertical need about ${best.supportVolume.toFixed(0)} mm³ of support.`;
1708
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath, message });
1709
+ return { path: outputFilePath, laid_flat: true, message };
1657
1710
 
1658
1711
  } catch (error) {
1659
1712
  this.emit('operationError', { operationId, type: 'layFlat', error: error instanceof Error ? error.message : String(error) });
@@ -1663,3 +1716,75 @@ export class STLManipulator extends EventEmitter {
1663
1716
  }
1664
1717
  }
1665
1718
  }
1719
+
1720
+ export type LayFlatResult = {
1721
+ /** The laid-flat STL, or the unchanged input when `laid_flat` is false. */
1722
+ path: string;
1723
+ laid_flat: boolean;
1724
+ message: string;
1725
+ };
1726
+
1727
+ /** Face corners (9 per face), outward unit normals (3 per face), areas, and the enclosed volume. */
1728
+ type LayFlatMesh = { positions: Float64Array; normals: Float64Array; areas: Float64Array; volume: number };
1729
+
1730
+ /**
1731
+ * A direction to turn down, with the bed contact and support it gives.
1732
+ * `stackHeight` is the part and support volume over the contact area: the mean
1733
+ * height of material standing on each mm² of bed. Standing a long part on its
1734
+ * end, or resting a part on a fin under a wide cap, makes it tall.
1735
+ */
1736
+ type LayFlatPose = { normal: THREE.Vector3; contactArea: number; supportVolume: number; stackHeight: number };
1737
+
1738
+ const LAY_FLAT_MAX_GROUP_CANDIDATES = 64;
1739
+ /** Faces tilted more than 45° from vertical overhang and need support. */
1740
+ const LAY_FLAT_OVERHANG_COS = Math.SQRT1_2 + 1e-6;
1741
+
1742
+ /**
1743
+ * Score turning `normal` to face the bed. The bed is the mesh's lowest plane in
1744
+ * that pose, its support plane. Faces lying on it, within 0.5% of the model's
1745
+ * height there (at most 0.2 mm), are bed contact. Overhanging faces above it
1746
+ * need support up from the bed: their projected area times their height.
1747
+ * Returns null when no face lies on the bed: turning that normal down would
1748
+ * rest the model on an edge or a point, not a face.
1749
+ */
1750
+ function evaluateLayFlatPose(mesh: LayFlatMesh, normal: THREE.Vector3): LayFlatPose | null {
1751
+ const { positions, normals, areas } = mesh;
1752
+ const nx = normal.x, ny = normal.y, nz = normal.z;
1753
+ // Distance along the normal grows toward the bed: the bed is at its maximum.
1754
+ let bed = -Infinity, top = Infinity;
1755
+ for (let index = 0; index < positions.length; index += 3) {
1756
+ const along = positions[index] * nx + positions[index + 1] * ny + positions[index + 2] * nz;
1757
+ if (along > bed) bed = along;
1758
+ if (along < top) top = along;
1759
+ }
1760
+ const tolerance = Math.min(0.2, Math.max(0.005 * (bed - top), 1e-4));
1761
+
1762
+ let contactArea = 0;
1763
+ let supportVolume = 0;
1764
+ for (let face = 0; face < areas.length; face++) {
1765
+ const facing = normals[face * 3] * nx + normals[face * 3 + 1] * ny + normals[face * 3 + 2] * nz;
1766
+ if (!(facing > 0)) continue;
1767
+ const base = face * 9;
1768
+ let heightSum = 0, heightMax = 0;
1769
+ for (let corner = base; corner < base + 9; corner += 3) {
1770
+ const height = bed - (positions[corner] * nx + positions[corner + 1] * ny + positions[corner + 2] * nz);
1771
+ heightSum += height;
1772
+ if (height > heightMax) heightMax = height;
1773
+ }
1774
+ if (heightMax <= tolerance) contactArea += areas[face];
1775
+ else if (facing > LAY_FLAT_OVERHANG_COS) supportVolume += areas[face] * facing * (heightSum / 3);
1776
+ }
1777
+ if (!(contactArea > 0)) return null;
1778
+ return { normal, contactArea, supportVolume, stackHeight: (mesh.volume + supportVolume) / contactArea };
1779
+ }
1780
+
1781
+ /**
1782
+ * Prefer the pose with the lowest stack height, then the larger contact. On a
1783
+ * tie, keep the face that already points down so a seated part is not flipped.
1784
+ */
1785
+ function isBetterLayFlatPose(pose: LayFlatPose, best: LayFlatPose): boolean {
1786
+ const same = (a: number, b: number) => Math.abs(a - b) <= 1e-9 + 1e-6 * Math.max(Math.abs(a), Math.abs(b));
1787
+ if (!same(pose.stackHeight, best.stackHeight)) return pose.stackHeight < best.stackHeight;
1788
+ if (!same(pose.contactArea, best.contactArea)) return pose.contactArea > best.contactArea;
1789
+ return pose.normal.z < best.normal.z;
1790
+ }