agentic-numerical-control 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/server/autostart.js +85 -0
- package/dist/server/cli.js +172 -0
- package/dist/server/cncjs.js +294 -0
- package/dist/server/jobs.js +93 -0
- package/dist/server/paths.js +16 -0
- package/dist/server/product.js +21 -0
- package/dist/server/relay.js +147 -0
- package/dist/server/server.js +263 -0
- package/dist/server/toolpath.js +123 -0
- package/dist/server/update.js +69 -0
- package/dist/viewer.html +4402 -0
- package/package.json +70 -4
- package/product.json +8 -0
- package/samples/demo-pocket-profile.nc +130 -0
- package/sim/virtual-port.cjs +134 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The job Agentic Numerical Control is currently showing. This is Agentic Numerical Control's own preview state;
|
|
2
|
+
// loading a job here never loads it into CNCjs or touches the machine.
|
|
3
|
+
import fsSync from "node:fs";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { JOBS_DIR, ROOT } from "./product.js";
|
|
7
|
+
import { buildToolpath, packToolpath } from "./toolpath.js";
|
|
8
|
+
const GCODE_EXT = /\.(nc|gcode|gc|ngc|tap|cnc|txt)$/i;
|
|
9
|
+
let current = null;
|
|
10
|
+
let revision = 0;
|
|
11
|
+
export function jobsDir() {
|
|
12
|
+
return JOBS_DIR;
|
|
13
|
+
}
|
|
14
|
+
export function currentJob() {
|
|
15
|
+
return current;
|
|
16
|
+
}
|
|
17
|
+
/** Creates the jobs folder on first use, with the demo job in it. */
|
|
18
|
+
async function ensureJobsDir() {
|
|
19
|
+
if (fsSync.existsSync(JOBS_DIR))
|
|
20
|
+
return;
|
|
21
|
+
await fs.mkdir(JOBS_DIR, { recursive: true });
|
|
22
|
+
await fs.copyFile(path.join(ROOT, "samples", "demo-pocket-profile.nc"), path.join(JOBS_DIR, "demo-pocket-profile.nc"));
|
|
23
|
+
}
|
|
24
|
+
export async function listJobFiles() {
|
|
25
|
+
await ensureJobsDir();
|
|
26
|
+
const entries = await fs.readdir(JOBS_DIR, { withFileTypes: true });
|
|
27
|
+
return entries
|
|
28
|
+
.filter((e) => e.isFile() && GCODE_EXT.test(e.name))
|
|
29
|
+
.map((e) => e.name)
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
function load(name, source, gcode) {
|
|
33
|
+
const toolpath = buildToolpath(gcode);
|
|
34
|
+
current = {
|
|
35
|
+
name,
|
|
36
|
+
source,
|
|
37
|
+
revision: ++revision,
|
|
38
|
+
gcode,
|
|
39
|
+
toolpath,
|
|
40
|
+
packed: packToolpath(toolpath),
|
|
41
|
+
loadedAt: new Date().toISOString(),
|
|
42
|
+
};
|
|
43
|
+
return current;
|
|
44
|
+
}
|
|
45
|
+
/** Reads a file from the jobs directory only; paths outside it are refused. */
|
|
46
|
+
export async function loadJobFile(file) {
|
|
47
|
+
await ensureJobsDir();
|
|
48
|
+
const resolved = path.resolve(JOBS_DIR, file);
|
|
49
|
+
if (path.dirname(resolved) !== JOBS_DIR || !GCODE_EXT.test(resolved)) {
|
|
50
|
+
throw new Error(`"${file}" is not a G-code file in the Agentic Numerical Control jobs folder (${JOBS_DIR}).`);
|
|
51
|
+
}
|
|
52
|
+
return load(path.basename(resolved), "file", await fs.readFile(resolved, "utf-8"));
|
|
53
|
+
}
|
|
54
|
+
export function loadJobText(name, gcode) {
|
|
55
|
+
return load(name, "inline", gcode);
|
|
56
|
+
}
|
|
57
|
+
/** Follows the program loaded in CNCjs, unless it is the one already shown. */
|
|
58
|
+
export function loadJobFromCncjs(name, gcode) {
|
|
59
|
+
if (current && current.name === name && current.gcode === gcode)
|
|
60
|
+
return current;
|
|
61
|
+
return load(name, "cncjs", gcode);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Maps a count of raw program lines (as CNCjs's sender counts them) to the
|
|
65
|
+
* viewer's line index, which skips the blank lines the parser drops.
|
|
66
|
+
*/
|
|
67
|
+
export function parsedLineAt(job, rawLines) {
|
|
68
|
+
let parsed = 0;
|
|
69
|
+
const raw = job.gcode.split(/\r?\n/);
|
|
70
|
+
const n = Math.min(rawLines, raw.length);
|
|
71
|
+
for (let i = 0; i < n; i++)
|
|
72
|
+
if (raw[i].trim() !== "")
|
|
73
|
+
parsed++;
|
|
74
|
+
return Math.min(parsed, job.toolpath.lines.length);
|
|
75
|
+
}
|
|
76
|
+
export function jobSummary(job) {
|
|
77
|
+
const tp = job.toolpath;
|
|
78
|
+
return {
|
|
79
|
+
name: job.name,
|
|
80
|
+
source: job.source,
|
|
81
|
+
revision: job.revision,
|
|
82
|
+
loadedAt: job.loadedAt,
|
|
83
|
+
lineCount: tp.lines.length,
|
|
84
|
+
segmentCount: tp.motions.length,
|
|
85
|
+
units: tp.units,
|
|
86
|
+
bounds: tp.bounds,
|
|
87
|
+
cutLength: Math.round(tp.cutLength * 10) / 10,
|
|
88
|
+
rapidLength: Math.round(tp.rapidLength * 10) / 10,
|
|
89
|
+
feedRates: tp.feedRates,
|
|
90
|
+
spindleSpeeds: tp.spindleSpeeds,
|
|
91
|
+
packedBytes: job.packed.byteLength,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Where the app keeps its own state on this computer: the access token, the
|
|
2
|
+
// bundled CNCjs config, the device link and the log. ANC_STATE_DIR overrides.
|
|
3
|
+
// The installers put the private Node.js and the app in the same folder.
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { PRODUCT } from "./product.js";
|
|
7
|
+
function defaultStateDir() {
|
|
8
|
+
const home = os.homedir();
|
|
9
|
+
if (process.platform === "win32") {
|
|
10
|
+
return path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), PRODUCT.dirName);
|
|
11
|
+
}
|
|
12
|
+
if (process.platform === "darwin")
|
|
13
|
+
return path.join(home, "Library", "Application Support", PRODUCT.dirName);
|
|
14
|
+
return path.join(process.env.XDG_DATA_HOME ?? path.join(home, ".local", "share"), PRODUCT.name);
|
|
15
|
+
}
|
|
16
|
+
export const STATE_DIR = path.resolve(process.env.ANC_STATE_DIR ?? defaultStateDir());
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Product identity and install locations, from product.json at the package
|
|
2
|
+
// root. Renaming the product is a change to that file only.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
/** Package root: one level above server/ (dev) or dist/server/'s parent's parent (built). */
|
|
8
|
+
export const ROOT = (() => {
|
|
9
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
while (!fs.existsSync(path.join(dir, "product.json"))) {
|
|
11
|
+
const up = path.dirname(dir);
|
|
12
|
+
if (up === dir)
|
|
13
|
+
throw new Error("product.json not found above " + fileURLToPath(import.meta.url));
|
|
14
|
+
dir = up;
|
|
15
|
+
}
|
|
16
|
+
return dir;
|
|
17
|
+
})();
|
|
18
|
+
export const PRODUCT = JSON.parse(fs.readFileSync(path.join(ROOT, "product.json"), "utf-8"));
|
|
19
|
+
export const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")).version;
|
|
20
|
+
/** Folder the user drops G-code into; Claude can open any job in it. */
|
|
21
|
+
export const JOBS_DIR = path.resolve(process.env.ANC_JOBS_DIR ?? path.join(os.homedir(), "Documents", PRODUCT.displayName));
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Keeps this PC linked to a Agentic Numerical Control cloud account. The PC dials out to the
|
|
2
|
+
// cloud over a WebSocket; MCP requests from Claude arrive on it and are
|
|
3
|
+
// answered by the local MCP endpoint. Nothing listens on the network.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import WebSocket from "ws";
|
|
8
|
+
import { STATE_DIR } from "./paths.js";
|
|
9
|
+
export const DEVICE_FILE = path.join(STATE_DIR, "device.json");
|
|
10
|
+
const PING_MS = 25_000;
|
|
11
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
12
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
export function readLink() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(DEVICE_FILE, "utf-8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function postJson(url, body) {
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
});
|
|
27
|
+
const json = (await res.json().catch(() => ({})));
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw new Error(json.error ?? `${res.status} ${res.statusText}`);
|
|
30
|
+
return json;
|
|
31
|
+
}
|
|
32
|
+
/** Device-code pairing: show a code, wait for the person to approve it online. */
|
|
33
|
+
export async function pair(cloudUrl) {
|
|
34
|
+
for (;;) {
|
|
35
|
+
const link = await pairOnce(cloudUrl);
|
|
36
|
+
if (link)
|
|
37
|
+
return link;
|
|
38
|
+
console.log(" That code expired; here is a new one.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function pairOnce(cloudUrl) {
|
|
42
|
+
const name = `${os.hostname()} (Agentic Numerical Control)`;
|
|
43
|
+
const start = await postJson(`${cloudUrl}/api/device/start`, { name });
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log(" Link this PC to your Agentic Numerical Control account:");
|
|
46
|
+
console.log(` 1. Open ${start.verificationUri}`);
|
|
47
|
+
console.log(` 2. Sign in and check the code is ${start.userCode}`);
|
|
48
|
+
console.log("");
|
|
49
|
+
const deadline = Date.now() + start.expiresIn * 1000;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
await sleep(start.interval * 1000);
|
|
52
|
+
let poll;
|
|
53
|
+
try {
|
|
54
|
+
poll = await postJson(`${cloudUrl}/api/device/poll`, { deviceCode: start.deviceCode });
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
console.error(`[anc] Pairing: ${e.message}`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (poll.status === "approved") {
|
|
61
|
+
const link = { cloudUrl, deviceToken: poll.deviceToken, name };
|
|
62
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
63
|
+
fs.writeFileSync(DEVICE_FILE, JSON.stringify(link, null, 2), { mode: 0o600 });
|
|
64
|
+
console.log(" Linked. Agentic Numerical Control is now available in Claude.\n");
|
|
65
|
+
return link;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs the relay forever: pairs if needed, then keeps the WebSocket open and
|
|
72
|
+
* forwards each request to `localMcpUrl`.
|
|
73
|
+
*/
|
|
74
|
+
export async function runRelay(cloudUrlArg, localMcpUrl) {
|
|
75
|
+
let link = readLink();
|
|
76
|
+
const cloudUrl = (cloudUrlArg ?? link?.cloudUrl ?? process.env.ANC_CLOUD_URL)?.replace(/\/+$/, "");
|
|
77
|
+
if (!cloudUrl)
|
|
78
|
+
throw new Error("Pass the Agentic Numerical Control cloud address, e.g. --relay https://agentic-numerical-control.example.workers.dev");
|
|
79
|
+
if (!link || link.cloudUrl !== cloudUrl)
|
|
80
|
+
link = await pair(cloudUrl);
|
|
81
|
+
let backoff = 1000;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const result = await connectOnce(link, localMcpUrl, () => {
|
|
84
|
+
backoff = 1000;
|
|
85
|
+
});
|
|
86
|
+
if (result === "unlinked") {
|
|
87
|
+
console.error("[anc] This PC is no longer linked. Pairing again.");
|
|
88
|
+
fs.rmSync(DEVICE_FILE, { force: true });
|
|
89
|
+
link = await pair(cloudUrl);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
await sleep(backoff);
|
|
93
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function connectOnce(link, localMcpUrl, onOpen) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const wsUrl = link.cloudUrl.replace(/^http/, "ws") + "/agent";
|
|
99
|
+
const ws = new WebSocket(wsUrl, { headers: { authorization: `Bearer ${link.deviceToken}` } });
|
|
100
|
+
let ping;
|
|
101
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
102
|
+
resolve(res.statusCode === 401 ? "unlinked" : "closed");
|
|
103
|
+
ws.terminate();
|
|
104
|
+
});
|
|
105
|
+
ws.on("open", () => {
|
|
106
|
+
onOpen();
|
|
107
|
+
console.error(`[anc] Connected to ${link.cloudUrl}`);
|
|
108
|
+
ping = setInterval(() => ws.readyState === WebSocket.OPEN && ws.send("ping"), PING_MS);
|
|
109
|
+
});
|
|
110
|
+
ws.on("message", async (data) => {
|
|
111
|
+
const text = data.toString();
|
|
112
|
+
if (text === "pong")
|
|
113
|
+
return;
|
|
114
|
+
const req = JSON.parse(text);
|
|
115
|
+
if (req.type !== "request")
|
|
116
|
+
return;
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(localMcpUrl, {
|
|
119
|
+
method: req.method,
|
|
120
|
+
headers: req.headers,
|
|
121
|
+
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
|
|
122
|
+
});
|
|
123
|
+
const headers = {};
|
|
124
|
+
res.headers.forEach((value, key) => {
|
|
125
|
+
if (key === "content-type" || key.startsWith("mcp-"))
|
|
126
|
+
headers[key] = value;
|
|
127
|
+
});
|
|
128
|
+
ws.send(JSON.stringify({ type: "response", id: req.id, status: res.status, headers, body: await res.text() }));
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
ws.send(JSON.stringify({
|
|
132
|
+
type: "response",
|
|
133
|
+
id: req.id,
|
|
134
|
+
status: 502,
|
|
135
|
+
headers: { "content-type": "text/plain" },
|
|
136
|
+
body: `Agentic Numerical Control on the PC could not handle the request: ${e.message}`,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
ws.on("close", () => {
|
|
141
|
+
clearInterval(ping);
|
|
142
|
+
console.error("[anc] Disconnected from the cloud; reconnecting.");
|
|
143
|
+
resolve("closed");
|
|
144
|
+
});
|
|
145
|
+
ws.on("error", (e) => console.error(`[anc] Cloud connection error: ${e.message}`));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server";
|
|
2
|
+
import { McpServer, } from "@modelcontextprotocol/server";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { currentJob, jobSummary, jobsDir, listJobFiles, loadJobFile, loadJobFromCncjs, loadJobText, parsedLineAt, } from "./jobs.js";
|
|
7
|
+
import { ROOT, VERSION } from "./product.js";
|
|
8
|
+
import { CNCJS_URL, cncjs } from "./cncjs.js";
|
|
9
|
+
// The viewer follows whatever program CNCjs has loaded, including files
|
|
10
|
+
// loaded from the CNCjs interface itself.
|
|
11
|
+
cncjs.onGcodeLoad((name, gcode) => loadJobFromCncjs(name, gcode));
|
|
12
|
+
const VIEWER_HTML = path.join(ROOT, "dist", "viewer.html");
|
|
13
|
+
const VIEWER_URI = "ui://anc/viewer.html";
|
|
14
|
+
// Base64 grows by 4/3: 384 KiB of binary is ~512 KiB per tool result, which
|
|
15
|
+
// keeps each relayed message under the cloud WebSocket's 1 MiB limit.
|
|
16
|
+
const CHUNK_BYTES = 384 * 1024;
|
|
17
|
+
const MAX_LINES_PER_CALL = 500;
|
|
18
|
+
function json(value) {
|
|
19
|
+
// The viewer reads the text block, not structuredContent: Claude Desktop has
|
|
20
|
+
// been reported to strip structuredContent for local servers.
|
|
21
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
22
|
+
}
|
|
23
|
+
function fail(message) {
|
|
24
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
25
|
+
}
|
|
26
|
+
export function createServer() {
|
|
27
|
+
const server = new McpServer({ name: "agentic-numerical-control", version: VERSION });
|
|
28
|
+
registerAppTool(server, "anc_show_job", {
|
|
29
|
+
title: "Show CNC job",
|
|
30
|
+
description: "Open the Agentic Numerical Control 3D toolpath viewer for a G-code job. Pass `file` (a name from anc_list_jobs) or `gcode` text. " +
|
|
31
|
+
"With neither, re-opens the job already shown. Preview only: this never loads anything into CNCjs or moves the machine.",
|
|
32
|
+
inputSchema: z.object({
|
|
33
|
+
file: z.string().optional().describe("G-code file name in the Agentic Numerical Control jobs folder"),
|
|
34
|
+
gcode: z.string().optional().describe("G-code program text to preview"),
|
|
35
|
+
name: z.string().optional().describe("Display name when passing gcode text"),
|
|
36
|
+
}),
|
|
37
|
+
_meta: { ui: { resourceUri: VIEWER_URI } },
|
|
38
|
+
}, async ({ file, gcode, name }) => {
|
|
39
|
+
try {
|
|
40
|
+
const job = file
|
|
41
|
+
? await loadJobFile(file)
|
|
42
|
+
: gcode
|
|
43
|
+
? loadJobText(name ?? "Untitled job", gcode)
|
|
44
|
+
: currentJob();
|
|
45
|
+
if (!job) {
|
|
46
|
+
return fail("No job is loaded yet. Pass `file` or `gcode`; anc_list_jobs lists the files.");
|
|
47
|
+
}
|
|
48
|
+
return json({ job: jobSummary(job) });
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return fail(e.message);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.registerTool("anc_list_jobs", {
|
|
55
|
+
title: "List CNC job files",
|
|
56
|
+
description: "List G-code files in the Agentic Numerical Control jobs folder that anc_show_job can open.",
|
|
57
|
+
inputSchema: z.object({}),
|
|
58
|
+
}, async () => {
|
|
59
|
+
try {
|
|
60
|
+
return json({ folder: jobsDir(), files: await listJobFiles() });
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return fail(e.message);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
// ---- App-only tools: the viewer polls these directly, without a model turn.
|
|
67
|
+
registerAppTool(server, "anc_snapshot", {
|
|
68
|
+
title: "Viewer snapshot",
|
|
69
|
+
description: "Current job identity and machine link state for the Agentic Numerical Control viewer.",
|
|
70
|
+
inputSchema: z.object({}),
|
|
71
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
72
|
+
}, async () => {
|
|
73
|
+
// Connect lazily; a failure is reported in machine.state, not thrown.
|
|
74
|
+
await cncjs.ensure().catch(() => { });
|
|
75
|
+
const job = currentJob();
|
|
76
|
+
const machine = cncjs.snapshot();
|
|
77
|
+
// Progress counts lines CNCjs has had acknowledged ("received"), not
|
|
78
|
+
// physical completion: the planner may still be executing them.
|
|
79
|
+
const tracking = job && machine.sender && machine.loadedFile === job.name;
|
|
80
|
+
return json({
|
|
81
|
+
job: job ? jobSummary(job) : null,
|
|
82
|
+
machine,
|
|
83
|
+
progress: tracking
|
|
84
|
+
? {
|
|
85
|
+
line: parsedLineAt(job, machine.sender.received),
|
|
86
|
+
sent: machine.sender.sent,
|
|
87
|
+
received: machine.sender.received,
|
|
88
|
+
total: machine.sender.total,
|
|
89
|
+
// CNCjs refreshes elapsedTime only when it sends a line; derive it
|
|
90
|
+
// from startTime so it keeps counting while the planner is full.
|
|
91
|
+
elapsedMs: machine.sender.startTime
|
|
92
|
+
? (machine.sender.finishTime || Date.now()) - machine.sender.startTime
|
|
93
|
+
: 0,
|
|
94
|
+
}
|
|
95
|
+
: null,
|
|
96
|
+
cncjsUrl: CNCJS_URL,
|
|
97
|
+
serverTime: new Date().toISOString(),
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
registerAppTool(server, "anc_get_toolpath", {
|
|
101
|
+
title: "Get toolpath chunk",
|
|
102
|
+
description: "Packed toolpath geometry for the current job, base64 in chunks.",
|
|
103
|
+
inputSchema: z.object({
|
|
104
|
+
revision: z.number().int(),
|
|
105
|
+
chunk: z.number().int().min(0),
|
|
106
|
+
}),
|
|
107
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
108
|
+
}, async ({ revision, chunk }) => {
|
|
109
|
+
const job = currentJob();
|
|
110
|
+
if (!job || job.revision !== revision) {
|
|
111
|
+
return fail(`Job revision ${revision} is no longer current.`);
|
|
112
|
+
}
|
|
113
|
+
const chunkCount = Math.max(1, Math.ceil(job.packed.byteLength / CHUNK_BYTES));
|
|
114
|
+
if (chunk >= chunkCount)
|
|
115
|
+
return fail(`Chunk ${chunk} out of range (${chunkCount}).`);
|
|
116
|
+
const slice = job.packed.subarray(chunk * CHUNK_BYTES, (chunk + 1) * CHUNK_BYTES);
|
|
117
|
+
return json({
|
|
118
|
+
revision,
|
|
119
|
+
chunk,
|
|
120
|
+
chunkCount,
|
|
121
|
+
totalBytes: job.packed.byteLength,
|
|
122
|
+
segmentCount: job.toolpath.motions.length,
|
|
123
|
+
lineCount: job.toolpath.lineEnds.length,
|
|
124
|
+
data: slice.toString("base64"),
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
registerAppTool(server, "anc_get_lines", {
|
|
128
|
+
title: "Get program lines",
|
|
129
|
+
description: "A window of G-code program lines for the current job.",
|
|
130
|
+
inputSchema: z.object({
|
|
131
|
+
revision: z.number().int(),
|
|
132
|
+
start: z.number().int().min(0),
|
|
133
|
+
count: z.number().int().min(1).max(MAX_LINES_PER_CALL),
|
|
134
|
+
}),
|
|
135
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
136
|
+
}, async ({ revision, start, count }) => {
|
|
137
|
+
const job = currentJob();
|
|
138
|
+
if (!job || job.revision !== revision) {
|
|
139
|
+
return fail(`Job revision ${revision} is no longer current.`);
|
|
140
|
+
}
|
|
141
|
+
return json({ revision, start, lines: job.toolpath.lines.slice(start, start + count) });
|
|
142
|
+
});
|
|
143
|
+
// ---- Machine link and run control (shared by Claude and the viewer).
|
|
144
|
+
server.registerTool("anc_list_ports", {
|
|
145
|
+
title: "List machine ports",
|
|
146
|
+
description: "List serial ports CNCjs can see, to find the CNC controller.",
|
|
147
|
+
inputSchema: z.object({}),
|
|
148
|
+
}, async () => {
|
|
149
|
+
try {
|
|
150
|
+
return json({ ports: await cncjs.listPorts(), machine: cncjs.snapshot() });
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
return fail(e.message);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
server.registerTool("anc_connect_machine", {
|
|
157
|
+
title: "Connect machine",
|
|
158
|
+
description: "Open the CNC controller's serial port in CNCjs. This connects to the real machine; only call it when the user asks to connect.",
|
|
159
|
+
inputSchema: z.object({
|
|
160
|
+
port: z.string().describe("Serial port, e.g. COM3"),
|
|
161
|
+
baudrate: z.number().int().default(115200),
|
|
162
|
+
controller: z.enum(["Grbl", "Marlin", "Smoothie", "TinyG", "g2core"]).default("Grbl"),
|
|
163
|
+
}),
|
|
164
|
+
}, async ({ port, baudrate, controller }) => {
|
|
165
|
+
try {
|
|
166
|
+
await cncjs.openPort(port, baudrate, controller);
|
|
167
|
+
return json({ machine: cncjs.snapshot() });
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
return fail(e.message);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
server.registerTool("anc_disconnect_machine", {
|
|
174
|
+
title: "Disconnect machine",
|
|
175
|
+
description: "Close the machine's serial port in CNCjs.",
|
|
176
|
+
inputSchema: z.object({}),
|
|
177
|
+
}, async () => {
|
|
178
|
+
try {
|
|
179
|
+
await cncjs.closePort();
|
|
180
|
+
return json({ machine: cncjs.snapshot() });
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
return fail(e.message);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
registerAppTool(server, "anc_send_to_cncjs", {
|
|
187
|
+
title: "Load job into CNCjs",
|
|
188
|
+
description: "Load the job shown in Agentic Numerical Control into CNCjs's sender so it is ready to run. Does not start it.",
|
|
189
|
+
inputSchema: z.object({}),
|
|
190
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
191
|
+
}, async () => {
|
|
192
|
+
const job = currentJob();
|
|
193
|
+
if (!job)
|
|
194
|
+
return fail("No job is shown in Agentic Numerical Control.");
|
|
195
|
+
try {
|
|
196
|
+
const busy = cncjs.snapshot().workflow;
|
|
197
|
+
if (busy !== "idle")
|
|
198
|
+
return fail(`CNCjs is ${busy}; stop the current job first.`);
|
|
199
|
+
await cncjs.load(job.name, job.gcode);
|
|
200
|
+
return json({ loaded: job.name, machine: cncjs.snapshot() });
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
return fail(e.message);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
registerAppTool(server, "anc_job_control", {
|
|
207
|
+
title: "Control CNC job",
|
|
208
|
+
description: "Start, pause, resume or stop the job loaded in CNCjs, or send feed hold, cycle start, unlock ($X), home ($H) or soft reset. " +
|
|
209
|
+
"This moves the real machine: only start or resume when the user explicitly asks. " +
|
|
210
|
+
"Soft reset is not an emergency stop; the physical E-stop is.",
|
|
211
|
+
inputSchema: z.object({
|
|
212
|
+
action: z.enum(["start", "pause", "resume", "stop", "feedhold", "cyclestart", "unlock", "home", "reset"]),
|
|
213
|
+
}),
|
|
214
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
215
|
+
}, async ({ action }) => {
|
|
216
|
+
try {
|
|
217
|
+
const m = cncjs.snapshot();
|
|
218
|
+
if (action === "start") {
|
|
219
|
+
const job = currentJob();
|
|
220
|
+
if (m.link !== "live")
|
|
221
|
+
return fail(`Not starting: machine status is not live (${m.state}).`);
|
|
222
|
+
if (m.activeState !== "Idle")
|
|
223
|
+
return fail(`Not starting: machine is ${m.activeState}, not Idle.`);
|
|
224
|
+
if (m.workflow !== "idle")
|
|
225
|
+
return fail(`Not starting: CNCjs workflow is ${m.workflow}.`);
|
|
226
|
+
if (!m.loadedFile)
|
|
227
|
+
return fail("Not starting: no program is loaded in CNCjs.");
|
|
228
|
+
if (job && job.name !== m.loadedFile) {
|
|
229
|
+
return fail(`Not starting: CNCjs has "${m.loadedFile}" loaded but Agentic Numerical Control is showing "${job.name}".`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const command = {
|
|
233
|
+
start: "gcode:start",
|
|
234
|
+
pause: "gcode:pause",
|
|
235
|
+
resume: "gcode:resume",
|
|
236
|
+
stop: "gcode:stop",
|
|
237
|
+
feedhold: "feedhold",
|
|
238
|
+
cyclestart: "cyclestart",
|
|
239
|
+
unlock: "unlock",
|
|
240
|
+
home: "homing",
|
|
241
|
+
reset: "reset",
|
|
242
|
+
}[action];
|
|
243
|
+
if (action === "stop")
|
|
244
|
+
await cncjs.command(command, { force: true });
|
|
245
|
+
else
|
|
246
|
+
await cncjs.command(command);
|
|
247
|
+
return json({ sent: action, machine: cncjs.snapshot() });
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
return fail(e.message);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
registerAppResource(server, VIEWER_URI, VIEWER_URI, { mimeType: RESOURCE_MIME_TYPE, description: "Agentic Numerical Control toolpath viewer" }, async () => ({
|
|
254
|
+
contents: [
|
|
255
|
+
{
|
|
256
|
+
uri: VIEWER_URI,
|
|
257
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
258
|
+
text: await fs.readFile(VIEWER_HTML, "utf-8"),
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
}));
|
|
262
|
+
return server;
|
|
263
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Turns G-code text into a flat segment list the viewer can draw directly.
|
|
2
|
+
// Parsing runs here, in Node, because gcode-toolpath depends on Node's
|
|
3
|
+
// fs/stream/events and cannot be bundled into the sandboxed app iframe.
|
|
4
|
+
import Toolpath from "gcode-toolpath";
|
|
5
|
+
export const MOTION_RAPID = 0;
|
|
6
|
+
export const MOTION_FEED = 1;
|
|
7
|
+
export const MOTION_ARC = 2;
|
|
8
|
+
const ARC_DIVISIONS = 30;
|
|
9
|
+
export function buildToolpath(gcode) {
|
|
10
|
+
const positions = [];
|
|
11
|
+
const motions = [];
|
|
12
|
+
const lineEnds = [];
|
|
13
|
+
const lines = [];
|
|
14
|
+
const feedRates = new Set();
|
|
15
|
+
const spindleSpeeds = new Set();
|
|
16
|
+
let units = "mm";
|
|
17
|
+
let cutLength = 0;
|
|
18
|
+
let rapidLength = 0;
|
|
19
|
+
const min = { x: Infinity, y: Infinity, z: Infinity };
|
|
20
|
+
const max = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
21
|
+
const push = (a, b, motion) => {
|
|
22
|
+
positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
|
|
23
|
+
motions.push(motion);
|
|
24
|
+
for (const p of [a, b]) {
|
|
25
|
+
if (p.x < min.x)
|
|
26
|
+
min.x = p.x;
|
|
27
|
+
if (p.y < min.y)
|
|
28
|
+
min.y = p.y;
|
|
29
|
+
if (p.z < min.z)
|
|
30
|
+
min.z = p.z;
|
|
31
|
+
if (p.x > max.x)
|
|
32
|
+
max.x = p.x;
|
|
33
|
+
if (p.y > max.y)
|
|
34
|
+
max.y = p.y;
|
|
35
|
+
if (p.z > max.z)
|
|
36
|
+
max.z = p.z;
|
|
37
|
+
}
|
|
38
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
|
39
|
+
if (motion === MOTION_RAPID)
|
|
40
|
+
rapidLength += len;
|
|
41
|
+
else
|
|
42
|
+
cutLength += len;
|
|
43
|
+
};
|
|
44
|
+
const toolpath = new Toolpath({
|
|
45
|
+
addLine: (modal, v1, v2) => {
|
|
46
|
+
push(v1, v2, modal.motion === "G0" ? MOTION_RAPID : MOTION_FEED);
|
|
47
|
+
},
|
|
48
|
+
// Same tessellation as CNCjs's GCodeVisualizer: 30 divisions, with the
|
|
49
|
+
// helical axis interpolated linearly across the arc.
|
|
50
|
+
addArcCurve: (modal, v1, v2, v0) => {
|
|
51
|
+
const clockwise = modal.motion === "G2";
|
|
52
|
+
const radius = Math.hypot(v1.x - v0.x, v1.y - v0.y);
|
|
53
|
+
let start = Math.atan2(v1.y - v0.y, v1.x - v0.x);
|
|
54
|
+
let end = Math.atan2(v2.y - v0.y, v2.x - v0.x);
|
|
55
|
+
if (start === end)
|
|
56
|
+
end += 2 * Math.PI;
|
|
57
|
+
// Normalise the sweep so it runs in the commanded direction.
|
|
58
|
+
let sweep = end - start;
|
|
59
|
+
if (clockwise && sweep > 0)
|
|
60
|
+
sweep -= 2 * Math.PI;
|
|
61
|
+
if (!clockwise && sweep < 0)
|
|
62
|
+
sweep += 2 * Math.PI;
|
|
63
|
+
let prev = null;
|
|
64
|
+
for (let i = 0; i <= ARC_DIVISIONS; i++) {
|
|
65
|
+
const t = i / ARC_DIVISIONS;
|
|
66
|
+
const a = start + sweep * t;
|
|
67
|
+
const px = v0.x + radius * Math.cos(a);
|
|
68
|
+
const py = v0.y + radius * Math.sin(a);
|
|
69
|
+
const pz = v1.z + (v2.z - v1.z) * t;
|
|
70
|
+
let p;
|
|
71
|
+
if (modal.plane === "G18")
|
|
72
|
+
p = { x: py, y: pz, z: px };
|
|
73
|
+
else if (modal.plane === "G19")
|
|
74
|
+
p = { x: pz, y: px, z: py };
|
|
75
|
+
else
|
|
76
|
+
p = { x: px, y: py, z: pz };
|
|
77
|
+
if (prev)
|
|
78
|
+
push(prev, p, MOTION_ARC);
|
|
79
|
+
prev = p;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
toolpath.loadFromStringSync(gcode, (line) => {
|
|
84
|
+
for (const [letter, value] of line.words ?? []) {
|
|
85
|
+
if (letter === "F")
|
|
86
|
+
feedRates.add(value);
|
|
87
|
+
if (letter === "S")
|
|
88
|
+
spindleSpeeds.add(value);
|
|
89
|
+
if (letter === "G" && value === 20)
|
|
90
|
+
units = "inch";
|
|
91
|
+
if (letter === "G" && value === 21)
|
|
92
|
+
units = "mm";
|
|
93
|
+
}
|
|
94
|
+
lineEnds.push(motions.length);
|
|
95
|
+
lines.push(line.line);
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
positions: Float32Array.from(positions),
|
|
99
|
+
motions: Uint8Array.from(motions),
|
|
100
|
+
lineEnds: Uint32Array.from(lineEnds),
|
|
101
|
+
lines,
|
|
102
|
+
bounds: motions.length ? { min, max } : null,
|
|
103
|
+
cutLength,
|
|
104
|
+
rapidLength,
|
|
105
|
+
feedRates: [...feedRates].sort((a, b) => a - b),
|
|
106
|
+
spindleSpeeds: [...spindleSpeeds].sort((a, b) => a - b),
|
|
107
|
+
units,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Packs the typed arrays into one buffer: positions, then lineEnds, then
|
|
112
|
+
* motions, so every view stays aligned without padding.
|
|
113
|
+
*/
|
|
114
|
+
export function packToolpath(tp) {
|
|
115
|
+
const total = tp.positions.byteLength + tp.lineEnds.byteLength + tp.motions.byteLength;
|
|
116
|
+
const buf = Buffer.alloc(total);
|
|
117
|
+
let offset = 0;
|
|
118
|
+
for (const arr of [tp.positions, tp.lineEnds, tp.motions]) {
|
|
119
|
+
Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength).copy(buf, offset);
|
|
120
|
+
offset += arr.byteLength;
|
|
121
|
+
}
|
|
122
|
+
return buf;
|
|
123
|
+
}
|