agentic-numerical-control 0.0.0-stage → 0.1.0
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/README.md +43 -2
- package/dist/server/autostart.js +78 -0
- package/dist/server/cli.js +154 -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 +66 -4
- package/product.json +8 -0
- package/samples/demo-pocket-profile.nc +130 -0
- package/sim/virtual-port.cjs +134 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Self-update for installed copies: on start, if npm has a newer version,
|
|
2
|
+
// install it into the same place with the same Node, then relaunch.
|
|
3
|
+
// Development checkouts (a .git folder at the root) never self-update.
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { PRODUCT, ROOT, VERSION } from "./product.js";
|
|
8
|
+
function newer(a, b) {
|
|
9
|
+
const pa = a.split(/[.-]/).map(Number);
|
|
10
|
+
const pb = b.split(/[.-]/).map(Number);
|
|
11
|
+
for (let i = 0; i < 3; i++) {
|
|
12
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
13
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
/** The npm prefix this copy was installed into: .../node_modules/<name> -> ... */
|
|
18
|
+
function installPrefix() {
|
|
19
|
+
const parts = ROOT.split(path.sep);
|
|
20
|
+
const i = parts.lastIndexOf("node_modules");
|
|
21
|
+
if (i < 0)
|
|
22
|
+
return null;
|
|
23
|
+
let prefix = parts.slice(0, i).join(path.sep);
|
|
24
|
+
if (process.platform !== "win32" && path.basename(prefix) === "lib")
|
|
25
|
+
prefix = path.dirname(prefix);
|
|
26
|
+
return prefix;
|
|
27
|
+
}
|
|
28
|
+
/** npm that ships with the Node running us. */
|
|
29
|
+
function npmCli() {
|
|
30
|
+
const bin = path.dirname(process.execPath);
|
|
31
|
+
return process.platform === "win32"
|
|
32
|
+
? path.join(bin, "node_modules", "npm", "bin", "npm-cli.js")
|
|
33
|
+
: path.join(bin, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
34
|
+
}
|
|
35
|
+
/** Returns true if a newer version was installed and started in our place. */
|
|
36
|
+
export async function updateIfAvailable() {
|
|
37
|
+
if (fs.existsSync(path.join(ROOT, ".git")))
|
|
38
|
+
return false;
|
|
39
|
+
const prefix = installPrefix();
|
|
40
|
+
const npm = npmCli();
|
|
41
|
+
if (!prefix || !fs.existsSync(npm))
|
|
42
|
+
return false;
|
|
43
|
+
let latest;
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`https://registry.npmjs.org/${PRODUCT.name}/latest`, { signal: AbortSignal.timeout(5000) });
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
return false;
|
|
48
|
+
latest = (await res.json()).version;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
console.error(`[update] Could not reach npm to check for updates; staying on ${VERSION}.`);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (!newer(latest, VERSION))
|
|
55
|
+
return false;
|
|
56
|
+
console.error(`[update] Updating ${VERSION} -> ${latest}`);
|
|
57
|
+
try {
|
|
58
|
+
execFileSync(process.execPath, [npm, "install", "-g", "--prefix", prefix, `${PRODUCT.name}@${latest}`], {
|
|
59
|
+
stdio: "inherit",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
console.error(`[update] Update failed (${e.message}); staying on ${VERSION}.`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// The package was replaced in place; run the new copy with the same arguments.
|
|
67
|
+
spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
68
|
+
return true;
|
|
69
|
+
}
|