mcp-3d-printer-server 1.2.25 → 1.2.27

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 (95) hide show
  1. package/CHANGELOG.md +67 -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 +211 -0
  7. package/dist/bambu-network-bridge.js +918 -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 +514 -70
  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 +68 -8
  53. package/dist/safety/printer-state.js +187 -40
  54. package/dist/slicer/bambu-cli.d.ts +2 -0
  55. package/dist/slicer/bambu-cli.js +7 -5
  56. package/dist/slicer/layer-pauses.d.ts +47 -0
  57. package/dist/slicer/layer-pauses.js +130 -0
  58. package/dist/stl/stl-manipulator.d.ts +1 -0
  59. package/dist/stl/stl-manipulator.js +40 -0
  60. package/dist/studio-print-options.d.ts +57 -0
  61. package/dist/studio-print-options.js +179 -0
  62. package/dist/tool-annotations.d.ts +25 -0
  63. package/dist/tool-annotations.js +58 -0
  64. package/package.json +27 -4
  65. package/server.json +2 -2
  66. package/src/ams-mapping.ts +132 -0
  67. package/src/bambu-network-bridge.ts +1106 -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 +528 -83
  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 +192 -41
  92. package/src/slicer/bambu-cli.ts +8 -5
  93. package/src/slicer/layer-pauses.ts +143 -0
  94. package/src/stl/stl-manipulator.ts +45 -0
  95. package/src/studio-print-options.ts +201 -0
@@ -0,0 +1,82 @@
1
+ /** counterpart_print_route: how this server reaches the printer for prints and controls (PRD §8.4). */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * lan: the local network through FULU's BambuNetwork bridge (or the configured X2D native or Bambu Connect mode).
7
+ * cloud: Bambu Cloud through FULU's bridge.
8
+ * developer: the local network straight to the printer (lan_mqtt_ftps), for a printer in LAN Only + Developer Mode.
9
+ */
10
+ export type PrintRoute = "lan" | "cloud" | "developer";
11
+ export const PRINT_ROUTES: readonly PrintRoute[] = ["lan", "cloud", "developer"];
12
+ export interface Connection { mode: string; type: string }
13
+
14
+ /** One connection a person can choose, as counterpart_print_route reports it. */
15
+ export interface RouteOption {
16
+ id: PrintRoute;
17
+ label: string;
18
+ available: boolean;
19
+ /** Why it can't be chosen here, when it can't. */
20
+ reason?: string;
21
+ /** What Pause, Resume and Stop do on this connection. */
22
+ controls: string;
23
+ }
24
+
25
+ /** Modes that are their own local-network route and stay as configured. */
26
+ const OWN_LOCAL_MODES = new Set(["bambu_native", "bambu_connect"]);
27
+
28
+ export function routeOf(connection: Connection): PrintRoute {
29
+ if (connection.mode === "bambu_network") return connection.type === "cloud" ? "cloud" : "lan";
30
+ if (connection.mode === "lan_mqtt_ftps") return "developer";
31
+ return "lan";
32
+ }
33
+
34
+ /** Every connection, and whether this server can use it (bridgeConfigured: BAMBU_NETWORK_BRIDGE_COMMAND is set). */
35
+ export function routeOptions(current: Connection, bridgeConfigured: boolean): RouteOption[] {
36
+ const signedControls = "Uses the configured authenticated connection for Print, Pause, Resume and Stop.";
37
+ const needsBridge = "Needs FULU's BambuNetwork bridge (BAMBU_NETWORK_BRIDGE_COMMAND).";
38
+ const ownLocal = OWN_LOCAL_MODES.has(current.mode);
39
+ return [
40
+ { id: "lan", label: "Local network", available: bridgeConfigured || ownLocal, ...(bridgeConfigured || ownLocal ? {} : { reason: needsBridge }), controls: signedControls },
41
+ { id: "cloud", label: "Bambu Cloud", available: bridgeConfigured, ...(bridgeConfigured ? {} : { reason: needsBridge }), controls: signedControls },
42
+ { id: "developer", label: "Local network, Developer Mode", available: true,
43
+ controls: "Pause, Resume and Stop go straight to the printer. Turn on LAN Only and Developer Mode on the printer first; Bambu Cloud and Bambu Handy then stop working for it." },
44
+ ];
45
+ }
46
+
47
+ /**
48
+ * The configured defaults for a route. Cloud and the local network use FULU's BambuNetwork; an X2D native or
49
+ * Bambu Connect setup keeps its own mode for the local network. Developer Mode is the direct LAN route.
50
+ */
51
+ export function connectionForRoute(route: PrintRoute, current: Connection, bridgeConfigured = true): Connection {
52
+ const option = routeOptions(current, bridgeConfigured).find(item => item.id === route);
53
+ if (!option?.available) throw new Error(`The ${option?.label ?? route} connection isn't available here. ${option?.reason ?? ""}`.trim());
54
+ if (route === "cloud") return { mode: "bambu_network", type: "cloud" };
55
+ if (route === "developer") return { mode: "lan_mqtt_ftps", type: "lan" };
56
+ return OWN_LOCAL_MODES.has(current.mode) ? current : { mode: "bambu_network", type: "lan" };
57
+ }
58
+
59
+ /** Set KEY=value lines in a dotenv file, keeping every other line and the file's permissions. */
60
+ export function writeEnvValues(file: string, values: Record<string, string>): void {
61
+ const original = fs.readFileSync(file, "utf8");
62
+ const lines = original.split("\n");
63
+ for (const [key, value] of Object.entries(values)) {
64
+ const pattern = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`);
65
+ let found = false;
66
+ for (let index = lines.length - 1; index >= 0; index--) {
67
+ if (!pattern.test(lines[index]!)) continue;
68
+ if (!found) { lines[index] = `${key}=${value}`; found = true; }
69
+ // dotenv lets a later line win: keep one line per key, the one written here.
70
+ else lines.splice(index, 1);
71
+ }
72
+ if (!found) {
73
+ if (lines.length && lines[lines.length - 1] === "") lines.splice(lines.length - 1, 0, `${key}=${value}`);
74
+ else lines.push(`${key}=${value}`);
75
+ }
76
+ }
77
+ const mode = fs.statSync(file).mode & 0o777;
78
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.tmp`);
79
+ fs.writeFileSync(temporary, lines.join("\n"), { mode });
80
+ fs.chmodSync(temporary, mode);
81
+ fs.renameSync(temporary, file);
82
+ }
@@ -7,7 +7,10 @@ import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { isAxiosError, type AxiosInstance } from "axios";
9
9
  import type { PrinterImplementation } from "../types.js";
