Files
p5-wallplotter/lib/WallPlotter.js
T
Martin FuchsandCursor e293b2f434 Add WallPlotter p5 library with zigzag example.
Standalone package for Marlin polargraph control over Web Serial, including API docs and a runnable zigzag sketch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 18:36:23 +02:00

1086 lines
30 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-friendly library for Marlin polargraph control via Web Serial.
*
* Provides moveTo (pen up, rapid travel) and drawTo (pen down, draw move).
* Optional: enableKeyInputs() and showInfos() for built-in jog UI.
* Requires Chrome/Edge and a page served over localhost or HTTPS.
*/
class WallPlotter {
static VERSION = 'wait-ok+M400';
static MAX_FEED_MM_MIN = 10000;
static MIN_FEED_MM_MIN = 1;
constructor(options = {}) {
this.baudRate = options.baudRate ?? 115200;
this.stepSize = options.stepSize ?? 10;
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.machineXMin = options.machineXMin ?? -850;
this.machineXMax = options.machineXMax ?? 850;
this.machineYMin = options.machineYMin ?? -1000;
this.machineYMax = options.machineYMax ?? 1000;
this.jogIntervalMs = options.jogIntervalMs ?? 150;
this.maxLogLines = options.maxLogLines ?? 8;
this.port = null;
this.writer = null;
this.reader = null;
this.readLoopActive = false;
this.connected = false;
this.homeSet = false;
this.isPenDown = false;
this.x = 0;
this.y = 0;
this.statusText = 'Press C to connect';
this.logLines = [];
this.drawBounds = { left: null, right: null, top: null, bottom: null };
this.previewPath = [];
this.isPlotting = false;
this.plotProgress = 0;
this.previewMinDelayMs = options.previewMinDelayMs ?? 30;
this.previewMaxDelayTravelMs =
options.previewMaxDelayTravelMs ?? options.previewMaxDelayMs ?? 120000;
this.previewMaxDelayDrawMs =
options.previewMaxDelayDrawMs ?? options.previewMaxDelayMs ?? 120000;
this.previewFrameMs = options.previewFrameMs ?? 16;
this.timingMultiplier = options.timingMultiplier ?? 1;
this._keysEnabled = false;
this._lastJogTime = 0;
this._jogInFlight = false;
this._prevKeyPressed = null;
this._homeWarning = null;
this._plotButtonsEnabled = false;
this._path = [];
this._plotTestOnly = false;
this._pathAbort = false;
this._pathRunId = 0;
this._pendingAfterAbort = null;
this._plotBtn = null;
this._testBtn = null;
this._plotLabel = 'Plot';
this._testLabel = 'Test';
this._stopLabel = 'Stop';
this._lastTestBtnClick = 0;
this._plotStartedAt = 0;
this._plotEstimateMs = 0;
this._lastSegmentMs = 0;
this._rxBuf = '';
this._okResolvers = [];
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';
userOnStatusChange(status);
};
}
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);
};
}
enablePlotButtons(options = {}) {
if (this._plotButtonsEnabled) return;
this._plotButtonsEnabled = true;
this._injectPlotButtonStyles();
const parentId = options.parentId ?? 'wallplotter-controls';
let parent = document.getElementById(parentId);
if (!parent) {
parent = document.createElement('div');
parent.id = parentId;
const host = document.getElementById('sketch-host');
if (host?.parentNode) host.parentNode.insertBefore(parent, host);
else document.body.appendChild(parent);
}
const plotter = this;
this._plotLabel = options.plotLabel ?? 'Plot';
this._testLabel = options.testLabel ?? 'Test';
this._stopLabel = options.stopLabel ?? 'Stop';
const plotBtn = createButton(this._plotLabel);
plotBtn.parent(parent);
plotBtn.mousePressed(() => plotter._onPlotButton());
const testBtn = createButton(this._testLabel);
testBtn.parent(parent);
testBtn.mousePressed(() => plotter._onTestButton());
this._plotBtn = plotBtn;
this._testBtn = testBtn;
}
_updatePlotButtons() {
if (!this._testBtn) return;
if (this.isPlotting && this._plotTestOnly) {
this._testBtn.html(this._stopLabel);
} else {
this._testBtn.html(this._testLabel);
}
}
stopPath() {
this._pathAbort = true;
}
_restartPathAfterAbort(mode) {
this._pendingAfterAbort = mode;
this._pathAbort = true;
}
_onTestButton() {
const now = millis();
if (this.isPlotting && this._plotTestOnly) {
if (now - this._lastTestBtnClick < 450) {
this._restartPathAfterAbort('test');
} else {
this.stopPath();
}
this._lastTestBtnClick = now;
return;
}
this._lastTestBtnClick = now;
if (this.isPlotting) {
this._restartPathAfterAbort('test');
return;
}
this._triggerSketchFn('test');
}
_onPlotButton() {
if (this.isPlotting) {
this._restartPathAfterAbort('plot');
return;
}
this._triggerSketchFn('plot');
}
_injectPlotButtonStyles() {
if (document.getElementById('wallplotter-btn-styles')) return;
const style = document.createElement('style');
style.id = 'wallplotter-btn-styles';
style.textContent = `
#wallplotter-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
#wallplotter-controls button {
background: #2a2a30;
border: 1px solid #444;
border-radius: 6px;
color: #ddd;
cursor: pointer;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
padding: 0.4rem 1rem;
}
#wallplotter-controls button:hover {
background: #35353c;
border-color: #666;
}
`;
document.head.appendChild(style);
}
_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);
});
}
showInfos() {
textFont('monospace', 14);
this._drawPlotArea();
this._drawHud();
if (this._keysEnabled && !this.isPlotting) this._handleContinuousJog();
}
/**
* Set travel/draw feed rates in mm/min (G-code F and wait/preview timing).
* Use setTimingMultiplier() if the machine is slower or faster than this estimate.
* @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);
}
_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));
}
/**
* Scale segment wait/preview duration. Use when the machine is slower than
* the speed-based estimate (e.g. 2 = wait twice as long, 0.5 = half as long).
* @param {number} mult
*/
setTimingMultiplier(mult) {
this.timingMultiplier = Math.max(0.1, Number(mult) || 1);
}
/**
* Begin a new path (clears the internal path buffer).
* Add segments with move(), draw(), wait(), speed(), then runPath(testOnly).
*/
startPath() {
this._path = [];
return this;
}
move(x, y) {
this._path.push({ type: 'move', x, y });
return this;
}
draw(x, y) {
this._path.push({ type: 'draw', x, y });
return this;
}
wait(ms) {
this._path.push({ type: 'wait', ms });
return this;
}
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 a plot path. Pass an array, or call runPath(testOnly) after startPath().
* @param {Array|boolean} commandsOrTestOnly - path array, or testOnly if using startPath()
* @param {boolean} [testOnly] - if true, preview only (no serial commands)
*/
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 buffer (or `commands`) in ms,
* using setSpeed / queued speed() and wait() plus timingMultiplier.
*/
estimatePathMs(commands) {
const cmds = commands ?? this._path;
return this._estimatePathDurationMs(cmds, this.x, this.y);
}
_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;
}
_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;
const mmss = `${m}:${String(sec).padStart(2, '0')}`;
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
}
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.plotProgress = 0;
this._plotStartedAt = performance.now();
this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y);
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;
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;
}
if (cmd.type === 'wait') {
const ms = max(0, cmd.ms ?? 0);
if (!testOnly && ms > 0) await this.send(`G4 P${Math.round(ms)}`);
await this._holdPosition(ms);
if (this._pathAbort || runId !== this._pathRunId) break;
this.plotProgress = i + 1;
continue;
}
const fromX = px;
const fromY = py;
const duration = this._segmentDurationMs(fromX, fromY, cmd);
this._lastSegmentMs = duration;
if (testOnly) {
await this._animatePosition(fromX, fromY, cmd, duration);
} else {
const motion = this._sendSegmentAndWait(cmd);
const anim = this._animatePosition(fromX, fromY, cmd, duration);
await Promise.all([motion, anim]);
}
if (this._pathAbort || runId !== this._pathRunId) break;
px = cmd.x;
py = cmd.y;
this.plotProgress = i + 1;
}
} finally {
this.feedRateTravel = savedFeedTravel;
this.feedRateDraw = savedFeedDraw;
this.isPlotting = false;
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');
}
}
/** ms to wait for one segment: (distance/feed + pen delay) × multiplier */
_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;
const raw = motionMs + penMs;
const floored = Math.max(raw, this.previewMinDelayMs);
return floored * this.timingMultiplier;
}
async _holdPosition(ms) {
if (ms <= 0) return;
const start = performance.now();
while (performance.now() - start < ms) {
if (this._pathAbort) return;
this._redrawSketch();
await this._delay(this.previewFrameMs);
}
}
async _animatePosition(fromX, fromY, cmd, duration) {
this.isPenDown = cmd.type === 'draw';
const start = performance.now();
const waitMs = Math.max(duration, 1);
while (true) {
if (this._pathAbort) {
const t = Math.min(1, Math.max(0, (performance.now() - start) / waitMs));
this.x = fromX + (cmd.x - fromX) * t;
this.y = fromY + (cmd.y - fromY) * t;
return;
}
const t = Math.min(1, Math.max(0, (performance.now() - start) / 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);
}
this.x = cmd.x;
this.y = cmd.y;
}
async _sendSegment(cmd) {
if (!this._requireHome()) return;
if (cmd.type === 'move') {
if (this.isPenDown) {
await this.send(this.penUpGcode);
this.isPenDown = false;
}
await this.send(
// G1 not G0: Marlin G0_FEEDRATE overrides F unless VARIABLE_G0_FEEDRATE is enabled
`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}`
);
}
}
/** Send the segment, then M400 so Marlin finishes the move before we continue. */
async _sendSegmentAndWait(cmd) {
await this._sendSegment(cmd);
await this.send('M400');
}
_redrawSketch() {
if (typeof redraw === 'function') redraw();
}
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;
this.port = await navigator.serial.requestPort();
await this.port.open({ baudRate: this.baudRate });
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');
await this.send('M17');
await this.penUp();
await this.send('M114');
}
async disconnect() {
if (!this.connected) return;
this.readLoopActive = false;
while (this._okResolvers.length) {
const next = this._okResolvers.shift();
if (next) next.resolve();
}
this._rxBuf = '';
if (this.reader) {
try {
await this.reader.cancel();
} catch (_) {
/* port may already be closed */
}
this.reader = null;
}
if (this.writer) {
try {
this.writer.releaseLock();
} catch (_) {
/* ignore */
}
this.writer = null;
}
if (this.port) {
try {
await this.port.close();
} catch (_) {
/* ignore */
}
this.port = null;
}
this.connected = false;
this.homeSet = false;
this._homeWarning = null;
this.onStatusChange('disconnected');
}
async send(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 ?? 300000) : null;
const payload = new TextEncoder().encode(command + '\n');
await this.writer.write(payload);
this.onLog('→ ' + command);
if (okPromise) await okPromise;
}
_waitForOk(timeoutMs) {
return new Promise((resolve, reject) => {
const entry = {
resolve: () => {
clearTimeout(timer);
resolve();
}
};
const timer = setTimeout(() => {
const idx = this._okResolvers.indexOf(entry);
if (idx >= 0) this._okResolvers.splice(idx, 1);
reject(new Error('timeout waiting for Marlin ok'));
}, timeoutMs);
this._okResolvers.push(entry);
});
}
_isCoordinateMove(command) {
return /^G[01]\b/i.test(command) && /\b[XY]/i.test(command);
}
_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;
}
async penUp() {
if (!this.isPenDown) return;
await this.send(this.penUpGcode);
await this._delay(this.penUpDelayMs);
this.isPenDown = false;
}
async penDown() {
if (this.isPenDown) return;
await this.send(this.penDownGcode);
await this._delay(this.penDownDelayMs);
this.isPenDown = true;
}
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;
}
async goToZero() {
await this.moveTo(0, 0);
}
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;
}
async step(dx, dy, draw = false) {
if (!this._requireHome()) return;
const nx = this.x + dx;
const ny = this.y + dy;
if (draw) {
await this.drawTo(nx, ny);
} else {
await this.moveTo(nx, ny);
}
}
async setHomeHere() {
await this.send('G92 X0 Y0');
this.x = 0;
this.y = 0;
this.homeSet = true;
this._homeWarning = null;
if (this.connected) this.statusText = 'Connected';
}
async _jogBy(dx, dy, draw = false) {
if (draw) {
if (!this._requireHome()) return;
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}`,
{ allowWithoutHome: true }
);
this.x = nx;
this.y = ny;
}
async _handleKeyPressed() {
const k = key.toLowerCase();
if (k === 'c') {
await this._toggleConnection();
return false;
}
if (!this._keysEnabled || !this.connected) return;
if (k === 'h') {
await this.setHomeHere();
return false;
}
if (k === '0') {
await this.goToZero();
return false;
}
if (k === 'p') {
if (this.isPenDown) await this.penUp();
else await this.penDown();
return false;
}
if (k === '+' || k === '=') {
this.stepSize = min(this.stepSize + 5, 100);
return false;
}
if (k === '-') {
this.stepSize = max(this.stepSize - 5, 1);
return false;
}
if (k === 'l') {
if (!this._requireHome()) return false;
this.drawBounds.left = this.x;
return false;
}
if (k === 'r') {
if (!this._requireHome()) return false;
this.drawBounds.right = this.x;
return false;
}
if (k === 't') {
if (!this._requireHome()) return false;
this.drawBounds.top = this.y;
return false;
}
if (k === 'b') {
if (!this._requireHome()) return false;
this.drawBounds.bottom = this.y;
return false;
}
}
_handleContinuousJog() {
if (!this.connected || this._jogInFlight) 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).finally(() => {
this._jogInFlight = false;
});
}
async _toggleConnection() {
try {
if (this.connected) {
await this.disconnect();
} else {
this.statusText = 'Connecting…';
await this.connect();
}
} catch (err) {
this.statusText = 'Error: ' + err.message;
this.onLog('! ' + err.message);
}
}
_getPlotLayout() {
const margin = 60;
const size = min(width, height) - margin * 2;
const worldW = this.machineXMax - this.machineXMin;
const worldH = this.machineYMax - this.machineYMin;
const scale = min(size / worldW, size / worldH);
return {
cx: width / 2,
cy: height / 2 - 20,
scale,
worldCx: (this.machineXMin + this.machineXMax) / 2,
worldCy: (this.machineYMin + this.machineYMax) / 2,
};
}
_worldToScreen(worldX, worldY, layout) {
return {
x: (worldX - layout.worldCx) * layout.scale,
y: -(worldY - layout.worldCy) * layout.scale,
};
}
_getEffectiveDrawBounds() {
const { left, right, top, bottom } = this.drawBounds;
if (left === null && right === null && top === null && bottom === null) {
return null;
}
return {
left: left ?? this.machineXMin,
right: right ?? this.machineXMax,
top: top ?? this.machineYMax,
bottom: bottom ?? this.machineYMin,
};
}
_formatBound(value) {
return value === null ? '—' : value.toFixed(1);
}
_getDrawSize() {
const b = this._getEffectiveDrawBounds();
if (!b) return null;
const w = b.right - b.left;
const h = b.top - b.bottom;
if (w <= 0 || h <= 0) return null;
return { w, h };
}
_drawPlotArea() {
const layout = this._getPlotLayout();
push();
translate(layout.cx, layout.cy);
stroke(50);
strokeWeight(1);
const tl = this._worldToScreen(this.machineXMin, this.machineYMax, layout);
const br = this._worldToScreen(this.machineXMax, this.machineYMin, layout);
noFill();
rectMode(CORNER);
rect(tl.x, tl.y, br.x - tl.x, br.y - tl.y);
stroke(60);
line(tl.x, 0, br.x, 0);
line(0, tl.y, 0, br.y);
const rectBounds = this._getEffectiveDrawBounds();
if (rectBounds) {
const dtl = this._worldToScreen(rectBounds.left, rectBounds.top, layout);
const dbr = this._worldToScreen(rectBounds.right, rectBounds.bottom, layout);
noFill();
stroke(120, 220, 120);
strokeWeight(2);
rect(dtl.x, dtl.y, dbr.x - dtl.x, dbr.y - dtl.y);
}
this._drawPreviewPath(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);
}
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;
}
}
_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.isPlotting) {
fill(180, 220, 255);
text(
`plotting ${this.plotProgress}/${this.previewPath.length}`,
16,
132
);
const elapsedMs = performance.now() - this._plotStartedAt;
const remainMs = Math.max(0, this._plotEstimateMs - elapsedMs);
text(
`elapsed ${this._formatDuration(elapsedMs)} est ${this._formatDuration(this._plotEstimateMs)} remain ${this._formatDuration(remainMs)}`,
16,
152
);
text(
`seg wait ${this._lastSegmentMs.toFixed(0)} ms ×${this.timingMultiplier}`,
16,
172
);
fill(220);
}
if (this.connected && !this.homeSet && this._homeWarning) {
fill(255, 180, 80);
text(`! ${this._homeWarning}`, 16, this.isPlotting ? 192 : 132);
fill(220);
}
text(
`bounds L ${this._formatBound(this.drawBounds.left)} R ${this._formatBound(this.drawBounds.right)} T ${this._formatBound(this.drawBounds.top)} B ${this._formatBound(this.drawBounds.bottom)}`,
16,
this.isPlotting ? 212 : 152
);
const size = this._getDrawSize();
const areaY = this.isPlotting ? 232 : 172;
if (size) {
text(`area ${size.w.toFixed(1)} × ${size.h.toFixed(1)} mm`, 16, areaY);
} else if (
this.drawBounds.left !== null ||
this.drawBounds.right !== null ||
this.drawBounds.top !== null ||
this.drawBounds.bottom !== null
) {
text('area (mark opposite edges for size)', 16, areaY);
}
if (this._keysEnabled) {
textAlign(LEFT, BOTTOM);
const help = [
'L R T B mark left / right / top / bottom bound',
'← → ↑ ↓ 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',
'C connect / disconnect',
];
for (let i = 0; i < help.length; i++) {
text(help[i], 16, height - 16 - i * 18);
}
}
textAlign(RIGHT, TOP);
fill(140);
for (let i = 0; i < this.logLines.length; i++) {
text(this.logLines[i], width - 16, 16 + i * 16);
}
}
_fmt(value) {
return Number(value).toFixed(3);
}
_delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
_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);
const lines = plotter._rxBuf.split(/\r?\n/);
plotter._rxBuf = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) plotter._handleResponse(trimmed);
}
}
} catch (err) {
if (plotter.readLoopActive) plotter.onLog('serial read error: ' + err.message);
} finally {
try {
plotter.reader.releaseLock();
} catch (_) {
/* ignore */
}
plotter.reader = null;
}
}
};
loop();
}
_handleResponse(line) {
this.onLog('← ' + line);
if (/^ok\b/i.test(line)) {
const next = this._okResolvers.shift();
if (next) next.resolve();
}
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]);
}
}
}
}