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