mcp-3d-printer-server 1.2.24 → 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 +16 -0
- package/dist/index.js +16 -10
- package/dist/slicer/bambu-cli.d.ts +4 -2
- package/dist/slicer/bambu-cli.js +12 -7
- package/dist/slicer/slicer-error.d.ts +11 -1
- package/dist/slicer/slicer-error.js +16 -2
- package/dist/stl/stl-manipulator.d.ts +26 -1
- package/dist/stl/stl-manipulator.js +176 -76
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/index.ts +20 -12
- package/src/slicer/bambu-cli.ts +14 -8
- package/src/slicer/slicer-error.ts +21 -2
- package/src/stl/stl-manipulator.ts +209 -84
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
3
19
|
## [1.2.24] – 2026-11-02
|
|
4
20
|
|
|
5
21
|
### Fixed
|
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.
|
|
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: "
|
|
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
|
-
|
|
1973
|
-
|
|
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
|
-
|
|
2392
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
*
|
|
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[];
|
package/dist/slicer/bambu-cli.js
CHANGED
|
@@ -530,18 +530,23 @@ const BAMBU_CLI_ERRORS = {
|
|
|
530
530
|
[-102]: "G-code in an unprintable area",
|
|
531
531
|
};
|
|
532
532
|
/**
|
|
533
|
-
* The
|
|
534
|
-
*
|
|
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
|
|
536
|
+
export function readCliResult(outputDir) {
|
|
537
537
|
try {
|
|
538
538
|
const result = JSON.parse(fs.readFileSync(path.join(outputDir, "result.json"), "utf8"));
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1228
|
-
for (let
|
|
1229
|
-
vA.fromBufferAttribute(positionAttribute,
|
|
1230
|
-
vB.fromBufferAttribute(positionAttribute,
|
|
1231
|
-
vC.fromBufferAttribute(positionAttribute,
|
|
1232
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
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 =
|
|
1297
|
+
const normalGroups = [];
|
|
1253
1298
|
const tolerance = 1e-5;
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
//
|
|
1301
|
-
|
|
1302
|
-
const
|
|
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
|
-
|
|
1324
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
15
|
+
"version": "1.2.25",
|
|
16
16
|
"runtimeHint": "npx",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
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<
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
2202
|
-
|
|
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
|
-
|
|
2803
|
-
|
|
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
|
-
|
|
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)) {
|
package/src/slicer/bambu-cli.ts
CHANGED
|
@@ -641,17 +641,23 @@ const BAMBU_CLI_ERRORS: Record<number, string> = {
|
|
|
641
641
|
};
|
|
642
642
|
|
|
643
643
|
/**
|
|
644
|
-
* The
|
|
645
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1547
|
-
for (let
|
|
1548
|
-
vA.fromBufferAttribute(positionAttribute,
|
|
1549
|
-
vB.fromBufferAttribute(positionAttribute,
|
|
1550
|
-
vC.fromBufferAttribute(positionAttribute,
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1575
|
-
const tolerance = 1e-5;
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
|
1599
|
-
|
|
1600
|
-
//
|
|
1601
|
-
//
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
//
|
|
1627
|
-
|
|
1628
|
-
const
|
|
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
|
-
|
|
1656
|
-
|
|
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
|
+
}
|