agentic-numerical-control 0.1.6 → 0.2.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.
@@ -0,0 +1,592 @@
1
+ // CAM: turning a 3D model into a G-code job with ANC's built-in Kiri:Moto
2
+ // (engines/kiri, built into dist/engines/kiri). ANC owns only the settings
3
+ // and the bookkeeping here; the toolpaths and G-code are Kiri:Moto's.
4
+ //
5
+ // Each generation runs in its own child process (the Kiri runner), which has
6
+ // no link to CNCjs or the machine. A generated program is saved as an
7
+ // ordinary job file in the jobs folder and is never loaded into CNCjs or run
8
+ // by this code: the person reviews it in the viewer and loads it themselves.
9
+ import { fork } from "node:child_process";
10
+ import crypto from "node:crypto";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { STATE_DIR } from "./paths.js";
14
+ import { JOBS_DIR, PRODUCT, ROOT, VERSION } from "./product.js";
15
+ const ENGINE_DIR = path.join(ROOT, "dist", "engines", "kiri");
16
+ const TOOLS_FILE = path.join(STATE_DIR, "tools.json");
17
+ const CAM_JOBS_FILE = path.join(STATE_DIR, "cam-jobs.json");
18
+ const MODEL_EXT = /\.stl$/i;
19
+ /** A generation still running after this is stopped. */
20
+ const GENERATE_TIMEOUT_MS = 10 * 60 * 1000;
21
+ /** mm above the top of the stock for travel moves. */
22
+ const SAFE_Z = 5;
23
+ const INSPECT_TIMEOUT_MS = 60 * 1000;
24
+ export function camEngine() {
25
+ try {
26
+ const m = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "manifest.json"), "utf-8"));
27
+ return { name: m.upstream.name, version: m.upstream.version, commit: m.upstream.commit };
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** Starts the Kiri runner for one request. The process ends when the request does. */
34
+ function runEngine(message, timeoutMs, onProgress) {
35
+ const manifest = path.join(ENGINE_DIR, "manifest.json");
36
+ if (!fs.existsSync(manifest))
37
+ throw new Error(`The built-in Kiri:Moto is missing (${ENGINE_DIR}). Run npm run build.`);
38
+ const entry = JSON.parse(fs.readFileSync(manifest, "utf-8")).entry;
39
+ // Only what Node needs: the CAM process gets nothing of ANC's own (tokens,
40
+ // the CNCjs address or secret).
41
+ const env = {};
42
+ for (const k of ["PATH", "Path", "SYSTEMROOT", "SystemRoot", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "LANG"]) {
43
+ if (process.env[k] !== undefined)
44
+ env[k] = process.env[k];
45
+ }
46
+ const child = fork(path.join(ENGINE_DIR, entry), [], {
47
+ cwd: path.dirname(path.join(ENGINE_DIR, entry)),
48
+ env,
49
+ // Plain Node: no loader or debugger flags from this process.
50
+ execArgv: [],
51
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
52
+ windowsHide: true,
53
+ });
54
+ let log = "";
55
+ const keep = (d) => {
56
+ log = (log + d.toString()).slice(-4000);
57
+ };
58
+ child.stdout?.on("data", keep);
59
+ child.stderr?.on("data", keep);
60
+ const done = new Promise((resolve, reject) => {
61
+ let settled = false;
62
+ const finish = (fn) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ clearTimeout(timer);
67
+ fn();
68
+ };
69
+ const timer = setTimeout(() => {
70
+ child.kill();
71
+ finish(() => reject(new Error(`Kiri:Moto took longer than ${Math.round(timeoutMs / 60000)} minutes and was stopped.`)));
72
+ }, timeoutMs);
73
+ child.on("message", (m) => {
74
+ if (m?.type === "ready")
75
+ child.send(message);
76
+ else if (m?.type === "progress")
77
+ onProgress?.(Number(m.fraction) || 0, String(m.status ?? ""));
78
+ else if (m?.type === "result")
79
+ finish(() => resolve(m));
80
+ else if (m?.type === "error")
81
+ finish(() => reject(new Error(String(m.message))));
82
+ });
83
+ child.on("exit", (code, signal) => {
84
+ finish(() => reject(new Error(signal ? "Stopped." : `Kiri:Moto stopped unexpectedly (exit ${code}).${log ? ` Last output: ${log.trim().split("\n").slice(-3).join(" | ")}` : ""}`)));
85
+ });
86
+ });
87
+ return { child, done };
88
+ }
89
+ const stlCache = new Map();
90
+ const TOP_CELLS = 72;
91
+ /** Reads an STL (binary or text) for its size and a top view. Cached until the file changes. */
92
+ function stlInfo(full) {
93
+ let stat;
94
+ try {
95
+ stat = fs.statSync(full);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const key = `${full}:${stat.size}:${stat.mtimeMs}`;
101
+ if (stlCache.has(key))
102
+ return stlCache.get(key);
103
+ let info = null;
104
+ try {
105
+ info = readStl(fs.readFileSync(full));
106
+ }
107
+ catch { }
108
+ stlCache.set(key, info);
109
+ return info;
110
+ }
111
+ function readStl(buf) {
112
+ let v;
113
+ const n = buf.length >= 84 ? buf.readUInt32LE(80) : -1;
114
+ if (n >= 0 && 84 + n * 50 === buf.length) {
115
+ v = new Float32Array(n * 9);
116
+ for (let i = 0; i < n; i++)
117
+ for (let j = 0; j < 9; j++)
118
+ v[i * 9 + j] = buf.readFloatLE(84 + i * 50 + 12 + j * 4);
119
+ }
120
+ else {
121
+ v = [];
122
+ for (const m of buf.toString("latin1").matchAll(/vertex\s+(\S+)\s+(\S+)\s+(\S+)/g))
123
+ v.push(Number(m[1]), Number(m[2]), Number(m[3]));
124
+ }
125
+ const tris = Math.floor(v.length / 9);
126
+ if (!tris)
127
+ return null;
128
+ const min = { x: Infinity, y: Infinity, z: Infinity };
129
+ const max = { x: -Infinity, y: -Infinity, z: -Infinity };
130
+ for (let i = 0; i < tris * 9; i += 3) {
131
+ min.x = Math.min(min.x, v[i]);
132
+ max.x = Math.max(max.x, v[i]);
133
+ min.y = Math.min(min.y, v[i + 1]);
134
+ max.y = Math.max(max.y, v[i + 1]);
135
+ min.z = Math.min(min.z, v[i + 2]);
136
+ max.z = Math.max(max.z, v[i + 2]);
137
+ }
138
+ if (![min.x, min.y, min.z, max.x, max.y, max.z].every(Number.isFinite))
139
+ return null;
140
+ // Highest point in each cell of a grid over the part, from the triangles' faces.
141
+ const sx = max.x - min.x || 1;
142
+ const sy = max.y - min.y || 1;
143
+ const cell = Math.max(sx, sy) / TOP_CELLS;
144
+ const w = Math.max(1, Math.ceil(sx / cell));
145
+ const h = Math.max(1, Math.ceil(sy / cell));
146
+ const top = new Float32Array(w * h).fill(-Infinity);
147
+ for (let t = 0; t < tris * 9; t += 9) {
148
+ const ax = (v[t] - min.x) / cell, ay = (v[t + 1] - min.y) / cell, az = v[t + 2];
149
+ const bx = (v[t + 3] - min.x) / cell, by = (v[t + 4] - min.y) / cell, bz = v[t + 5];
150
+ const cx = (v[t + 6] - min.x) / cell, cy = (v[t + 7] - min.y) / cell, cz = v[t + 8];
151
+ const d = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
152
+ if (Math.abs(d) < 1e-12)
153
+ continue;
154
+ const i0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))), i1 = Math.min(w - 1, Math.ceil(Math.max(ax, bx, cx)));
155
+ const j0 = Math.max(0, Math.floor(Math.min(ay, by, cy))), j1 = Math.min(h - 1, Math.ceil(Math.max(ay, by, cy)));
156
+ for (let j = j0; j <= j1; j++) {
157
+ for (let i = i0; i <= i1; i++) {
158
+ const px = i + 0.5, py = j + 0.5;
159
+ const l1 = ((by - cy) * (px - cx) + (cx - bx) * (py - cy)) / d;
160
+ const l2 = ((cy - ay) * (px - cx) + (ax - cx) * (py - cy)) / d;
161
+ const l3 = 1 - l1 - l2;
162
+ if (l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9)
163
+ continue;
164
+ const z = l1 * az + l2 * bz + l3 * cz;
165
+ if (z > top[j * w + i])
166
+ top[j * w + i] = z;
167
+ }
168
+ }
169
+ }
170
+ const sz = max.z - min.z || 1;
171
+ const rows = [];
172
+ for (let j = h - 1; j >= 0; j--) {
173
+ let row = "";
174
+ for (let i = 0; i < w; i++) {
175
+ const z = top[j * w + i];
176
+ row += z === -Infinity ? "." : String(Math.min(9, Math.floor(((z - min.z) / sz) * 10)));
177
+ }
178
+ rows.push(row);
179
+ }
180
+ return { triangles: tris, min, max, top: { w, h, rows } };
181
+ }
182
+ /** Jobs made from each model, from the records kept when they were generated. */
183
+ function jobsByModel() {
184
+ const out = new Map();
185
+ let all = {};
186
+ try {
187
+ all = JSON.parse(fs.readFileSync(CAM_JOBS_FILE, "utf-8"));
188
+ }
189
+ catch { }
190
+ for (const [file, r] of Object.entries(all)) {
191
+ if (!r?.model?.file || !fs.existsSync(path.join(JOBS_DIR, file)))
192
+ continue;
193
+ const list = out.get(r.model.file) ?? [];
194
+ list.push({
195
+ file,
196
+ createdAt: r.createdAt,
197
+ units: r.model.units,
198
+ operations: Array.isArray(r.operations) ? r.operations.map((o) => o.type) : [],
199
+ tool: r.tools?.[0]?.name ?? null,
200
+ stock: r.stock ? { x: r.stock.x, y: r.stock.y, z: r.stock.z } : null,
201
+ });
202
+ out.set(r.model.file, list);
203
+ }
204
+ for (const list of out.values())
205
+ list.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
206
+ return out;
207
+ }
208
+ /** STL files in the jobs folder, next to the G-code, with their size and the jobs made from them. */
209
+ export function listModels(opts = {}) {
210
+ if (!fs.existsSync(JOBS_DIR))
211
+ return [];
212
+ const jobs = jobsByModel();
213
+ return fs
214
+ .readdirSync(JOBS_DIR, { withFileTypes: true })
215
+ .filter((e) => e.isFile() && MODEL_EXT.test(e.name))
216
+ .map((e) => {
217
+ const full = path.join(JOBS_DIR, e.name);
218
+ const stat = fs.statSync(full);
219
+ const info = stlInfo(full);
220
+ const made = jobs.get(e.name) ?? [];
221
+ const model = {
222
+ file: e.name,
223
+ bytes: stat.size,
224
+ modified: stat.mtime.toISOString(),
225
+ triangles: info?.triangles ?? null,
226
+ size: info ? { x: round(info.max.x - info.min.x), y: round(info.max.y - info.min.y), z: round(info.max.z - info.min.z) } : null,
227
+ units: made[0]?.units ?? null,
228
+ jobs: made.map(({ units: _, ...j }) => j),
229
+ };
230
+ if (opts.preview)
231
+ model.top = info?.top ?? null;
232
+ return model;
233
+ })
234
+ .sort((a, b) => a.file.localeCompare(b.file));
235
+ }
236
+ /** Resolves a model name to an STL file directly in the jobs folder; anything else is refused. */
237
+ function modelPath(file) {
238
+ const resolved = path.resolve(JOBS_DIR, file);
239
+ if (path.dirname(resolved) !== JOBS_DIR || !MODEL_EXT.test(resolved) || !fs.existsSync(resolved)) {
240
+ throw new Error(`"${file}" is not an STL file in the ${PRODUCT.displayName} jobs folder (${JOBS_DIR}). anc_list_models lists them.`);
241
+ }
242
+ return resolved;
243
+ }
244
+ const round = (n, d = 3) => Math.round(n * 10 ** d) / 10 ** d;
245
+ /**
246
+ * Size of a model. STL files carry no units, so both readings are given and
247
+ * the person has to say which is right before anything is generated.
248
+ */
249
+ export async function inspectModel(file) {
250
+ const full = modelPath(file);
251
+ const r = await runEngine({ type: "inspect", model: full }, INSPECT_TIMEOUT_MS).done;
252
+ const s = r.size;
253
+ const mm = { x: round(s.x), y: round(s.y), z: round(s.z) };
254
+ const fromInches = { x: round(s.x * 25.4, 2), y: round(s.y * 25.4, 2), z: round(s.z * 25.4, 2) };
255
+ const largest = Math.max(s.x, s.y, s.z);
256
+ return {
257
+ file,
258
+ bytes: fs.statSync(full).size,
259
+ triangles: r.triangles,
260
+ size: s,
261
+ ifMillimetres: mm,
262
+ ifInches: fromInches,
263
+ unitsNote: "STL files don't record units. Ask the person whether this model is in millimetres or inches before generating" +
264
+ (largest < 20 ? "; it is small for millimetres, so it may be inches." : largest > 600 ? "; it is large for inches." : "."),
265
+ engine: `${r.engine} ${r.version}`,
266
+ };
267
+ }
268
+ export function listTools() {
269
+ try {
270
+ const tools = JSON.parse(fs.readFileSync(TOOLS_FILE, "utf-8"));
271
+ return Array.isArray(tools) ? tools : [];
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ }
277
+ function writeTools(tools) {
278
+ fs.mkdirSync(STATE_DIR, { recursive: true });
279
+ fs.writeFileSync(TOOLS_FILE, JSON.stringify(tools, null, 2));
280
+ }
281
+ export function saveTool(input) {
282
+ const tools = listTools();
283
+ if (!(input.diameter > 0 && input.diameter < 100))
284
+ throw new Error("Tool diameter must be between 0 and 100 mm.");
285
+ if (!(input.fluteLength > 0 && input.fluteLength < 300))
286
+ throw new Error("Flute length must be between 0 and 300 mm.");
287
+ if (input.type === "vbit" && !(input.angle && input.angle > 0 && input.angle < 180))
288
+ throw new Error("A V-bit needs its angle (degrees).");
289
+ const existing = input.id !== undefined ? tools.find((t) => t.id === input.id) : undefined;
290
+ if (input.id !== undefined && !existing)
291
+ throw new Error(`No tool with id ${input.id}.`);
292
+ const id = existing?.id ?? Math.max(0, ...tools.map((t) => t.id)) + 1;
293
+ const tool = {
294
+ id,
295
+ number: input.number ?? existing?.number ?? id,
296
+ name: input.name.trim() || `${input.diameter} mm ${input.type}`,
297
+ type: input.type,
298
+ diameter: input.diameter,
299
+ fluteLength: input.fluteLength,
300
+ shankDiameter: input.shankDiameter ?? existing?.shankDiameter ?? input.diameter,
301
+ angle: input.type === "vbit" ? (input.angle ?? null) : null,
302
+ };
303
+ writeTools([...tools.filter((t) => t.id !== id), tool].sort((a, b) => a.id - b.id));
304
+ return tool;
305
+ }
306
+ export function removeTool(id) {
307
+ const tools = listTools();
308
+ if (!tools.some((t) => t.id === id))
309
+ throw new Error(`No tool with id ${id}.`);
310
+ writeTools(tools.filter((t) => t.id !== id));
311
+ }
312
+ /** A tool as Kiri:Moto describes one (src/kiri/app/conf/defaults.js, template.tools). */
313
+ function kiriTool(t) {
314
+ const type = { flat: "endmill", ball: "ballmill", vbit: "tapermill", drill: "drill" }[t.type];
315
+ return {
316
+ id: t.id,
317
+ number: t.number,
318
+ type,
319
+ name: t.name,
320
+ metric: true,
321
+ shaft_diam: t.shankDiameter,
322
+ shaft_len: 20,
323
+ flute_diam: t.diameter,
324
+ flute_len: t.fluteLength,
325
+ taper_tip: 0,
326
+ // Kiri's taper_angle is measured from the tool's axis: half the included angle.
327
+ ...(t.type === "vbit" ? { taper_angle: (t.angle ?? 90) / 2 } : {}),
328
+ };
329
+ }
330
+ /** Grbl's $130-$132 travel, $30 spindle and $110-$112 rates, when the controller reported them. */
331
+ export function camMachine(settings) {
332
+ const n = (k) => {
333
+ const v = Number(settings?.[`$${k}`]);
334
+ return Number.isFinite(v) && v > 0 ? v : null;
335
+ };
336
+ const travel = n(130) && n(131) && n(132) ? { x: n(130), y: n(131), z: n(132) } : null;
337
+ return {
338
+ travel: travel ?? { x: 300, y: 300, z: 80 },
339
+ spindleMax: n(30) ?? 24000,
340
+ maxFeed: n(110) && n(111) && n(112) ? { xy: Math.min(n(110), n(111)), z: n(112) } : null,
341
+ source: travel ? "machine" : "assumed",
342
+ };
343
+ }
344
+ /**
345
+ * The G-code dialect for Grbl and grblHAL. Kiri's own Grbl profile has no
346
+ * spindle command and starts with M6, which Grbl refuses; this one turns the
347
+ * spindle on with M3, off with M5 at the end, and never sends M6.
348
+ */
349
+ function grblDevice(machine) {
350
+ return {
351
+ mode: "CAM",
352
+ internal: 0,
353
+ deviceName: "Grbl (Agentic Numerical Control)",
354
+ bedWidth: machine.travel.x,
355
+ bedDepth: machine.travel.y,
356
+ maxHeight: machine.travel.z,
357
+ spindleMax: machine.spindleMax,
358
+ gcodePre: ["G21", "G90", "G17"],
359
+ gcodePost: ["M5", "M30"],
360
+ gcodeSpindle: ["M3 S{speed}"],
361
+ gcodeDwell: ["G4 P{time}"],
362
+ gcodeChange: ["M5", "M0"],
363
+ gcodeFExt: "nc",
364
+ gcodeSpace: true,
365
+ gcodeStrip: true,
366
+ };
367
+ }
368
+ /** ANC's names for an operation's settings, as the fields Kiri:Moto's operation takes. */
369
+ function kiriOp(op) {
370
+ const common = { tool: op.tool, spindle: op.spindle, rate: op.feed };
371
+ switch (op.type) {
372
+ case "rough":
373
+ return {
374
+ type: "rough",
375
+ ...common,
376
+ down: op.stepDown,
377
+ step: op.stepOver,
378
+ plunge: op.plunge,
379
+ leave: op.leave ?? 0,
380
+ all: (op.area ?? "all") === "all",
381
+ inside: op.area === "inside",
382
+ direction: op.direction ?? "climb",
383
+ };
384
+ case "outline":
385
+ return {
386
+ type: "outline",
387
+ ...common,
388
+ down: op.stepDown,
389
+ plunge: op.plunge,
390
+ outside: (op.side ?? "outside") === "outside",
391
+ inside: op.side === "inside",
392
+ direction: op.direction ?? "climb",
393
+ };
394
+ case "contour":
395
+ return { type: "contour", ...common, step: op.stepOver, leave: op.leave ?? 0, axis: op.axis ?? "X" };
396
+ }
397
+ }
398
+ /** Refuses settings that can't be right; returns warnings for ones worth a second look. */
399
+ function checkOperations(ops, tools, machine) {
400
+ const warnings = [];
401
+ if (!ops.length)
402
+ throw new Error("Give at least one operation.");
403
+ const used = new Set(ops.map((o) => o.tool));
404
+ if (used.size > 1) {
405
+ throw new Error("One tool per job for now: generate a separate job for each tool, so each tool change is done by hand between jobs.");
406
+ }
407
+ for (const [i, op] of ops.entries()) {
408
+ const label = `Operation ${i + 1} (${op.type})`;
409
+ const tool = tools.find((t) => t.id === op.tool);
410
+ if (!tool)
411
+ throw new Error(`${label}: no tool with id ${op.tool}. anc_save_tool adds one.`);
412
+ if (tool.type !== "flat" && tool.type !== "ball")
413
+ throw new Error(`${label}: ${op.type} needs a flat or ball end mill, not a ${tool.type}.`);
414
+ if (op.type !== "contour") {
415
+ if (!(op.stepDown && op.stepDown > 0))
416
+ throw new Error(`${label}: stepDown (mm per pass) is required.`);
417
+ if (!(op.plunge && op.plunge > 0))
418
+ throw new Error(`${label}: plunge (mm/min) is required.`);
419
+ if (op.stepDown > tool.fluteLength)
420
+ throw new Error(`${label}: stepDown ${op.stepDown} mm is more than the tool's ${tool.fluteLength} mm flute length.`);
421
+ if (op.stepDown > tool.diameter)
422
+ warnings.push(`${label}: stepDown ${op.stepDown} mm is more than the tool diameter (${tool.diameter} mm); that is a heavy cut for most hobby machines.`);
423
+ }
424
+ if (op.type !== "outline") {
425
+ if (!(op.stepOver && op.stepOver >= 0.05 && op.stepOver <= 1))
426
+ throw new Error(`${label}: stepOver must be a fraction of the tool diameter between 0.05 and 1.`);
427
+ }
428
+ if (!(op.feed > 0))
429
+ throw new Error(`${label}: feed (mm/min) is required.`);
430
+ if (!(op.spindle > 0))
431
+ throw new Error(`${label}: spindle speed (rpm) is required.`);
432
+ if (op.spindle > machine.spindleMax)
433
+ warnings.push(`${label}: spindle ${op.spindle} rpm is above the machine's maximum (${machine.spindleMax}).`);
434
+ if (machine.maxFeed && op.feed > machine.maxFeed.xy)
435
+ warnings.push(`${label}: feed ${op.feed} mm/min is above the machine's maximum rate (${machine.maxFeed.xy}).`);
436
+ if (machine.maxFeed && op.plunge && op.plunge > machine.maxFeed.z)
437
+ warnings.push(`${label}: plunge ${op.plunge} mm/min is above the machine's Z maximum (${machine.maxFeed.z}).`);
438
+ }
439
+ return warnings;
440
+ }
441
+ const camJobs = new Map();
442
+ function publicJob(j) {
443
+ const { id, model, state, progress, status, startedAt, finishedAt, file, warnings, error, setup } = j;
444
+ return { id, model, state, progress: round(progress, 2), status, startedAt, finishedAt, file, warnings, error, setup };
445
+ }
446
+ function freeName(base) {
447
+ const clean = base.replace(/\.[^.]+$/, "").replace(/[^\w.\- ]+/g, "_").trim() || "job";
448
+ let name = `${clean}.nc`;
449
+ for (let i = 2; fs.existsSync(path.join(JOBS_DIR, name)); i++)
450
+ name = `${clean}-${i}.nc`;
451
+ return name;
452
+ }
453
+ function recordCamJob(file, meta) {
454
+ let all = {};
455
+ try {
456
+ all = JSON.parse(fs.readFileSync(CAM_JOBS_FILE, "utf-8"));
457
+ }
458
+ catch { }
459
+ all[file] = meta;
460
+ try {
461
+ fs.mkdirSync(STATE_DIR, { recursive: true });
462
+ fs.writeFileSync(CAM_JOBS_FILE, JSON.stringify(all, null, 2));
463
+ }
464
+ catch { }
465
+ }
466
+ /** How a generated job was made: model, units, stock, tools, operations and engine. */
467
+ export function camJobInfo(file) {
468
+ try {
469
+ return JSON.parse(fs.readFileSync(CAM_JOBS_FILE, "utf-8"))[file] ?? null;
470
+ }
471
+ catch {
472
+ return null;
473
+ }
474
+ }
475
+ /** Starts generating; returns at once with the job's id. */
476
+ export async function startGenerate(req, machine) {
477
+ const modelFull = modelPath(req.model);
478
+ const engine = camEngine();
479
+ if (!engine)
480
+ throw new Error("The built-in Kiri:Moto is missing. Run npm run build.");
481
+ if (req.units !== "mm" && req.units !== "in")
482
+ throw new Error('units must be "mm" or "in"; ask the person which the model is in.');
483
+ const tools = listTools();
484
+ const warnings = checkOperations(req.operations, tools, machine);
485
+ const usedTools = tools.filter((t) => req.operations.some((o) => o.tool === t.id));
486
+ const size = (await inspectModel(req.model)).size;
487
+ const scale = req.units === "in" ? 25.4 : 1;
488
+ const part = { x: round(size.x * scale), y: round(size.y * scale), z: round(size.z * scale) };
489
+ const stock = req.stock ?? { x: round(part.x + 10), y: round(part.y + 10), z: part.z };
490
+ if (stock.x + 1e-3 < part.x || stock.y + 1e-3 < part.y || stock.z + 1e-3 < part.z) {
491
+ throw new Error(`The stock (${stock.x} x ${stock.y} x ${stock.z} mm) is smaller than the part (${part.x} x ${part.y} x ${part.z} mm).`);
492
+ }
493
+ if (stock.x > machine.travel.x || stock.y > machine.travel.y) {
494
+ warnings.push(`The stock (${stock.x} x ${stock.y} mm) is bigger than the machine's work area (${machine.travel.x} x ${machine.travel.y} mm${machine.source === "assumed" ? ", assumed: no machine has reported its travel" : ""}).`);
495
+ }
496
+ const origin = req.origin ?? "front-left";
497
+ const id = crypto.randomUUID().slice(0, 8);
498
+ const job = {
499
+ id,
500
+ model: req.model,
501
+ state: "running",
502
+ progress: 0,
503
+ status: "starting",
504
+ startedAt: new Date().toISOString(),
505
+ finishedAt: null,
506
+ file: null,
507
+ warnings,
508
+ error: null,
509
+ setup: null,
510
+ };
511
+ camJobs.set(id, job);
512
+ const runner = runEngine({
513
+ type: "generate",
514
+ job: {
515
+ model: modelFull,
516
+ units: req.units,
517
+ stock,
518
+ origin,
519
+ device: grblDevice(machine),
520
+ tools: usedTools.map(kiriTool),
521
+ // One tool, set up by hand before the job: no tool change at the start.
522
+ // Travel 5 mm above the stock rather than Kiri's 1 mm, to clear clamps
523
+ // and uneven stock.
524
+ process: { camToolInit: false, camZClearance: SAFE_Z, ops: req.operations.map(kiriOp) },
525
+ },
526
+ }, GENERATE_TIMEOUT_MS, (fraction, status) => {
527
+ job.progress = Math.max(job.progress, Math.min(1, fraction));
528
+ if (status)
529
+ job.status = status;
530
+ });
531
+ job.child = runner.child;
532
+ job.done = runner.done.then((r) => {
533
+ const file = freeName(req.name ?? req.model);
534
+ const t = usedTools[0];
535
+ const setup = `Zero X and Y at the ${origin === "center" ? "centre" : "front-left corner"} of the stock and Z on the top of the stock. ` +
536
+ `Stock ${stock.x} x ${stock.y} x ${stock.z} mm. Tool: ${t.name} (${t.diameter} mm ${t.type}).`;
537
+ const header = [
538
+ `; ${PRODUCT.displayName} ${VERSION}, toolpaths by ${engine.name} ${engine.version} (${engine.commit.slice(0, 12)})`,
539
+ `; Model: ${req.model} (${req.units}), part ${part.x} x ${part.y} x ${part.z} mm`,
540
+ `; ${setup}`,
541
+ `; Operations: ${req.operations.map((o) => o.type).join(", ")}`,
542
+ `; Travel moves at Z ${SAFE_Z}.` +
543
+ (r.info?.linksAsCuts ? ` ${r.info.linksAsCuts} linking moves inside the stock are feed moves (G1) instead of rapids.` : ""),
544
+ "; Check this program in the viewer before running it, and run an air cut first on a new setup.",
545
+ ];
546
+ fs.writeFileSync(path.join(JOBS_DIR, file), header.join("\n") + "\n" + String(r.gcode).replace(/\r\n/g, "\n").trimEnd() + "\n");
547
+ const modelHash = crypto.createHash("sha256").update(fs.readFileSync(modelFull)).digest("hex");
548
+ recordCamJob(file, {
549
+ createdAt: new Date().toISOString(),
550
+ model: { file: req.model, sha256: modelHash, units: req.units, part },
551
+ stock,
552
+ origin,
553
+ machine,
554
+ tools: usedTools,
555
+ operations: req.operations,
556
+ engine,
557
+ output: { safeZ: SAFE_Z, linksAsCuts: r.info?.linksAsCuts ?? 0 },
558
+ app: VERSION,
559
+ });
560
+ if (r.info?.alert)
561
+ job.warnings.push(`Kiri:Moto: ${r.info.alert}`);
562
+ Object.assign(job, { state: "done", progress: 1, status: "done", file, setup, finishedAt: new Date().toISOString() });
563
+ return publicJob(job);
564
+ }, (e) => {
565
+ if (job.state === "running")
566
+ Object.assign(job, { state: "failed", error: e.message, finishedAt: new Date().toISOString() });
567
+ return publicJob(job);
568
+ });
569
+ return publicJob(job);
570
+ }
571
+ /** Waits up to `ms` for a generation to finish, then reports where it is. */
572
+ export async function waitForCamJob(id, ms) {
573
+ const job = camJobs.get(id);
574
+ if (!job)
575
+ throw new Error(`No CAM job ${id}.`);
576
+ if (job.done)
577
+ await Promise.race([job.done, new Promise((r) => setTimeout(r, ms))]);
578
+ return publicJob(job);
579
+ }
580
+ export function cancelCamJob(id) {
581
+ const job = camJobs.get(id);
582
+ if (!job)
583
+ throw new Error(`No CAM job ${id}.`);
584
+ if (job.state === "running") {
585
+ Object.assign(job, { state: "cancelled", status: "cancelled", finishedAt: new Date().toISOString() });
586
+ job.child?.kill();
587
+ }
588
+ return publicJob(job);
589
+ }
590
+ export function listCamJobs() {
591
+ return [...camJobs.values()].map(publicJob).slice(-20);
592
+ }