UNPKG

@thi.ng/axidraw

Version:

Minimal AxiDraw plotter/drawing machine controller for Node.js

410 lines (409 loc) 12 kB
import { isString } from "@thi.ng/checks/is-string"; import { delayed } from "@thi.ng/compose/delayed"; import { formatDuration } from "@thi.ng/date/format"; import { assert } from "@thi.ng/errors/assert"; import { ioerror } from "@thi.ng/errors/io"; import { unsupportedOp } from "@thi.ng/errors/unsupported"; import { ConsoleLogger } from "@thi.ng/logger/console"; import { DIN_A3_LANDSCAPE } from "@thi.ng/units/constants/paper-sizes"; import { convert, div, Quantity } from "@thi.ng/units/unit"; import { inch } from "@thi.ng/units/units/length"; import { abs2 } from "@thi.ng/vectors/abs"; import { ZERO2 } from "@thi.ng/vectors/api"; import { clamp2 } from "@thi.ng/vectors/clamp"; import { maddN2 } from "@thi.ng/vectors/maddn"; import { mag2 } from "@thi.ng/vectors/mag"; import { mulN2 } from "@thi.ng/vectors/muln"; import { set2 } from "@thi.ng/vectors/set"; import { setN2 } from "@thi.ng/vectors/setn"; import { sub2 } from "@thi.ng/vectors/sub"; import { AxiDrawState } from "./api.js"; import { complete, HOME, OFF, ON, PEN, UP } from "./commands.js"; import { AxiDrawControl } from "./control.js"; import { SERIAL_PORT } from "./serial.js"; const DEFAULT_OPTS = { serial: SERIAL_PORT, logger: new ConsoleLogger("axidraw"), control: new AxiDrawControl(), refresh: 1e3, bounds: DIN_A3_LANDSCAPE, home: [0, 0], unitsPerInch: 25.4, stepsPerInch: 2032, speedDown: 4e3, speedUp: 4e3, up: 60, down: 30, delayUp: 150, delayDown: 150, preDelay: 0, start: [ON, PEN(), UP()], stop: [UP(), HOME, OFF], sigint: true }; class AxiDraw { serial; opts; isConnected = false; isPenDown = false; penLimits; penState = []; pos = [0, 0]; targetPos = [0, 0]; homePos; scale; bounds; constructor(opts = {}) { this.opts = { ...DEFAULT_OPTS, ...opts }; this.penLimits = [this.opts.down, this.opts.up]; this.scale = this.opts.stepsPerInch / this.opts.unitsPerInch; this.setHome(this.opts.home); if (this.opts.bounds) { this.bounds = this.opts.bounds instanceof Quantity ? [ [0, 0], convert( this.opts.bounds, div(inch, this.opts.stepsPerInch) ) ] : [ mulN2([], this.opts.bounds[0], this.scale), mulN2([], this.opts.bounds[1], this.scale) ]; } this.save(); } reset() { setN2(this.pos, 0); setN2(this.targetPos, 0); this.send("R\r"); return this; } /** * Async function. Attempts to connect to the drawing machine via given * (partial) serial port path/name, returns true if successful. * * @remarks * First matching port will be used. If `path` is a sting, a port name must * only start with it in order to be considered a match. * * An error is thrown if no matching port could be found. * * @param path */ async connect(path = "/dev/tty.usbmodem") { const isStr = isString(path); for (const port of await this.opts.serial.list(path.toString())) { if (isStr && port.path.startsWith(path) || !isStr && path.test(port.path)) { this.opts.logger.info(`using device: ${port.path}...`); this.serial = this.opts.serial.ctor(port.path, 38400); this.isConnected = true; if (this.opts.sigint) { this.opts.logger.debug("installing signal handler..."); process.on("SIGINT", this.onSignal.bind(this)); } return; } } ioerror(`no matching device for ${path}`); } disconnect() { this.serial.close(); } /** * Async function. Converts sequence of {@link DrawCommand}s into actual EBB * commands and sends them via configured serial port to the AxiDraw. If * `wrap` is enabled (default), the given commands will be automatically * wrapped with start/stop commands via {@link complete}. Returns object of * collected {@link Metrics}. If `showMetrics` is enabled (default), the * metrics will also be written to the configured logger. * * @remarks * This function is async and if using `await` will only return once all * commands have been processed or cancelled. * * The `control` implementation/ provided as part of {@link AxiDrawOpts} can * be used to pause, resume or cancel the drawing (see * {@link AxiDrawOpts.control} for details). * * Reference: * http://evil-mad.github.io/EggBot/ebb.html * * Also see {@link complete}. * * @example * ```ts tangle:../export/draw.ts * import { AxiDraw, polyline, START, STOP } from "@thi.ng/axidraw"; * * const axi = new AxiDraw(); * * // execute start sequence, draw a triangle, then exec stop sequence * axi.draw([ * START, * ...polyline([[50, 50], [100, 50], [75, 100], [50, 50]]), * STOP * ]); * ``` * * @param commands * @param wrap * @param showMetrics */ async draw(commands, wrap = true, showMetrics = true) { assert( this.isConnected, "AxiDraw not yet connected, need to call .connect() first" ); let t0 = Date.now(); let numCommands = 0; let penCommands = 0; let totalDist = 0; let drawDist = 0; const $recordDist = (dist) => { totalDist += dist; if (this.isPenDown) drawDist += dist; }; const { control, logger, preDelay, refresh } = this.opts; for (let $cmd of wrap ? complete(commands) : commands) { numCommands++; if (control) { let state = control.deref(); if (state === AxiDrawState.PAUSE) { const penDown = this.isPenDown; if (penDown) this.penUp(); do { await delayed(0, refresh); } while ((state = control.deref()) === AxiDrawState.PAUSE); if (state === AxiDrawState.CONTINUE && penDown) { this.penDown(); } } if (state === AxiDrawState.CANCEL) { this.penUp(); break; } } const [cmd, a, b] = $cmd; let wait = -1; let dist; switch (cmd) { case "start": case "stop": { const metrics = await this.draw( this.opts[cmd], false, false ); numCommands += metrics.commands; penCommands += metrics.penCommands; totalDist += metrics.totalDist; drawDist += metrics.drawDist; break; } case "home": [wait, dist] = this.home(); $recordDist(dist); break; case "reset": this.reset(); break; case "on": this.motorsOn(); break; case "off": this.motorsOff(); break; case "pen": this.penConfig(a, b); break; case "u": wait = this.penUp(a, b); penCommands++; break; case "d": wait = this.penDown(a, b); penCommands++; break; case "save": this.save(); break; case "restore": this.restore(); break; case "w": wait = a; break; case "M": [wait, dist] = this.moveTo(a, b); $recordDist(dist); break; case "m": [wait, dist] = this.moveRelative(a, b); $recordDist(dist); break; case "comment": logger.info(`comment: ${a}`); break; default: unsupportedOp(`unknown command: ${$cmd}`); } if (wait > 0) { wait = Math.max(0, wait - preDelay); logger.debug(`waiting ${wait}ms...`); await delayed(0, wait); } if (cmd === "d" && b !== void 0) { this.sendPenConfig(5, this.penLimits[0]); } else if (cmd === "u" && b !== void 0) { this.sendPenConfig(4, this.penLimits[1]); } } const duration = Date.now() - t0; if (showMetrics) { logger.info(`total duration : ${formatDuration(duration)}`); logger.info(`total commands : ${numCommands}`); logger.info(`pen up/downs : ${penCommands}`); logger.info(`total distance : ${totalDist.toFixed(2)}`); logger.info(`draw distance : ${drawDist.toFixed(2)}`); } return { duration, drawDist, totalDist, penCommands, commands: numCommands }; } /** * Syntax sugar for drawing a **single** command only, otherwise same as * {@link AxiDraw.draw}. * * @param cmd */ draw1(cmd) { return this.draw([cmd], false); } motorsOn() { this.send("EM,1,1\r"); } motorsOff() { this.send("EM,0,0\r"); } save() { this.opts.logger.debug("saving pen state:", this.penLimits); this.penState.push(this.penLimits.slice()); } restore() { if (this.penState.length < 2) { this.opts.logger.warn("stack underflow, can't restore pen state"); return; } const [down, up] = this.penLimits = this.penState.pop(); this.sendPenConfig(5, down); this.sendPenConfig(4, up); this.opts.logger.debug("restored pen state:", this.penLimits); } penConfig(down, up) { down = down !== void 0 ? down : this.opts.down; this.sendPenConfig(5, down); this.penLimits[0] = down; up = up !== void 0 ? up : this.opts.up; this.sendPenConfig(4, up); this.penLimits[1] = up; this.send(`SC,10,65535\r`); } penUp(delay, level) { if (level !== void 0) this.sendPenConfig(4, level); delay = delay !== void 0 && delay >= 0 ? delay : this.opts.delayUp; this.send(`SP,1,${delay}\r`); this.isPenDown = false; return delay; } penDown(delay, level) { if (level !== void 0) this.sendPenConfig(5, level); delay = delay !== void 0 && delay >= 0 ? delay : this.opts.delayDown; this.send(`SP,0,${delay}\r`); this.isPenDown = true; return delay; } /** * Sends a "moveto" command (absolute coords). Returns tuple of `[duration, * distance]` (distance in original/configured units) * * @remarks * Even though this method accepts absolute coords, all AxiDraw movements * are relative. Depending on pen up/down state, movement speed will be * either the configured {@link AxiDrawOpts.speedDown} or * {@link AxiDrawOpts.speedUp}. * * @param p * @param tempo */ moveTo(p, tempo) { const { homePos, scale, targetPos } = this; maddN2(targetPos, p, scale, homePos); return this.sendMove(tempo); } /** * Similar to {@link AxiDraw.moveTo}, but using **relative** coordinates. * * @param delta * @param tempo */ moveRelative(delta, tempo) { const { pos, scale, targetPos } = this; maddN2(targetPos, delta, scale, pos); return this.sendMove(tempo); } /** * Syntax sugar for {@link AxiDraw.moveTo} position `[0,0]`. */ home() { return this.moveTo(ZERO2); } setHome(pos) { this.homePos = mulN2([], pos, this.scale); this.opts.logger.debug("setting home position:", pos); } async onSignal() { this.opts.logger.warn(`SIGNINT received, stop drawing...`); this.penUp(0); this.motorsOff(); await delayed(0, 100); process.exit(1); } send(msg) { this.opts.logger.debug(msg); this.serial.write(msg); } sendMove(tempo = 1) { const { bounds, pos, scale, targetPos, opts, isPenDown } = this; if (bounds) clamp2(null, targetPos, ...bounds); const delta = sub2([], targetPos, pos); set2(pos, targetPos); const maxAxis = Math.max(...abs2([], delta)); const duration = 1e3 * maxAxis / ((isPenDown ? opts.speedDown : opts.speedUp) * tempo); this.send(`XM,${duration | 0},${delta[0] | 0},${delta[1] | 0}\r`); return [duration, mag2(delta) / scale]; } /** * Sends pen up/down config * * @remarks * Reference: * https://github.com/evil-mad/AxiDraw-Processing/blob/80d81a8c897b8a1872b0555af52a8d1b5b13cec4/AxiGen1/AxiGen1.pde#L213 * * @param id * @param x */ sendPenConfig(id, x) { this.send(`SC,${id},${7500 + 175 * x | 0}\r`); } } export { AxiDraw, DEFAULT_OPTS };