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
@@ -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,
@@ -20,9 +21,13 @@ import {
20
21
  } from '../slicer/bambu-cli.js';
21
22
  import { runSlicerProcess } from '../slicer/run-slicer.js';
22
23
  import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
24
+ import { customGcodesWithPauses, layerTops, machinePauseGcode, pausedLayers, readPlateGcode, readProjectCustomGcodes } from '../slicer/layer-pauses.js';
23
25
 
24
26
  export type { BambuSliceOptions } from '../slicer/bambu-cli.js';
25
27
 
28
+ /** A sliced file, with the slicer's own result.json when it wrote one. */
29
+ export type SliceOutput = { path: string; slicer_result?: Record<string, unknown> };
30
+
26
31
  /** Print-sheet slicing overrides (PRD §8.4), applied on top of the selected profile. */
27
32
  export interface ProcessOverrides {
28
33
  /** mm */
@@ -1097,6 +1102,70 @@ export class STLManipulator extends EventEmitter {
1097
1102
  bambuOptions?: BambuSliceOptions,
1098
1103
  processOverrides?: ProcessOverrides
1099
1104
  ): Promise<string> {
1105
+ return (await this.sliceSTLWithResult(stlFilePath, slicerType, slicerPath, slicerProfile, progressCallback,
1106
+ printerPreset, filamentProfile, bambuOptions, processOverrides)).path;
1107
+ }
1108
+
1109
+ /**
1110
+ * sliceSTL with the Bambu-compatible CLI's own result.json, parsed verbatim.
1111
+ * A failure's SlicerError carries it too, and its message starts with the
1112
+ * slicer's error_string and plate warnings.
1113
+ */
1114
+ async sliceSTLWithResult(
1115
+ stlFilePath: string,
1116
+ slicerType: SlicerType,
1117
+ slicerPath: string,
1118
+ slicerProfile?: string,
1119
+ progressCallback?: ProgressCallback,
1120
+ printerPreset?: string,
1121
+ filamentProfile?: string,
1122
+ bambuOptions?: BambuSliceOptions,
1123
+ processOverrides?: ProcessOverrides
1124
+ ): Promise<SliceOutput> {
1125
+ const once = (options?: BambuSliceOptions) => this.sliceSTLOnce(stlFilePath, slicerType, slicerPath,
1126
+ slicerProfile, progressCallback, printerPreset, filamentProfile, options, processOverrides);
1127
+ const layers = bambuOptions?.pauseAtLayers?.length ? bambuOptions.pauseAtLayers : undefined;
1128
+ const cli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
1129
+ const plate = bambuOptions?.slicePlate && bambuOptions.slicePlate > 0 ? bambuOptions.slicePlate : 1;
1130
+ const project = cli && /\.3mf$/i.test(stlFilePath) ? await readProjectCustomGcodes(stlFilePath, plate) : undefined;
1131
+ if (!layers && !project?.gcodes.length) return once(bambuOptions);
1132
+ if (layers && !cli) throw new Error(`Pausing at a layer needs the Bambu Studio or OrcaSlicer CLI, not ${slicerType}.`);
1133
+ const customGcodesFile = path.join(this.tempDir, `custom-gcodes-${crypto.randomUUID()}.json`);
1134
+ try {
1135
+ let tops: number[] = [];
1136
+ if (layers) {
1137
+ if (progressCallback) progressCallback(10, `Finding layer heights for the pause before layer ${layers.join(", ")}`);
1138
+ const plain = await once({ ...bambuOptions, pauseAtLayers: undefined, customGcodesFile: undefined });
1139
+ const gcode = await readPlateGcode(plain.path, plate);
1140
+ if (!gcode) throw new Error('The slicer wrote no plate G-code, so the pause layers cannot be placed.');
1141
+ tops = layerTops(gcode);
1142
+ }
1143
+ const info = layers ? customGcodesWithPauses(tops, layers, project) : project!;
1144
+ await fs.promises.writeFile(customGcodesFile, JSON.stringify(info));
1145
+ const result = await once({ ...bambuOptions, customGcodesFile });
1146
+ if (layers) {
1147
+ const gcode = await readPlateGcode(result.path, plate);
1148
+ const placed = gcode ? pausedLayers(gcode, machinePauseGcode(gcode)) : [];
1149
+ const missing = layers.filter(layer => !placed.includes(layer));
1150
+ if (!gcode || missing.length || layerTops(gcode).length !== tops.length) {
1151
+ throw new Error(`The slicer did not place the pause before layer ${(missing.length ? missing : layers).join(", ")}. Nothing was sent to the printer.`);
1152
+ }
1153
+ }
1154
+ return result;
1155
+ } finally { await fs.promises.rm(customGcodesFile, { force: true }); }
1156
+ }
1157
+
1158
+ private async sliceSTLOnce(
1159
+ stlFilePath: string,
1160
+ slicerType: SlicerType,
1161
+ slicerPath: string,
1162
+ slicerProfile?: string,
1163
+ progressCallback?: ProgressCallback,
1164
+ printerPreset?: string,
1165
+ filamentProfile?: string,
1166
+ bambuOptions?: BambuSliceOptions,
1167
+ processOverrides?: ProcessOverrides
1168
+ ): Promise<SliceOutput> {
1100
1169
  const operationId = this.generateOperationId();
1101
1170
  this.activeOperations.set(operationId, true);
1102
1171
  const bambuCli = STLManipulator.usesBambuCliPreparation(slicerType, printerPreset);
@@ -1256,14 +1325,30 @@ export class STLManipulator extends EventEmitter {
1256
1325
  console.error(`Executing: ${slicerPath} ${args.join(' ')}`);
1257
1326
 
1258
1327
  const cliOutputDir = bambuOutputDir;
1259
- await runSlicerProcess({
1260
- command: slicerPath,
1261
- args,
1262
- timeoutMs: this.resolveSlicerTimeoutMs(),
1263
- slicerType,
1264
- isCancelled: () => !this.activeOperations.get(operationId),
1265
- failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
1266
- });
1328
+ try {
1329
+ await runSlicerProcess({
1330
+ command: slicerPath,
1331
+ args,
1332
+ timeoutMs: this.resolveSlicerTimeoutMs(),
1333
+ slicerType,
1334
+ isCancelled: () => !this.activeOperations.get(operationId),
1335
+ failureReason: cliOutputDir ? (exitCode) => bambuCliFailureReason(exitCode, cliOutputDir) : undefined,
1336
+ });
1337
+ } catch (error) {
1338
+ // Keep the CLI's own result.json before the output directory is removed.
1339
+ const failedResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
1340
+ if (failedResult && error instanceof SlicerError) throw new SlicerError(error.phase, error.message, error.details, failedResult);
1341
+ throw error;
1342
+ }
1343
+ const slicerResult = cliOutputDir ? readCliResult(cliOutputDir) : undefined;
1344
+ if (cliOutputDir && !slicerResult) {
1345
+ // Older or Linux CLIs may write result.json only for handled errors.
1346
+ console.error(`${slicerType} wrote no result.json for this slice, so its own checks are not available.`);
1347
+ }
1348
+ if (typeof slicerResult?.return_code === "number" && slicerResult.return_code !== 0) {
1349
+ throw new SlicerError('execution', `Slicer exited with code 0, but its result.json reports return_code ${slicerResult.return_code}.`,
1350
+ { slicerType, slicerPath, exitCode: 0 }, slicerResult);
1351
+ }
1267
1352
 
1268
1353
  if (!this.activeOperations.get(operationId)) {
1269
1354
  throw new SlicerError('execution', 'Slicing operation cancelled after process finished', { slicerType, slicerPath });
@@ -1290,7 +1375,7 @@ export class STLManipulator extends EventEmitter {
1290
1375
  }
1291
1376
  } catch (error) {
1292
1377
  if (error instanceof SlicerError) throw error;
1293
- throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath });
1378
+ throw new SlicerError('output', error instanceof Error ? error.message : String(error), { slicerType, slicerPath }, slicerResult);
1294
1379
  }
1295
1380
 
1296
1381
  if (progressCallback) progressCallback(100, "Slicing completed successfully");
@@ -1302,7 +1387,7 @@ export class STLManipulator extends EventEmitter {
1302
1387
  output: outputFilePath
1303
1388
  });
