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
|
@@ -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
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1547
|
-
for (let
|
|
1548
|
-
vA.fromBufferAttribute(positionAttribute,
|
|
1549
|
-
vB.fromBufferAttribute(positionAttribute,
|
|
1550
|
-
vC.fromBufferAttribute(positionAttribute,
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1575
|
-
const tolerance = 1e-5;
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
break;
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
if (!foundGroup) {
|
|
1588
|
-
const key = `${face.normal.x.toFixed(5)},${face.normal.y.toFixed(5)},${face.normal.z.toFixed(5)}`;
|
|
1589
|
-
if (!normalGroups.has(key)) { // Ensure unique key even if floats slightly differ after toFixed
|
|
1590
|
-
normalGroups.set(key, { totalArea: face.area, representativeNormal: face.normal });
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
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
|
|
1599
|
-
|
|
1600
|
-
//
|
|
1601
|
-
//
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
for (const group of normalGroups.values()) {
|
|
1610
|
-
const tied = targetNormal !== null && Math.abs(group.totalArea - largestArea) <= largestArea * 1e-6;
|
|
1611
|
-
if (tied ? group.representativeNormal.z < targetNormal!.z : group.totalArea > largestArea) {
|
|
1612
|
-
largestArea = group.totalArea;
|
|
1613
|
-
targetNormal = group.representativeNormal;
|
|
1614
|
-
}
|
|
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
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
//
|
|
1627
|
-
|
|
1628
|
-
const
|
|
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
|
-
|
|
1656
|
-
|
|
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
|
+
}
|