mcp-3d-printer-server 1.2.24 → 1.2.26
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 +71 -0
- package/CONTRIBUTORS.md +5 -1
- package/README.md +64 -29
- package/dist/ams-mapping.d.ts +38 -0
- package/dist/ams-mapping.js +125 -0
- package/dist/bambu-network-bridge.d.ts +200 -0
- package/dist/bambu-network-bridge.js +881 -0
- package/dist/bambu-network-certs.d.ts +13 -0
- package/dist/bambu-network-certs.js +152 -0
- package/dist/control-reply.d.ts +21 -0
- package/dist/control-reply.js +44 -0
- package/dist/counterpart/camera-sources.d.ts +16 -0
- package/dist/counterpart/camera-sources.js +138 -0
- package/dist/counterpart/camera-stream.d.ts +66 -0
- package/dist/counterpart/camera-stream.js +246 -0
- package/dist/counterpart/camera-tools.d.ts +247 -0
- package/dist/counterpart/camera-tools.js +35 -0
- package/dist/counterpart/contract.d.ts +21 -5
- package/dist/counterpart/contract.js +8 -2
- package/dist/counterpart/route.d.ts +31 -0
- package/dist/counterpart/route.js +70 -0
- package/dist/counterpart/service.d.ts +60 -4
- package/dist/counterpart/service.js +142 -16
- package/dist/design-guidance.d.ts +26 -0
- package/dist/design-guidance.js +177 -0
- package/dist/fulu-orca.d.ts +7 -0
- package/dist/fulu-orca.js +18 -2
- package/dist/index.js +528 -72
- package/dist/printers/bambu-filaments.d.ts +68 -0
- package/dist/printers/bambu-filaments.js +262 -0
- package/dist/printers/bambu.d.ts +99 -6
- package/dist/printers/bambu.js +478 -72
- package/dist/printers/creality.d.ts +2 -1
- package/dist/printers/creality.js +5 -1
- package/dist/printers/generic-base.d.ts +11 -6
- package/dist/printers/generic-base.js +21 -25
- package/dist/printers/klipper.d.ts +2 -1
- package/dist/printers/klipper.js +10 -7
- package/dist/printers/octoprint.d.ts +2 -1
- package/dist/printers/octoprint.js +15 -8
- package/dist/printers/printer-factory.d.ts +1 -1
- package/dist/printers/printer-factory.js +3 -3
- package/dist/printers/prusa.js +0 -1
- package/dist/safety/confirmation.d.ts +11 -16
- package/dist/safety/confirmation.js +8 -32
- package/dist/safety/hidden-hms.d.ts +32 -0
- package/dist/safety/hidden-hms.js +405 -0
- package/dist/safety/limits.d.ts +8 -2
- package/dist/safety/limits.js +14 -5
- package/dist/safety/print-file.d.ts +5 -0
- package/dist/safety/print-file.js +156 -19
- package/dist/safety/printer-state.d.ts +59 -8
- package/dist/safety/printer-state.js +174 -39
- package/dist/slicer/bambu-cli.d.ts +6 -2
- package/dist/slicer/bambu-cli.js +19 -12
- package/dist/slicer/layer-pauses.d.ts +47 -0
- package/dist/slicer/layer-pauses.js +130 -0
- package/dist/slicer/slicer-error.d.ts +11 -1
- package/dist/slicer/slicer-error.js +16 -2
- package/dist/stl/stl-manipulator.d.ts +27 -1
- package/dist/stl/stl-manipulator.js +216 -76
- package/dist/studio-print-options.d.ts +57 -0
- package/dist/studio-print-options.js +179 -0
- package/package.json +25 -4
- package/server.json +2 -2
- package/src/ams-mapping.ts +132 -0
- package/src/bambu-network-bridge.ts +1075 -0
- package/src/bambu-network-certs.ts +154 -0
- package/src/control-reply.ts +41 -0
- package/src/counterpart/camera-sources.ts +102 -0
- package/src/counterpart/camera-stream.ts +195 -0
- package/src/counterpart/camera-tools.ts +35 -0
- package/src/counterpart/contract.ts +12 -4
- package/src/counterpart/route.ts +82 -0
- package/src/counterpart/service.ts +150 -16
- package/src/design-guidance.ts +188 -0
- package/src/fulu-orca.ts +18 -2
- package/src/index.ts +546 -87
- package/src/printers/bambu-filaments.ts +346 -0
- package/src/printers/bambu.ts +506 -81
- package/src/printers/creality.ts +8 -3
- package/src/printers/generic-base.ts +33 -40
- package/src/printers/klipper.ts +12 -8
- package/src/printers/octoprint.ts +16 -9
- package/src/printers/printer-factory.ts +3 -3
- package/src/printers/prusa.ts +0 -1
- package/src/safety/confirmation.ts +15 -38
- package/src/safety/hidden-hms.ts +406 -0
- package/src/safety/limits.ts +15 -5
- package/src/safety/print-file.ts +139 -17
- package/src/safety/printer-state.ts +179 -40
- package/src/slicer/bambu-cli.ts +22 -13
- package/src/slicer/layer-pauses.ts +143 -0
- package/src/slicer/slicer-error.ts +21 -2
- package/src/stl/stl-manipulator.ts +254 -84
- package/src/studio-print-options.ts +201 -0
|
@@ -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,13 @@ 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>;
|
|
168
|
+
private sliceSTLOnce;
|
|
157
169
|
/**
|
|
158
170
|
* Enhanced version of confirmTemperatures with better error handling
|
|
159
171
|
* @param gcodePath Path to the G-code file
|
|
@@ -193,7 +205,21 @@ export declare class STLManipulator extends EventEmitter {
|
|
|
193
205
|
*/
|
|
194
206
|
centerModel(stlFilePath: string, progressCallback?: ProgressCallback): Promise<string>;
|
|
195
207
|
/**
|
|
196
|
-
* Rotate the model so
|
|
208
|
+
* Rotate the model so a face rests on the bed, in the pose with the least
|
|
209
|
+
* part and support material per mm² of bed contact, then move it down to
|
|
210
|
+
* Z=0. Returns the new file's path, or the input path when no pose qualifies.
|
|
197
211
|
*/
|
|
198
212
|
layFlat(stlFilePath: string, progressCallback?: ProgressCallback): Promise<string>;
|
|
213
|
+
/**
|
|
214
|
+
* layFlat with its outcome. A pose qualifies only when some face, turned to
|
|
215
|
+
* face down, lies on the rotated mesh's lowest plane; `laid_flat` is false
|
|
216
|
+
* and the model is unchanged when none does.
|
|
217
|
+
*/
|
|
218
|
+
layFlatWithResult(stlFilePath: string, progressCallback?: ProgressCallback): Promise<LayFlatResult>;
|
|
199
219
|
}
|
|
220
|
+
export type LayFlatResult = {
|
|
221
|
+
/** The laid-flat STL, or the unchanged input when `laid_flat` is false. */
|
|
222
|
+
path: string;
|
|
223
|
+
laid_flat: boolean;
|
|
224
|
+
message: string;
|
|
225
|
+
};
|
|
@@ -9,9 +9,10 @@ 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
|
+
import { customGcodesWithPauses, layerTops, machinePauseGcode, pausedLayers, readPlateGcode, readProjectCustomGcodes } from '../slicer/layer-pauses.js';
|
|
15
16
|
/** Orca/Bambu Studio process keys; profile JSON values are strings. */
|
|
16
17
|
export function orcaProcessOverrides(overrides) {
|
|
17
18
|
const keys = {};
|
|
@@ -840,6 +841,53 @@ export class STLManipulator extends EventEmitter {
|
|
|
840
841
|
* @returns Path to the generated G-code or sliced 3MF file
|
|
841
842
|
*/
|
|
842
843
|
async sliceSTL(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
|
|
844
|
+
return (await this.sliceSTLWithResult(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides)).path;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* sliceSTL with the Bambu-compatible CLI's own result.json, parsed verbatim.
|
|
848
|
+
* A failure's SlicerError carries it too, and its message starts with the
|
|
849
|
+
* slicer's error_string and plate warnings.
|
|
850
|
+
*/
|
|
851
|
+
async sliceSTLWithResult(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
|
|
852
|
+
const once = (options) => this.sliceSTLOnce(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, options, processOverrides);
|
|
853
|
+
const layers = bambuOptions?.pauseAtLayers?.length ? bambuOptions.pauseAtLayers : undefined;
|
|
854
|
+
const cli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
|
|
855
|
+
const plate = bambuOptions?.slicePlate && bambuOptions.slicePlate > 0 ? bambuOptions.slicePlate : 1;
|
|
856
|
+
const project = cli && /\.3mf$/i.test(stlFilePath) ? await readProjectCustomGcodes(stlFilePath, plate) : undefined;
|
|
857
|
+
if (!layers && !project?.gcodes.length)
|
|
858
|
+
return once(bambuOptions);
|
|
859
|
+
if (layers && !cli)
|
|
860
|
+
throw new Error(`Pausing at a layer needs the Bambu Studio or OrcaSlicer CLI, not ${slicerType}.`);
|
|
861
|
+
const customGcodesFile = path.join(this.tempDir, `custom-gcodes-${crypto.randomUUID()}.json`);
|
|
862
|
+
try {
|
|
863
|
+
let tops = [];
|
|
864
|
+
if (layers) {
|
|
865
|
+
if (progressCallback)
|
|
866
|
+
progressCallback(10, `Finding layer heights for the pause before layer ${layers.join(", ")}`);
|
|
867
|
+
const plain = await once({ ...bambuOptions, pauseAtLayers: undefined, customGcodesFile: undefined });
|
|
868
|
+
const gcode = await readPlateGcode(plain.path, plate);
|
|
869
|
+
if (!gcode)
|
|
870
|
+
throw new Error('The slicer wrote no plate G-code, so the pause layers cannot be placed.');
|
|
871
|
+
tops = layerTops(gcode);
|
|
872
|
+
}
|
|
873
|
+
const info = layers ? customGcodesWithPauses(tops, layers, project) : project;
|
|
874
|
+
await fs.promises.writeFile(customGcodesFile, JSON.stringify(info));
|
|
875
|
+
const result = await once({ ...bambuOptions, customGcodesFile });
|
|
876
|
+
if (layers) {
|
|
877
|
+
const gcode = await readPlateGcode(result.path, plate);
|
|
878
|
+
const placed = gcode ? pausedLayers(gcode, machinePauseGcode(gcode)) : [];
|
|
879
|
+
const missing = layers.filter(layer => !placed.includes(layer));
|
|
880
|
+
if (!gcode || missing.length || layerTops(gcode).length !== tops.length) {
|
|
881
|
+
throw new Error(`The slicer did not place the pause before layer ${(missing.length ? missing : layers).join(", ")}. Nothing was sent to the printer.`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return result;
|
|
885
|
+
}
|
|
886
|
+
finally {
|
|
887
|
+
await fs.promises.rm(customGcodesFile, { force: true });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async sliceSTLOnce(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback, printerPreset, filamentProfile, bambuOptions, processOverrides) {
|
|
843
891
|
const operationId = this.generateOperationId();
|
|
844
892
|
this.activeOperations.set(operationId, true);
|
|
845
893
|
const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
|
|
@@ -979,14 +1027,31 @@ export class STLManipulator extends EventEmitter {
|
|
|
979
1027
|
progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
|
|
980
1028
|
console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
|
|
981
1029
|
const cliOutputDir = bambuOutputDir;
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1030
|
+
try {
|
|
1031
|
+
await runSlicerProcess({
|
|
1032
|
+
command: slicerPath,
|
|
1033
|
+
args,
|
|
1034
|
+
timeoutMs: this.resolveSlicerTimeoutMs(),
|
|
1035
|
+
slicerType,
|
|
1036
|
+
isCancelled: () => !this.activeOperations.get(operationId),
|
|
1037
|
+
failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
// Keep the CLI's own result.json before the output directory is removed.
|
|
1042
|
+
const failedResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
|
|
1043
|
+
if (failedResult && error instanceof SlicerError)
|
|
1044
|
+
throw new SlicerError(error.phase, error.message, error.details, failedResult);
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
const slicerResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
|
|
1048
|
+
if (cliOutputDir && !slicerResult) {
|
|
1049
|
+
// Older or Linux CLIs may write result.json only for handled errors.
|
|
1050
|
+
console.error(`${slicerType} wrote no result.json for this slice, so its own checks are not available.`);
|
|
1051
|
+
}
|
|
1052
|
+
if (typeof slicerResult?.return_code === "number" && slicerResult.return_code !== 0) {
|
|
1053
|
+
throw new SlicerError('execution', `Slicer exited with code 0, but its result.json reports return_code ${slicerResult.return_code}.`, { slicerType, slicerPath, exitCode: 0 }, slicerResult);
|
|
1054
|
+
}
|
|
990
1055
|
if (!this.activeOperations.get(operationId)) {
|
|
991
1056
|
throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
|
|
992
1057
|
}
|
|
@@ -1013,7 +1078,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
1013
1078
|
catch (error) {
|
|
1014
1079
|
if (error instanceof SlicerError)
|
|
1015
1080
|
throw error;
|
|
1016
|
-
throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath });
|
|
1081
|
+
throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath }, slicerResult);
|
|
1017
1082
|
}
|
|
1018
1083
|
if (progressCallback)
|
|
1019
1084
|
progressCallback(100, "Slicing completed successfully");
|
|
@@ -1023,7 +1088,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
1023
1088
|
success: true,
|
|
1024
1089
|
output: outputFilePath
|
|
1025
1090
|
});
|
|
1026
|
-
return outputFilePath;
|
|
1091
|
+
return slicerResult ? { path: outputFilePath, slicer_result: slicerResult } : { path: outputFilePath };
|
|
1027
1092
|
}
|
|
1028
1093
|
catch (error) {
|
|
1029
1094
|
console.error(`Slicing failed for ${stlFilePath}:`, error instanceof Error ? error.message : error);
|
|
@@ -1202,9 +1267,19 @@ export class STLManipulator extends EventEmitter {
|
|
|
1202
1267
|
}
|
|
1203
1268
|
}
|
|
1204
1269
|
/**
|
|
1205
|
-
* Rotate the model so
|
|
1270
|
+
* Rotate the model so a face rests on the bed, in the pose with the least
|
|
1271
|
+
* part and support material per mm² of bed contact, then move it down to
|
|
1272
|
+
* Z=0. Returns the new file's path, or the input path when no pose qualifies.
|
|
1206
1273
|
*/
|
|
1207
1274
|
async layFlat(stlFilePath, progressCallback) {
|
|
1275
|
+
return (await this.layFlatWithResult(stlFilePath, progressCallback)).path;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* layFlat with its outcome. A pose qualifies only when some face, turned to
|
|
1279
|
+
* face down, lies on the rotated mesh's lowest plane; `laid_flat` is false
|
|
1280
|
+
* and the model is unchanged when none does.
|
|
1281
|
+
*/
|
|
1282
|
+
async layFlatWithResult(stlFilePath, progressCallback) {
|
|
1208
1283
|
const operationId = this.generateOperationId();
|
|
1209
1284
|
this.activeOperations.set(operationId, true);
|
|
1210
1285
|
try {
|
|
@@ -1218,91 +1293,96 @@ export class STLManipulator extends EventEmitter {
|
|
|
1218
1293
|
const positionAttribute = geometry.getAttribute('position');
|
|
1219
1294
|
if (!positionAttribute)
|
|
1220
1295
|
throw new Error("Geometry has no position attribute.");
|
|
1221
|
-
const
|
|
1296
|
+
const faceCount = Math.floor(positionAttribute.count / 3);
|
|
1297
|
+
if (faceCount === 0)
|
|
1298
|
+
throw new Error("No faces found in geometry.");
|
|
1299
|
+
const mesh = {
|
|
1300
|
+
positions: new Float64Array(faceCount * 9),
|
|
1301
|
+
normals: new Float64Array(faceCount * 3),
|
|
1302
|
+
areas: new Float64Array(faceCount),
|
|
1303
|
+
volume: 0,
|
|
1304
|
+
};
|
|
1305
|
+
const center = new THREE.Vector3();
|
|
1306
|
+
boundingBox.getCenter(center);
|
|
1222
1307
|
const vA = new THREE.Vector3();
|
|
1223
1308
|
const vB = new THREE.Vector3();
|
|
1224
1309
|
const vC = new THREE.Vector3();
|
|
1225
|
-
const faceNormal = new THREE.Vector3();
|
|
1226
1310
|
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
|
-
|
|
1311
|
+
const fromA = new THREE.Vector3(), fromB = new THREE.Vector3(), fromC = new THREE.Vector3();
|
|
1312
|
+
for (let face = 0; face < faceCount; face++) {
|
|
1313
|
+
vA.fromBufferAttribute(positionAttribute, face * 3);
|
|
1314
|
+
vB.fromBufferAttribute(positionAttribute, face * 3 + 1);
|
|
1315
|
+
vC.fromBufferAttribute(positionAttribute, face * 3 + 2);
|
|
1316
|
+
vA.toArray(mesh.positions, face * 9);
|
|
1317
|
+
vB.toArray(mesh.positions, face * 9 + 3);
|
|
1318
|
+
vC.toArray(mesh.positions, face * 9 + 6);
|
|
1319
|
+
// Signed tetrahedron volumes about the center sum to the enclosed volume.
|
|
1320
|
+
fromA.subVectors(vA, center);
|
|
1321
|
+
fromB.subVectors(vB, center);
|
|
1322
|
+
fromC.subVectors(vC, center);
|
|
1323
|
+
mesh.volume += fromA.dot(fromB.cross(fromC)) / 6;
|
|
1324
|
+
// The cross product's direction is the outward normal; half its length is the area.
|
|
1233
1325
|
cb.subVectors(vC, vB);
|
|
1234
1326
|
ab.subVectors(vA, vB);
|
|
1235
1327
|
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
|
-
});
|
|
1328
|
+
mesh.areas[face] = cb.length() / 2;
|
|
1329
|
+
cb.normalize().toArray(mesh.normals, face * 3);
|
|
1244
1330
|
}
|
|
1245
|
-
|
|
1246
|
-
throw new Error("No faces found in geometry.");
|
|
1331
|
+
mesh.volume = Math.abs(mesh.volume);
|
|
1247
1332
|
if (!this.activeOperations.get(operationId))
|
|
1248
1333
|
throw new Error("Operation cancelled");
|
|
1249
1334
|
if (progressCallback)
|
|
1250
1335
|
progressCallback(65, "Grouping faces by normal...");
|
|
1251
1336
|
// Group faces by similar normal vectors (tolerance for floating point errors)
|
|
1252
|
-
const normalGroups =
|
|
1337
|
+
const normalGroups = [];
|
|
1253
1338
|
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
|
-
}
|
|
1339
|
+
const faceNormal = new THREE.Vector3();
|
|
1340
|
+
for (let face = 0; face < faceCount; face++) {
|
|
1341
|
+
if (!(mesh.areas[face] > 0))
|
|
1342
|
+
continue;
|
|
1343
|
+
faceNormal.fromArray(mesh.normals, face * 3);
|
|
1344
|
+
const group = normalGroups.find(candidate => faceNormal.distanceToSquared(candidate.representativeNormal) < tolerance);
|
|
1345
|
+
if (group)
|
|
1346
|
+
group.totalArea += mesh.areas[face];
|
|
1347
|
+
else
|
|
1348
|
+
normalGroups.push({ totalArea: mesh.areas[face], representativeNormal: faceNormal.clone() });
|
|
1270
1349
|
}
|
|
1271
|
-
if (normalGroups.size === 0)
|
|
1272
|
-
throw new Error("Could not group faces by normal.");
|
|
1273
1350
|
if (!this.activeOperations.get(operationId))
|
|
1274
1351
|
throw new Error("Operation cancelled");
|
|
1275
1352
|
if (progressCallback)
|
|
1276
|
-
progressCallback(75, "Finding
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1353
|
+
progressCallback(75, "Finding the pose that rests on the bed...");
|
|
1354
|
+
// Candidate directions to turn down: the largest face groups, plus the
|
|
1355
|
+
// model's own axes so a part with a domed side can be flipped exactly.
|
|
1356
|
+
const candidates = normalGroups
|
|
1357
|
+
.sort((a, b) => b.totalArea - a.totalArea)
|
|
1358
|
+
.slice(0, LAY_FLAT_MAX_GROUP_CANDIDATES)
|
|
1359
|
+
.map(group => group.representativeNormal);
|
|
1360
|
+
for (const axis of [[0, 0, -1], [0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) {
|
|
1361
|
+
const normal = new THREE.Vector3(axis[0], axis[1], axis[2]);
|
|
1362
|
+
if (!candidates.some(candidate => candidate.distanceToSquared(normal) < tolerance))
|
|
1363
|
+
candidates.push(normal);
|
|
1364
|
+
}
|
|
1365
|
+
let best = null;
|
|
1366
|
+
for (const normal of candidates) {
|
|
1367
|
+
const pose = evaluateLayFlatPose(mesh, normal);
|
|
1368
|
+
if (pose && (!best || isBetterLayFlatPose(pose, best)))
|
|
1369
|
+
best = pose;
|
|
1291
1370
|
}
|
|
1292
|
-
if (!
|
|
1293
|
-
|
|
1294
|
-
console.
|
|
1295
|
-
this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message
|
|
1296
|
-
return stlFilePath;
|
|
1371
|
+
if (!best) {
|
|
1372
|
+
const message = "No face lies on the model's lowest plane in any candidate orientation, so the model is unchanged.";
|
|
1373
|
+
console.error(`lay_flat: ${message}`);
|
|
1374
|
+
this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message });
|
|
1375
|
+
return { path: stlFilePath, laid_flat: false, message };
|
|
1297
1376
|
}
|
|
1298
1377
|
if (progressCallback)
|
|
1299
1378
|
progressCallback(80, "Calculating rotation...");
|
|
1300
|
-
//
|
|
1301
|
-
|
|
1302
|
-
const
|
|
1379
|
+
// Turn the chosen normal to face straight down. Turning +Z down rotates
|
|
1380
|
+
// half a turn about X, which keeps the model's X layout.
|
|
1381
|
+
const downVector = new THREE.Vector3(0, 0, -1);
|
|
1382
|
+
const rotationMatrix = best.normal.dot(downVector) < -1 + 1e-9
|
|
1383
|
+
? new THREE.Matrix4().makeRotationX(Math.PI)
|
|
1384
|
+
: new THREE.Matrix4().makeRotationFromQuaternion(new THREE.Quaternion().setFromUnitVectors(best.normal, downVector));
|
|
1303
1385
|
// Rotate around the model's center
|
|
1304
|
-
const center = new THREE.Vector3();
|
|
1305
|
-
boundingBox.getCenter(center);
|
|
1306
1386
|
const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
|
|
1307
1387
|
const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
|
|
1308
1388
|
// Apply rotation
|
|
@@ -1320,8 +1400,9 @@ export class STLManipulator extends EventEmitter {
|
|
|
1320
1400
|
const outputFileName = path.basename(stlFilePath, '.stl') + '_flat.stl';
|
|
1321
1401
|
const outputFilePath = this.operationOutputPath(operationId, outputFileName);
|
|
1322
1402
|
await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
|
|
1323
|
-
|
|
1324
|
-
|
|
1403
|
+
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.`;
|
|
1404
|
+
this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath, message });
|
|
1405
|
+
return { path: outputFilePath, laid_flat: true, message };
|
|
1325
1406
|
}
|
|
1326
1407
|
catch (error) {
|
|
1327
1408
|
this.emit('operationError', { operationId, type: 'layFlat', error: error instanceof Error ? error.message : String(error) });
|
|
@@ -1332,3 +1413,62 @@ export class STLManipulator extends EventEmitter {
|
|
|
1332
1413
|
}
|
|
1333
1414
|
}
|
|
1334
1415
|
}
|
|
1416
|
+
const LAY_FLAT_MAX_GROUP_CANDIDATES = 64;
|
|
1417
|
+
/** Faces tilted more than 45° from vertical overhang and need support. */
|
|
1418
|
+
const LAY_FLAT_OVERHANG_COS = Math.SQRT1_2 + 1e-6;
|
|
1419
|
+
/**
|
|
1420
|
+
* Score turning `normal` to face the bed. The bed is the mesh's lowest plane in
|
|
1421
|
+
* that pose, its support plane. Faces lying on it, within 0.5% of the model's
|
|
1422
|
+
* height there (at most 0.2 mm), are bed contact. Overhanging faces above it
|
|
1423
|
+
* need support up from the bed: their projected area times their height.
|
|
1424
|
+
* Returns null when no face lies on the bed: turning that normal down would
|
|
1425
|
+
* rest the model on an edge or a point, not a face.
|
|
1426
|
+
*/
|
|
1427
|
+
function evaluateLayFlatPose(mesh, normal) {
|
|
1428
|
+
const { positions, normals, areas } = mesh;
|
|
1429
|
+
const nx = normal.x, ny = normal.y, nz = normal.z;
|
|
1430
|
+
// Distance along the normal grows toward the bed: the bed is at its maximum.
|
|
1431
|
+
let bed = -Infinity, top = Infinity;
|
|
1432
|
+
for (let index = 0; index < positions.length; index += 3) {
|
|
1433
|
+
const along = positions[index] * nx + positions[index + 1] * ny + positions[index + 2] * nz;
|
|
1434
|
+
if (along > bed)
|
|
1435
|
+
bed = along;
|
|
1436
|
+
if (along < top)
|
|
1437
|
+
top = along;
|
|
1438
|
+
}
|
|
1439
|
+
const tolerance = Math.min(0.2, Math.max(0.005 * (bed - top), 1e-4));
|
|
1440
|
+
let contactArea = 0;
|
|
1441
|
+
let supportVolume = 0;
|
|
1442
|
+
for (let face = 0; face < areas.length; face++) {
|
|
1443
|
+
const facing = normals[face * 3] * nx + normals[face * 3 + 1] * ny + normals[face * 3 + 2] * nz;
|
|
1444
|
+
if (!(facing > 0))
|
|
1445
|
+
continue;
|
|
1446
|
+
const base = face * 9;
|
|
1447
|
+
let heightSum = 0, heightMax = 0;
|
|
1448
|
+
for (let corner = base; corner < base + 9; corner += 3) {
|
|
1449
|
+
const height = bed - (positions[corner] * nx + positions[corner + 1] * ny + positions[corner + 2] * nz);
|
|
1450
|
+
heightSum += height;
|
|
1451
|
+
if (height > heightMax)
|
|
1452
|
+
heightMax = height;
|
|
1453
|
+
}
|
|
1454
|
+
if (heightMax <= tolerance)
|
|
1455
|
+
contactArea += areas[face];
|
|
1456
|
+
else if (facing > LAY_FLAT_OVERHANG_COS)
|
|
1457
|
+
supportVolume += areas[face] * facing * (heightSum / 3);
|
|
1458
|
+
}
|
|
1459
|
+
if (!(contactArea > 0))
|
|
1460
|
+
return null;
|
|
1461
|
+
return { normal, contactArea, supportVolume, stackHeight: (mesh.volume + supportVolume) / contactArea };
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Prefer the pose with the lowest stack height, then the larger contact. On a
|
|
1465
|
+
* tie, keep the face that already points down so a seated part is not flipped.
|
|
1466
|
+
*/
|
|
1467
|
+
function isBetterLayFlatPose(pose, best) {
|
|
1468
|
+
const same = (a, b) => Math.abs(a - b) <= 1e-9 + 1e-6 * Math.max(Math.abs(a), Math.abs(b));
|
|
1469
|
+
if (!same(pose.stackHeight, best.stackHeight))
|
|
1470
|
+
return pose.stackHeight < best.stackHeight;
|
|
1471
|
+
if (!same(pose.contactArea, best.contactArea))
|
|
1472
|
+
return pose.contactArea > best.contactArea;
|
|
1473
|
+
return pose.normal.z < best.normal.z;
|
|
1474
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type StudioOptionValue = "off" | "on" | "auto";
|
|
2
|
+
export type StudioOptionSource = "caller" | "studio-config" | "studio-default";
|
|
3
|
+
/** Explicit caller choices; each one wins over Studio's resolution. */
|
|
4
|
+
export type CallerPrintOptions = {
|
|
5
|
+
bedLeveling?: boolean;
|
|
6
|
+
flowCalibration?: boolean;
|
|
7
|
+
vibrationCalibration?: boolean;
|
|
8
|
+
layerInspect?: boolean;
|
|
9
|
+
/** 0 off, 1 on, 2 auto, as Studio's getValueInt. */
|
|
10
|
+
autoBedLeveling?: number;
|
|
11
|
+
autoFlowCali?: number;
|
|
12
|
+
autoOffsetCali?: number;
|
|
13
|
+
extruderCaliManualMode?: number;
|
|
14
|
+
};
|
|
15
|
+
export type StudioPrintOptions = {
|
|
16
|
+
printerType?: string;
|
|
17
|
+
values: {
|
|
18
|
+
bed_leveling: StudioOptionValue;
|
|
19
|
+
flow_cali: StudioOptionValue;
|
|
20
|
+
nozzle_offset_cali: StudioOptionValue;
|
|
21
|
+
};
|
|
22
|
+
sources: {
|
|
23
|
+
bed_leveling: StudioOptionSource;
|
|
24
|
+
flow_cali: StudioOptionSource;
|
|
25
|
+
nozzle_offset_cali: StudioOptionSource;
|
|
26
|
+
};
|
|
27
|
+
task_bed_leveling: boolean;
|
|
28
|
+
task_flow_cali: boolean;
|
|
29
|
+
task_vibration_cali: boolean;
|
|
30
|
+
task_layer_inspect: boolean;
|
|
31
|
+
auto_bed_leveling: number;
|
|
32
|
+
auto_flow_cali: number;
|
|
33
|
+
auto_offset_cali: number;
|
|
34
|
+
extruder_cali_manual_mode: number;
|
|
35
|
+
};
|
|
36
|
+
/** Where Studio keeps BambuStudio.conf; BAMBU_STUDIO_CONFIG overrides it (empty: none). */
|
|
37
|
+
export declare function studioConfigPath(): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* The Send dialog options Studio saved for a printer type, and nothing else:
|
|
40
|
+
* BambuStudio.conf also holds access codes, so only that one section is kept,
|
|
41
|
+
* and a parse failure is reported without any of the file's text.
|
|
42
|
+
*/
|
|
43
|
+
export declare function readStudioSavedOptions(printerTypes: string[], configPath: string | undefined): Record<string, string> | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* What Studio's Send dialog would send for this model, with the caller's
|
|
46
|
+
* explicit choices taking precedence. usedNozzles is the number of nozzles the
|
|
47
|
+
* job uses, for dual-nozzle printers.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveStudioPrintOptions(model: string, caller?: CallerPrintOptions, options?: {
|
|
50
|
+
usedNozzles?: number;
|
|
51
|
+
configPath?: string;
|
|
52
|
+
}): StudioPrintOptions;
|
|
53
|
+
/** The number of nozzles a job's used filaments print from, when the inspection knows. */
|
|
54
|
+
export declare function usedNozzleCount(inspection: {
|
|
55
|
+
usedFilamentPositions: number[];
|
|
56
|
+
filamentNozzleIndices?: Array<number | undefined>;
|
|
57
|
+
}): number | undefined;
|