mcp-3d-printer-server 1.2.27 → 1.2.29
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 +12 -0
- package/README.md +2 -0
- package/dist/bambu-network-bridge.d.ts +8 -2
- package/dist/bambu-network-bridge.js +25 -3
- package/dist/index.js +49 -11
- package/dist/stl/stl-manipulator.d.ts +7 -0
- package/dist/stl/stl-manipulator.js +20 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/bambu-network-bridge.ts +25 -3
- package/src/index.ts +48 -11
- package/src/stl/stl-manipulator.ts +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.29] – 2026-11-04
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- With no method given, a FULU print over Bambu Cloud goes the way Bambu Studio sends it to a cloud-bound printer it can reach on the LAN (`PrintJob.cpp`): `start_local_print_with_record`, which uploads to the printer over the LAN and records the task in the account, in the account's cloud session. When that fails before the print command (the plug-in's `-2010` to `-2140` preparation results, other than a missing or oversized file), the same checked job goes once through `start_print`, with the printer checked again first. Any other failure may have reached the printer and is not sent again. A printer that reports no storage goes straight to `start_print`, as in Studio. Same change as bambu-printer-mcp 1.1.55. On a P1S with firmware 01.10.00.00, `start_print` alone returns `-3120`, HTTP 403 "internal blocking", every time. Errors name `-2120`, `-2130` and `-3120`. Checked with the stand-in host and intercepted transports only; not yet on a printer.
|
|
8
|
+
|
|
9
|
+
## [1.2.28] – 2026-11-04
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `slice_stl` takes `enable_support`, `support_type` (`normal(auto)`, `tree(auto)`, `normal(manual)`, `tree(manual)`) and `support_threshold_angle`, written over the selected process profile like `layer_height` and `wall_loops`. Bambu Studio and OrcaSlicer only; PrusaSlicer and Cura refuse them. Tested with the fake Bambu Studio CLI install; bambu-printer-mcp 1.1.54 checks the same keys against the installed Bambu Studio 02.08.02.61. No printer was involved.
|
|
14
|
+
|
|
3
15
|
## [1.2.27] – 2026-11-04
|
|
4
16
|
|
|
5
17
|
### Added
|
package/README.md
CHANGED
|
@@ -690,6 +690,8 @@ Options for the Bambu-compatible path:
|
|
|
690
690
|
|
|
691
691
|
`layer_height` (mm), `wall_loops` (a whole number), and `sparse_infill_density` (such as `"15%"`) are written over the selected profile on every slicer: as the process keys `layer_height`, `wall_loops`, and `sparse_infill_density` for Bambu Studio and OrcaSlicer, and as `--layer-height`, `--perimeters`, and `--fill-density` for PrusaSlicer and Slic3r. They win over a template. Cura refuses them; set them in its definition file. Your profile files are never modified.
|
|
692
692
|
|
|
693
|
+
`enable_support` (true or false), `support_type` (`normal(auto)`, `tree(auto)`, `normal(manual)` or `tree(manual)`) and `support_threshold_angle` (0 to 90 degrees; 0 lets the slicer decide) are written over the selected process profile as the same-named Bambu Studio and OrcaSlicer keys. PrusaSlicer, Slic3r and Cura refuse them; set supports in their own profile.
|
|
694
|
+
|
|
693
695
|
Generic slicers keep their own profile formats: PrusaSlicer and Slic3r load one exported config, plus an optional filament preset `.ini` from `load_filaments`, and generic OrcaSlicer takes `machine.json;process.json`, optionally followed by `|filament.json`. `slicer_type`, `slicer_profile`, and `filament_profile` fall back to `SLICER_TYPE`, `SLICER_PROFILE`, and `FILAMENT_PROFILE`. The slicer executable comes from `SLICER_PATH`; a per-call `slicer_path` requires `MCP_ALLOW_EXECUTABLE_ARG=1`. See the [slicing guide](https://github.com/DMontgomery40/mcp-3D-printer-server/blob/main/docs/SLICING.md#bambu-compatible-slicing).
|
|
694
696
|
|
|
695
697
|
#### slice_with_template
|
|
@@ -186,10 +186,16 @@ export declare class BambuNetworkBridge {
|
|
|
186
186
|
* Shares the LAN session queue: one reader of the plug-in's events at a time.
|
|
187
187
|
*/
|
|
188
188
|
withCloudSession<T>(devId: string, options: BambuNetworkBridgeOptions, assertActive: () => void, timing: Readonly<CloudTiming>, run: (session: CloudSession) => Promise<T>): Promise<T>;
|
|
189
|
-
/**
|
|
190
|
-
|
|
189
|
+
/**
|
|
190
|
+
* A cloud print in a cloud session, so the plug-in has the printer's state. With a fallback this is
|
|
191
|
+
* Bambu Studio's order for a cloud-bound printer it can also reach on the LAN (PrintJob.cpp,
|
|
192
|
+
* SendAttemptRoute::LanWithRecord, then ::Cloud): start_local_print_with_record, then start_print
|
|
193
|
+
* when the first failed before its print command was sent. `firstFailure` is the first method's result.
|
|
194
|
+
*/
|
|
195
|
+
startCloudPrint(devId: string, method: string, payload: JsonObject, options?: BambuNetworkBridgeOptions, assertActive?: () => void, timing?: Readonly<CloudTiming>, fallbackMethod?: string): Promise<{
|
|
191
196
|
result: unknown;
|
|
192
197
|
jobUpdates: BridgeJobUpdate[];
|
|
198
|
+
firstFailure?: number;
|
|
193
199
|
}>;
|
|
194
200
|
/**
|
|
195
201
|
* A print control over Bambu Cloud: how Bambu Studio and FULU's slicer control a printer
|
|
@@ -21,8 +21,16 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
|
|
|
21
21
|
const LOCAL_CONNECT_STATUS = ["ok", "failed", "lost"];
|
|
22
22
|
// bambu_networking.hpp BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED.
|
|
23
23
|
const LOCAL_PRINT_PUBLISH_FAILED = -4030;
|
|
24
|
+
// A with-record send falls back to start_print only on its own preparation failures (the plug-in's
|
|
25
|
+
// PRINT_WR_* results, -2010 to -2140: project, upload, task record, FTPS), which come before the print
|
|
26
|
+
// command, so the printer has no job. Any other result may have reached the printer and is never
|
|
27
|
+
// replayed. Of those, Studio also stops on a file missing or too large (should_stop_without_fallback).
|
|
28
|
+
const WITH_RECORD_PREPARATION_FAILURE = (value) => value <= -2010 && value >= -2140 && value !== -2040 && value !== -2100;
|
|
24
29
|
// bambu_networking.hpp print errors that name a recoverable cause.
|
|
25
30
|
const BAMBU_NETWORK_PRINT_ERRORS = {
|
|
31
|
+
[-2120]: "Bambu Cloud refused to create the print task",
|
|
32
|
+
[-2130]: "FTPS upload to the printer failed",
|
|
33
|
+
[-3120]: "Bambu Cloud refused to create the print task",
|
|
26
34
|
[-4010]: "the file exceeds the 1 GB upload limit",
|
|
27
35
|
[-4020]: "FTPS upload to the printer failed",
|
|
28
36
|
[LOCAL_PRINT_PUBLISH_FAILED]: "the networking plug-in could not send the print command to the printer over MQTT",
|
|
@@ -703,9 +711,23 @@ export class BambuNetworkBridge {
|
|
|
703
711
|
release();
|
|
704
712
|
}
|
|
705
713
|
}
|
|
706
|
-
/**
|
|
707
|
-
|
|
708
|
-
|
|
714
|
+
/**
|
|
715
|
+
* A cloud print in a cloud session, so the plug-in has the printer's state. With a fallback this is
|
|
716
|
+
* Bambu Studio's order for a cloud-bound printer it can also reach on the LAN (PrintJob.cpp,
|
|
717
|
+
* SendAttemptRoute::LanWithRecord, then ::Cloud): start_local_print_with_record, then start_print
|
|
718
|
+
* when the first failed before its print command was sent. `firstFailure` is the first method's result.
|
|
719
|
+
*/
|
|
720
|
+
async startCloudPrint(devId, method, payload, options = {}, assertActive = () => { }, timing = CLOUD_PRINT_TIMING, fallbackMethod) {
|
|
721
|
+
return await this.withCloudSession(devId, options, assertActive, timing, async (session) => {
|
|
722
|
+
const result = await this.callWithAgent(method, payload, options);
|
|
723
|
+
const value = bridgeNumericResult(result);
|
|
724
|
+
if (!fallbackMethod || value === undefined || !WITH_RECORD_PREPARATION_FAILURE(value))
|
|
725
|
+
return { result, jobUpdates: session.jobUpdates };
|
|
726
|
+
options.trace?.({ step: "fallback", from: method, to: fallbackMethod, result: value });
|
|
727
|
+
const params = payload.params;
|
|
728
|
+
const fallbackPayload = params ? { ...payload, params: { ...params, comments: `failed(${value})` } } : payload;
|
|
729
|
+
return { result: await this.callWithAgent(fallbackMethod, fallbackPayload, options), jobUpdates: session.jobUpdates, firstFailure: value };
|
|
730
|
+
});
|
|
709
731
|
}
|
|
710
732
|
/**
|
|
711
733
|
* A print control over Bambu Cloud: how Bambu Studio and FULU's slicer control a printer
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { createServer as createHttpServer } from "node:http";
|
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
18
|
import * as THREE from 'three';
|
|
19
19
|
import { PrinterFactory } from "./printers/printer-factory.js";
|
|
20
|
-
import { STLManipulator } from "./stl/stl-manipulator.js";
|
|
20
|
+
import { STLManipulator, SUPPORT_TYPES } from "./stl/stl-manipulator.js";
|
|
21
21
|
import { parsePauseLayers, readPlateGcode, pausedLayers, machinePauseGcode } from "./slicer/layer-pauses.js";
|
|
22
22
|
import { CounterpartService } from "./counterpart/service.js";
|
|
23
23
|
import { CAMERA_STREAM_TOOLS } from "./counterpart/camera-tools.js";
|
|
@@ -34,7 +34,7 @@ import { BambuNetworkBridge, describeBambuNetworkPrintError } from "./bambu-netw
|
|
|
34
34
|
import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues } from "./counterpart/route.js";
|
|
35
35
|
import { resolveStudioPrintOptions, usedNozzleCount } from "./studio-print-options.js";
|
|
36
36
|
import { inspectPrintFile } from "./safety/print-file.js";
|
|
37
|
-
import { validatePrinterState } from "./safety/printer-state.js";
|
|
37
|
+
import { externalStorageState, validatePrinterState } from "./safety/printer-state.js";
|
|
38
38
|
import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
|
|
39
39
|
import { checkedFile } from "./printers/bambu.js";
|
|
40
40
|
import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
|
|
@@ -318,6 +318,12 @@ const SLICE_TOOL_PROPERTIES = {
|
|
|
318
318
|
type: "string",
|
|
319
319
|
description: "Sparse infill density such as \"15%\" (0–100%), written over the selected process profile (Orca/Bambu Studio sparse_infill_density; PrusaSlicer --fill-density). Not mapped for Cura.",
|
|
320
320
|
},
|
|
321
|
+
enable_support: { type: "boolean", description: "Turn support generation on or off (Orca/Bambu Studio enable_support), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
|
|
322
|
+
support_type: { type: "string", enum: [...SUPPORT_TYPES], description: "Support style (Orca/Bambu Studio support_type), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
|
|
323
|
+
support_threshold_angle: {
|
|
324
|
+
type: "integer", minimum: 0, maximum: 90,
|
|
325
|
+
description: "Overhang threshold in degrees for automatic supports (Orca/Bambu Studio support_threshold_angle; 0 lets the slicer decide). Bambu Studio and OrcaSlicer only.",
|
|
326
|
+
},
|
|
321
327
|
};
|
|
322
328
|
/**
|
|
323
329
|
* upload_gcode / start_print results name the remote file that was stored or
|
|
@@ -348,7 +354,7 @@ const OUTPUT_SCHEMA_TOOLS = new Set([
|
|
|
348
354
|
"camera_stream_start", "camera_frame", "camera_stream_stop",
|
|
349
355
|
"fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
|
|
350
356
|
]);
|
|
351
|
-
/** layer_height / wall_loops / sparse_infill_density (PRD §8.4), validated before slicing. */
|
|
357
|
+
/** layer_height / wall_loops / sparse_infill_density / support settings (PRD §8.4), validated before slicing. */
|
|
352
358
|
function parseProcessOverrides(args) {
|
|
353
359
|
const overrides = {};
|
|
354
360
|
if (args?.layer_height !== undefined) {
|
|
@@ -375,6 +381,25 @@ function parseProcessOverrides(args) {
|
|
|
375
381
|
}
|
|
376
382
|
overrides.sparseInfillDensity = `${value}%`;
|
|
377
383
|
}
|
|
384
|
+
if (args?.enable_support !== undefined) {
|
|
385
|
+
if (typeof args.enable_support !== "boolean")
|
|
386
|
+
throw new Error("Invalid enable_support: expected true or false.");
|
|
387
|
+
overrides.enableSupport = args.enable_support;
|
|
388
|
+
}
|
|
389
|
+
if (args?.support_type !== undefined) {
|
|
390
|
+
const value = args.support_type;
|
|
391
|
+
if (typeof value !== "string" || !SUPPORT_TYPES.includes(value)) {
|
|
392
|
+
throw new Error(`Invalid support_type: expected one of ${SUPPORT_TYPES.join(", ")}.`);
|
|
393
|
+
}
|
|
394
|
+
overrides.supportType = value;
|
|
395
|
+
}
|
|
396
|
+
if (args?.support_threshold_angle !== undefined) {
|
|
397
|
+
const value = args.support_threshold_angle;
|
|
398
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 90) {
|
|
399
|
+
throw new Error("Invalid support_threshold_angle: expected an integer number of degrees from 0 to 90.");
|
|
400
|
+
}
|
|
401
|
+
overrides.supportThresholdAngle = value;
|
|
402
|
+
}
|
|
378
403
|
return overrides;
|
|
379
404
|
}
|
|
380
405
|
function validateSliceBambuModel(model) {
|
|
@@ -856,8 +881,10 @@ class ThreeDPrinterMCPServer {
|
|
|
856
881
|
throw new Error("FULU BambuNetwork printing requires bambu_serial, the printer host and its LAN access code: fresh printer state is checked over MQTT before every bridge print, including cloud dispatch.");
|
|
857
882
|
}
|
|
858
883
|
const isLocalBridgePrint = connectionType === "lan";
|
|
859
|
-
|
|
860
|
-
|
|
884
|
+
// Bambu Studio sends a cloud-bound printer it can reach on the LAN with record first, in the cloud
|
|
885
|
+
// session, then start_print (PrintJob.cpp). This server always has the printer's IP and access code.
|
|
886
|
+
let bridgeMethod = isLocalBridgePrint ? "net.start_local_print" : "net.start_local_print_with_record";
|
|
887
|
+
let cloudFallbackMethod = isLocalBridgePrint ? undefined : "net.start_print";
|
|
861
888
|
const { plateIndex } = options;
|
|
862
889
|
const threeMfFilename = path.basename(options.threeMFPath);
|
|
863
890
|
const projectName = threeMfFilename.replace(/\.3mf$/i, "");
|
|
@@ -898,6 +925,11 @@ class ThreeDPrinterMCPServer {
|
|
|
898
925
|
validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
|
|
899
926
|
const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
|
|
900
927
|
validatePrinterState(confirmedStatus, requirements);
|
|
928
|
+
if (cloudFallbackMethod && externalStorageState(confirmedStatus.raw) !== "normal") {
|
|
929
|
+
// Studio sends straight through the cloud when the printer has no storage for the LAN upload.
|
|
930
|
+
bridgeMethod = cloudFallbackMethod;
|
|
931
|
+
cloudFallbackMethod = undefined;
|
|
932
|
+
}
|
|
901
933
|
const wireMapping = mapping.map((value) => bridgeAmsMappingValue(value, dualNozzle));
|
|
902
934
|
const remoteFileName = uniquePrintName(options.threeMFPath);
|
|
903
935
|
const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
|
|
@@ -954,23 +986,29 @@ class ThreeDPrinterMCPServer {
|
|
|
954
986
|
const bridgeOptions = {
|
|
955
987
|
// Trusted in-process preflight immediately before the print frame.
|
|
956
988
|
beforeDispatch: async (method) => {
|
|
957
|
-
if (method !== bridgeMethod)
|
|
989
|
+
if (method !== bridgeMethod && method !== cloudFallbackMethod)
|
|
958
990
|
return;
|
|
959
991
|
assertActive();
|
|
960
992
|
validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
|
|
961
993
|
},
|
|
962
|
-
assertDispatchAllowed: (method) => { if (method === bridgeMethod)
|
|
994
|
+
assertDispatchAllowed: (method) => { if (method === bridgeMethod || method === cloudFallbackMethod)
|
|
963
995
|
assertActive(); },
|
|
964
|
-
onDispatched: (method) => { if (method === bridgeMethod)
|
|
996
|
+
onDispatched: (method) => { if (method === bridgeMethod || method === cloudFallbackMethod)
|
|
965
997
|
retainForDispatchedUpload(); },
|
|
966
998
|
};
|
|
967
999
|
let jobUpdates = [];
|
|
968
1000
|
let bridgeResult;
|
|
1001
|
+
let firstAttempt = "";
|
|
969
1002
|
if (isLocalBridgePrint) {
|
|
970
1003
|
({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint({ devId, devIp, username: "bblp", password, useSsl: true }, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive));
|
|
971
1004
|
}
|
|
972
1005
|
else {
|
|
973
|
-
|
|
1006
|
+
let firstFailure;
|
|
1007
|
+
({ result: bridgeResult, jobUpdates, firstFailure } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive, undefined, cloudFallbackMethod));
|
|
1008
|
+
if (firstFailure !== undefined) {
|
|
1009
|
+
bridgeMethod = cloudFallbackMethod;
|
|
1010
|
+
firstAttempt = ` It first tried net.start_local_print_with_record, as Bambu Studio does, which returned ${firstFailure}${describeBambuNetworkPrintError(firstFailure)}.`;
|
|
1011
|
+
}
|
|
974
1012
|
}
|
|
975
1013
|
if (typeof bridgeResult === "object" && bridgeResult !== null && bridgeResult.ok === false) {
|
|
976
1014
|
throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String(bridgeResult.error || "unknown bridge error")}`);
|
|
@@ -980,14 +1018,14 @@ class ThreeDPrinterMCPServer {
|
|
|
980
1018
|
const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
|
|
981
1019
|
.map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
|
|
982
1020
|
throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
|
|
983
|
-
(reported ? ` Plug-in reported: ${reported}.` : ""));
|
|
1021
|
+
(reported ? ` Plug-in reported: ${reported}.` : "") + firstAttempt);
|
|
984
1022
|
}
|
|
985
1023
|
assertActive();
|
|
986
1024
|
// Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
|
|
987
1025
|
bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
|
|
988
1026
|
return {
|
|
989
1027
|
status: "success",
|
|
990
|
-
message: `FULU BambuNetwork ${
|
|
1028
|
+
message: `FULU BambuNetwork ${bridgeMethod.replace(/^net\./, "")} command for ${threeMfFilename} sent successfully.${firstAttempt}`,
|
|
991
1029
|
connectionMode: "bambu_network",
|
|
992
1030
|
connectionType,
|
|
993
1031
|
bridgeMethod,
|
|
@@ -15,7 +15,14 @@ export interface ProcessOverrides {
|
|
|
15
15
|
wallLoops?: number;
|
|
16
16
|
/** e.g. "15%" */
|
|
17
17
|
sparseInfillDensity?: string;
|
|
18
|
+
enableSupport?: boolean;
|
|
19
|
+
/** normal(auto), tree(auto), normal(manual) or tree(manual) */
|
|
20
|
+
supportType?: string;
|
|
21
|
+
/** degrees, 0-90 */
|
|
22
|
+
supportThresholdAngle?: number;
|
|
18
23
|
}
|
|
24
|
+
/** Bambu Studio's and OrcaSlicer's support_type values. */
|
|
25
|
+
export declare const SUPPORT_TYPES: readonly ["normal(auto)", "tree(auto)", "normal(manual)", "tree(manual)"];
|
|
19
26
|
/** Orca/Bambu Studio process keys; profile JSON values are strings. */
|
|
20
27
|
export declare function orcaProcessOverrides(overrides: ProcessOverrides | undefined): Record<string, string>;
|
|
21
28
|
/** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
|
|
@@ -13,6 +13,16 @@ import { BambuCliProfilePreparer, assertSlicedProjectOutput, bambuCliFailureReas
|
|
|
13
13
|
import { runSlicerProcess } from '../slicer/run-slicer.js';
|
|
14
14
|
import { SlicerError, asPreparationError } from '../slicer/slicer-error.js';
|
|
15
15
|
import { customGcodesWithPauses, layerTops, machinePauseGcode, pausedLayers, readPlateGcode, readProjectCustomGcodes } from '../slicer/layer-pauses.js';
|
|
16
|
+
/** Bambu Studio's and OrcaSlicer's support_type values. */
|
|
17
|
+
export const SUPPORT_TYPES = ["normal(auto)", "tree(auto)", "normal(manual)", "tree(manual)"];
|
|
18
|
+
/** Support overrides only Bambu Studio and OrcaSlicer take; PrusaSlicer and Cura refuse them. */
|
|
19
|
+
function supportOverrideNames(overrides) {
|
|
20
|
+
return [
|
|
21
|
+
overrides?.enableSupport !== undefined ? "enable_support" : "",
|
|
22
|
+
overrides?.supportType !== undefined ? "support_type" : "",
|
|
23
|
+
overrides?.supportThresholdAngle !== undefined ? "support_threshold_angle" : "",
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
}
|
|
16
26
|
/** Orca/Bambu Studio process keys; profile JSON values are strings. */
|
|
17
27
|
export function orcaProcessOverrides(overrides) {
|
|
18
28
|
const keys = {};
|
|
@@ -22,10 +32,19 @@ export function orcaProcessOverrides(overrides) {
|
|
|
22
32
|
keys.wall_loops = String(overrides.wallLoops);
|
|
23
33
|
if (overrides?.sparseInfillDensity !== undefined)
|
|
24
34
|
keys.sparse_infill_density = overrides.sparseInfillDensity;
|
|
35
|
+
if (overrides?.enableSupport !== undefined)
|
|
36
|
+
keys.enable_support = overrides.enableSupport ? "1" : "0";
|
|
37
|
+
if (overrides?.supportType !== undefined)
|
|
38
|
+
keys.support_type = overrides.supportType;
|
|
39
|
+
if (overrides?.supportThresholdAngle !== undefined)
|
|
40
|
+
keys.support_threshold_angle = String(overrides.supportThresholdAngle);
|
|
25
41
|
return keys;
|
|
26
42
|
}
|
|
27
43
|
/** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
|
|
28
44
|
export function prusaSlicerOverrideArgs(overrides) {
|
|
45
|
+
const unsupported = supportOverrideNames(overrides);
|
|
46
|
+
if (unsupported.length)
|
|
47
|
+
throw new Error(`${unsupported.join(", ")} apply to Bambu Studio and OrcaSlicer only; set them in the PrusaSlicer config passed as slicer_profile.`);
|
|
29
48
|
const args = [];
|
|
30
49
|
if (overrides?.layerHeight !== undefined)
|
|
31
50
|
args.push('--layer-height', String(overrides.layerHeight));
|
|
@@ -1000,7 +1019,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
1000
1019
|
break;
|
|
1001
1020
|
case 'cura':
|
|
1002
1021
|
if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
|
|
1003
|
-
throw new SlicerError('preparation', 'layer_height, wall_loops and
|
|
1022
|
+
throw new SlicerError('preparation', 'layer_height, wall_loops, sparse_infill_density and the support settings are not mapped for Cura; set them in the Cura definition passed as slicer_profile.', { slicerType, slicerPath });
|
|
1004
1023
|
}
|
|
1005
1024
|
// CuraEngine CLI args are different, often requiring -s for settings
|
|
1006
1025
|
// Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-3d-printer-server",
|
|
3
3
|
"mcpName": "io.github.DMontgomery40/mcp-3d-printer-server",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.29",
|
|
5
5
|
"description": "MCP server for connecting Claude with 3D printer management systems",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"repository": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/DMontgomery40/mcp-3D-printer-server",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.2.
|
|
10
|
+
"version": "1.2.29",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "mcp-3d-printer-server",
|
|
15
|
-
"version": "1.2.
|
|
15
|
+
"version": "1.2.29",
|
|
16
16
|
"runtimeHint": "npx",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
@@ -23,8 +23,16 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
|
23
23
|
const LOCAL_CONNECT_STATUS = ["ok", "failed", "lost"];
|
|
24
24
|
// bambu_networking.hpp BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED.
|
|
25
25
|
const LOCAL_PRINT_PUBLISH_FAILED = -4030;
|
|
26
|
+
// A with-record send falls back to start_print only on its own preparation failures (the plug-in's
|
|
27
|
+
// PRINT_WR_* results, -2010 to -2140: project, upload, task record, FTPS), which come before the print
|
|
28
|
+
// command, so the printer has no job. Any other result may have reached the printer and is never
|
|
29
|
+
// replayed. Of those, Studio also stops on a file missing or too large (should_stop_without_fallback).
|
|
30
|
+
const WITH_RECORD_PREPARATION_FAILURE = (value: number) => value <= -2010 && value >= -2140 && value !== -2040 && value !== -2100;
|
|
26
31
|
// bambu_networking.hpp print errors that name a recoverable cause.
|
|
27
32
|
const BAMBU_NETWORK_PRINT_ERRORS: Record<number, string> = {
|
|
33
|
+
[-2120]: "Bambu Cloud refused to create the print task",
|
|
34
|
+
[-2130]: "FTPS upload to the printer failed",
|
|
35
|
+
[-3120]: "Bambu Cloud refused to create the print task",
|
|
28
36
|
[-4010]: "the file exceeds the 1 GB upload limit",
|
|
29
37
|
[-4020]: "FTPS upload to the printer failed",
|
|
30
38
|
[LOCAL_PRINT_PUBLISH_FAILED]: "the networking plug-in could not send the print command to the printer over MQTT",
|
|
@@ -853,10 +861,24 @@ export class BambuNetworkBridge {
|
|
|
853
861
|
}
|
|
854
862
|
}
|
|
855
863
|
|
|
856
|
-
/**
|
|
864
|
+
/**
|
|
865
|
+
* A cloud print in a cloud session, so the plug-in has the printer's state. With a fallback this is
|
|
866
|
+
* Bambu Studio's order for a cloud-bound printer it can also reach on the LAN (PrintJob.cpp,
|
|
867
|
+
* SendAttemptRoute::LanWithRecord, then ::Cloud): start_local_print_with_record, then start_print
|
|
868
|
+
* when the first failed before its print command was sent. `firstFailure` is the first method's result.
|
|
869
|
+
*/
|
|
857
870
|
async startCloudPrint(devId: string, method: string, payload: JsonObject, options: BambuNetworkBridgeOptions = {},
|
|
858
|
-
assertActive: () => void = () => {}, timing: Readonly<CloudTiming> = CLOUD_PRINT_TIMING
|
|
859
|
-
|
|
871
|
+
assertActive: () => void = () => {}, timing: Readonly<CloudTiming> = CLOUD_PRINT_TIMING, fallbackMethod?: string
|
|
872
|
+
): Promise<{ result: unknown; jobUpdates: BridgeJobUpdate[]; firstFailure?: number }> {
|
|
873
|
+
return await this.withCloudSession(devId, options, assertActive, timing, async session => {
|
|
874
|
+
const result = await this.callWithAgent(method, payload, options);
|
|
875
|
+
const value = bridgeNumericResult(result);
|
|
876
|
+
if (!fallbackMethod || value === undefined || !WITH_RECORD_PREPARATION_FAILURE(value)) return { result, jobUpdates: session.jobUpdates };
|
|
877
|
+
options.trace?.({ step: "fallback", from: method, to: fallbackMethod, result: value });
|
|
878
|
+
const params = (payload as { params?: JsonObject }).params;
|
|
879
|
+
const fallbackPayload = params ? { ...payload, params: { ...params, comments: `failed(${value})` } } : payload;
|
|
880
|
+
return { result: await this.callWithAgent(fallbackMethod, fallbackPayload, options), jobUpdates: session.jobUpdates, firstFailure: value };
|
|
881
|
+
});
|
|
860
882
|
}
|
|
861
883
|
|
|
862
884
|
/**
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { createServer as createHttpServer, type IncomingMessage, type Server as
|
|
|
34
34
|
import { randomUUID } from "node:crypto";
|
|
35
35
|
import * as THREE from 'three';
|
|
36
36
|
import { PrinterFactory } from "./printers/printer-factory.js";
|
|
37
|
-
import { STLManipulator, type BambuSliceOptions, type ProcessOverrides, type SliceOutput } from "./stl/stl-manipulator.js";
|
|
37
|
+
import { STLManipulator, SUPPORT_TYPES, type BambuSliceOptions, type ProcessOverrides, type SliceOutput } from "./stl/stl-manipulator.js";
|
|
38
38
|
import { parsePauseLayers, readPlateGcode, pausedLayers, machinePauseGcode } from "./slicer/layer-pauses.js";
|
|
39
39
|
import { CounterpartService, type BambuControlRoute, type PrinterTarget } from "./counterpart/service.js";
|
|
40
40
|
import {CAMERA_STREAM_TOOLS} from "./counterpart/camera-tools.js";
|
|
@@ -75,7 +75,7 @@ import { BambuNetworkBridge, describeBambuNetworkPrintError, type BridgeJobUpdat
|
|
|
75
75
|
import { PRINT_ROUTES, connectionForRoute, routeOf, routeOptions, writeEnvValues, type PrintRoute } from "./counterpart/route.js";
|
|
76
76
|
import { resolveStudioPrintOptions, usedNozzleCount, type CallerPrintOptions } from "./studio-print-options.js";
|
|
77
77
|
import { inspectPrintFile } from "./safety/print-file.js";
|
|
78
|
-
import { validatePrinterState } from "./safety/printer-state.js";
|
|
78
|
+
import { externalStorageState, validatePrinterState } from "./safety/printer-state.js";
|
|
79
79
|
import { uniquePrintName, withPrinterOperation, withPrintSnapshot } from "./safety/artifact.js";
|
|
80
80
|
import { checkedFile } from "./printers/bambu.js";
|
|
81
81
|
import { CONFIRMATION_UNAVAILABLE, confirmationTimedOut, confirmationTimeoutMs } from "./safety/confirmation.js";
|
|
@@ -396,6 +396,12 @@ const SLICE_TOOL_PROPERTIES = {
|
|
|
396
396
|
type: "string",
|
|
397
397
|
description: "Sparse infill density such as \"15%\" (0–100%), written over the selected process profile (Orca/Bambu Studio sparse_infill_density; PrusaSlicer --fill-density). Not mapped for Cura.",
|
|
398
398
|
},
|
|
399
|
+
enable_support: { type: "boolean", description: "Turn support generation on or off (Orca/Bambu Studio enable_support), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
|
|
400
|
+
support_type: { type: "string", enum: [...SUPPORT_TYPES], description: "Support style (Orca/Bambu Studio support_type), written over the selected process profile. Bambu Studio and OrcaSlicer only." },
|
|
401
|
+
support_threshold_angle: {
|
|
402
|
+
type: "integer", minimum: 0, maximum: 90,
|
|
403
|
+
description: "Overhang threshold in degrees for automatic supports (Orca/Bambu Studio support_threshold_angle; 0 lets the slicer decide). Bambu Studio and OrcaSlicer only.",
|
|
404
|
+
},
|
|
399
405
|
};
|
|
400
406
|
|
|
401
407
|
/**
|
|
@@ -428,7 +434,7 @@ const OUTPUT_SCHEMA_TOOLS = new Set([
|
|
|
428
434
|
"fusion_mcp_status", "fusion_mcp_call", "fusion_mcp_new_design", "fusion_mcp_export",
|
|
429
435
|
]);
|
|
430
436
|
|
|
431
|
-
/** layer_height / wall_loops / sparse_infill_density (PRD §8.4), validated before slicing. */
|
|
437
|
+
/** layer_height / wall_loops / sparse_infill_density / support settings (PRD §8.4), validated before slicing. */
|
|
432
438
|
function parseProcessOverrides(args: Record<string, unknown> | undefined): ProcessOverrides {
|
|
433
439
|
const overrides: ProcessOverrides = {};
|
|
434
440
|
if (args?.layer_height !== undefined) {
|
|
@@ -455,6 +461,24 @@ function parseProcessOverrides(args: Record<string, unknown> | undefined): Proce
|
|
|
455
461
|
}
|
|
456
462
|
overrides.sparseInfillDensity = `${value}%`;
|
|
457
463
|
}
|
|
464
|
+
if (args?.enable_support !== undefined) {
|
|
465
|
+
if (typeof args.enable_support !== "boolean") throw new Error("Invalid enable_support: expected true or false.");
|
|
466
|
+
overrides.enableSupport = args.enable_support;
|
|
467
|
+
}
|
|
468
|
+
if (args?.support_type !== undefined) {
|
|
469
|
+
const value = args.support_type;
|
|
470
|
+
if (typeof value !== "string" || !(SUPPORT_TYPES as readonly string[]).includes(value)) {
|
|
471
|
+
throw new Error(`Invalid support_type: expected one of ${SUPPORT_TYPES.join(", ")}.`);
|
|
472
|
+
}
|
|
473
|
+
overrides.supportType = value;
|
|
474
|
+
}
|
|
475
|
+
if (args?.support_threshold_angle !== undefined) {
|
|
476
|
+
const value = args.support_threshold_angle;
|
|
477
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 90) {
|
|
478
|
+
throw new Error("Invalid support_threshold_angle: expected an integer number of degrees from 0 to 90.");
|
|
479
|
+
}
|
|
480
|
+
overrides.supportThresholdAngle = value;
|
|
481
|
+
}
|
|
458
482
|
return overrides;
|
|
459
483
|
}
|
|
460
484
|
|
|
@@ -1050,8 +1074,10 @@ class ThreeDPrinterMCPServer {
|
|
|
1050
1074
|
throw new Error("FULU BambuNetwork printing requires bambu_serial, the printer host and its LAN access code: fresh printer state is checked over MQTT before every bridge print, including cloud dispatch.");
|
|
1051
1075
|
}
|
|
1052
1076
|
const isLocalBridgePrint = connectionType === "lan";
|
|
1053
|
-
|
|
1054
|
-
|
|
1077
|
+
// Bambu Studio sends a cloud-bound printer it can reach on the LAN with record first, in the cloud
|
|
1078
|
+
// session, then start_print (PrintJob.cpp). This server always has the printer's IP and access code.
|
|
1079
|
+
let bridgeMethod = isLocalBridgePrint ? "net.start_local_print" : "net.start_local_print_with_record";
|
|
1080
|
+
let cloudFallbackMethod = isLocalBridgePrint ? undefined : "net.start_print";
|
|
1055
1081
|
const { plateIndex } = options;
|
|
1056
1082
|
const threeMfFilename = path.basename(options.threeMFPath);
|
|
1057
1083
|
const projectName = threeMfFilename.replace(/\.3mf$/i, "");
|
|
@@ -1092,6 +1118,11 @@ class ThreeDPrinterMCPServer {
|
|
|
1092
1118
|
validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
|
|
1093
1119
|
const confirmedStatus = await bambu.getSafetyStatus(devIp, devId, password);
|
|
1094
1120
|
validatePrinterState(confirmedStatus, requirements);
|
|
1121
|
+
if (cloudFallbackMethod && externalStorageState(confirmedStatus.raw) !== "normal") {
|
|
1122
|
+
// Studio sends straight through the cloud when the printer has no storage for the LAN upload.
|
|
1123
|
+
bridgeMethod = cloudFallbackMethod;
|
|
1124
|
+
cloudFallbackMethod = undefined;
|
|
1125
|
+
}
|
|
1095
1126
|
const wireMapping = mapping!.map((value) => bridgeAmsMappingValue(value, dualNozzle));
|
|
1096
1127
|
const remoteFileName = uniquePrintName(options.threeMFPath);
|
|
1097
1128
|
const checkedName = remoteFileName.replace(/(?:\.gcode)?\.3mf$/i, "");
|
|
@@ -1148,22 +1179,28 @@ class ThreeDPrinterMCPServer {
|
|
|
1148
1179
|
const bridgeOptions = {
|
|
1149
1180
|
// Trusted in-process preflight immediately before the print frame.
|
|
1150
1181
|
beforeDispatch: async (method: string) => {
|
|
1151
|
-
if (method !== bridgeMethod) return;
|
|
1182
|
+
if (method !== bridgeMethod && method !== cloudFallbackMethod) return;
|
|
1152
1183
|
assertActive();
|
|
1153
1184
|
validatePrinterState(await bambu.getSafetyStatus(devIp, devId, password), requirements);
|
|
1154
1185
|
},
|
|
1155
|
-
assertDispatchAllowed: (method: string) => { if (method === bridgeMethod) assertActive(); },
|
|
1156
|
-
onDispatched: (method: string) => { if (method === bridgeMethod) retainForDispatchedUpload(); },
|
|
1186
|
+
assertDispatchAllowed: (method: string) => { if (method === bridgeMethod || method === cloudFallbackMethod) assertActive(); },
|
|
1187
|
+
onDispatched: (method: string) => { if (method === bridgeMethod || method === cloudFallbackMethod) retainForDispatchedUpload(); },
|
|
1157
1188
|
};
|
|
1158
1189
|
let jobUpdates: BridgeJobUpdate[] = [];
|
|
1159
1190
|
let bridgeResult: unknown;
|
|
1191
|
+
let firstAttempt = "";
|
|
1160
1192
|
if (isLocalBridgePrint) {
|
|
1161
1193
|
({ result: bridgeResult, jobUpdates } = await this.bambuNetwork.startLocalPrint(
|
|
1162
1194
|
{ devId, devIp, username: "bblp", password, useSsl: true },
|
|
1163
1195
|
bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive
|
|
1164
1196
|
));
|
|
1165
1197
|
} else {
|
|
1166
|
-
|
|
1198
|
+
let firstFailure: number | undefined;
|
|
1199
|
+
({ result: bridgeResult, jobUpdates, firstFailure } = await this.bambuNetwork.startCloudPrint(devId, bridgeMethod, { client_job_id: clientJobId, params }, bridgeOptions, assertActive, undefined, cloudFallbackMethod));
|
|
1200
|
+
if (firstFailure !== undefined) {
|
|
1201
|
+
bridgeMethod = cloudFallbackMethod!;
|
|
1202
|
+
firstAttempt = ` It first tried net.start_local_print_with_record, as Bambu Studio does, which returned ${firstFailure}${describeBambuNetworkPrintError(firstFailure)}.`;
|
|
1203
|
+
}
|
|
1167
1204
|
}
|
|
1168
1205
|
if (typeof bridgeResult === "object" && bridgeResult !== null && (bridgeResult as { ok?: unknown }).ok === false) {
|
|
1169
1206
|
throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} failed: ${String((bridgeResult as { error?: unknown }).error || "unknown bridge error")}`);
|
|
@@ -1173,14 +1210,14 @@ class ThreeDPrinterMCPServer {
|
|
|
1173
1210
|
const reported = jobUpdates.filter((update) => update.code !== 0 || update.msg).slice(-3)
|
|
1174
1211
|
.map((update) => `status ${update.status}, code ${update.code}${update.msg ? `: ${update.msg}` : ""}`).join("; ");
|
|
1175
1212
|
throw new Error(`FULU BambuNetwork bridge method ${bridgeMethod} returned non-zero result ${value}${describeBambuNetworkPrintError(value)}.` +
|
|
1176
|
-
(reported ? ` Plug-in reported: ${reported}.` : ""));
|
|
1213
|
+
(reported ? ` Plug-in reported: ${reported}.` : "") + firstAttempt);
|
|
1177
1214
|
}
|
|
1178
1215
|
assertActive();
|
|
1179
1216
|
// Bambu's plug-in placed the file, so a resume re-reads it if it can and otherwise asks a person.
|
|
1180
1217
|
bambu.recordCheckedJob(devIp, devId, remoteFileName, requirements, resumeFile, false);
|
|
1181
1218
|
return {
|
|
1182
1219
|
status: "success",
|
|
1183
|
-
message: `FULU BambuNetwork ${
|
|
1220
|
+
message: `FULU BambuNetwork ${bridgeMethod.replace(/^net\./, "")} command for ${threeMfFilename} sent successfully.${firstAttempt}`,
|
|
1184
1221
|
connectionMode: "bambu_network",
|
|
1185
1222
|
connectionType,
|
|
1186
1223
|
bridgeMethod,
|
|
@@ -36,6 +36,23 @@ export interface ProcessOverrides {
|
|
|
36
36
|
wallLoops?: number;
|
|
37
37
|
/** e.g. "15%" */
|
|
38
38
|
sparseInfillDensity?: string;
|
|
39
|
+
enableSupport?: boolean;
|
|
40
|
+
/** normal(auto), tree(auto), normal(manual) or tree(manual) */
|
|
41
|
+
supportType?: string;
|
|
42
|
+
/** degrees, 0-90 */
|
|
43
|
+
supportThresholdAngle?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Bambu Studio's and OrcaSlicer's support_type values. */
|
|
47
|
+
export const SUPPORT_TYPES = ["normal(auto)", "tree(auto)", "normal(manual)", "tree(manual)"] as const;
|
|
48
|
+
|
|
49
|
+
/** Support overrides only Bambu Studio and OrcaSlicer take; PrusaSlicer and Cura refuse them. */
|
|
50
|
+
function supportOverrideNames(overrides: ProcessOverrides | undefined): string[] {
|
|
51
|
+
return [
|
|
52
|
+
overrides?.enableSupport !== undefined ? "enable_support" : "",
|
|
53
|
+
overrides?.supportType !== undefined ? "support_type" : "",
|
|
54
|
+
overrides?.supportThresholdAngle !== undefined ? "support_threshold_angle" : "",
|
|
55
|
+
].filter(Boolean);
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
/** Orca/Bambu Studio process keys; profile JSON values are strings. */
|
|
@@ -44,11 +61,16 @@ export function orcaProcessOverrides(overrides: ProcessOverrides | undefined): R
|
|
|
44
61
|
if (overrides?.layerHeight !== undefined) keys.layer_height = String(overrides.layerHeight);
|
|
45
62
|
if (overrides?.wallLoops !== undefined) keys.wall_loops = String(overrides.wallLoops);
|
|
46
63
|
if (overrides?.sparseInfillDensity !== undefined) keys.sparse_infill_density = overrides.sparseInfillDensity;
|
|
64
|
+
if (overrides?.enableSupport !== undefined) keys.enable_support = overrides.enableSupport ? "1" : "0";
|
|
65
|
+
if (overrides?.supportType !== undefined) keys.support_type = overrides.supportType;
|
|
66
|
+
if (overrides?.supportThresholdAngle !== undefined) keys.support_threshold_angle = String(overrides.supportThresholdAngle);
|
|
47
67
|
return keys;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
/** PrusaSlicer/Slic3r command-line options, which override the loaded config. */
|
|
51
71
|
export function prusaSlicerOverrideArgs(overrides: ProcessOverrides | undefined): string[] {
|
|
72
|
+
const unsupported = supportOverrideNames(overrides);
|
|
73
|
+
if (unsupported.length) throw new Error(`${unsupported.join(", ")} apply to Bambu Studio and OrcaSlicer only; set them in the PrusaSlicer config passed as slicer_profile.`);
|
|
52
74
|
const args: string[] = [];
|
|
53
75
|
if (overrides?.layerHeight !== undefined) args.push('--layer-height', String(overrides.layerHeight));
|
|
54
76
|
if (overrides?.wallLoops !== undefined) args.push('--perimeters', String(overrides.wallLoops));
|
|
@@ -1295,7 +1317,7 @@ export class STLManipulator extends EventEmitter {
|
|
|
1295
1317
|
case 'cura':
|
|
1296
1318
|
if (Object.keys(orcaProcessOverrides(processOverrides)).length) {
|
|
1297
1319
|
throw new SlicerError('preparation',
|
|
1298
|
-
'layer_height, wall_loops and
|
|
1320
|
+
'layer_height, wall_loops, sparse_infill_density and the support settings are not mapped for Cura; set them in the Cura definition passed as slicer_profile.',
|
|
1299
1321
|
{ slicerType, slicerPath });
|
|
1300
1322
|
}
|
|
1301
1323
|
// CuraEngine CLI args are different, often requiring -s for settings
|