Files
p5-wallplotter/lib/WallPlotter.js
T
2026-09-02 17:34:31 +02:00

2065 lines
61 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.3.13';
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;
/** If a USB write does not complete, the ACM device is usually gone. */
static WRITE_TIMEOUT_MS = 3000;
static RESUME_STORAGE_KEY = 'wallplotter.resume.v1';
/** Send a quiet M400 if the line has been silent this long (keeps USB awake). */
static KEEPALIVE_MS = 1200;
// ---------------------------------------------------------------------------
// 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=40]
* @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 ?? 40;
this.previewMinDelayMs = options.previewMinDelayMs ?? 30;
this.previewFrameMs = options.previewFrameMs ?? 16;
// --- Link: disconnected | connecting | connected ---
this._link = 'disconnected';
this.homeSet = false;
this.isPenDown = false;
this.x = 0;
this.y = 0;
this.statusText = 'Disconnected';
this.logLines = [];
// --- Job: idle | running | paused | aborting ---
this._job = 'idle';
this._path = [];
this.previewPath = [];
this.plotProgress = 0;
this._plotTestOnly = false;
this._pathRunId = 0;
this._pathLoopActive = false;
this._interrupted = null;
this._plotStartedAt = 0;
this._plotEstimateMs = 0;
this._pausedAccumMs = 0;
this._pauseStartedAt = 0;
this._lastSegmentMs = 0;
// --- Serial: one command in flight, wait for its ok ---
this.port = null;
this.writer = null;
this.reader = null;
this.readLoopActive = false;
this._rxBuf = '';
this._okWaiter = null;
this._sendChain = Promise.resolve();
this._lastBusyLogAt = 0;
this._lastSendAt = 0;
this._keepaliveTimer = null;
// --- 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._lastPlotBtnClick = 0;
// --- Callbacks ---
const userOnLog = options.onLog ?? (() => {});
this.onLog = (line) => {
this.logLines.push(line);
if (this.logLines.length > this.maxLogLines) this.logLines.shift();
this._syncLogOverlay();
userOnLog(line);
};
const userOnStatusChange = options.onStatusChange ?? (() => {});
this.onStatusChange = (status) => {
this._syncStatus();
this._updatePlotButtons();
userOnStatusChange(status);
};
this._unloadBound = false;
this._installUnloadHandler();
this._loadResumeState();
}
get connected() {
return this._link === 'connected';
}
get isPlotting() {
return this._job !== 'idle';
}
get isPaused() {
return this._job === 'paused';
}
_setJob(next) {
if (this._job === next) return;
if (this._job === 'paused' && this._pauseStartedAt) {
this._pausedAccumMs += performance.now() - this._pauseStartedAt;
}
this._job = next;
this._pauseStartedAt = next === 'paused' ? performance.now() : 0;
this._syncStatus();
this._updatePlotButtons();
this._redrawSketch();
}
_abortJob() {
if (this._job === 'idle') return;
this._setJob('aborting');
}
_syncStatus() {
if (this._link === 'disconnected') this.statusText = 'Disconnected';
else if (this._link === 'connecting') this.statusText = 'Connecting…';
else if (this._job === 'paused') this.statusText = 'Paused';
else if (this._job === 'aborting') this.statusText = 'Stopped';
else if (this._job === 'running') {
this.statusText = this._plotTestOnly ? 'Testing' : 'Plotting';
} else this.statusText = 'Connected';
}
// ===========================================================================
// 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();
this._syncLogOverlay();
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._clearResumeState();
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 after the in-flight command's `ok`. */
stopPath() {
this._clearResumeState();
this._abortJob();
}
/** Pause the current runPath after the in-flight segment. */
pausePlot() {
if (this._job !== 'running') return;
this._setJob('paused');
this.onLog('plot paused');
}
/** Continue a paused runPath (including after Stop or a timeout). */
resumePlot() {
if (this._job !== 'paused') return;
this._setJob('running');
this.onLog('plot resumed');
}
/**
* 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() {
if (this._job === 'paused') {
this._abortJob();
this.onLog(
'plot aborted — motors finish the in-flight move (unplug power for a hard stop)'
);
return;
}
if (this._job === 'running') {
this.pausePlot();
this.onLog('plot stopped after current move — resume to continue');
return;
}
if (this._okWaiter) {
this._cancelPendingOk();
this.onLog('serial wait cancelled');
this._syncStatus();
}
}
// ===========================================================================
// 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._link === '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._bindSerialEvents(port);
this.writer = this.port.writable.getWriter();
this._startReadLoop();
this._startKeepalive();
this._link = 'connected';
this.onStatusChange('connected');
try {
await this.send('M17', { allowWithoutHome: true });
await this.penUp(true);
await this.send('M665', { allowWithoutHome: true });
await this._syncPoseFromMachine();
if (this._interrupted) {
this.onLog(
`saved path ${this._interrupted.index}/${this._interrupted.commands.length} — Plot resumes`
);
this.statusText = 'Connected — Plot to resume';
this._updatePlotButtons();
}
} 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,
bufferSize: 65536,
flowControl: 'none',
});
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]);
if (typeof port.setSignals === 'function') {
try {
await port.setSignals({ dataTerminalReady: true, requestToSend: false });
} catch (_) {
/* some stacks ignore control lines */
}
}
} catch (err) {
try {
await port.close();
} catch (_) {
/* ignore */
}
throw err;
}
}
/** Close the serial port. */
async disconnect() {
if (this._link === 'disconnected' && !this.port) return;
this.readLoopActive = false;
this._stopKeepalive();
this._cancelPendingOk();
this._rxBuf = '';
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._link = 'disconnected';
this.onStatusChange('disconnected');
}
/** Best-effort close for page reload (must not await — Mac keeps the USB lock otherwise). */
_releaseSerialNow() {
if (!this.port && this._link === 'disconnected') return;
this.readLoopActive = false;
this._stopKeepalive();
this._cancelPendingOk();
this._rxBuf = '';
const reader = this.reader;
const writer = this.writer;
const port = this.port;
this.reader = null;
this.writer = null;
this.port = null;
this._link = 'disconnected';
try {
reader?.cancel();
} catch (_) {
/* ignore */
}
try {
reader?.releaseLock();
} catch (_) {
/* ignore */
}
try {
writer?.releaseLock();
} catch (_) {
/* ignore */
}
try {
port?.close();
} catch (_) {
/* ignore */
}
}
_writeLine(command, quiet = false) {
if (!this.writer) {
return Promise.reject(new Error('Not connected to plotter'));
}
const bytes = new TextEncoder().encode(command + '\n');
return new Promise((resolve, reject) => {
let done = false;
const timer = setTimeout(() => {
if (done) return;
done = true;
const err = new Error('serial write timeout — USB port likely gone');
err.name = 'TimeoutError';
reject(err);
}, WallPlotter.WRITE_TIMEOUT_MS);
this.writer.write(bytes).then(
() => {
if (done) return;
done = true;
clearTimeout(timer);
if (!quiet) this.onLog('→ ' + command);
resolve();
},
(err) => {
if (done) return;
done = true;
clearTimeout(timer);
reject(err);
}
);
});
}
_bindSerialEvents(port) {
if (this._serialDisconnectBound) {
navigator.serial.removeEventListener('disconnect', this._serialDisconnectBound);
}
this._serialDisconnectBound = (event) => {
if (event.target === port || event.target === this.port) {
this._onSerialGone('device unplugged or re-enumerated');
}
};
navigator.serial.addEventListener('disconnect', this._serialDisconnectBound);
if (port && typeof port.addEventListener === 'function') {
port.addEventListener('disconnect', () => {
this._onSerialGone('device unplugged or re-enumerated');
});
}
}
_onSerialGone(reason) {
if (this._link === 'disconnected') return;
this._stashInterruptedPath();
this.onLog('! USB serial lost — ' + reason);
this.onLog(
`kept pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} — reconnect will ask Marlin (M114)`
);
this._abortJob();
this._releaseSerialNow();
this.statusText = this._interrupted
? 'USB disconnected — reconnect, then Plot to resume'
: 'USB disconnected — reconnect';
this._updatePlotButtons();
this._redrawSketch();
}
_stashInterruptedPath() {
if (this._job === 'idle' && !this._pathLoopActive) return;
const commands = this.previewPath;
if (!commands.length) return;
if (this.plotProgress >= commands.length) return;
this._interrupted = {
commands,
index: this.plotProgress,
testOnly: this._plotTestOnly,
startedAt: this._plotStartedAt,
pausedAccum: this._pausedAccumMs,
estimateMs: this._plotEstimateMs,
};
this.onLog(
`plot paused at ${this.plotProgress}/${commands.length} — Plot resumes this path`
);
this._persistResumeState();
}
_resumeInterruptedPath() {
const hit = this._interrupted;
if (!hit) return false;
this._interrupted = null;
this.onLog(
`resuming path at ${hit.index}/${hit.commands.length}`
);
Promise.resolve(
this._executePath(hit.commands, hit.testOnly, hit.index, hit)
).catch((err) => {
this.statusText = 'Error: ' + err.message;
this.onLog('! ' + err.message);
});
return true;
}
_installUnloadHandler() {
if (this._unloadBound) return;
this._unloadBound = true;
const release = () => {
this._stashInterruptedPath();
this._persistResumeState(true);
this._releaseSerialNow();
};
window.addEventListener('pagehide', release);
window.addEventListener('beforeunload', release);
window.addEventListener('unload', release);
window.addEventListener('pageshow', (event) => {
if (event.persisted) this._releaseSerialNow();
});
}
_resumeSnapshot() {
if (this._interrupted) return this._interrupted;
if (this._job === 'idle' || this._plotTestOnly) return null;
if (!this.previewPath.length || this.plotProgress >= this.previewPath.length) {
return null;
}
return {
commands: this.previewPath,
index: this.plotProgress,
testOnly: false,
startedAt: this._plotStartedAt,
pausedAccum: this._pausedAccumMs,
estimateMs: this._plotEstimateMs,
x: this.x,
y: this.y,
};
}
_persistResumeState(force = false) {
const snap = this._resumeSnapshot();
if (!snap || snap.testOnly) {
if (force) this._clearResumeState();
return;
}
const now = performance.now();
if (!force && now - (this._lastResumePersistAt || 0) < 800) return;
this._lastResumePersistAt = now;
try {
sessionStorage.setItem(
WallPlotter.RESUME_STORAGE_KEY,
JSON.stringify({
index: snap.index,
testOnly: false,
startedAt: snap.startedAt,
pausedAccum: snap.pausedAccum,
estimateMs: snap.estimateMs,
x: snap.x ?? this.x,
y: snap.y ?? this.y,
commands: snap.commands,
})
);
} catch (_) {
/* quota / private mode */
}
}
_loadResumeState() {
let raw = null;
try {
raw = sessionStorage.getItem(WallPlotter.RESUME_STORAGE_KEY);
} catch (_) {
return;
}
if (!raw) return;
try {
const snap = JSON.parse(raw);
if (!Array.isArray(snap.commands) || !snap.commands.length) return;
const index = Math.max(0, Math.min(snap.index ?? 0, snap.commands.length));
if (index >= snap.commands.length) {
this._clearResumeState();
return;
}
this.previewPath = snap.commands;
this.plotProgress = index;
this._plotEstimateMs = snap.estimateMs ?? 0;
this._plotStartedAt = snap.startedAt ?? 0;
this._pausedAccumMs = snap.pausedAccum ?? 0;
if (Number.isFinite(snap.x)) this.x = snap.x;
if (Number.isFinite(snap.y)) this.y = snap.y;
this._interrupted = {
commands: snap.commands,
index,
testOnly: false,
startedAt: this._plotStartedAt,
pausedAccum: this._pausedAccumMs,
estimateMs: this._plotEstimateMs,
x: this.x,
y: this.y,
};
} catch (_) {
this._clearResumeState();
}
}
_clearResumeState() {
this._interrupted = null;
try {
sessionStorage.removeItem(WallPlotter.RESUME_STORAGE_KEY);
} catch (_) {
/* ignore */
}
}
/**
* 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;
}
this._lastSendAt = performance.now();
const waitOk = options.waitOk !== false;
const quiet = options.quiet === true || /^M400\b/i.test(command);
const okPromise = waitOk
? this._waitForOk(options.timeoutMs ?? WallPlotter.OK_TIMEOUT_MS, {
command,
waitLogAfterMs: options.waitLogAfterMs,
quiet,
})
: null;
try {
const written = this._writeLine(command, quiet);
if (okPromise) {
await Promise.all([
written.catch((err) => {
this._cancelPendingOk();
throw err;
}),
okPromise,
]);
} else {
await written;
}
} catch (err) {
if (err.name === 'AbortError') throw err;
if (/USB|serial write timeout|NetworkError/i.test(err.message)) {
this._onSerialGone(err.message);
}
const timeoutErr = new Error(err.message);
timeoutErr.name = 'TimeoutError';
this.onLog('! ' + err.message);
if (this._job === 'idle') 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._job !== 'idle') {
this.onLog('! cannot set home while plotting — resume or abort first');
this.statusText = 'Finish or abort plot before H';
return;
}
if (this._okWaiter) {
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;
this._syncStatus();
}
/**
* Ask Marlin where it thinks it is. USB re-enum often keeps G92;
* only the polargraph boot pose (belts at max length, Y ≈ -2600) means unhomed.
*/
async _syncPoseFromMachine() {
const beforeX = this.x;
const beforeY = this.y;
try {
await this.send('M114', { timeoutMs: 3000, allowWithoutHome: true });
} catch (_) {
/* keep last browser pose */
}
if (this._isUnhomedBootPose(this.x, this.y)) {
this.homeSet = false;
this._homeWarning = 'Home not set — jog to origin and press H';
this.onLog(
`Marlin at boot pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} — press H`
);
return;
}
this.homeSet = true;
this._homeWarning = null;
this.onLog(
`restored Marlin pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} (was ${beforeX.toFixed(1)}, ${beforeY.toFixed(1)})`
);
this._syncStatus();
}
/** Polargraph default after reset: equal max belts → X≈0, Y≈-2600. */
_isUnhomedBootPose(x, y) {
const span = (this.machineXMax - this.machineXMin) / 2;
const maxBelt = 3500;
const expectedY =
this.machineYMax - Math.sqrt(Math.max(0, maxBelt * maxBelt - span * span));
return Math.abs(x) < 20 && Math.abs(y - expectedY) < 80;
}
// ===========================================================================
// 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, fromIndex = 0, timing = null) {
if (!testOnly && !this._requireHome()) return;
const runId = ++this._pathRunId;
if (this._pathLoopActive) this._abortJob();
while (this._pathLoopActive) {
if (runId !== this._pathRunId) return;
await this._delay(16);
}
if (runId !== this._pathRunId) return;
this._interrupted = null;
this._pathLoopActive = true;
this._plotTestOnly = testOnly;
this.previewPath = commands;
const startAt = Math.max(0, Math.min(fromIndex, commands.length));
this.plotProgress = startAt;
if (timing) {
this._plotStartedAt = timing.startedAt ?? performance.now();
this._pausedAccumMs = timing.pausedAccum ?? 0;
this._plotEstimateMs = timing.estimateMs ?? this._estimatePathDurationMs(commands, this.x, this.y);
} else {
this._plotStartedAt = performance.now();
this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y);
this._pausedAccumMs = 0;
}
this._lastSegmentMs = 0;
this._setJob('running');
const savedFeedTravel = this.feedRateTravel;
const savedFeedDraw = this.feedRateDraw;
for (let i = 0; i < startAt; i++) {
const cmd = commands[i];
if (cmd.type === 'speed') {
if (cmd.travel != null) this.feedRateTravel = cmd.travel;
if (cmd.draw != null) this.feedRateDraw = cmd.draw;
}
}
let px = this.x;
let py = this.y;
const stopped = () => this._job === 'aborting' || runId !== this._pathRunId;
try {
for (let i = startAt; i < commands.length; i++) {
if (stopped()) break;
await this._waitIfPaused();
if (stopped()) 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._persistResumeState();
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),
waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, ms + 1500),
});
}
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' || stopped()) break;
if (err.name === 'TimeoutError') {
if (this._job === 'running') this.pausePlot();
this.onLog('segment skipped after timeout — resume continues from here');
px = this.x;
py = this.y;
await this._waitIfPaused();
if (stopped()) break;
this.plotProgress = i + 1;
this._persistResumeState();
continue;
}
throw err;
}
if (stopped()) break;
if (cmd.type === 'move' || cmd.type === 'draw') {
px = cmd.x;
py = cmd.y;
}
this.plotProgress = i + 1;
this._persistResumeState();
}
} 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._plotTestOnly = false;
this._setJob('idle');
this._pathLoopActive = false;
if (this.plotProgress >= commands.length) this._clearResumeState();
}
}
/** 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._job === 'aborting') return;
if (this._job === 'paused') {
await this._waitIfPaused();
last = performance.now();
if (this._job === 'aborting') 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._job === 'aborting') break;
if (this._plotTestOnly && this._job === 'paused') {
await this._waitIfPaused();
last = performance.now();
if (this._job === 'aborting') 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._job !== 'aborting') {
this.x = cmd.x;
this.y = cmd.y;
}
}
/** Wait while paused. Live plots lift the pen until the next segment. */
async _waitIfPaused() {
if (this._job !== 'paused') return;
if (!this._plotTestOnly && this.connected && this.isPenDown) {
try {
await this.penUp();
} catch (_) {
/* port may be busy */
}
}
while (this._job === 'paused') {
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 quiet = options.quiet === true;
const waitLogAfterMs = quiet
? Math.max(options.waitLogAfterMs ?? WallPlotter.OK_WAIT_LOG_MS, 8000)
: options.waitLogAfterMs ?? WallPlotter.OK_WAIT_LOG_MS;
const waitLabel = command && !quiet ? ` (${command})` : '';
return new Promise((resolve, reject) => {
if (this._okWaiter) {
reject(new Error('serial already waiting for ok'));
return;
}
let settled = false;
let timer = null;
let waitLogTimer = null;
let waitLogInterval = null;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
if (this._okWaiter === entry) this._okWaiter = null;
clearTimeout(timer);
clearTimeout(waitLogTimer);
if (waitLogInterval != null) clearInterval(waitLogInterval);
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: () => {
finish(resolve);
this._syncStatus();
},
reject: (err) => finish(reject, err),
bump: () => {
if (!settled) armTimer();
},
};
this._okWaiter = entry;
armTimer();
waitLogTimer = setTimeout(() => {
if (settled) return;
const started = performance.now();
this.onLog(`… waiting for Marlin ok${waitLabel}`);
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);
});
}
_cancelPendingOk() {
const waiter = this._okWaiter;
if (!waiter) return;
const err = new Error('aborted');
err.name = 'AbortError';
waiter.reject(err);
}
_startKeepalive() {
this._stopKeepalive();
this._keepaliveTimer = setInterval(() => {
this._keepaliveTick();
}, WallPlotter.KEEPALIVE_MS);
}
_stopKeepalive() {
if (this._keepaliveTimer != null) {
clearInterval(this._keepaliveTimer);
this._keepaliveTimer = null;
}
}
_keepaliveTick() {
if (this._link !== 'connected' || !this.writer) return;
if (this._okWaiter || this._job === 'aborting') return;
if (performance.now() - this._lastSendAt < WallPlotter.KEEPALIVE_MS) return;
this.send('M400', {
quiet: true,
allowWithoutHome: true,
timeoutMs: 4000,
waitLogAfterMs: 10000,
}).catch(() => {});
}
_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) {
if (plotter.readLoopActive) {
plotter._onSerialGone('serial stream closed');
}
break;
}
plotter._rxBuf += new TextDecoder().decode(value);
plotter._drainRxBuffer();
}
} catch (err) {
if (plotter.readLoopActive) {
plotter._onSerialGone(err.message || 'serial read error');
}
} 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)) {
if (this._okWaiter && typeof this._okWaiter.bump === 'function') {
this._okWaiter.bump();
}
const now = performance.now();
if (now - this._lastBusyLogAt > 4000) {
this._lastBusyLogAt = now;
this.onLog('← busy (move still running)');
}
return;
}
const isOk = /^(echo:\s*)?ok\b/i.test(line);
if (!isOk) this.onLog('← ' + line);
if (isOk) {
const waiter = this._okWaiter;
if (waiter) waiter.resolve();
}
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._job === 'idle' && !this._okWaiter;
}
_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._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._isMachineIdle()) return;
if (!this.homeSet) 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._link === 'connecting') return;
try {
if (this.connected) await this.disconnect();
else {
this._link = 'connecting';
this._syncStatus();
this._updatePlotButtons();
await this.connect();
}
} catch (err) {
if (err.name === 'AbortError') return;
this._link = 'disconnected';
this.statusText = 'Error: ' + err.message;
this.onLog('! ' + err.message);
} finally {
if (this._link === 'connecting') this._link = 'disconnected';
this._updatePlotButtons();
}
}
_onConnectButton() {
if (this._link !== 'disconnected') return;
this._toggleConnection();
}
_onPlotButton() {
if (this._job === 'paused') {
this.resumePlot();
return;
}
if (this._job === 'running') {
this.pausePlot();
return;
}
if (this._job !== 'idle' || !this.connected) return;
const now = typeof millis === 'function' ? millis() : Date.now();
if (this._interrupted && !this._interrupted.testOnly) {
if (now - this._lastPlotBtnClick < 450) {
this._lastPlotBtnClick = now;
this._clearResumeState();
this._triggerSketchFn('plot');
return;
}
this._lastPlotBtnClick = now;
this._resumeInterruptedPath();
return;
}
this._lastPlotBtnClick = now;
this._triggerSketchFn('plot');
}
_onTestButton() {
const now = millis();
if (this._job === 'running' && this._plotTestOnly) {
if (now - this._lastTestBtnClick < 450) this._restartPath('test');
else this.stopPlot();
this._lastTestBtnClick = now;
return;
}
this._lastTestBtnClick = now;
this._restartPath('test');
}
_restartPath(mode) {
this._clearResumeState();
if (this._pathLoopActive) this._abortJob();
this._triggerSketchFn(mode);
}
_updatePlotButtons() {
if (this._connectBtn) {
if (this.connected) this._connectBtn.hide();
else this._connectBtn.show();
this._connectBtn.elt.disabled = this._link === 'connecting';
}
if (this._plotBtn) {
let label = this._plotLabel;
if (this._job === 'paused') label = this._resumeLabel;
else if (this._interrupted && this._job === 'idle') label = this._resumeLabel;
else if (this._job === 'running' || this._job === 'aborting') {
label = this._pauseLabel;
}
this._plotBtn.html(label);
this._plotBtn.elt.disabled =
this._job === 'aborting' || (!this.connected && this._job === 'idle');
}
if (this._testBtn) {
this._testBtn.html(
this._job !== 'idle' && 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;
}
#wallplotter-log {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
z-index: 20;
max-width: min(52rem, calc(100vw - 1.5rem));
max-height: 42vh;
overflow: auto;
user-select: text;
-webkit-user-select: text;
pointer-events: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.4;
color: #c8c8d0;
background: rgba(18, 18, 22, 0.88);
border: 1px solid #3a3a42;
border-radius: 6px;
padding: 0.45rem 0.6rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
`;
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 || !!this._interrupted) && 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 || this._interrupted) {
fill(180, 220, 255);
const label = this._interrupted && this._job === 'idle'
? 'interrupted'
: this._job === 'paused'
? 'paused'
: this._job === 'aborting'
? 'stopped'
: 'plotting';
text(
`${label} ${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);
}
}
}
_ensureLogOverlay() {
this._injectPlotButtonStyles();
let el = document.getElementById('wallplotter-log');
if (el) return el;
el = document.createElement('pre');
el.id = 'wallplotter-log';
el.setAttribute('aria-label', 'Serial log');
document.body.appendChild(el);
return el;
}
_syncLogOverlay() {
const el = this._ensureLogOverlay();
const text = this.logLines.join('\n');
if (el.textContent !== text) {
el.textContent = text;
el.scrollTop = el.scrollHeight;
}
el.style.display = this.logLines.length ? 'block' : 'none';
}
// ===========================================================================
// 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();
}
}