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.
Files changed (96) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/CONTRIBUTORS.md +5 -1
  3. package/README.md +64 -29
  4. package/dist/ams-mapping.d.ts +38 -0
  5. package/dist/ams-mapping.js +125 -0
  6. package/dist/bambu-network-bridge.d.ts +200 -0
  7. package/dist/bambu-network-bridge.js +881 -0
  8. package/dist/bambu-network-certs.d.ts +13 -0
  9. package/dist/bambu-network-certs.js +152 -0
  10. package/dist/control-reply.d.ts +21 -0
  11. package/dist/control-reply.js +44 -0
  12. package/dist/counterpart/camera-sources.d.ts +16 -0
  13. package/dist/counterpart/camera-sources.js +138 -0
  14. package/dist/counterpart/camera-stream.d.ts +66 -0
  15. package/dist/counterpart/camera-stream.js +246 -0
  16. package/dist/counterpart/camera-tools.d.ts +247 -0
  17. package/dist/counterpart/camera-tools.js +35 -0
  18. package/dist/counterpart/contract.d.ts +21 -5
  19. package/dist/counterpart/contract.js +8 -2
  20. package/dist/counterpart/route.d.ts +31 -0
  21. package/dist/counterpart/route.js +70 -0
  22. package/dist/counterpart/service.d.ts +60 -4
  23. package/dist/counterpart/service.js +142 -16
  24. package/dist/design-guidance.d.ts +26 -0
  25. package/dist/design-guidance.js +177 -0
  26. package/dist/fulu-orca.d.ts +7 -0
  27. package/dist/fulu-orca.js +18 -2
  28. package/dist/index.js +528 -72
  29. package/dist/printers/bambu-filaments.d.ts +68 -0
  30. package/dist/printers/bambu-filaments.js +262 -0
  31. package/dist/printers/bambu.d.ts +99 -6
  32. package/dist/printers/bambu.js +478 -72
  33. package/dist/printers/creality.d.ts +2 -1
  34. package/dist/printers/creality.js +5 -1
  35. package/dist/printers/generic-base.d.ts +11 -6
  36. package/dist/printers/generic-base.js +21 -25
  37. package/dist/printers/klipper.d.ts +2 -1
  38. package/dist/printers/klipper.js +10 -7
  39. package/dist/printers/octoprint.d.ts +2 -1
  40. package/dist/printers/octoprint.js +15 -8
  41. package/dist/printers/printer-factory.d.ts +1 -1
  42. package/dist/printers/printer-factory.js +3 -3
  43. package/dist/printers/prusa.js +0 -1
  44. package/dist/safety/confirmation.d.ts +11 -16
  45. package/dist/safety/confirmation.js +8 -32
  46. package/dist/safety/hidden-hms.d.ts +32 -0
  47. package/dist/safety/hidden-hms.js +405 -0
  48. package/dist/safety/limits.d.ts +8 -2
  49. package/dist/safety/limits.js +14 -5
  50. package/dist/safety/print-file.d.ts +5 -0
  51. package/dist/safety/print-file.js +156 -19
  52. package/dist/safety/printer-state.d.ts +59 -8
  53. package/dist/safety/printer-state.js +174 -39
  54. package/dist/slicer/bambu-cli.d.ts +6 -2
  55. package/dist/slicer/bambu-cli.js +19 -12
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/slicer/slicer-error.d.ts +11 -1
  59. package/dist/slicer/slicer-error.js +16 -2
  60. package/dist/stl/stl-manipulator.d.ts +27 -1
  61. package/dist/stl/stl-manipulator.js +216 -76
  62. package/dist/studio-print-options.d.ts +57 -0
  63. package/dist/studio-print-options.js +179 -0
  64. package/package.json +25 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1075 -0
  68. package/src/bambu-network-certs.ts +154 -0
  69. package/src/control-reply.ts +41 -0
  70. package/src/counterpart/camera-sources.ts +102 -0
  71. package/src/counterpart/camera-stream.ts +195 -0
  72. package/src/counterpart/camera-tools.ts +35 -0
  73. package/src/counterpart/contract.ts +12 -4
  74. package/src/counterpart/route.ts +82 -0
  75. package/src/counterpart/service.ts +150 -16
  76. package/src/design-guidance.ts +188 -0
  77. package/src/fulu-orca.ts +18 -2
  78. package/src/index.ts +546 -87
  79. package/src/printers/bambu-filaments.ts +346 -0
  80. package/src/printers/bambu.ts +506 -81
  81. package/src/printers/creality.ts +8 -3
  82. package/src/printers/generic-base.ts +33 -40
  83. package/src/printers/klipper.ts +12 -8
  84. package/src/printers/octoprint.ts +16 -9
  85. package/src/printers/printer-factory.ts +3 -3
  86. package/src/printers/prusa.ts +0 -1
  87. package/src/safety/confirmation.ts +15 -38
  88. package/src/safety/hidden-hms.ts +406 -0
  89. package/src/safety/limits.ts +15 -5
  90. package/src/safety/print-file.ts +139 -17
  91. package/src/safety/printer-state.ts +179 -40
  92. package/src/slicer/bambu-cli.ts +22 -13
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/slicer/slicer-error.ts +21 -2
  95. package/src/stl/stl-manipulator.ts +254 -84
  96. 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 its largest flat face lies on the XY plane (Z=0).
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
- await runSlicerProcess({
983
- command: slicerPath,
984
- args,
985
- timeoutMs: this.resolveSlicerTimeoutMs(),
986
- slicerType,
987
- isCancelled: () => !this.activeOperations.get(operationId),
988
- failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
989
- });
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 its largest flat face lies on the XY plane (Z=0).
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 faces = [];
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
- // Iterate through faces (triangles)
1228
- for (let i = 0; i < positionAttribute.count; i += 3) {
1229
- vA.fromBufferAttribute(positionAttribute, i);
1230
- vB.fromBufferAttribute(positionAttribute, i + 1);
1231
- vC.fromBufferAttribute(positionAttribute, i + 2);
1232
- // Calculate face normal
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
- faceNormal.copy(cb).normalize();
1237
- // Calculate face area (using cross product magnitude / 2)
1238
- const area = cb.length() / 2;
1239
- faces.push({
1240
- normal: faceNormal.clone(),
1241
- area: area,
1242
- vertices: [vA.clone(), vB.clone(), vC.clone()] // Store vertices if needed for center calculation
1243
- });
1328
+ mesh.areas[face] = cb.length() / 2;
1329
+ cb.normalize().toArray(mesh.normals, face * 3);
1244
1330
  }