1304
1389
 
1305
- return outputFilePath;
1390
+ return slicerResult ? { path: outputFilePath, slicer_result: slicerResult } : { path: outputFilePath };
1306
1391
  } catch (error) {
1307
1392
  console.error(`Slicing failed for ${stlFilePath}:`, error instanceof Error ? error.message : error);
1308
1393
  this.emit('operationError', {
@@ -1516,12 +1601,26 @@ export class STLManipulator extends EventEmitter {
1516
1601
  }
1517
1602
 
1518
1603
  /**
1519
- * Rotate the model so its largest flat face lies on the XY plane (Z=0).
1604
+ * Rotate the model so a face rests on the bed, in the pose with the least
1605
+ * part and support material per mm² of bed contact, then move it down to
1606
+ * Z=0. Returns the new file's path, or the input path when no pose qualifies.
1520
1607
  */
1521
1608
  async layFlat(
1522
1609
  stlFilePath: string,
1523
1610
  progressCallback?: ProgressCallback
1524
1611
  ): Promise<string> {
1612
+ return (await this.layFlatWithResult(stlFilePath, progressCallback)).path;
1613
+ }
1614
+
1615
+ /**
1616
+ * layFlat with its outcome. A pose qualifies only when some face, turned to
1617
+ * face down, lies on the rotated mesh's lowest plane; `laid_flat` is false
1618
+ * and the model is unchanged when none does.
1619
+ */
1620
+ async layFlatWithResult(
1621
+ stlFilePath: string,
1622
+ progressCallback?: ProgressCallback
1623
+ ): Promise<LayFlatResult> {
1525
1624
  const operationId = this.generateOperationId();
1526
1625
  this.activeOperations.set(operationId, true);
1527
1626
 
@@ -1536,100 +1635,98 @@ export class STLManipulator extends EventEmitter {
1536
1635
  const positionAttribute = geometry.getAttribute('position');
1537
1636
  if (!positionAttribute) throw new Error("Geometry has no position attribute.");
1538
1637
 
1539
- const faces: { normal: THREE.Vector3; area: number; vertices: THREE.Vector3[] }[] = [];
1638
+ const faceCount = Math.floor(positionAttribute.count / 3);
1639
+ if (faceCount === 0) throw new Error("No faces found in geometry.");
1640
+ const mesh: LayFlatMesh = {
1641
+ positions: new Float64Array(faceCount * 9),
1642
+ normals: new Float64Array(faceCount * 3),
1643
+ areas: new Float64Array(faceCount),
1644
+ volume: 0,
1645
+ };
1646
+ const center = new THREE.Vector3();
1647
+ boundingBox.getCenter(center);
1540
1648
  const vA = new THREE.Vector3();
1541
1649
  const vB = new THREE.Vector3();
1542
1650
  const vC = new THREE.Vector3();
1543
- const faceNormal = new THREE.Vector3();
1544
1651
  const cb = new THREE.Vector3(), ab = new THREE.Vector3();
1545
-
1546
- // Iterate through faces (triangles)
1547
- for (let i = 0; i < positionAttribute.count; i += 3) {
1548
- vA.fromBufferAttribute(positionAttribute, i);
1549
- vB.fromBufferAttribute(positionAttribute, i + 1);
1550
- vC.fromBufferAttribute(positionAttribute, i + 2);
1551
-
1552
- // Calculate face normal
1652
+ const fromA = new THREE.Vector3(), fromB = new THREE.Vector3(), fromC = new THREE.Vector3();
1653
+
1654
+ for (let face = 0; face < faceCount; face++) {
1655
+ vA.fromBufferAttribute(positionAttribute, face * 3);
1656
+ vB.fromBufferAttribute(positionAttribute, face * 3 + 1);
1657
+ vC.fromBufferAttribute(positionAttribute, face * 3 + 2);
1658
+ vA.toArray(mesh.positions, face * 9);
1659
+ vB.toArray(mesh.positions, face * 9 + 3);
1660
+ vC.toArray(mesh.positions, face * 9 + 6);
1661
+ // Signed tetrahedron volumes about the center sum to the enclosed volume.
1662
+ fromA.subVectors(vA, center);
1663
+ fromB.subVectors(vB, center);
1664
+ fromC.subVectors(vC, center);
1665
+ mesh.volume += fromA.dot(fromB.cross(fromC)) / 6;
1666
+
1667
+ // The cross product's direction is the outward normal; half its length is the area.
1553
1668
  cb.subVectors(vC, vB);
1554
1669
  ab.subVectors(vA, vB);
1555
1670
  cb.cross(ab);
1556
- faceNormal.copy(cb).normalize();
1557
-
1558
- // Calculate face area (using cross product magnitude / 2)
1559
- const area = cb.length() / 2;
1560
-
1561
- faces.push({
1562
- normal: faceNormal.clone(),
1563
- area: area,
1564
- vertices: [vA.clone(), vB.clone(), vC.clone()] // Store vertices if needed for center calculation
1565
- });
1671
+ mesh.areas[face] = cb.length() / 2;
1672
+ cb.normalize().toArray(mesh.normals, face * 3);
1566
1673
  }
1567
1674
 
1568
- if (faces.length === 0) throw new Error("No faces found in geometry.");
1675
+ mesh.volume = Math.abs(mesh.volume);
1569
1676
 
1570
1677
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1571
1678
  if (progressCallback) progressCallback(65, "Grouping faces by normal...");
1572
1679
 
1573
1680
  // Group faces by similar normal vectors (tolerance for floating point errors)
1574
- const normalGroups: Map<string, { totalArea: number; representativeNormal: THREE.Vector3 }> = new Map();
1575
- const tolerance = 1e-5;
1576
-
1577
- for (const face of faces) {
1578
- let foundGroup = false;
1579
- for (const [key, group] of normalGroups.entries()) {
1580
- if (face.normal.distanceToSquared(group.representativeNormal) < tolerance) {
1581
- group.totalArea += face.area;
1582
- // Optional: Average the normal? For now, just use the first one.
1583
- foundGroup = true;
1584
- break;
1585
- }
1586
- }
1587
- if (!foundGroup) {
1588
- const key = `${face.normal.x.toFixed(5)},${face.normal.y.toFixed(5)},${face.normal.z.toFixed(5)}`;
1589
- if (!normalGroups.has(key)) { // Ensure unique key even if floats slightly differ after toFixed
1590
- normalGroups.set(key, { totalArea: face.area, representativeNormal: face.normal });
1591
- }
1592
- }
1681
+ const normalGroups: { totalArea: number; representativeNormal: THREE.Vector3 }[] = [];
1682
+ const tolerance = 1e-5;
1683
+ const faceNormal = new THREE.Vector3();
1684
+
1685
+ for (let face = 0; face < faceCount; face++) {
1686
+ if (!(mesh.areas[face] > 0)) continue;
1687
+ faceNormal.fromArray(mesh.normals, face * 3);
1688
+ const group = normalGroups.find(candidate => faceNormal.distanceToSquared(candidate.representativeNormal) < tolerance);
1689
+ if (group) group.totalArea += mesh.areas[face];
1690
+ else normalGroups.push({ totalArea: mesh.areas[face], representativeNormal: faceNormal.clone() });
1593
1691
  }
1594
-
1595
- if (normalGroups.size === 0) throw new Error("Could not group faces by normal.");
1596
1692
 
1597
1693
  if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1598
- if (progressCallback) progressCallback(75, "Finding largest flat surface...");
1599
-
1600
- // Include already-horizontal faces: excluding Z-aligned normals can
1601
- // stand a flat model on its edge, including on a repeated layFlat call.
1602
- let largestArea = 0;
1603
- let targetNormal: THREE.Vector3 | null = null;
1604
- const downVector = new THREE.Vector3(0, 0, -1); // Target normal (pointing down)
1605
-
1606
- // Faces are grouped by direction, so a part whose flat faces are all
1607
- // horizontal has equal up- and down-facing areas. On a tie, keep the
1608
- // face that already points down so a seated part is not flipped.
1609
- for (const group of normalGroups.values()) {
1610
- const tied = targetNormal !== null && Math.abs(group.totalArea - largestArea) <= largestArea * 1e-6;
1611
- if (tied ? group.representativeNormal.z < targetNormal!.z : group.totalArea > largestArea) {
1612
- largestArea = group.totalArea;
1613
- targetNormal = group.representativeNormal;
1614
- }
1694
+ if (progressCallback) progressCallback(75, "Finding the pose that rests on the bed...");
1695
+
1696
+ // Candidate directions to turn down: the largest face groups, plus the
1697
+ // model's own axes so a part with a domed side can be flipped exactly.
1698
+ const candidates = normalGroups
1699
+ .sort((a, b) => b.totalArea - a.totalArea)
1700
+ .slice(0, LAY_FLAT_MAX_GROUP_CANDIDATES)
1701
+ .map(group => group.representativeNormal);
1702
+ for (const axis of [[0, 0, -1], [0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) {
1703
+ const normal = new THREE.Vector3(axis[0], axis[1], axis[2]);
1704
+ if (!candidates.some(candidate => candidate.distanceToSquared(normal) < tolerance)) candidates.push(normal);
1615
1705
  }
1616
1706
 
1617
- if (!targetNormal) {
1618
- // Only a mesh without any face area reaches this.
1619
- console.warn("Could not find a suitable large flat face to lay down. No changes made.");
1620
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
1621
- return stlFilePath;
1707
+ let best: LayFlatPose | null = null;
1708
+ for (const normal of candidates) {
1709
+ const pose = evaluateLayFlatPose(mesh, normal);
1710
+ if (pose && (!best || isBetterLayFlatPose(pose, best))) best = pose;
1622
1711
  }
1623
-
1712
+
1713
+ if (!best) {
1714
+ const message = "No face lies on the model's lowest plane in any candidate orientation, so the model is unchanged.";
1715
+ console.error(`lay_flat: ${message}`);
1716
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message });
1717
+ return { path: stlFilePath, laid_flat: false, message };
1718
+ }
1719
+
1624
1720
  if (progressCallback) progressCallback(80, "Calculating rotation...");
1625
-
1626
- // Calculate the rotation needed to align targetNormal with downVector
1627
- const quaternion = new THREE.Quaternion().setFromUnitVectors(targetNormal, downVector);
1628
- const rotationMatrix = new THREE.Matrix4().makeRotationFromQuaternion(quaternion);
1721
+
1722
+ // Turn the chosen normal to face straight down. Turning +Z down rotates
1723
+ // half a turn about X, which keeps the model's X layout.
1724
+ const downVector = new THREE.Vector3(0, 0, -1);
1725
+ const rotationMatrix = best.normal.dot(downVector) < -1 + 1e-9
1726
+ ? new THREE.Matrix4().makeRotationX(Math.PI)
1727
+ : new THREE.Matrix4().makeRotationFromQuaternion(new THREE.Quaternion().setFromUnitVectors(best.normal, downVector));
1629
1728
 
1630
1729
  // Rotate around the model's center
1631
- const center = new THREE.Vector3();
1632
- boundingBox.getCenter(center);
1633
1730
  const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
1634
1731
  const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
1635
1732
 
@@ -1652,8 +1749,9 @@ export class STLManipulator extends EventEmitter {
1652
1749
 
1653
1750
  await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
1654
1751
 
1655
- this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath });
1656
- return outputFilePath;
1752
+ 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.`;
1753
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath, message });
1754
+ return { path: outputFilePath, laid_flat: true, message };
1657
1755
 
1658
1756
  } catch (error) {
1659
1757
  this.emit('operationError', { operationId, type: 'layFlat', error: error instanceof Error ? error.message : String(error) });
@@ -1663,3 +1761,75 @@ export class STLManipulator extends EventEmitter {
1663
1761
  }
1664
1762
  }
1665
1763
  }
1764
+
1765
+ export type LayFlatResult = {
1766
+ /** The laid-flat STL, or the unchanged input when `laid_flat` is false. */
1767
+ path: string;
1768
+ laid_flat: boolean;
1769
+ message: string;
1770
+ };
1771
+
1772
+ /** Face corners (9 per face), outward unit normals (3 per face), areas, and the enclosed volume. */
1773
+ type LayFlatMesh = { positions: Float64Array; normals: Float64Array; areas: Float64Array; volume: number };
1774
+
1775
+ /**
1776
+ * A direction to turn down, with the bed contact and support it gives.
1777
+ * `stackHeight` is the part and support volume over the contact area: the mean
1778
+ * height of material standing on each mm² of bed. Standing a long part on its
1779
+ * end, or resting a part on a fin under a wide cap, makes it tall.
1780
+ */
1781
+ type LayFlatPose = { normal: THREE.Vector3; contactArea: number; supportVolume: number; stackHeight: number };
1782
+
1783
+ const LAY_FLAT_MAX_GROUP_CANDIDATES = 64;
1784
+ /** Faces tilted more than 45° from vertical overhang and need support. */
1785
+ const LAY_FLAT_OVERHANG_COS = Math.SQRT1_2 + 1e-6;
1786
+
1787
+ /**
1788
+ * Score turning `normal` to face the bed. The bed is the mesh's lowest plane in
1789
+ * that pose, its support plane. Faces lying on it, within 0.5% of the model's
1790
+ * height there (at most 0.2 mm), are bed contact. Overhanging faces above it
1791
+ * need support up from the bed: their projected area times their height.
1792
+ * Returns null when no face lies on the bed: turning that normal down would
1793
+ * rest the model on an edge or a point, not a face.
1794
+ */
1795
+ function evaluateLayFlatPose(mesh: LayFlatMesh, normal: THREE.Vector3): LayFlatPose | null {
1796
+ const { positions, normals, areas } = mesh;
1797
+ const nx = normal.x, ny = normal.y, nz = normal.z;
1798
+ // Distance along the normal grows toward the bed: the bed is at its maximum.
1799
+ let bed = -Infinity, top = Infinity;
1800
+ for (let index = 0; index < positions.length; index += 3) {
1801
+ const along = positions[index] * nx + positions[index + 1] * ny + positions[index + 2] * nz;
1802
+ if (along > bed) bed = along;
1803
+ if (along < top) top = along;
1804
+ }
1805
+ const tolerance = Math.min(0.2, Math.max(0.005 * (bed - top), 1e-4));
1806
+
1807
+ let contactArea = 0;
1808
+ let supportVolume = 0;
1809
+ for (let face = 0; face < areas.length; face++) {
1810
+ const facing = normals[face * 3] * nx + normals[face * 3 + 1] * ny + normals[face * 3 + 2] * nz;
1811
+ if (!(facing > 0)) continue;
1812
+ const base = face * 9;
1813
+ let heightSum = 0, heightMax = 0;
1814
+ for (let corner = base; corner < base + 9; corner += 3) {
1815
+ const height = bed - (positions[corner] * nx + positions[corner + 1] * ny + positions[corner + 2] * nz);
1816
+ heightSum += height;
1817
+ if (height > heightMax) heightMax = height;
1818
+ }
1819
+ if (heightMax <= tolerance) contactArea += areas[face];
1820
+ else if (facing > LAY_FLAT_OVERHANG_COS) supportVolume += areas[face] * facing * (heightSum / 3);
1821
+ }
1822
+ if (!(contactArea > 0)) return null;
1823
+ return { normal, contactArea, supportVolume, stackHeight: (mesh.volume + supportVolume) / contactArea };
1824
+ }
1825
+
1826
+ /**
1827
+ * Prefer the pose with the lowest stack height, then the larger contact. On a
1828
+ * tie, keep the face that already points down so a seated part is not flipped.
1829
+ */
1830
+ function isBetterLayFlatPose(pose: LayFlatPose, best: LayFlatPose): boolean {
1831
+ const same = (a: number, b: number) => Math.abs(a - b) <= 1e-9 + 1e-6 * Math.max(Math.abs(a), Math.abs(b));
1832
+ if (!same(pose.stackHeight, best.stackHeight)) return pose.stackHeight < best.stackHeight;
1833
+ if (!same(pose.contactArea, best.contactArea)) return pose.contactArea > best.contactArea;
1834
+ return pose.normal.z < best.normal.z;
1835
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The print options Bambu Studio's Send dialog sends, resolved the way it
3
+ * resolves them, so every print route sends what Bambu's own Print button
4
+ * would. Source: Bambu Studio v02.08.02.61 (commit 926a7192574b), the version
5
+ * the hidden-HMS and stage tables are pinned to:
6
+ *
7
+ * - SelectMachine.cpp:3344-3356 `set_print_config(bed_type,
8
+ * bed_leveling == "on", flow_cali == "on", false, timelapse, true,
9
+ * ext_change_assist, bed_leveling.getValueInt(), flow_cali.getValueInt(),
10
+ * nozzle_offset_cali.getValueInt(), pa_value_switch ? 0 : 1)`. Vibration
11
+ * calibration is always false and layer inspection always true; the
12
+ * parameter order is PrintJob.hpp:98-116.
13
+ * - SelectMachine.cpp:7318-7329 `getValueInt`: off 0, on 1, auto 2.
14
+ * - SelectMachine.cpp:1117-1123 `update_select_layout`: update_option_opts
15
+ * (which choices each option offers on this printer) runs before
16
+ * load_option_vals.
17
+ * - SelectMachine.cpp:2993-3027 `load_option_vals`: the value saved in
18
+ * BambuStudio.conf under the printer type, if the option offers it; else
19
+ * "auto" if offered; else "on". Dual-nozzle printers then set nozzle offset
20
+ * calibration to "auto" for a two-nozzle job and, without a filament
21
+ * switcher, "off" for a one-nozzle job.
22
+ * - SelectMachine.cpp:2586-2615 `update_option_opts`: bed leveling offers
23
+ * auto/on/off when the printer's support_bed_leveling is 2 and on/off when it
24
+ * is 1; with 0 it is hidden and keeps the auto/on/off it was built with
25
+ * (SelectMachine.cpp:676-691), as does flow calibration when the printer has
26
+ * no PA calibration. DeviceManager.cpp:1100/4441 give P-series printers
27
+ * (series_p1p: P1P, P1S) no PA calibration, so their flow calibration is
28
+ * hidden and resolves to "auto". Nozzle offset calibration always offers auto.
29
+ * - SelectMachine.cpp:705-707: the PA value switch starts on and is not saved,
30
+ * so extruder_cali_manual_mode is 0.
31
+ *
32
+ * Approximations, outside the P1P/P1S: flow calibration on other printers is
33
+ * shown when the printer's push status reports PA calibration (assumed here),
34
+ * and offers auto when support_auto_flow_calibration is true and the mapped
35
+ * filaments allow it (can_support_pa_auto_cali, assumed true). A filament
36
+ * switcher on a dual-nozzle printer is assumed absent.
37
+ */
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+
42
+ export type StudioOptionValue = "off" | "on" | "auto";
43
+ type Offers = readonly StudioOptionValue[];
44
+ const AUTO_ON_OFF: Offers = ["auto", "on", "off"];
45
+ const ON_OFF: Offers = ["on", "off"];
46
+
47
+ /**
48
+ * Studio's printer types (resources/printers/<type>.json at the pinned commit:
49
+ * model_id/printer_type, printer_series, support_bed_leveling,
50
+ * support_auto_flow_calibration), keyed by this server's model names.
51
+ */
52
+ const STUDIO_PRINTERS: Record<string, { types: string[]; pSeries: boolean; bedLeveling: 0 | 1 | 2; autoFlow: boolean; dualNozzle: boolean }> = {
53
+ p1p: { types: ["C11"], pSeries: true, bedLeveling: 1, autoFlow: false, dualNozzle: false },
54
+ p1s: { types: ["C12"], pSeries: true, bedLeveling: 1, autoFlow: false, dualNozzle: false },
55
+ x1c: { types: ["3DPrinter-X1-Carbon"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
56
+ x1: { types: ["3DPrinter-X1"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
57
+ x1e: { types: ["C13"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
58
+ a1mini: { types: ["N1"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
59
+ a1: { types: ["N2S"], pSeries: false, bedLeveling: 1, autoFlow: false, dualNozzle: false },
60
+ p2s: { types: ["N7"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: false },
61
+ x2d: { types: ["N6"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
62
+ h2s: { types: ["O1S"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: false },
63
+ h2d: { types: ["O1D"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
64
+ h2dpro: { types: ["O1E"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
65
+ h2c: { types: ["O1C", "O1C2"], pSeries: false, bedLeveling: 2, autoFlow: true, dualNozzle: true },
66
+ };
67
+
68
+ export type StudioOptionSource = "caller" | "studio-config" | "studio-default";
69
+
70
+ /** Explicit caller choices; each one wins over Studio's resolution. */
71
+ export type CallerPrintOptions = {
72
+ bedLeveling?: boolean;
73
+ flowCalibration?: boolean;
74
+ vibrationCalibration?: boolean;
75
+ layerInspect?: boolean;
76
+ /** 0 off, 1 on, 2 auto, as Studio's getValueInt. */
77
+ autoBedLeveling?: number;
78
+ autoFlowCali?: number;
79
+ autoOffsetCali?: number;
80
+ extruderCaliManualMode?: number;
81
+ };
82
+
83
+ export type StudioPrintOptions = {
84
+ printerType?: string;
85
+ values: { bed_leveling: StudioOptionValue; flow_cali: StudioOptionValue; nozzle_offset_cali: StudioOptionValue };
86
+ sources: { bed_leveling: StudioOptionSource; flow_cali: StudioOptionSource; nozzle_offset_cali: StudioOptionSource };
87
+ task_bed_leveling: boolean;
88
+ task_flow_cali: boolean;
89
+ task_vibration_cali: boolean;
90
+ task_layer_inspect: boolean;
91
+ auto_bed_leveling: number;
92
+ auto_flow_cali: number;
93
+ auto_offset_cali: number;
94
+ extruder_cali_manual_mode: number;
95
+ };
96
+
97
+ /** Where Studio keeps BambuStudio.conf; BAMBU_STUDIO_CONFIG overrides it (empty: none). */
98
+ export function studioConfigPath(): string | undefined {
99
+ const override = process.env.BAMBU_STUDIO_CONFIG;
100
+ if (override !== undefined) return override.trim() || undefined;
101
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "BambuStudio", "BambuStudio.conf");
102
+ if (process.platform === "win32") return process.env.APPDATA ? path.join(process.env.APPDATA, "BambuStudio", "BambuStudio.conf") : undefined;
103
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "BambuStudio", "BambuStudio.conf");
104
+ }
105
+
106
+ /**
107
+ * The Send dialog options Studio saved for a printer type, and nothing else:
108
+ * BambuStudio.conf also holds access codes, so only that one section is kept,
109
+ * and a parse failure is reported without any of the file's text.
110
+ */
111
+ export function readStudioSavedOptions(printerTypes: string[], configPath: string | undefined): Record<string, string> | undefined {
112
+ if (!configPath) return undefined;
113
+ let text: string;
114
+ try {
115
+ const stat = fs.statSync(configPath);
116
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024) return undefined;
117
+ text = fs.readFileSync(configPath, "utf8");
118
+ } catch { return undefined; }
119
+ let config: unknown;
120
+ // Studio appends "# MD5 checksum <hex>" after the JSON.
121
+ try { config = JSON.parse(text.split("\n").filter(line => !line.trimStart().startsWith("#")).join("\n")); }
122
+ catch { return undefined; }
123
+ if (!config || typeof config !== "object") return undefined;
124
+ for (const type of printerTypes) {
125
+ const section = (config as Record<string, unknown>)[type];
126
+ if (!section || typeof section !== "object" || Array.isArray(section)) continue;
127
+ const saved: Record<string, string> = {};
128
+ for (const key of ["bed_leveling", "flow_cali", "nozzle_offset_cali", "timelapse"]) {
129
+ const value = (section as Record<string, unknown>)[key];
130
+ if (typeof value === "string") saved[key] = value;
131
+ }
132
+ return saved;
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function fromInt(value: number, label: string): StudioOptionValue {
138
+ if (value === 0) return "off";
139
+ if (value === 1) return "on";
140
+ if (value === 2) return "auto";
141
+ throw new Error(`${label} must be 0 (off), 1 (on) or 2 (auto).`);
142
+ }
143
+
144
+ const toInt = (value: StudioOptionValue) => value === "off" ? 0 : value === "on" ? 1 : 2;
145
+
146
+ /** load_option_vals for one option: saved if offered, else auto if offered, else on. */
147
+ function loaded(saved: string | undefined, offers: Offers): { value: StudioOptionValue; source: StudioOptionSource } {
148
+ if (saved !== undefined && (offers as readonly string[]).includes(saved)) return { value: saved as StudioOptionValue, source: "studio-config" };
149
+ return { value: offers.includes("auto") ? "auto" : "on", source: "studio-default" };
150
+ }
151
+
152
+ /**
153
+ * What Studio's Send dialog would send for this model, with the caller's
154
+ * explicit choices taking precedence. usedNozzles is the number of nozzles the
155
+ * job uses, for dual-nozzle printers.
156
+ */
157
+ export function resolveStudioPrintOptions(model: string, caller: CallerPrintOptions = {}, options: { usedNozzles?: number; configPath?: string } = {}): StudioPrintOptions {
158
+ const printer = STUDIO_PRINTERS[model];
159
+ const saved = printer ? readStudioSavedOptions(printer.types, "configPath" in options ? options.configPath : studioConfigPath()) ?? {} : {};
160
+ const printerType = printer?.types[0];
161
+
162
+ const bedOffers = !printer || printer.bedLeveling !== 1 ? AUTO_ON_OFF : ON_OFF;
163
+ const flowOffers = !printer || printer.pSeries || printer.autoFlow ? AUTO_ON_OFF : ON_OFF;
164
+ const bed = loaded(saved.bed_leveling, bedOffers);
165
+ const flow = loaded(saved.flow_cali, flowOffers);
166
+ let offset = loaded(saved.nozzle_offset_cali, AUTO_ON_OFF);
167
+ if (printer?.dualNozzle && options.usedNozzles !== undefined) {
168
+ if (options.usedNozzles >= 2) offset = { value: "auto", source: "studio-default" };
169
+ else if (options.usedNozzles === 1) offset = { value: "off", source: "studio-default" };
170
+ }
171
+
172
+ const choose = (current: { value: StudioOptionValue; source: StudioOptionSource }, auto: number | undefined, flag: boolean | undefined, label: string) =>
173
+ auto !== undefined ? { value: fromInt(auto, label), source: "caller" as const }
174
+ : flag !== undefined ? { value: flag ? "on" as const : "off" as const, source: "caller" as const }
175
+ : current;
176
+ const bedLeveling = choose(bed, caller.autoBedLeveling, caller.bedLeveling, "auto_bed_leveling");
177
+ const flowCali = choose(flow, caller.autoFlowCali, caller.flowCalibration, "auto_flow_cali");
178
+ const nozzleOffset = choose(offset, caller.autoOffsetCali, undefined, "auto_offset_cali");
179
+ const manualMode = caller.extruderCaliManualMode ?? 0;
180
+ if (!Number.isInteger(manualMode) || manualMode < -1 || manualMode > 1) throw new Error("extruder_cali_manual_mode must be -1, 0 or 1.");
181
+
182
+ return {
183
+ ...(printerType ? { printerType } : {}),
184
+ values: { bed_leveling: bedLeveling.value, flow_cali: flowCali.value, nozzle_offset_cali: nozzleOffset.value },
185
+ sources: { bed_leveling: bedLeveling.source, flow_cali: flowCali.source, nozzle_offset_cali: nozzleOffset.source },
186
+ task_bed_leveling: bedLeveling.value === "on",
187
+ task_flow_cali: flowCali.value === "on",
188
+ task_vibration_cali: caller.vibrationCalibration ?? false,
189
+ task_layer_inspect: caller.layerInspect ?? true,
190
+ auto_bed_leveling: toInt(bedLeveling.value),
191
+ auto_flow_cali: toInt(flowCali.value),
192
+ auto_offset_cali: toInt(nozzleOffset.value),
193
+ extruder_cali_manual_mode: manualMode,
194
+ };
195
+ }
196
+
197
+ /** The number of nozzles a job's used filaments print from, when the inspection knows. */
198
+ export function usedNozzleCount(inspection: { usedFilamentPositions: number[]; filamentNozzleIndices?: Array<number | undefined> }): number | undefined {
199
+ const nozzles = inspection.usedFilamentPositions.map(position => inspection.filamentNozzleIndices?.[position]).filter((value): value is number => Number.isInteger(value));
200
+ return nozzles.length === inspection.usedFilamentPositions.length && nozzles.length ? new Set(nozzles).size : undefined;
201
+ }