10
- import { GenericPrinterImplementation, type SnapshotCandidate } from "../printers/generic-base.js";
10
+ import { GenericPrinterImplementation, type SnapshotCandidate, type CameraCandidate } from "../printers/generic-base.js";
11
+ import {CameraStreams, cameraNativeAvailable, type FrameOptions} from "./camera-stream.js";
12
+ import {openHttpCameraSource} from "./camera-sources.js";
13
+ import {cameraNumber} from "./camera-tools.js";
11
14
  import { BambuImplementation } from "../printers/bambu.js";
12
15
  import {
13
16
  COUNTERPART_CONTRACT,
@@ -23,6 +26,27 @@ import {
23
26
  type PrinterKind,
24
27
  } from "./contract.js";
25
28
  import { mapBambu } from "./progress.js";
29
+ import {
30
+ counterpartSlots,
31
+ externalSpools,
32
+ normalizePrinterFilamentInventory,
33
+ type FilamentProfileSource,
34
+ type PrinterFilamentInventory,
35
+ } from "../printers/bambu-filaments.js";
36
+
37
+ /**
38
+ * The Bambu print-control dispatch for the configured connection (FULU's
39
+ * BambuNetwork); undefined keeps LAN MQTT. Resume's dispatch receives the
40
+ * checks to repeat before it sends.
41
+ */
42
+ export type BambuControlRoute = (command: "stop" | "pause" | "resume") =>
43
+ ((assertActive?: () => void, beforeDispatch?: () => Promise<void>) => Promise<any>) | undefined;
44
+
45
+ /** Bambu inventory hints: the model and nozzle select model-specific filament profiles. */
46
+ export interface BambuFilamentOptions {
47
+ bambuModel?: string;
48
+ nozzleDiameter?: string;
49
+ }
26
50
 
27
51
  export const DEFAULT_SNAPSHOT_TIMEOUT_MS = 8_000;
28
52
  const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024;
@@ -57,12 +81,17 @@ function overrideSnapshotUrl(): string | undefined {
57
81
  }
58
82
 
59
83
  export class CounterpartService {
84
+ private readonly streams = new CameraStreams();
85
+ private readonly generations = new Map<string, number>();
86
+ private closed = false;
60
87
  constructor(
61
88
  private readonly implementation: (type: string) => PrinterImplementation,
62
89
  private readonly http: AxiosInstance,
63
90
  private readonly server: { name: string; version: string },
64
91
  /** The slicer filament profile for a slot's material (undefined: the configured default). */
65
- private readonly filamentProfile?: (material: string | undefined) => Promise<string | undefined>
92
+ private readonly filamentProfile?: (material: string | undefined) => Promise<string | undefined>,
93
+ /** The slicer whose BBL/filament profiles resolve Bambu trays by tray_info_idx. */
94
+ private readonly bambuProfileSource: () => FilamentProfileSource = () => ({})
66
95
  ) {}
67
96
 
68
97
  private generic(target: PrinterTarget): GenericPrinterImplementation | undefined {
@@ -99,10 +128,10 @@ export class CounterpartService {
99
128
  const status = await this.bambu(target).getStatus(target.host, target.port, target.apiKey).catch(() => undefined);
100
129
  const model = typeof status?.model === "string" && status.model !== "Unknown" ? status.model : process.env.BAMBU_MODEL?.trim();
101
130
  identity = { model: model || undefined, serial: typeof status?.serial === "string" && status.serial ? status.serial : undefined };
102
- // This server has no Bambu camera, pause or resume path; bambu-printer-mcp does.
131
+ // This server has no Bambu camera path; bambu-printer-mcp does.
103
132
  capabilities = {
104
- progress: true, layers: true, camera: overrideSnapshotUrl() ? "http-snapshot" : "none", filaments: "none",
105
- pause: false, resume: false, cancel: true, print3mf: true, gcode: true,
133
+ progress: true, layers: true, camera: overrideSnapshotUrl() ? "http-snapshot" : "none", filaments: "ams",
134
+ pause: true, resume: true, cancel: true, print3mf: true, gcode: true,
106
135
  };
107
136
  } else {
108
137
  const { host, port, apiKey } = target;
@@ -117,6 +146,9 @@ export class CounterpartService {
117
146
  pause: pauseResume, resume: pauseResume, cancel: true, print3mf: false, gcode: true,
118
147
  };
119
148
  }
149
+ const streamCandidates = await this.cameraCandidates(target).catch(() => []);
150
+ capabilities.cameraStream = await cameraNativeAvailable() && streamCandidates.length > 0 &&
151
+ (capabilities.camera !== "none" || streamCandidates.some(candidate => candidate.configured));
120
152
  return compact({
121
153
  contract: COUNTERPART_CONTRACT,
122
154
  server: this.server,
@@ -141,11 +173,31 @@ export class CounterpartService {
141
173
  return "none";
142
174
  }
143
175
 
144
- async filaments(target: PrinterTarget): Promise<{ slots: FilamentSlot[] }> {
145
- const generic = this.generic(target);
146
- if (!generic) {
147
- throw new Error("Unsupported: Bambu AMS and external-spool inventory is read by bambu-printer-mcp's get_printer_filaments.");
176
+ /**
177
+ * bambu-printer-mcp's get_printer_filaments for Bambu: the live AMS inventory
178
+ * from MQTT. The first push from an idle printer can be sparse (model and
179
+ * modules); AMS data follows, so an empty live inventory is read once more.
180
+ */
181
+ async bambuFilaments(target: PrinterTarget, options: BambuFilamentOptions = {}): Promise<PrinterFilamentInventory & { slots: FilamentSlot[] }> {
182
+ const bambu = this.bambu(target);
183
+ const source = this.bambuProfileSource();
184
+ const read = async () => {
185
+ const status = await bambu.getStatus(target.host, target.port, target.apiKey);
186
+ if (status?.connected === false) throw new Error(`Could not read the printer's filament: ${status?.error ?? "not connected"}`);
187
+ return { status, inventory: normalizePrinterFilamentInventory(status, source, options.bambuModel, options.nozzleDiameter) };
188
+ };
189
+ let { status, inventory } = await read();
190
+ if (inventory.trays.length === 0) {
191
+ await new Promise((resolve) => setTimeout(resolve, 1500));
192
+ ({ status, inventory } = await read());
148
193
  }
194
+ const external = externalSpools(status, source, options.bambuModel, options.nozzleDiameter);
195
+ return { slots: counterpartSlots(inventory, external), ...inventory };
196
+ }
197
+
198
+ async filaments(target: PrinterTarget, options: BambuFilamentOptions = {}): Promise<{ slots: FilamentSlot[] }> {
199
+ const generic = this.generic(target);
200
+ if (!generic) return this.bambuFilaments(target, options);
149
201
  const slots = await generic.counterpartFilaments(target.host, target.port, target.apiKey);
150
202
  const profiled = await Promise.all(slots.map(async (slot) => {
151
203
  const profile = await this.filamentProfile?.(slot.material).catch(() => undefined);
@@ -154,15 +206,19 @@ export class CounterpartService {
154
206
  return compact({ slots: profiled });
155
207
  }
156
208
 
157
- async pause(target: PrinterTarget) {
209
+ /** Bambu: over the route that printed (route), else LAN MQTT. */
210
+ async pause(target: PrinterTarget, route?: BambuControlRoute) {
158
211
  const generic = this.generic(target);
159
- if (!generic) throw new Error("Unsupported: pause_print for Bambu printers is provided by bambu-printer-mcp.");
212
+ if (!generic) return this.bambu(target).pauseJob(target.host, target.port, target.apiKey, route?.("pause"));
160
213
  return generic.pauseJob(target.host, target.port, target.apiKey);
161
214
  }
162
215
 
163
- async resume(target: PrinterTarget) {
216
+ async resume(target: PrinterTarget, route?: BambuControlRoute) {
164
217
  const generic = this.generic(target);
165
- if (!generic) throw new Error("Unsupported: resume_print for Bambu printers is provided by bambu-printer-mcp.");
218
+ if (!generic) {
219
+ const dispatch = route?.("resume");
220
+ return this.bambu(target).resumeJob(target.host, target.port, target.apiKey, dispatch);
221
+ }
166
222
  return generic.resumeJob(target.host, target.port, target.apiKey);
167
223
  }
168
224
 
@@ -172,6 +228,14 @@ export class CounterpartService {
172
228
  if (options.savePath !== undefined && (!path.isAbsolute(options.savePath) || options.savePath.includes("\0"))) {
173
229
  throw new Error("Invalid save_path: expected an absolute file path.");
174
230
  }
231
+ const active = this.streams.get(this.cameraKey(target));
232
+ if (active && !active.stopped) {
233
+ const jpeg = await active.latest(Math.min(timeoutMs, 10000));
234
+ if (!jpeg) throw new Error("The active camera stream has not produced a frame.");
235
+ if (options.savePath) await fs.writeFile(options.savePath, jpeg);
236
+ return compact({status: "success", format: "image/jpeg", sizeBytes: jpeg.length, base64: jpeg.toString("base64"),
237
+ savedTo: options.savePath, capturedAt: new Date().toISOString(), source: active.transport}) as CameraSnapshotResult;
238
+ }
175
239
  const deadline = Date.now() + timeoutMs;
176
240
  const override = overrideSnapshotUrl();
177
241
  let candidates: SnapshotCandidate[];
@@ -207,6 +271,78 @@ export class CounterpartService {
207
271
  throw new Error(`No camera snapshot within ${timeoutMs} ms. Tried ${failures.length ? failures.join("; ") : "no URL before the deadline"}.`);
208
272
  }
209
273
 
274
+ private cameraKey(target: PrinterTarget): string {
275
+ return JSON.stringify([kindOf(target.type), target.host, target.port, target.type.toLowerCase() === "bambu" ? target.apiKey.split(":")[0] : ""]);
276
+ }
277
+
278
+ private async cameraCandidates(target: PrinterTarget): Promise<CameraCandidate[]> {
279
+ const override = overrideSnapshotUrl();
280
+ const candidates: CameraCandidate[] = override ? [{url: override, source: "env-override", configured: true, transport: "http-snapshot"}]
281
+ : await this.generic(target)?.cameraCandidates(target.host, target.port, target.apiKey) ?? [];
282
+ // Choose one configured camera; unsupported formats cannot select another camera.
283
+ const candidate = candidates[0];
284
+ if (!candidate) return [];
285
+ try {
286
+ let supportedURL = true;
287
+ try { this.resolve(candidate.url, target); } catch { supportedURL = false; }
288
+ if (!supportedURL || candidate.unsupportedStream || /\.m3u8(?:[?#]|$)/i.test(candidate.url)) {
289
+ if (!candidate.snapshotUrl) return [];
290
+ this.resolve(candidate.snapshotUrl, target);
291
+ return [{...candidate, url: candidate.snapshotUrl, transport: "http-snapshot"}];
292
+ }
293
+ this.resolve(candidate.url, target);
294
+ if (candidate.snapshotUrl) this.resolve(candidate.snapshotUrl, target);
295
+ return [candidate];
296
+ } catch { return []; }
297
+ }
298
+
299
+ async cameraStreamStart(target: PrinterTarget, options: {fps?: number; idleTimeoutMs?: number} = {}) {
300
+ const fps = cameraNumber(options.fps, 1, 0.2, 2, "fps");
301
+ const idleTimeoutMs = cameraNumber(options.idleTimeoutMs, 30000, 5000, 120000, "idle_timeout_ms", true);
302
+ const key = this.cameraKey(target), generation = this.generations.get(key) ?? 0;
303
+ if (!await cameraNativeAvailable()) return {status: "not-supported", reason: "runtime"};
304
+ if (this.closed || generation !== (this.generations.get(key) ?? 0)) throw new Error("Camera stream start was cancelled.");
305
+ const existing = this.streams.get(key);
306
+ if (existing && !existing.stopped) {
307
+ existing.touch(idleTimeoutMs);
308
+ return {status: "running", transport: existing.transport, fps: existing.fps, startedAt: existing.startedAt};
309
+ }
310
+ const candidates = await this.cameraCandidates(target);
311
+ if (this.closed || generation !== (this.generations.get(key) ?? 0)) throw new Error("Camera stream start was cancelled.");
312
+ if (!candidates.length) throw new Error("No camera is configured for this printer. Existing snapshot configuration is used for streaming.");
313
+ const candidate = candidates[0];
314
+ const urls = (raw: string) => this.resolve(raw, target).map(url => ({url, headers: this.cameraHeaders(url, target)}));
315
+ const plan = {urls: urls(candidate.url), snapshots: candidate.snapshotUrl ? urls(candidate.snapshotUrl) : undefined, mode: candidate.transport};
316
+ const {stream, status} = this.streams.start(key, {transport: "http", fps, idleTimeoutMs, skipFirstFrame: false,
317
+ open: handlers => openHttpCameraSource(this.http, plan, fps, handlers)});
318
+ return {status, transport: stream.transport, fps: stream.fps, startedAt: stream.startedAt};
319
+ }
320
+
321
+ async cameraStreamFrame(target: PrinterTarget, options: FrameOptions = {}) {
322
+ const sinceSeq = cameraNumber(options.sinceSeq, 0, 0, Number.MAX_SAFE_INTEGER, "since_seq", true);
323
+ const waitMs = cameraNumber(options.waitMs, 0, 0, 10000, "wait_ms", true);
324
+ const minChange = cameraNumber(options.minChange, 0.02, 0, 1, "min_change");
325
+ const maxWidth = options.maxWidth === undefined ? undefined : cameraNumber(options.maxWidth, 300, 1, 4096, "max_width", true);
326
+ const maxBytes = options.maxBytes === undefined ? undefined : cameraNumber(options.maxBytes, 42000, 4096, 5000000, "max_bytes", true);
327
+ if (options.changedOnly !== undefined && typeof options.changedOnly !== "boolean") throw new Error("changed_only must be a boolean");
328
+ return this.streams.get(this.cameraKey(target))?.frame({...options, sinceSeq, waitMs, minChange, maxWidth, maxBytes}) ?? {status: "stopped", seq: 0};
329
+ }
330
+
331
+ cameraStreamStop(target: PrinterTarget) {
332
+ const key = this.cameraKey(target);
333
+ this.generations.set(key, (this.generations.get(key) ?? 0) + 1);
334
+ this.streams.stop(key);
335
+ return {status: "stopped"};
336
+ }
337
+
338
+ close(): void { this.closed = true; this.streams.stopAll(); }
339
+
340
+ private cameraHeaders(url: string, target: PrinterTarget): Record<string, string> {
341
+ const host = target.host.includes(":") && !target.host.startsWith("[") ? `[${target.host}]` : target.host;
342
+ const sameOrigin = new URL(url).origin === new URL(`http://${host}:${target.port}`).origin;
343
+ return sameOrigin && target.apiKey && target.type.toLowerCase() !== "bambu" ? {"X-Api-Key": target.apiKey} : {};
344
+ }
345
+
210
346
  /**
211
347
  * Snapshot URLs are written from the printer host's point of view: relative
212
348
  * paths and loopback hosts mean the printer itself.
@@ -233,10 +369,8 @@ export class CounterpartService {
233
369
  }
234
370
 
235
371
  private async fetchJpeg(url: string, target: PrinterTarget, timeout: number): Promise<Buffer> {
236
- const parsed = new URL(url);
237
372
  // The API key goes only to the printer's own API origin, never to another host.
238
- const sameOrigin = parsed.origin === new URL(`http://${target.host}:${target.port}`).origin;
239
- const headers: Record<string, string> = sameOrigin && target.apiKey && target.type.toLowerCase() !== "bambu" ? { "X-Api-Key": target.apiKey } : {};
373
+ const headers = this.cameraHeaders(url, target);
240
374
  const response = await this.http.get(url, {
241
375
  headers,
242
376
  responseType: "arraybuffer",
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Manufacturing guidance for agents that design printable models: David Montgomery's FDM design
3
+ * prompt pack (3 November 2026), verbatim. Served as MCP prompts and a resource; the other printer
4
+ * MCP (bambu-printer-mcp / mcp-3D-printer-server) serves the same text. Clients choose when to
5
+ * attach each piece.
6
+ */
7
+
8
+ export interface DesignPrompt { name: string; title: string; description: string; text: string }
9
+
10
+ export const FDM_CORE = `Design the finished object the user wants, with the manufacturing plan as part of the design. Preserve its function, fit, and visual identity. Use your design ability freely to make that outcome practical through geometry, orientation, materials, color placement, supports, and assembly.
11
+
12
+ Use the actual printer and material context: nozzle arrangement and filament routing, build volume, nozzle and line widths, layer profile, cooling, available filaments, and relevant successful prints or calibration. Missing information is unknown, not a capability the printer automatically has. Use reasonable, labeled assumptions for reversible choices; ask only when an unknown materially changes the result.
13
+
14
+ Before committing to detailed geometry, choose a plausible way to manufacture and assemble it. For a consequential tradeoff, compare the strongest one-piece approach with a meaningfully different orientation or split. Simple parts do not need a design tournament. Judge total effort to obtain a good finished object: printing, consumed filament, likely rework, support cleanup, assembly, and extra hardware. Respect the user's priorities instead of applying fixed numerical weights.
15
+
16
+ Keep assembled orientation and print orientation distinct. Preserve critical dimensions and mating surfaces. Account for measurement uncertainty and fit clearance separately; a scan or printed measuring tool does not establish its own accuracy. Use the smallest useful fit sample when uncertainty could otherwise waste a substantial print.
17
+
18
+ Evaluate printability locally. Distinguish a progressively supported overhang, a bridge anchored at both ends, and a disconnected island. State overhang angles from vertical: 0 degrees is a vertical wall; 90 degrees is a horizontal underside. Convert conventions before setting slicer angles. Use relevant measured capability ahead of generic rules. A 45-degree threshold is not a universal support requirement, and a steep surface is not automatically unprintable. Judge bridge length along the actual unsupported extrusion path, with its anchors, cooling, material, and required underside quality. A feature that survives printing may still miss a cosmetic or dimensional requirement.
19
+
20
+ Resolve a difficult region with the least disruptive effective change: a better orientation, local geometry, suitable process settings, accessible support, a deliberate breakaway feature, or a split. Supports are an ordinary manufacturing option. Preserve attractive curves and useful complexity when modest support or cleanup achieves them. Check support removal and tool access, especially inside cavities. Avoid changing the whole object to solve a local problem.
21
+
22
+ Choose layer orientation, material, walls, and joints for the actual loads and environment. Check the load path, flexing features, fastener access, and installation sequence. A reduction in supports or color changes must not silently weaken the part or defeat its fit.
23
+
24
+ On a shared-nozzle multicolor setup, reason about filament transitions across layers, including support-interface material. Total color count alone is a poor cost estimate. Consider color placement, shallow inlays, separately printed accents, and part orientation while preserving the requested appearance. Separate bodies on one layer-printed plate can still cause repeated swaps. Use the slicer's actual schedule and estimates when available; do not invent a fixed cost per color change.
25
+
26
+ Use assembly when its benefit is worth the user's additional work. Design alignment, clearance, engagement, and assembly access deliberately. Glue is a valid planned joining method for suitable materials and loads; make joints self-locating and give them useful bonding area. Use mechanical retention when loads, repeated flexing, or serviceability call for it. State needed glue, hardware, tools, and assembly effort plainly. Avoid adding parts when a modest support or a simple one-piece print is the better result.
27
+
28
+ Verify the chosen geometry in its actual print orientation and inspect the slice when tools permit. Check critical fits, missing or floating features, toolpaths, supports, color assignments, total time, and material consumption. Treat a slicer prediction as a prediction, not a physical test. Fix consequential problems and re-slice relevant changes. Stop when the design meets the brief and no remaining improvement justifies its cost.
29
+
30
+ Keep the user-facing result short: the recommended design, why its manufacturing approach makes sense, how it prints and assembles, and any consequential unresolved assumption. Distinguish measured dimensions, slicer estimates, and untested predictions. Keep detailed analysis in the working process.`;
31
+
32
+ export const FDM_MULTICOLOR = `Apply this guidance when multiple colors or support materials share a print.
33
+
34
+ Preserve the requested visual pattern. Evaluate its colors in build coordinates and across layers before committing to color geometry. On a shared nozzle, a small accent spanning many layers can create more transitions than a large region confined to a few layers. Count the slicer's actual transitions when available, including swaps back to a prior filament and support-interface swaps; color count and colored volume are only screening clues. On printers with multiple nozzles, evaluate each shared melt path; AMS slots are not independent nozzles.
35
+
36
+ For a costly pattern, consider the most promising alternatives: a different build orientation, color regions confined to fewer layers, shallow face inlays, or separately printed single-color pieces with intentional assembly. A vertical stripe in use may become a low-change feature in a different print orientation. Judge each option against load direction, surface quality, support burden, and the user's exact visual requirements. Finer layers can help an overhang while adding color-change layers; evaluate local adaptive layers and re-slice the combined plan.
37
+
38
+ Separating color bodies does not by itself eliminate swaps. Check plate scheduling. Single-color plates can remove repeated color changes; by-layer printing of different-color objects together usually cannot. Use sequential object printing only when supported and its clearance constraints are satisfied. Batch copies only when the user wants them.
39
+
40
+ Use the actual directional flushing matrix and filament compatibility. Purging clears previous material; priming restores consistent extrusion. Track those separately when the slicer does. Flushing into compatible infill or supports may reduce discarded material, but it does not remove switching time, has finite capacity, and can affect appearance or strength. Do not add unnecessary infill or objects merely to hide waste.
41
+
42
+ Compare the same requested quantity and finish. Report total filament and time across all plates, plus purge, tower, and support quantities only where available without double counting. Unknown metrics remain unknown. Recommend the original multicolor approach when the user values its seamless result enough to justify its cost.`;
43
+
44
+ export const FDM_ASSEMBLY = `Apply this guidance when a split, insert, or joint may materially improve the result.
45
+
46
+ Compare assembly against the best practical one-piece approach, including reasonable supports. A split earns its place by improving important surfaces, layer strength, fit, color efficiency, support access, repairability, or bed fit enough to justify handling and joining the pieces. Prefer a one-piece solution when its support cleanup is easier than assembly and the alternatives meet the other important requirements comparably well. Respect an explicit one-piece or no-glue requirement.
47
+
48
+ Put boundaries where they are structurally sensible and visually deliberate. Use shoulders, keys, overlapping faces, tabs, or pins to locate parts and resist sliding or rotation as appropriate. Include fit clearance, lead-ins, adhesive space where needed, and a feasible insertion path. Check the assembled geometry and the order of operations, not only the separated pieces. Avoid joints whose screws, clamps, or mating faces become inaccessible during assembly.
49
+
50
+ For suitable lightly loaded rigid details, a locating pocket plus a compatible adhesive can be the simplest reliable solution. For a loaded clamp, a joint under peel or repeated bending, or a serviceable assembly, consider fasteners or mechanically captured geometry. Do not assume a tiny glued butt joint can carry the same load as an intact printed section. Select adhesive from evidence for the actual materials and conditions; superglue is an option, not a universal material property.
51
+
52
+ State the practical consequence directly: which parts print in which orientations and colors, how they locate, how they are retained, which supplies are needed, and the estimated hands-on work. Keep hands-on time separate from adhesive cure time. Check a small joint sample only when its uncertain fit or strength would change the design decision.`;
53
+
54
+ export const FDM_AFTER_SLICE = `Review this slice against the user's brief and the selected manufacturing plan. Use the returned slice, geometry, profile, and calibration evidence; do not assume missing metrics are zero.
55
+
56
+ Check that the actual toolpaths preserve critical features, fit, connected material, intended colors, and load-bearing sections. Inspect consequential first layers, bridges, islands, supported undersides, and removal access. Compare expected total print time, total material across all plates, and manual work with the best credible alternative. Keep purge, prime-tower, support, and model quantities distinct only when the data provides a non-overlapping breakdown.
57
+
58
+ If the plan is unexpectedly costly or fragile, identify the dominant cause and make the smallest change that preserves the brief. Re-slice after a relevant geometry, orientation, material, or process change. Do not optimize a minor imperfection at the expense of function or appearance, and do not invent a redesign simply to produce criticism.
59
+
60
+ Return a concise decision: keep, revise, or validate one specific uncertainty. Include the decisive evidence and the exact next action. A clean slice supports a printability prediction; it does not verify physical fit, strength, or bridge quality.`;
61
+
62
+ export const FDM_CAPABILITY_GUIDANCE = `# Bridges and overhangs: useful capability without arbitrary bans
63
+
64
+ Load this reference only when the feature matters to the manufacturing decision. These are proposed planning bands, not manufacturer guarantees, support-generation settings, or automatic acceptance/rejection rules.
65
+
66
+ ## Scope and terminology
67
+
68
+ The provisional baseline is an ordinary modern Bambu P-series-class FDM setup, a 0.4 mm nozzle, a suitable PLA profile, normal planar toolpaths, and effective part cooling. Actual printer/material/profile evidence takes priority. Do not transfer the same expectations unchanged to PETG, flexible materials, a different layer height, or a different cooling arrangement.
69
+
70
+ Angles in the proposed review bands are measured **from vertical**: a vertical wall is 0 degrees; a horizontal underside is 90 degrees. Source values retain their stated or unresolved convention as noted. Slicer controls and test models can use a different convention. Establish their convention before comparing or entering a value. For a simple planar slope, the angle from the build plate is 90 degrees minus the angle from vertical.
71
+
72
+ An overhang grows sideways from material below. A conventional bridge has extruded paths anchored at both ends. A disconnected island begins with no suitable underlying material or anchorage. They require different decisions.
73
+
74
+ ## What the retrieved evidence establishes
75
+
76
+ - Prusa's current modeling documentation gives a general 45–60 degree range depending on nozzle/settings, and describes up to 75 degrees for its specifically named Nextruder/360-degree-cooling machines. That is evidence against a universal 45-degree limit. It does not certify 75 degrees on every Bambu printer. [C1]
77
+ - A Fab Academy 2026 hands-on comparison of Bambu P2S and H2S used matte-white PLA and reported 50 mm unsupported bridge strands without a visible problem. It also reported some drooping above 60 degrees on the overhang test. This is a useful documented result on particular geometry, not a population benchmark or proof of flat mating-surface accuracy. [C2]
78
+ - An MIT HTMAA 2025 P1S characterization reported no noticeable defects on its longest tested bridge, 20 mm, and explicitly left the actual maximum unmeasured. A largest passing sample is not the machine's limit. [C3]
79
+ - Bambu's PETG Basic V3.0 data sheet lists approximately 30 mm maximum bridging length and approximately 70 degrees maximum overhang; its PLA Tough+ V3.0 sheet lists 30 mm and 55 degrees. These are material-specific reference values. The retrieved sheets do not define the overhang angle convention or a bridge sag/finish acceptance criterion, so keep the angle values as reported rather than silently normalizing them. Do not assume the mechanical-specimen print conditions also describe these feature tests. [C4, C5]
80
+
81
+ The material sheets provide useful 30 mm reference points; the firsthand longer prints show why a published or configured maximum should not become a universal geometric prohibition. Filament formulation matters. Avoid a rule that all PLA must outperform all PETG.
82
+
83
+ ## Proposed review bands
84
+
85
+ These bands decide how much attention to give a feature. They never force a flat design, a split, supports, or a calibration print by themselves.
86
+
87
+ | Feature on the provisional PLA setup | Working stance |
88
+ | --- | --- |
89
+ | Progressively supported overhang around 45–60 degrees | Keep an unsupported version as an ordinary candidate. Inspect local curvature, overlap, cooling, and required finish. |
90
+ | Overhang around 60–70 degrees | Retain the shape as a candidate; examine local layer height, geometry, cooling, and relevant prior results more closely. Modest support is also an ordinary option. |
91
+ | Overhang around 70 degrees or more | Require a stronger process/geometry justification to rely on a clean unsupported result. Consider a representative sample when its result would decide between substantially different plans. |
92
+ | Conventional bridge up to about 20 mm | Treat as a plausible ordinary candidate when anchors, toolpaths, and underside requirements are suitable. |
93
+ | Bridge roughly 20–50 mm | Keep bridging as a real design option. Pay more attention to actual unsupported path length, anchorage, width, cooling, and sag acceptance. |
94
+ | Bridge beyond about 50 mm | Keep it in the design space; seek relevant prior evidence or a small representative test before depending on its quality for an expensive or precise part. |
95
+
96
+ The 60–70-degree and 20–50-mm bands are deliberate engineering priors for this app. They are not claims that a manufacturer has validated the whole interval. A 12 mm bridge can be unacceptable for a tight sliding surface; a much longer one may be adequate above a noncritical cavity. Avoid turning these bands into a hidden \`max_bridge\` or \`max_overhang\` rejection threshold.
97
+
98
+ ## A useful geometric check
99
+
100
+ For an idealized straight overhang at angle alpha from vertical, layer height h produces a lateral step:
101
+
102
+ \`step = h * tan(alpha)\`
103
+
104
+ Using a nominal extrusion width w, a simple projected overlap estimate is:
105
+
106
+ \`overlap_fraction = max(0, 1 - step / w)\`
107
+
108
+ For a 60-degree overhang and a 0.45 mm line width:
109
+
110
+ | Layer height | Lateral step | Nominal overlap |
111
+ | --- | --- | --- |
112
+ | 0.20 mm | 0.346 mm | About 23% |
113
+ | 0.12 mm | 0.208 mm | About 54% |
114
+
115
+ These values follow from geometry, not a printer test. The model omits bead shape, curved toolpaths, cooling, adhesion, and other effects. It explains why layer height can change the outlook; it cannot prove a print will succeed. Use the actual extrusion widths when known.
116
+
117
+ Ordinary overhang-layer reasoning does not prescribe the best bridge extrusion. OrcaSlicer's documented thick-bridge mode uses a line height equal to nozzle diameter and trades a rougher surface for stronger, more reliable bridges and potentially longer spans. Inspect the actual selected mode instead of assuming bridge height equals ordinary layer height. [C6]
118
+
119
+ Reducing layer height throughout a multicolor part can also increase the number of color-change layers. Prefer a local intervention when appropriate and re-slice the whole manufacturing plan to see the tradeoff.
120
+
121
+ ## Local interventions to consider
122
+
123
+ Keep the intervention proportionate to the specific problem. Useful candidates include changing build orientation, adjusting only a hidden underside, replacing the top of a horizontal hole with a suitable printable roof while preserving its necessary clearance, using local adaptive layers, improving a bridge's anchors or extrusion direction, adding a small removable sacrificial feature, placing accessible support, or splitting at a sensible joint.
124
+
125
+ Preserve roundness where a mating shaft genuinely needs it. Preserve curves where they matter visually. An unremovable support inside a duct is not a valid plan. Do not trade away layer bonding to improve cooling without considering the part's mechanical job.
126
+
127
+ Treat the slicer's support-generation thresholds as policy settings, not measured capability. For example, OrcaSlicer's \`max_bridge_length\` decides which anchored spans tree supports leave unsupported. Its configured value does not establish what the machine can physically bridge. [C7]
128
+
129
+ ## What to learn from physical prints
130
+
131
+ Record the feature and its conditions, not merely a global pass/fail label. Useful evidence includes:
132
+
133
+ - Largest tested span or angle, plus known failures and untested regions.
134
+ - Actual printer, filament identity, nozzle, layer/line profile, cooling, and build orientation.
135
+ - Bridge direction, width, anchors, and nearby geometry.
136
+ - Whether the criterion was survival, cosmetic quality, measured sag, fit, or load performance.
137
+
138
+ A small combined coupon at onboarding can complement the printed measuring tool, but it should be optional. Later, print a targeted sample when an important unknown would otherwise put a much larger print at risk. Routine geometry should not repeatedly trigger calibration work.
139
+
140
+ ## Primary sources
141
+
142
+ [C1] Prusa, Modeling with 3D printing in mind:
143
+
144
+ https://help.prusa3d.com/article/modeling-with-3d-printing-in-mind_164135
145
+
146
+ [C2] FabLab Nagoya, Fab Academy 2026 Week 05, firsthand P2S/H2S characterization:
147
+
148
+ https://fabacademy.org/2026/labs/nagoya/assignments/week05/
149
+
150
+ [C3] Ryan Hoffman, MIT HTMAA 2025, firsthand P1S characterization:
151
+
152
+ https://fab.cba.mit.edu/classes/863.25/people/RyanHoffman/week3.html
153
+
154
+ [C4] Bambu PETG Basic Technical Data Sheet V3.0:
155
+
156
+ https://store.bblcdn.com/s1/default/cb94589bf7994fdcbfa833badefae9cd/Bambu_PETG_Basic_Technical_Data_Sheet.pdf
157
+
158
+ [C5] Bambu PLA Tough+ Technical Data Sheet V3.0:
159
+
160
+ https://store.bblcdn.com/s7/default/80a85371db2e40bda97069d283fed76c/BambuPLA_Tough_TechnicalData_Sheet_%282%29.pdf
161
+
162
+ [C6] OrcaSlicer bridge settings:
163
+
164
+ https://github.com/OrcaSlicer/OrcaSlicer/wiki/quality_settings_bridging
165
+
166
+ [C7] OrcaSlicer advanced support settings:
167
+
168
+ https://github.com/OrcaSlicer/OrcaSlicer/wiki/support_settings_advanced
169
+
170
+ All sources inspected 3 November 2026.`;
171
+
172
+ export const FDM_CAPABILITY_URI = "guidance://fdm/capability-guidance";
173
+
174
+ export const FDM_CAPABILITY_RESOURCE = { uri: FDM_CAPABILITY_URI, name: "FDM bridge and overhang guidance", mimeType: "text/markdown",
175
+ description: "Planning bands and evidence for bridges and overhangs, for when one is consequential and there is no sufficiently specific measured capability available" };
176
+
177
+ export const DESIGN_PROMPTS: DesignPrompt[] = [
178
+ { name: "fdm_design", title: "Design for FDM printing", text: FDM_CORE,
179
+ description: "Persistent instructions for every design or model-editing task: plan how the object prints as part of the design, using the actual printer, filaments and slice." },
180
+ { name: "fdm_multicolor", title: "Multicolour and support materials", text: FDM_MULTICOLOR,
181
+ description: "Add when several colours or support materials share a print: filament transitions, colour placement, plate scheduling and flushing." },
182
+ { name: "fdm_assembly", title: "Splits, inserts and joints", text: FDM_ASSEMBLY,
183
+ description: "Add when a split, accent insert or joint could materially improve the result." },
184
+ { name: "fdm_after_slice", title: "Review a slice", text: FDM_AFTER_SLICE,
185
+ description: "A separate review turn after a candidate has been sliced, before presenting the manufacturing recommendation: keep, revise, or validate one specific uncertainty." },
186
+ ];
187
+
188
+ export const SERVER_INSTRUCTIONS = "When designing or editing a model to print, apply the fdm_design prompt. Add fdm_multicolor when several colours or support materials share a print, fdm_assembly when a split or joint could help, and fdm_after_slice as a separate review turn after a candidate has been sliced, before presenting the manufacturing recommendation. Read " + FDM_CAPABILITY_URI + " when a bridge or overhang is consequential and there is no sufficiently specific measured capability available.";
package/src/fulu-orca.ts CHANGED
@@ -600,7 +600,7 @@ export async function invokeFuluBridgeRpc(options: FuluBridgeRpcOptions): Promis
600
600
  if (printMethod) {
601
601
  throw new Error(
602
602
  `Print safety: raw FULU bridge print method "${method}" is disabled because it would start an uninspected job ` +
603
- "without the thermal, model, nozzle, printer-state and human-confirmation checks. Use print_3mf for a checked LAN print."
603
+ "without the thermal, model, nozzle and printer-state checks. Use print_3mf for a checked LAN print."
604
604
  );
605
605
  }
606
606
 
@@ -711,7 +711,7 @@ export async function inspectFuluOrcaSetup(options: FuluOrcaSetupOptions): Promi
711
711
  notes: [
712
712
  "Slicing support uses FULU OrcaSlicer-bambulab CLI output as a sliced 3MF.",
713
713
  "Bambu print start still enforces BAMBU_MODEL before generating or sending project G-code.",
714
- "Raw bridge print methods are disabled; print_3mf inspects the exact plate, checks the live printer and asks for human confirmation.",
714
+ "Raw bridge print methods are disabled; print_3mf inspects the exact plate and checks the live printer.",
715
715
  "The FULU BambuNetwork bridge is a separate runtime from the LAN MQTT/FTPS path; use bridge probing to verify that runtime before relying on cloud behavior.",
716
716
  ...(platform === "darwin"
717
717
  ? ["FULU macOS release bundles may ship the bridge dylib and host shims before the Linux plugin .so payload is present; missing linux-plugin entries mean the slicer can still be tested, but the BambuNetwork bridge is not fully ready."]
@@ -722,3 +722,19 @@ export async function inspectFuluOrcaSetup(options: FuluOrcaSetupOptions): Promi
722
722
  ],
723
723
  };
724
724
  }
725
+
726
+ /**
727
+ * The FULU BambuNetwork bridge command this machine's FULU install provides:
728
+ * FULU_BAMBU_BRIDGE_COMMAND, else the host discovered next to FULU's
729
+ * OrcaSlicer-bambulab (the same discovery check_fulu_orca_setup reports).
730
+ * print_3mf's bambu_network route and its controls fall back to it.
731
+ */
732
+ export function discoveredFuluBridgeCommand(): string | undefined {
733
+ try {
734
+ const platform = normalizePlatform(undefined);
735
+ const slicerPath = resolveSlicerPath(undefined, platform);
736
+ return defaultBridgeCommand(platform, derivePluginDir(undefined, slicerPath, platform), defaultRuntimeDir(platform));
737
+ } catch {
738
+ return undefined;
739
+ }
740
+ }