1245
- if (faces.length === 0)
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 = new Map();
1337
+ const normalGroups = [];
1253
1338
  const tolerance = 1e-5;
1254
- for (const face of faces) {
1255
- let foundGroup = false;
1256
- for (const [key, group] of normalGroups.entries()) {
1257
- if (face.normal.distanceToSquared(group.representativeNormal) < tolerance) {
1258
- group.totalArea += face.area;
1259
- // Optional: Average the normal? For now, just use the first one.
1260
- foundGroup = true;
1261
- break;
1262
- }
1263
- }
1264
- if (!foundGroup) {
1265
- const key = `${face.normal.x.toFixed(5)},${face.normal.y.toFixed(5)},${face.normal.z.toFixed(5)}`;
1266
- if (!normalGroups.has(key)) { // Ensure unique key even if floats slightly differ after toFixed
1267
- normalGroups.set(key, { totalArea: face.area, representativeNormal: face.normal });
1268
- }
1269
- }
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 largest flat surface...");
1277
- // Include already-horizontal faces: excluding Z-aligned normals can
1278
- // stand a flat model on its edge, including on a repeated layFlat call.
1279
- let largestArea = 0;
1280
- let targetNormal = null;
1281
- const downVector = new THREE.Vector3(0, 0, -1); // Target normal (pointing down)
1282
- // Faces are grouped by direction, so a part whose flat faces are all
1283
- // horizontal has equal up- and down-facing areas. On a tie, keep the
1284
- // face that already points down so a seated part is not flipped.
1285
- for (const group of normalGroups.values()) {
1286
- const tied = targetNormal !== null && Math.abs(group.totalArea - largestArea) <= largestArea * 1e-6;
1287
- if (tied ? group.representativeNormal.z < targetNormal.z : group.totalArea > largestArea) {
1288
- largestArea = group.totalArea;
1289
- targetNormal = group.representativeNormal;
1290
- }
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 (!targetNormal) {
1293
- // Only a mesh without any face area reaches this.
1294
- console.warn("Could not find a suitable large flat face to lay down. No changes made.");
1295
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
1296
- return stlFilePath;
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
- // Calculate the rotation needed to align targetNormal with downVector
1301
- const quaternion = new THREE.Quaternion().setFromUnitVectors(targetNormal, downVector);
1302
- const rotationMatrix = new THREE.Matrix4().makeRotationFromQuaternion(quaternion);
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
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath });
1324
- return outputFilePath;
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;