Files
p5-wallplotter/lib/WallPlotter.js
T
2026-08-16 19:56:38 +02:00

1714 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WallPlotter — p5.js helper for a Marlin polargraph over Web Serial.
*
* Coordinates are millimetres. Origin is machine home (G92). Y positive is up.
* Requires Chrome/Edge and a page served on localhost or HTTPS.
*
* Typical sketch flow:
* wp = new WallPlotter({ ... });
* wp.enableKeyInputs();
* wp.enablePlotButtons();
* // in draw(): wp.showInfos();
* // in plot()/test():
* wp.startPath().move(...).draw(...);
* await wp.runPath(testOnly);
*/
class WallPlotter {
// ---------------------------------------------------------------------------
// Static constants
// ---------------------------------------------------------------------------
static VERSION = '2.2.5';
static MIN_FEED_MM_MIN = 1;
static MAX_FEED_MM_MIN = 10000;
/** Offline overlay size when not connected (mm) — 150×150 cm centred on origin */
static PREVIEW_SIZE_MM = 1500;
/** Default wait for Marlin `ok` (ms). M400 / G4 use a longer per-command timeout. */
static OK_TIMEOUT_MS = 15000;
/** Log “waiting for ok” if Marlin has not replied by then */
static OK_WAIT_LOG_MS = 1000;
/** Repeat “still waiting” while blocked on `ok` */
static OK_WAIT_PING_MS = 5000;
// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------
/**
* @param {object} [options]
* @param {number} [options.baudRate=115200]
* @param {number} [options.stepSize=10] Jog step in mm
* @param {number} [options.feedRateTravel=1500] Pen-up speed, mm/min
* @param {number} [options.feedRateDraw=300] Pen-down speed, mm/min
* @param {string} [options.penUpGcode]
* @param {string} [options.penDownGcode]
* @param {number} [options.penUpDelayMs=150]
* @param {number} [options.penDownDelayMs=50]
* @param {number} [options.machineXMin] Fallback overlay X until M665
* @param {number} [options.machineXMax]
* @param {number} [options.machineYMin]
* @param {number} [options.machineYMax]
* @param {number} [options.previewSizeMm] Offline square size (mm)
* @param {number} [options.jogIntervalMs=150]
* @param {number} [options.maxLogLines=8]
* @param {number} [options.previewMinDelayMs=30] Floor for preview segment wait
* @param {number} [options.previewFrameMs=16] Preview animation frame time
* @param {number} [options.timingMultiplier=1] Scales Test preview waits
* @param {function} [options.onLog]
* @param {function} [options.onStatusChange]
*/
constructor(options = {}) {
// --- Serial / pen / speeds ---
this.baudRate = options.baudRate ?? 115200;
this.feedRateTravel = this._clampFeed(options.feedRateTravel ?? 1500);
this.feedRateDraw = this._clampFeed(options.feedRateDraw ?? 300);
this.penUpGcode = options.penUpGcode ?? 'M280 P0 S90';
this.penDownGcode = options.penDownGcode ?? 'M280 P0 S50';
this.penUpDelayMs = options.penUpDelayMs ?? 150;
this.penDownDelayMs = options.penDownDelayMs ?? 50;
this.timingMultiplier = options.timingMultiplier ?? 1;
// --- Machine / overlay extents (mm) ---
this.machineXMin = options.machineXMin ?? -830;
this.machineXMax = options.machineXMax ?? 830;
this.machineYMin = options.machineYMin ?? -1200;
this.machineYMax = options.machineYMax ?? 800;
this.previewSizeMm = options.previewSizeMm ?? WallPlotter.PREVIEW_SIZE_MM;
// --- Jog / UI ---
this.stepSize = options.stepSize ?? 10;
this.jogIntervalMs = options.jogIntervalMs ?? 150;
this.maxLogLines = options.maxLogLines ?? 8;
this.previewMinDelayMs = options.previewMinDelayMs ?? 30;
this.previewFrameMs = options.previewFrameMs ?? 16;
// --- Live machine state ---
this.connected = false;
this.homeSet = false;
this.isPenDown = false;
this.x = 0;
this.y = 0;
this.statusText = 'Disconnected';
this.logLines = [];
// --- Path / plot progress ---
this._path = [];
this.previewPath = [];
this.isPlotting = false;
this.isPaused = false;
this.plotProgress = 0;
this._plotTestOnly = false;
this._pathAbort = false;
this._pathRunId = 0;
this._pendingAfterAbort = null;
this._plotStartedAt = 0;
this._plotEstimateMs = 0;
this._pausedAccumMs = 0;
this._pauseStartedAt = 0;
this._statusBeforePause = null;
this._statusBeforeOkWait = null;
this._freezeMotion = false;
this._lastSegmentMs = 0;
// --- Web Serial ---
this.port = null;
this.writer = null;
this.reader = null;
this.readLoopActive = false;
this._rxBuf = '';
this._okResolvers = [];
this._sendChain = Promise.resolve();
this._okIgnoreUntil = 0;
this._machineBusy = false;
this._lastBusyLogAt = 0;
// --- Keyboard / buttons ---
this._keysEnabled = false;
this._prevKeyPressed = null;
this._lastJogTime = 0;
this._jogInFlight = false;
this._homeWarning = null;
this._plotButtonsEnabled = false;
this._connectBtn = null;
this._plotBtn = null;
this._testBtn = null;
this._connectLabel = 'Connect';
this._plotLabel = 'Plot';
this._pauseLabel = 'Pause';
this._resumeLabel = 'Resume';
this._testLabel = 'Test';
this._stopLabel = 'Stop';
this._lastTestBtnClick = 0;
this._connecting = false;
// --- Callbacks ---
const userOnLog = options.onLog ?? (() => {});
this.onLog = (line) => {
this.logLines.push(line);
if (this.logLines.length > this.maxLogLines) this.logLines.shift();
userOnLog(line);
};
const userOnStatusChange = options.onStatusChange ?? (() => {});
this.onStatusChange = (status) => {
if (status === 'connected') this.statusText = 'Connected';
else if (status === 'disconnected') this.statusText = 'Disconnected';
this._updatePlotButtons();
userOnStatusChange(status);
};
this._unloadBound = false;
this._installUnloadHandler();
}
// ===========================================================================
// Public API — setup / UI
// ===========================================================================
/** Enable keyboard jog, home, pen toggle, connect (C), test (T), stop (S). Call from setup(). */
enableKeyInputs() {
if (this._keysEnabled) return;
this._keysEnabled = true;
this._prevKeyPressed = window.keyPressed;
const plotter = this;
window.keyPressed = async function () {
const handled = await plotter._handleKeyPressed();
if (handled === false) return false;
if (plotter._prevKeyPressed) return plotter._prevKeyPressed.apply(this, arguments);
};
}
/**
* Add Connect / Plot / Test buttons. Expects global sketch functions plot() and test().
* Connect is shown only while disconnected. Plot becomes Pause while running, Resume while paused.
* @param {{parentId?: string, connectLabel?: string, plotLabel?: string, pauseLabel?: string, resumeLabel?: string, testLabel?: string, stopLabel?: string}} [options]
*/
enablePlotButtons(options = {}) {
if (this._plotButtonsEnabled) return;
this._plotButtonsEnabled = true;
this._injectPlotButtonStyles();
const parentId = options.parentId ?? 'wallplotter-controls';
let parent = document.getElementById(parentId);
// Prefer inserting controls into the page body so absolute positioning is viewport-relative
if (!parent) {
parent = document.createElement('div');
parent.id = parentId;
document.body.appendChild(parent);
}
this._connectLabel = options.connectLabel ?? 'Connect';
this._plotLabel = options.plotLabel ?? 'Plot';
this._pauseLabel = options.pauseLabel ?? 'Pause';
this._resumeLabel = options.resumeLabel ?? 'Resume';
this._testLabel = options.testLabel ?? 'Test';
this._stopLabel = options.stopLabel ?? 'Stop';
const plotter = this;
const connectBtn = createButton(this._connectLabel);
connectBtn.parent(parent);
connectBtn.mousePressed(() => plotter._onConnectButton());
this._connectBtn = connectBtn;
const plotBtn = createButton(this._plotLabel);
plotBtn.parent(parent);
plotBtn.mousePressed(() => plotter._onPlotButton());
this._plotBtn = plotBtn;
const testBtn = createButton(this._testLabel);
testBtn.parent(parent);
testBtn.mousePressed(() => plotter._onTestButton());
this._testBtn = testBtn;
this._updatePlotButtons();
}
/** Draw overlay + HUD. Call every frame from draw(). */
showInfos() {
textFont('monospace', 14);
this._drawPlotArea();
this._drawHud();
if (this._keysEnabled && !this.isPlotting) this._handleContinuousJog();
}
// ===========================================================================
// Public API — speed
// ===========================================================================
/**
* Set feed rates in mm/min (G-code F and Test timing). Clamped 1–10000.
* @param {number|{travel?: number, draw?: number}} travel
* @param {number} [draw]
*/
setSpeed(travel, draw) {
if (typeof travel === 'object' && travel !== null) {
if (travel.travel != null) this.feedRateTravel = this._clampFeed(travel.travel);
if (travel.draw != null) this.feedRateDraw = this._clampFeed(travel.draw);
return;
}
this.feedRateTravel = this._clampFeed(travel);
if (draw != null) this.feedRateDraw = this._clampFeed(draw);
}
/**
* Scale Test preview waits only (Plot is paced by Marlin M400).
* @param {number} mult 1 = match setSpeed; 2 = twice as slow
*/
setTimingMultiplier(mult) {
this.timingMultiplier = Math.max(0.1, Number(mult) || 1);
}
// ===========================================================================
// Public API — path buffer (queue only; nothing is sent until runPath)
// ===========================================================================
/** Clear the path buffer. Returns this for chaining. */
startPath() {
this._path = [];
return this;
}
/** Queue pen-up travel to (x, y) mm. */
move(x, y) {
this._path.push({ type: 'move', x, y });
return this;
}
/** Queue pen-down stroke to (x, y) mm. */
draw(x, y) {
this._path.push({ type: 'draw', x, y });
return this;
}
/** Queue a pause in milliseconds (sends G4 on Plot). */
wait(ms) {
this._path.push({ type: 'wait', ms });
return this;
}
/** Queue a mid-path speed change (mm/min). Restored after runPath. */
speed(travel, drawRate) {
this._path.push({
type: 'speed',
travel: travel != null ? this._clampFeed(travel) : undefined,
draw: drawRate != null ? this._clampFeed(drawRate) : undefined,
});
return this;
}
/**
* Run the path buffer, or an explicit command array.
* @param {Array|boolean} commandsOrTestOnly
* @param {boolean} [testOnly=false] true = preview only (no serial)
*/
async runPath(commandsOrTestOnly, testOnly = false) {
let commands;
if (Array.isArray(commandsOrTestOnly)) {
commands = commandsOrTestOnly;
} else {
testOnly = commandsOrTestOnly ?? false;
commands = this._path;
}
await this._executePath(commands, testOnly);
}
/**
* Estimated duration of the current path (or commands) in ms.
* Uses setSpeed, queued speed()/wait(), and timingMultiplier.
*/
estimatePathMs(commands) {
return this._estimatePathDurationMs(commands ?? this._path, this.x, this.y);
}
/** Abort the current runPath (browser side only) and unblock any `ok` wait. */
stopPath() {
this._pathAbort = true;
this.isPaused = false;
this._freezeMotion = true;
this._cancelPendingOk();
}
/** Pause the current runPath after the in-flight segment. */
pausePlot() {
if (!this.isPlotting || this.isPaused) return;
this.isPaused = true;
this._pauseStartedAt = performance.now();
this._statusBeforePause = this.statusText;
this.statusText = 'Paused';
this.onLog('plot paused');
this._updatePlotButtons();
this._redrawSketch();
}
/** Continue a paused runPath (including after Stop or a timeout). */
resumePlot() {
if (!this.isPlotting || !this.isPaused) return;
if (this._pauseStartedAt) {
this._pausedAccumMs += performance.now() - this._pauseStartedAt;
}
this._pauseStartedAt = 0;
this.isPaused = false;
this._freezeMotion = false;
if (this._statusBeforePause) this.statusText = this._statusBeforePause;
this._statusBeforePause = null;
this.onLog('plot resumed');
this._updatePlotButtons();
this._redrawSketch();
}
/**
* First press: pause after the current stroke (safe to Resume).
* Second press: stop sending more moves. The in-flight G1 still finishes
* (Marlin has no emergency parser). Do not send M410 — it cannot interrupt
* a running move, and on a polargraph it desyncs belt lengths.
*/
async stopPlot() {
const hadWait = this._okResolvers.length > 0;
if (this.isPlotting && this.isPaused) {
this.stopPath();
this.statusText = 'Stopped — current stroke may still finish';
this.onLog(
'plot aborted — motors finish the in-flight move (unplug power for a hard stop)'
);
return;
}
if (this.isPlotting) {
this.pausePlot();
this.onLog('plot stopped after current move — resume to continue');
return;
}
if (hadWait) {
this._cancelPendingOk();
this.onLog('serial wait cancelled');
this.statusText = this.connected ? 'Connected' : 'Disconnected';
}
}
// ===========================================================================
// Public API — serial connection
// ===========================================================================
/** Open Web Serial, enable steppers, pen up, fetch M665 bounds, M114. */
async connect() {
if (!('serial' in navigator)) {
throw new Error('Web Serial is not supported in this browser. Use Chrome or Edge.');
}
if (this.connected) return;
let port;
try {
port = await this._acquirePort();
await this._openPort(port);
} catch (err) {
this.onLog('! ' + err.message);
port = await navigator.serial.requestPort();
await this._openPort(port);
}
this.port = port;
this.writer = this.port.writable.getWriter();
this._startReadLoop();
this.connected = true;
this.homeSet = false;
this._homeWarning = 'Home not set — jog to origin and press H';
this.onStatusChange('connected');
try {
await this.send('M17');
await this.penUp(true);
await this.send('M665');
await this.send('M114');
} catch (err) {
if (err.name === 'AbortError') return;
throw err;
}
}
async _acquirePort() {
const existing = await navigator.serial.getPorts();
if (existing.length === 1) return existing[0];
return navigator.serial.requestPort();
}
async _openPort(port) {
const opened = port.open({ baudRate: this.baudRate });
const timedOut = this._delay(3000).then(() => {
const err = new Error(
'serial open timeout — port still held after reload. Unplug USB or close other serial tabs.'
);
err.name = 'TimeoutError';
throw err;
});
try {
await Promise.race([opened, timedOut]);
} catch (err) {
try {
await port.close();
} catch (_) {
/* ignore */
}
if (typeof port.forget === 'function') {
try {
await port.forget();
} catch (_) {
/* ignore */
}
}
throw err;
}
}
/** Close the serial port. */
async disconnect() {
if (!this.connected && !this.port) return;
this.readLoopActive = false;
this._cancelPendingOk();
this._rxBuf = '';
this._machineBusy = false;
const reader = this.reader;
const writer = this.writer;
const port = this.port;
this.reader = null;
this.writer = null;
this.port = null;
if (reader) {
try {
await reader.cancel();
} catch (_) {
/* already closed */
}
try {
reader.releaseLock();
} catch (_) {
/* ignore */
}
}
if (writer) {
try {
writer.releaseLock();
} catch (_) {
/* ignore */
}
}
if (port) {
try {
await port.close();
} catch (_) {
/* ignore */
}
}
this.connected = false;
this.homeSet = false;
this._homeWarning = null;
this.onStatusChange('disconnected');
}
/** Best-effort close for page reload (must not await — Mac keeps the USB lock otherwise). */
_releaseSerialNow() {
if (!this.port && !this.connected) return;
this.readLoopActive = false;
this._cancelPendingOk();
this._rxBuf = '';
this._machineBusy = false;
const reader = this.reader;
const writer = this.writer;
const port = this.port;
this.reader = null;
this.writer = null;
this.port = null;
this.connected = false;
this.homeSet = false;
try {
reader?.cancel();
} catch (_) {
/* ignore */
}
try {
reader?.releaseLock();
} catch (_) {
/* ignore */
}
try {
writer?.releaseLock();
} catch (_) {
/* ignore */
}
try {
port?.close();
} catch (_) {
/* ignore */
}
}
_installUnloadHandler() {
if (this._unloadBound) return;
this._unloadBound = true;
const release = () => this._releaseSerialNow();
window.addEventListener('pagehide', release);
window.addEventListener('beforeunload', release);
window.addEventListener('unload', release);
window.addEventListener('pageshow', (event) => {
if (event.persisted) this._releaseSerialNow();
});
}
/**
* Write one G-code line; by default wait for Marlin ok.
* @param {string} line
* @param {{allowWithoutHome?: boolean, waitOk?: boolean, timeoutMs?: number}} [options]
*/
async send(line, options = {}) {
const queued = this._sendChain.then(() => this._sendNow(line, options));
this._sendChain = queued.catch(() => {});
return queued;
}
async _sendNow(line, options = {}) {
if (!this.connected || !this.writer) {
throw new Error('Not connected to plotter');
}
const command = line.trim();
if (!command) return;
if (
!options.allowWithoutHome &&
!this.homeSet &&
this._isCoordinateMove(command)
) {
this._showHomeRequiredWarning();
return;
}
const waitOk = options.waitOk !== false;
const okPromise = waitOk
? this._waitForOk(options.timeoutMs ?? WallPlotter.OK_TIMEOUT_MS, {
command,
waitLogAfterMs: options.waitLogAfterMs,
})
: null;
try {
const written = this.writer.write(
new TextEncoder().encode(command + '\n')
);
if (okPromise) {
await Promise.all([
written.then(() => this.onLog('→ ' + command)),
okPromise,
]);
} else {
await written;
this.onLog('→ ' + command);
}
} catch (err) {
if (err.name === 'AbortError') throw err;
this._okIgnoreUntil = performance.now() + 400;
const timeoutErr = new Error(err.message);
timeoutErr.name = 'TimeoutError';
this.onLog('! ' + err.message);
if (!this.isPlotting) this.statusText = 'Error: ' + err.message;
throw timeoutErr;
}
}
// ===========================================================================
// Public API — immediate machine commands
// ===========================================================================
/**
* Lift pen. Pass true to always send (used by P and connect).
* @param {boolean} [force=false]
*/
async penUp(force = false) {
if (!force && !this.isPenDown) return;
await this.send(this.penUpGcode);
await this._delay(this.penUpDelayMs);
this.isPenDown = false;
}
/**
* Lower pen. Pass true to always send.
* @param {boolean} [force=false]
*/
async penDown(force = false) {
if (!force && this.isPenDown) return;
await this.send(this.penDownGcode);
await this._delay(this.penDownDelayMs);
this.isPenDown = true;
}
/** Pen up and travel to (x, y) mm immediately. Requires home. */
async moveTo(x, y) {
if (!this._requireHome()) return;
await this.penUp();
await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateTravel}`);
this.x = x;
this.y = y;
}
/** Pen down and draw to (x, y) mm immediately. Requires home. */
async drawTo(x, y) {
if (!this._requireHome()) return;
await this.penDown();
await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateDraw}`);
this.x = x;
this.y = y;
}
/** Travel to (0, 0). */
async goToZero() {
await this.moveTo(0, 0);
}
/**
* Relative move from current position.
* @param {number} dx
* @param {number} dy
* @param {boolean} [draw=false]
*/
async step(dx, dy, draw = false) {
if (!this._requireHome()) return;
if (draw) await this.drawTo(this.x + dx, this.y + dy);
else await this.moveTo(this.x + dx, this.y + dy);
}
/** G92 X0 Y0 — set current pose as home. Refused while plotting or waiting for Marlin. */
async setHomeHere() {
if (this.isPlotting) {
this.onLog('! cannot set home while plotting — resume or abort first');
this.statusText = 'Finish or abort plot before H';
return;
}
if (!this._isMachineIdle()) {
this.onLog('! plotter busy — wait for ok before setting home');
this.statusText = 'Waiting for plotter…';
return;
}
await this.send('G92 X0 Y0');
try {
await this.send('M114', { timeoutMs: 3000, allowWithoutHome: true });
} catch (_) {
/* position report optional */
}
this.x = 0;
this.y = 0;
this.homeSet = true;
this._homeWarning = null;
if (this.connected) this.statusText = 'Connected';
}
// ===========================================================================
// Internals — feed helpers
// ===========================================================================
_clampFeed(value) {
const n = Number(value);
if (!Number.isFinite(n)) return WallPlotter.MIN_FEED_MM_MIN;
return Math.min(
WallPlotter.MAX_FEED_MM_MIN,
Math.max(WallPlotter.MIN_FEED_MM_MIN, n)
);
}
// ===========================================================================
// Internals — path execution & timing
// ===========================================================================
async _executePath(commands, testOnly = false) {
const runId = ++this._pathRunId;
this._pathAbort = false;
this._plotTestOnly = testOnly;
this.previewPath = commands;
if (!testOnly && !this._requireHome()) {
this._plotTestOnly = false;
return;
}
this.isPlotting = true;
this.isPaused = false;
this.plotProgress = 0;
this._plotStartedAt = performance.now();
this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y);
this._pausedAccumMs = 0;
this._pauseStartedAt = 0;
this._statusBeforePause = null;
this._freezeMotion = false;
this._lastSegmentMs = 0;
this._updatePlotButtons();
const savedFeedTravel = this.feedRateTravel;
const savedFeedDraw = this.feedRateDraw;
let px = this.x;
let py = this.y;
try {
for (let i = 0; i < commands.length; i++) {
if (this._pathAbort || runId !== this._pathRunId) break;
await this._waitIfPaused();
this._freezeMotion = false;
if (this._pathAbort || runId !== this._pathRunId) break;
const cmd = commands[i];
if (cmd.type === 'speed') {
if (cmd.travel != null) this.feedRateTravel = cmd.travel;
if (cmd.draw != null) this.feedRateDraw = cmd.draw;
this.plotProgress = i + 1;
this._redrawSketch();
continue;
}
try {
if (cmd.type === 'wait') {
const ms = Math.max(0, cmd.ms ?? 0);
if (!testOnly && ms > 0) {
await this.send(`G4 P${Math.round(ms)}`, {
timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, ms + 5000),
});
}
await this._holdPosition(ms);
} else {
const duration = this._segmentDurationMs(px, py, cmd);
this._lastSegmentMs = duration;
if (testOnly) {
await this._animatePosition(px, py, cmd, duration);
} else {
await Promise.all([
this._sendSegmentAndWait(cmd),
this._animatePosition(px, py, cmd, duration),
]);
}
}
} catch (err) {
if (err.name === 'AbortError') break;
if (err.name === 'TimeoutError') {
if (!this.isPaused) this.pausePlot();
this.onLog('segment skipped after timeout — resume continues from here');
px = this.x;
py = this.y;
await this._waitIfPaused();
if (this._pathAbort || runId !== this._pathRunId) break;
if (cmd.type === 'move' || cmd.type === 'draw') {
px = this.x;
py = this.y;
}
this.plotProgress = i + 1;
continue;
}
throw err;
}
if (this._pathAbort || runId !== this._pathRunId) break;
if (this._freezeMotion) {
px = this.x;
py = this.y;
await this._waitIfPaused();
this._freezeMotion = false;
if (this._pathAbort || runId !== this._pathRunId) break;
continue;
}
if (cmd.type === 'move' || cmd.type === 'draw') {
px = cmd.x;
py = cmd.y;
}
this.plotProgress = i + 1;
}
} catch (err) {
if (err.name !== 'AbortError' && err.name !== 'TimeoutError') {
/* send() already logged serial errors */
}
} finally {
this.feedRateTravel = savedFeedTravel;
this.feedRateDraw = savedFeedDraw;
if (!testOnly && this.connected) {
try {
await this.penUp();
} catch (_) {
/* port may be gone */
}
} else {
this.isPenDown = false;
}
this.isPlotting = false;
this.isPaused = false;
this._pauseStartedAt = 0;
this._statusBeforePause = null;
this._plotTestOnly = false;
this._updatePlotButtons();
this._redrawSketch();
const next = this._pendingAfterAbort;
this._pendingAfterAbort = null;
if (next === 'test') this._triggerSketchFn('test');
else if (next === 'plot') this._triggerSketchFn('plot');
}
}
/** Segment wait ms: (distance/feed + pen delay) × timingMultiplier. */
_segmentDurationMs(fromX, fromY, cmd) {
const isMove = cmd.type === 'move';
const dist = Math.hypot(cmd.x - fromX, cmd.y - fromY);
const rate = isMove ? this.feedRateTravel : this.feedRateDraw;
const motionMs = dist > 0 && rate > 0 ? (dist / rate) * 60000 : 0;
let penMs = 0;
if (isMove && this.isPenDown) penMs = this.penUpDelayMs;
if (!isMove && !this.isPenDown) penMs = this.penDownDelayMs;
return Math.max(motionMs + penMs, this.previewMinDelayMs) * this.timingMultiplier;
}
_estimatePathDurationMs(commands, fromX, fromY) {
const savedTravel = this.feedRateTravel;
const savedDraw = this.feedRateDraw;
const savedPen = this.isPenDown;
let px = fromX;
let py = fromY;
let total = 0;
try {
for (const cmd of commands) {
if (cmd.type === 'speed') {
if (cmd.travel != null) this.feedRateTravel = cmd.travel;
if (cmd.draw != null) this.feedRateDraw = cmd.draw;
continue;
}
if (cmd.type === 'wait') {
total += Math.max(0, cmd.ms ?? 0);
continue;
}
if (cmd.type !== 'move' && cmd.type !== 'draw') continue;
total += this._segmentDurationMs(px, py, cmd);
this.isPenDown = cmd.type === 'draw';
px = cmd.x;
py = cmd.y;
}
} finally {
this.feedRateTravel = savedTravel;
this.feedRateDraw = savedDraw;
this.isPenDown = savedPen;
}
return total;
}
async _holdPosition(ms) {
if (ms <= 0) return;
let elapsed = 0;
let last = performance.now();
while (elapsed < ms) {
if (this._pathAbort || this._freezeMotion) return;
if (this._plotTestOnly && this.isPaused) {
await this._waitIfPaused();
last = performance.now();
if (this._pathAbort) return;
}
const now = performance.now();
elapsed += now - last;
last = now;
this._redrawSketch();
await this._delay(this.previewFrameMs);
}
}
async _animatePosition(fromX, fromY, cmd, duration) {
if (this._plotTestOnly) this.isPenDown = cmd.type === 'draw';
const waitMs = Math.max(duration, 1);
let elapsed = 0;
let last = performance.now();
while (true) {
if (this._pathAbort || this._freezeMotion) break;
if (this._plotTestOnly && this.isPaused) {
await this._waitIfPaused();
last = performance.now();
if (this._pathAbort || this._freezeMotion) break;
}
const now = performance.now();
elapsed += now - last;
last = now;
const t = Math.min(1, Math.max(0, elapsed / waitMs));
this.x = fromX + (cmd.x - fromX) * t;
this.y = fromY + (cmd.y - fromY) * t;
this._redrawSketch();
if (t >= 1) break;
await this._delay(this.previewFrameMs);
}
if (!this._pathAbort && !this._freezeMotion) {
this.x = cmd.x;
this.y = cmd.y;
}
}
/** Wait while paused. Live plots lift the pen until the next segment. */
async _waitIfPaused() {
if (!this.isPaused) return;
if (!this._plotTestOnly && this.connected && this.isPenDown) {
try {
await this.penUp();
} catch (_) {
/* port may be busy */
}
}
while (this.isPaused && !this._pathAbort) {
this._redrawSketch();
await this._delay(this.previewFrameMs);
}
}
_plotElapsedMs() {
let elapsed = performance.now() - this._plotStartedAt - this._pausedAccumMs;
if (this.isPaused && this._pauseStartedAt) {
elapsed -= performance.now() - this._pauseStartedAt;
}
return Math.max(0, elapsed);
}
async _sendSegment(cmd) {
if (!this._requireHome()) return;
if (cmd.type === 'move') {
if (this.isPenDown) {
await this.send(this.penUpGcode);
this.isPenDown = false;
}
// G1 not G0: Marlin G0_FEEDRATE overrides F unless VARIABLE_G0_FEEDRATE is on
await this.send(
`G1 X${this._fmt(cmd.x)} Y${this._fmt(cmd.y)} F${this.feedRateTravel}`
);
} else {
if (!this.isPenDown) {
await this.send(this.penDownGcode);
this.isPenDown = true;
}
await this.send(
`G1 X${this._fmt(cmd.x)} Y${this._fmt(cmd.y)} F${this.feedRateDraw}`
);
}
}
async _sendSegmentAndWait(cmd) {
await this._sendSegment(cmd);
const motionMs = Math.round(this._lastSegmentMs);
await this.send('M400', {
timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, motionMs + 20000),
// M400 is supposed to last as long as the move. Don't call that "stuck".
waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, motionMs + 1500),
});
}
// ===========================================================================
// Internals — serial I/O
// ===========================================================================
_waitForOk(timeoutMs, options = {}) {
const command = options.command || '';
const waitLogAfterMs = options.waitLogAfterMs ?? WallPlotter.OK_WAIT_LOG_MS;
const waitLabel = command ? ` (${command})` : '';
return new Promise((resolve, reject) => {
let settled = false;
let timer = null;
let waitLogTimer = null;
let waitLogInterval = null;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(waitLogTimer);
if (waitLogInterval != null) clearInterval(waitLogInterval);
const idx = this._okResolvers.indexOf(entry);
if (idx >= 0) this._okResolvers.splice(idx, 1);
fn(arg);
};
const armTimer = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const err = new Error(
`timeout waiting for Marlin ok${waitLabel} (${Math.round(timeoutMs / 1000)}s silent)`
);
err.name = 'TimeoutError';
entry.reject(err);
}, timeoutMs);
};
const entry = {
resolve: () => {
if (this.statusText === 'Waiting for plotter…') {
this.statusText =
this._statusBeforeOkWait ??
(this.connected ? 'Connected' : 'Disconnected');
this._statusBeforeOkWait = null;
}
finish(resolve);
},
reject: (err) => finish(reject, err),
bump: () => {
if (!settled) armTimer();
},
};
armTimer();
waitLogTimer = setTimeout(() => {
if (settled) return;
const started = performance.now();
this.onLog(`… waiting for Marlin ok${waitLabel}`);
if (this.statusText !== 'Waiting for plotter…') {
this._statusBeforeOkWait = this.statusText;
}
this.statusText = 'Waiting for plotter…';
this._redrawSketch();
waitLogInterval = setInterval(() => {
const s = Math.round((performance.now() - started) / 1000);
this.onLog(`… still waiting for Marlin ok${waitLabel} (${s}s)`);
}, WallPlotter.OK_WAIT_PING_MS);
}, waitLogAfterMs);
this._okResolvers.push(entry);
});
}
_cancelPendingOk() {
this._okIgnoreUntil = performance.now() + 400;
const pending = this._okResolvers.slice();
for (const entry of pending) {
const err = new Error('aborted');
err.name = 'AbortError';
entry.reject(err);
}
}
_startReadLoop() {
this.readLoopActive = true;
const plotter = this;
const loop = async () => {
while (plotter.port?.readable && plotter.readLoopActive) {
plotter.reader = plotter.port.readable.getReader();
try {
while (plotter.readLoopActive) {
const { value, done } = await plotter.reader.read();
if (done) break;
plotter._rxBuf += new TextDecoder().decode(value);
plotter._drainRxBuffer();
}
} catch (err) {
if (plotter.readLoopActive) {
plotter.onLog('serial read error: ' + err.message);
}
} finally {
try {
plotter.reader.releaseLock();
} catch (_) {
/* ignore */
}
plotter.reader = null;
}
}
};
loop();
}
_drainRxBuffer() {
const lines = this._rxBuf.split(/\r?\n/);
this._rxBuf = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) this._handleResponse(trimmed);
}
// USB-CDC sometimes delivers a complete "ok" without a trailing newline
// until the next chunk. Don't leave that sitting in the buffer.
const rest = this._rxBuf.trim();
if (/^(echo:\s*)?ok\b/i.test(rest)) {
this._handleResponse(rest);
this._rxBuf = '';
}
}
_handleResponse(line) {
if (/busy:\s*processing/i.test(line)) {
this._machineBusy = true;
for (const entry of this._okResolvers) {
if (typeof entry.bump === 'function') entry.bump();
}
const now = performance.now();
if (now - this._lastBusyLogAt > 4000) {
this._lastBusyLogAt = now;
this.onLog('← busy (move still running)');
}
return;
}
this.onLog('← ' + line);
if (/^(echo:\s*)?ok\b/i.test(line)) {
this._machineBusy = false;
const next = this._okResolvers.shift();
if (next) {
next.resolve();
} else if (performance.now() < this._okIgnoreUntil) {
this.onLog('← ok ignored (stale, no waiter)');
}
}
this._parseMachineBounds(line);
if (line.startsWith('X:')) {
const match = line.match(/X:([-\d.]+)\s+Y:([-\d.]+)/);
if (match) {
this.x = parseFloat(match[1]);
this.y = parseFloat(match[2]);
}
}
}
/** Parse polargraph M665 report → machineX/Y min/max. */
_parseMachineBounds(line) {
if (!/\bM665\b/i.test(line)) return;
const l = line.match(/\bL(-?[\d.]+)/i);
const r = line.match(/\bR(-?[\d.]+)/i);
const t = line.match(/\bT(-?[\d.]+)/i);
const b = line.match(/\bB(-?[\d.]+)/i);
if (!l || !r || !t || !b) return;
this.machineXMin = parseFloat(l[1]);
this.machineXMax = parseFloat(r[1]);
this.machineYMax = parseFloat(t[1]);
this.machineYMin = parseFloat(b[1]);
this.onLog(
`machine bounds X ${this.machineXMin}…${this.machineXMax} Y ${this.machineYMin}…${this.machineYMax}`
);
}
_isCoordinateMove(command) {
return /^G[01]\b/i.test(command) && /\b[XY]/i.test(command);
}
_isMachineIdle() {
return this._okResolvers.length === 0 && !this._machineBusy;
}
_showHomeRequiredWarning() {
const msg = 'Home not set — press H before sending moves';
this._homeWarning = msg;
this.statusText = msg;
this.onLog('! ' + msg);
}
_requireHome() {
if (this.homeSet) {
this._homeWarning = null;
return true;
}
this._showHomeRequiredWarning();
return false;
}
// ===========================================================================
// Internals — keyboard, jog, plot buttons
// ===========================================================================
async _handleKeyPressed() {
const k = key.toLowerCase();
if (k === 'c') {
await this._toggleConnection();
return false;
}
if (k === 's') {
await this.stopPlot();
return false;
}
if (k === 't') {
this._onTestButton();
return false;
}
if (!this._keysEnabled || !this.connected) return;
if (k === 'h') {
await this.setHomeHere();
return false;
}
if (k === '0') {
if (this.isPlotting || !this._isMachineIdle()) return false;
await this.goToZero();
return false;
}
if (k === 'p') {
if (this.isPenDown) await this.penUp(true);
else await this.penDown(true);
return false;
}
if (k === '+' || k === '=') {
this.stepSize = Math.min(this.stepSize + 5, 100);
return false;
}
if (k === '-') {
this.stepSize = Math.max(this.stepSize - 5, 1);
return false;
}
}
_handleContinuousJog() {
if (!this.connected || this._jogInFlight || this.isPlotting) return;
if (!this.homeSet || !this._isMachineIdle()) return;
const draw = keyIsDown(SHIFT);
const s = this.stepSize;
let dx = 0;
let dy = 0;
if (keyIsDown(LEFT_ARROW)) dx -= s;
if (keyIsDown(RIGHT_ARROW)) dx += s;
if (keyIsDown(UP_ARROW)) dy += s;
if (keyIsDown(DOWN_ARROW)) dy -= s;
if (dx === 0 && dy === 0) {
this._lastJogTime = 0;
return;
}
const now = millis();
if (this._lastJogTime > 0 && now - this._lastJogTime < this.jogIntervalMs) return;
this._lastJogTime = now;
this._jogInFlight = true;
this._jogBy(dx, dy, draw)
.catch(() => {})
.finally(() => {
this._jogInFlight = false;
});
}
async _jogBy(dx, dy, draw = false) {
try {
if (!this._requireHome()) return;
if (draw) {
await this.drawTo(this.x + dx, this.y + dy);
return;
}
await this.penUp();
const nx = this.x + dx;
const ny = this.y + dy;
await this.send(
`G1 X${this._fmt(nx)} Y${this._fmt(ny)} F${this.feedRateTravel}`
);
this.x = nx;
this.y = ny;
} catch (err) {
if (err.name === 'AbortError') return;
throw err;
}
}
async _toggleConnection() {
if (this._connecting) return;
try {
if (this.connected) await this.disconnect();
else {
this._connecting = true;
this.statusText = 'Connecting…';
this._updatePlotButtons();
await this.connect();
}
} catch (err) {
if (err.name === 'AbortError') return;
this.statusText = 'Error: ' + err.message;
this.onLog('! ' + err.message);
} finally {
this._connecting = false;
this._updatePlotButtons();
}
}
_onConnectButton() {
if (this.connected || this._connecting) return;
this._toggleConnection();
}
_onPlotButton() {
if (this.isPlotting) {
if (this.isPaused) this.resumePlot();
else this.pausePlot();
return;
}
if (!this.connected) return;
this._triggerSketchFn('plot');
}
_onTestButton() {
const now = millis();
if (this.isPlotting && this._plotTestOnly) {
if (now - this._lastTestBtnClick < 450) this._restartPathAfterAbort('test');
else this.stopPlot();
this._lastTestBtnClick = now;
return;
}
this._lastTestBtnClick = now;
if (this.isPlotting) {
this._restartPathAfterAbort('test');
return;
}
this._triggerSketchFn('test');
}
_restartPathAfterAbort(mode) {
this._pendingAfterAbort = mode;
this._pathAbort = true;
this.isPaused = false;
this._freezeMotion = true;
this._cancelPendingOk();
}
_updatePlotButtons() {
if (this._connectBtn) {
if (this.connected) this._connectBtn.hide();
else this._connectBtn.show();
this._connectBtn.elt.disabled = this._connecting;
}
if (this._plotBtn) {
let label = this._plotLabel;
if (this.isPlotting) {
label = this.isPaused ? this._resumeLabel : this._pauseLabel;
}
this._plotBtn.html(label);
this._plotBtn.elt.disabled = !this.connected && !this.isPlotting;
}
if (this._testBtn) {
this._testBtn.html(
this.isPlotting && this._plotTestOnly ? this._stopLabel : this._testLabel
);
}
}
_triggerSketchFn(name) {
const fn = window[name];
if (typeof fn !== 'function') {
this.onLog(`! sketch has no ${name}() function`);
return;
}
Promise.resolve(fn()).catch((err) => {
this.statusText = 'Error: ' + err.message;
this.onLog('! ' + err.message);
});
}
_injectPlotButtonStyles() {
if (document.getElementById('wallplotter-btn-styles')) return;
const style = document.createElement('style');
style.id = 'wallplotter-btn-styles';
style.textContent = `
#wallplotter-controls {
position: absolute;
top: 2.35rem;
right: 0.75rem;
z-index: 20;
display: flex;
gap: 0.5rem;
margin: 0;
}
#wallplotter-controls button {
background: rgba(42, 42, 48, 0.9);
border: 1px solid #444;
border-radius: 6px;
color: #ddd;
cursor: pointer;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
line-height: 1;
padding: 0.4rem 1rem;
}
#wallplotter-controls button:hover {
background: #35353c;
border-color: #666;
color: #eee;
}
#wallplotter-controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
color: #888;
}
#wallplotter-controls button:disabled:hover {
background: rgba(42, 42, 48, 0.9);
border-color: #444;
color: #888;
}
`;
document.head.appendChild(style);
}
// ===========================================================================
// Internals — overlay / HUD drawing
// ===========================================================================
/** Connected → Marlin bounds; offline → centred preview square. */
_getDisplayBounds() {
if (this.connected) {
return {
xMin: this.machineXMin,
xMax: this.machineXMax,
yMin: this.machineYMin,
yMax: this.machineYMax,
preview: false,
};
}
const half = this.previewSizeMm / 2;
return {
xMin: -half,
xMax: half,
yMin: -half,
yMax: half,
preview: true,
};
}
_getPlotLayout() {
const margin = 60;
const size = Math.min(width, height) - margin * 2;
const b = this._getDisplayBounds();
const worldW = Math.max(1, b.xMax - b.xMin);
const worldH = Math.max(1, b.yMax - b.yMin);
return {
cx: width / 2,
cy: height / 2 - 20,
scale: Math.min(size / worldW, size / worldH),
worldCx: 0,
worldCy: 0,
};
}
_worldToScreen(worldX, worldY, layout) {
return {
x: (worldX - layout.worldCx) * layout.scale,
y: -(worldY - layout.worldCy) * layout.scale,
};
}
_drawPlotArea() {
const layout = this._getPlotLayout();
const bounds = this._getDisplayBounds();
push();
translate(layout.cx, layout.cy);
const tl = this._worldToScreen(bounds.xMin, bounds.yMax, layout);
const br = this._worldToScreen(bounds.xMax, bounds.yMin, layout);
const w = br.x - tl.x;
const h = br.y - tl.y;
noFill();
rectMode(CORNER);
strokeWeight(1);
if (bounds.preview) {
stroke(90, 90, 110);
drawingContext.setLineDash([8, 6]);
} else {
stroke(70);
drawingContext.setLineDash([]);
}
rect(tl.x, tl.y, w, h);
drawingContext.setLineDash([]);
stroke(60);
line(tl.x, 0, br.x, 0);
line(0, tl.y, 0, br.y);
this._drawBoundSizeLabels(bounds, tl, br, w, h);
this._drawPreviewPath(layout);
this._drawGondola(layout);
pop();
}
_drawBoundSizeLabels(bounds, tl, br, w, h) {
const widthCm = (bounds.xMax - bounds.xMin) / 10;
const heightCm = (bounds.yMax - bounds.yMin) / 10;
const fmtCm = (cm) => (Number.isInteger(cm) ? `${cm}` : cm.toFixed(1));
const widthLabel = `${fmtCm(widthCm)} cm · ${Math.round(Math.abs(w))} px`;
const heightLabel = `${fmtCm(heightCm)} cm · ${Math.round(Math.abs(h))} px`;
noStroke();
fill(bounds.preview ? 140 : 160);
textFont('monospace', 12);
textAlign(CENTER, TOP);
text(widthLabel, (tl.x + br.x) / 2, br.y + 8);
push();
translate(br.x + 8, (tl.y + br.y) / 2);
rotate(HALF_PI);
textAlign(CENTER, BOTTOM);
text(heightLabel, 0, 0);
pop();
}
_drawPreviewPath(layout) {
if (!this.previewPath.length) return;
let px = null;
let py = null;
for (let i = 0; i < this.previewPath.length; i++) {
const cmd = this.previewPath[i];
if (cmd.type !== 'move' && cmd.type !== 'draw') continue;
const p = this._worldToScreen(cmd.x, cmd.y, layout);
const isFuture = this.isPlotting && i >= this.plotProgress;
if (px !== null && py !== null) {
if (cmd.type === 'draw') {
stroke(255, 200, 60, isFuture ? 60 : 255);
strokeWeight(2);
line(px, py, p.x, p.y);
} else {
stroke(100, 100, 120, isFuture ? 60 : 200);
strokeWeight(1);
drawingContext.setLineDash([4, 4]);
line(px, py, p.x, p.y);
drawingContext.setLineDash([]);
}
}
px = p.x;
py = p.y;
}
}
_drawGondola(layout) {
const pos = this._worldToScreen(this.x, this.y, layout);
if (this.isPenDown) {
stroke(255, 90, 90);
fill(255, 90, 90);
} else {
stroke(90, 200, 255);
fill(90, 200, 255);
}
strokeWeight(2);
circle(pos.x, pos.y, this.isPlotting ? 18 : 12);
if (this.isPlotting) {
noFill();
stroke(255, 255, 255, 120);
strokeWeight(1);
circle(pos.x, pos.y, 26);
}
}
_drawHud() {
fill(220);
noStroke();
textAlign(LEFT, TOP);
text(this.statusText, 16, 16);
fill(140);
text(`lib ${WallPlotter.VERSION}`, 16, 32);
fill(220);
text(
`pos X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} step ${this.stepSize} mm`,
16,
52
);
text(`pen ${this.isPenDown ? 'down (drawing)' : 'up (travel)'}`, 16, 72);
text(
`speed travel ${this.feedRateTravel} draw ${this.feedRateDraw} mm/min ×${this.timingMultiplier}`,
16,
92
);
text(`home ${this.homeSet ? 'set' : 'not set'}`, 16, 112);
if (this.connected) {
text(
`machine X ${this.machineXMin.toFixed(0)}…${this.machineXMax.toFixed(0)} Y ${this.machineYMin.toFixed(0)}…${this.machineYMax.toFixed(0)} mm`,
16,
132
);
} else {
const cm = this.previewSizeMm / 10;
text(`preview ${cm} × ${cm} cm (connect for machine bounds)`, 16, 132);
}
if (this.isPlotting) {
fill(180, 220, 255);
text(
`${this.isPaused ? 'paused' : 'plotting'} ${this.plotProgress}/${this.previewPath.length}`,
16,
152
);
const elapsedMs = this._plotElapsedMs();
const remainMs = Math.max(0, this._plotEstimateMs - elapsedMs);
text(
`elapsed ${this._formatDuration(elapsedMs)} est ${this._formatDuration(this._plotEstimateMs)} remain ${this._formatDuration(remainMs)}`,
16,
172
);
text(
`seg wait ${this._lastSegmentMs.toFixed(0)} ms ×${this.timingMultiplier}`,
16,
192
);
fill(220);
}
if (this.connected && !this.homeSet && this._homeWarning) {
fill(255, 180, 80);
text(`! ${this._homeWarning}`, 16, this.isPlotting ? 212 : 152);
fill(220);
}
if (this._keysEnabled) {
textAlign(LEFT, BOTTOM);
const help = [
'← → ↑ ↓ move (hold for continuous)',
'Shift+arrow draw (pen down)',
'+ / - step size',
'H set current pos as home (G92) — required first',
'0 go to origin (0, 0)',
'P toggle pen (test up/down angles)',
'T run test (preview)',
'S stop sending (in-flight stroke still finishes)',
'C connect / disconnect',
];
for (let i = 0; i < help.length; i++) {
text(help[i], 16, height - 16 - i * 18);
}
}
textAlign(RIGHT, BOTTOM);
fill(140);
const n = this.logLines.length;
for (let i = 0; i < n; i++) {
text(this.logLines[i], width - 16, height - 16 - (n - 1 - i) * 16);
}
}
// ===========================================================================
// Internals — small utilities
// ===========================================================================
_fmt(value) {
return Number(value).toFixed(3);
}
_formatDuration(ms) {
const s = Math.max(0, ms) / 1000;
if (s < 60) return `${s.toFixed(1)} s`;
const totalSec = Math.round(s);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const sec = totalSec % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
return `${m}:${String(sec).padStart(2, '0')}`;
}
_delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
_redrawSketch() {
if (typeof redraw === 'function') redraw();
}
}