@thi.ng/axidraw
Version:
Minimal AxiDraw plotter/drawing machine controller for Node.js
410 lines (409 loc) • 12 kB
JavaScript
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
};