Files
p5-wallplotter/lib/WallPlotter.js
T
2026-08-16 13:50:49 +02:00

1192 lines
34 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;
/** Offline overlay size (mm) — 150×150 cm centred on origin */
static PREVIEW_SIZE_MM = 1500;
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 ?? -830;
this.machineXMax = options.machineXMax ?? 830;
this.machineYMin = options.machineYMin ?? -1200;
this.machineYMax = options.machineYMax ?? 800;
this.previewSizeMm = options.previewSizeMm ?? WallPlotter.PREVIEW_SIZE_MM;
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;
}
/** Abort path; if a live plot is running, also quick-stop Marlin (M410). */
async stopPlot() {
if (!this.isPlotting) return;
this.stopPath();
if (this.connected && !this._plotTestOnly) {
try {
await this.send('M410', { waitOk: false, allowWithoutHome: true });
} catch (_) {
/* port may be busy */
}
}
this.statusText = 'Stopped';
this.onLog('plot stopped');
}
_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(true);
await this.send('M665'); // polargraph draw area → updates machine bounds
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(force = false) {
if (!force && !this.isPenDown) return;
await this.send(this.penUpGcode);
await this._delay(this.penUpDelayMs);
this.isPenDown = false;
}
async penDown(force = false) {
if (!force && 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 (k === 's') {
if (this.isPlotting) await this.stopPlot();
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(true);
else await this.penDown(true);
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);
}
}
_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 = 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);
// Fit full display rectangle; centre the view on work origin (0,0)
const scale = min(size / worldW, size / worldH);
return {
cx: width / 2,
cy: height / 2 - 20,
scale,
worldCx: 0,
worldCy: 0,
};
}
_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();
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);
// Size labels in cm + screen pixels along bottom and right edges
const widthCm = (bounds.xMax - bounds.xMin) / 10;
const heightCm = (bounds.yMax - bounds.yMin) / 10;
const widthPx = Math.round(Math.abs(w));
const heightPx = Math.round(Math.abs(h));
const fmtCm = (cm) => (Number.isInteger(cm) ? `${cm}` : cm.toFixed(1));
const widthLabel = `${fmtCm(widthCm)} cm · ${widthPx} px`;
const heightLabel = `${fmtCm(heightCm)} cm · ${heightPx} 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();
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.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(
`plotting ${this.plotProgress}/${this.previewPath.length}`,
16,
152
);
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,
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);
}
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 ? 232 : 172
);
const size = this._getDrawSize();
const areaY = this.isPlotting ? 252 : 192;
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 (test up/down angles)',
'S stop plot / test',
'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();
}
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 Marlin polargraph M665 report: M665 S… L… R… T… B… H… */
_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}`
);
}
}