/** * 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.4.0'; static MIN_FEED_MM_MIN = 1; static MAX_FEED_MM_MIN = 10000; /** Offline overlay size when not connected (mm) — 150×150 cm centred on origin */ static PREVIEW_SIZE_MM = 1500; /** Default wait for Marlin `ok` (ms). M400 / G4 use a longer per-command timeout. */ static OK_TIMEOUT_MS = 15000; /** Log “waiting for ok” if Marlin has not replied by then */ static OK_WAIT_LOG_MS = 1000; /** Repeat “still waiting” while blocked on `ok` */ static OK_WAIT_PING_MS = 5000; /** If a USB write does not complete, the ACM device is usually gone. */ static WRITE_TIMEOUT_MS = 3000; static RESUME_STORAGE_KEY = 'wallplotter.resume.v1'; /** Send a quiet M400 if the line has been silent this long (keeps USB awake). */ static KEEPALIVE_MS = 1200; // --------------------------------------------------------------------------- // Construction // --------------------------------------------------------------------------- /** * @param {object} [options] * @param {number} [options.baudRate=115200] * @param {number} [options.stepSize=10] Jog step in mm * @param {number} [options.feedRateTravel=1500] Pen-up speed, mm/min * @param {number} [options.feedRateDraw=300] Pen-down speed, mm/min * @param {string} [options.penUpGcode] * @param {string} [options.penDownGcode] * @param {number} [options.penUpDelayMs=150] * @param {number} [options.penDownDelayMs=50] * @param {number} [options.machineXMin] Fallback overlay X until M665 * @param {number} [options.machineXMax] * @param {number} [options.machineYMin] * @param {number} [options.machineYMax] * @param {number} [options.previewSizeMm] Offline square size (mm) * @param {number} [options.jogIntervalMs=150] * @param {number} [options.maxLogLines=40] * @param {number} [options.previewMinDelayMs=30] Floor for preview segment wait * @param {number} [options.previewFrameMs=16] Preview animation frame time * @param {number} [options.timingMultiplier=1] Scales Test preview waits * @param {function} [options.onLog] * @param {function} [options.onStatusChange] */ constructor(options = {}) { // --- Serial / pen / speeds --- this.baudRate = options.baudRate ?? 115200; this.feedRateTravel = this._clampFeed(options.feedRateTravel ?? 1500); this.feedRateDraw = this._clampFeed(options.feedRateDraw ?? 300); this.penUpGcode = options.penUpGcode ?? 'M280 P0 S90'; this.penDownGcode = options.penDownGcode ?? 'M280 P0 S50'; this.penUpDelayMs = options.penUpDelayMs ?? 150; this.penDownDelayMs = options.penDownDelayMs ?? 50; this.timingMultiplier = options.timingMultiplier ?? 1; // --- Machine / overlay extents (mm) --- this.machineXMin = options.machineXMin ?? -830; this.machineXMax = options.machineXMax ?? 830; this.machineYMin = options.machineYMin ?? -1200; this.machineYMax = options.machineYMax ?? 800; this.previewSizeMm = options.previewSizeMm ?? WallPlotter.PREVIEW_SIZE_MM; // --- Jog / UI --- this.stepSize = options.stepSize ?? 10; this.jogIntervalMs = options.jogIntervalMs ?? 150; this.maxLogLines = options.maxLogLines ?? 40; this.previewMinDelayMs = options.previewMinDelayMs ?? 30; this.previewFrameMs = options.previewFrameMs ?? 16; // --- Link: disconnected | connecting | connected --- this._link = 'disconnected'; this.homeSet = false; this.isPenDown = false; this.penPressure = 0; this.x = 0; this.y = 0; this.statusText = 'Disconnected'; this.logLines = []; // --- Job: idle | running | paused | aborting --- this._job = 'idle'; this._path = []; this.previewPath = []; this.plotProgress = 0; this._plotTestOnly = false; this._pathRunId = 0; this._pathLoopActive = false; this._interrupted = null; this._plotStartedAt = 0; this._plotEstimateMs = 0; this._pausedAccumMs = 0; this._pauseStartedAt = 0; this._lastSegmentMs = 0; // --- Serial: one command in flight, wait for its ok --- this.port = null; this.writer = null; this.reader = null; this.readLoopActive = false; this._rxBuf = ''; this._okWaiter = null; this._sendChain = Promise.resolve(); this._lastBusyLogAt = 0; this._lastSendAt = 0; this._keepaliveTimer = null; // --- Keyboard / buttons --- this._keysEnabled = false; this._prevKeyPressed = null; this._lastJogTime = 0; this._jogInFlight = false; this._homeWarning = null; this._plotButtonsEnabled = false; this._connectBtn = null; this._plotBtn = null; this._testBtn = null; this._bboxBtn = null; this._abortBtn = null; this._connectLabel = 'Connect'; this._plotLabel = 'Plot'; this._pauseLabel = 'Pause'; this._resumeLabel = 'Resume'; this._testLabel = 'Test'; this._bboxLabel = 'Bounding box'; this._abortLabel = 'Abort'; this._bboxRunning = false; this._lastTestBtnClick = 0; this._lastPlotBtnClick = 0; this._pathStartX = 0; this._pathStartY = 0; this._wantPathStartReset = false; // --- Callbacks --- const userOnLog = options.onLog ?? (() => {}); this.onLog = (line) => { this.logLines.push(line); if (this.logLines.length > this.maxLogLines) this.logLines.shift(); this._syncLogOverlay(); userOnLog(line); }; const userOnStatusChange = options.onStatusChange ?? (() => {}); this.onStatusChange = (status) => { this._syncStatus(); this._updatePlotButtons(); userOnStatusChange(status); }; this._unloadBound = false; this._installUnloadHandler(); this._loadResumeState(); } get connected() { return this._link === 'connected'; } get isPlotting() { return this._job !== 'idle'; } get isPaused() { return this._job === 'paused'; } _setJob(next) { if (this._job === next) return; if (this._job === 'paused' && this._pauseStartedAt) { this._pausedAccumMs += performance.now() - this._pauseStartedAt; } this._job = next; this._pauseStartedAt = next === 'paused' ? performance.now() : 0; this._syncStatus(); this._updatePlotButtons(); this._redrawSketch(); } _abortJob() { if (this._job === 'idle') return; this._setJob('aborting'); } _syncStatus() { if (this._link === 'disconnected') this.statusText = 'Disconnected'; else if (this._link === 'connecting') this.statusText = 'Connecting…'; else if (this._job === 'paused') this.statusText = 'Paused'; else if (this._job === 'aborting') this.statusText = 'Stopped'; else if (this._job === 'running') { this.statusText = this._plotTestOnly ? 'Testing' : 'Plotting'; } else this.statusText = 'Connected'; } // =========================================================================== // Public API — setup / UI // =========================================================================== /** Enable keyboard jog, home, pen toggle, connect (C), test (T), stop (S). Call from setup(). */ enableKeyInputs() { if (this._keysEnabled) return; this._keysEnabled = true; this._prevKeyPressed = window.keyPressed; const plotter = this; window.keyPressed = async function () { const handled = await plotter._handleKeyPressed(); if (handled === false) return false; if (plotter._prevKeyPressed) return plotter._prevKeyPressed.apply(this, arguments); }; } /** * Add Connect / Test / Plot / Bounding box / Abort. * No sketch yet: Test (+ Connect if disconnected). After Test, Bounding box * appears; Plot appears once connected. Plot sends that path (does not regenerate) and * becomes Pause / Resume while plotting. Abort only while plotting or when a plot is saved. * Bounding box travels pen-up around the drawing extents. * @param {{parentId?: string, connectLabel?: string, plotLabel?: string, pauseLabel?: string, resumeLabel?: string, testLabel?: string, bboxLabel?: string, abortLabel?: 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._bboxLabel = options.bboxLabel ?? 'Bounding box'; this._abortLabel = options.abortLabel ?? 'Abort'; const plotter = this; const connectBtn = createButton(this._connectLabel); connectBtn.parent(parent); connectBtn.mousePressed(() => plotter._onConnectButton()); this._connectBtn = connectBtn; const testBtn = createButton(this._testLabel); testBtn.parent(parent); testBtn.mousePressed(() => plotter._onTestButton()); this._testBtn = testBtn; const plotBtn = createButton(this._plotLabel); plotBtn.parent(parent); plotBtn.mousePressed(() => plotter._onPlotButton()); this._plotBtn = plotBtn; const bboxBtn = createButton(this._bboxLabel); bboxBtn.parent(parent); bboxBtn.mousePressed(() => plotter._onBoundsButton()); this._bboxBtn = bboxBtn; const abortBtn = createButton(this._abortLabel); abortBtn.parent(parent); abortBtn.mousePressed(() => plotter._abortLivePlot()); this._abortBtn = abortBtn; this._updatePlotButtons(); } /** Draw overlay + HUD. Call every frame from draw(). */ showInfos() { textFont('monospace', 14); this._drawPlotArea(); this._drawHud(); this._syncLogOverlay(); if (this._keysEnabled && !this.isPlotting) this._handleContinuousJog(); } // =========================================================================== // Public API — speed // =========================================================================== /** * Set feed rates in mm/min (G-code F and Test timing). Clamped 1–10000. * @param {number|{travel?: number, draw?: number}} travel * @param {number} [draw] */ setSpeed(travel, draw) { if (typeof travel === 'object' && travel !== null) { if (travel.travel != null) this.feedRateTravel = this._clampFeed(travel.travel); if (travel.draw != null) this.feedRateDraw = this._clampFeed(travel.draw); return; } this.feedRateTravel = this._clampFeed(travel); if (draw != null) this.feedRateDraw = this._clampFeed(draw); } /** * Scale Test preview waits only (Plot is paced by Marlin M400). * @param {number} mult 1 = match setSpeed; 2 = twice as slow */ setTimingMultiplier(mult) { this.timingMultiplier = Math.max(0.1, Number(mult) || 1); } /** * Convert canvas pixel coordinates to plot coordinates in mm. * By default this uses machine bounds so live-click targeting stays stable. * @param {number} px * @param {number} py * @param {{clamp?: boolean, useDisplayBounds?: boolean}} [options] */ screenToPlot(px, py, options = {}) { const layout = this._getPlotLayout(); const bounds = options.useDisplayBounds === true ? this._getDisplayBounds() : { xMin: this.machineXMin, xMax: this.machineXMax, yMin: this.machineYMin, yMax: this.machineYMax, }; const scale = Math.max(layout.scale || 0, 1e-9); const worldX = (px - layout.cx) / scale + layout.worldCx; const worldY = -(py - layout.cy) / scale + layout.worldCy; const clamp = options.clamp !== false; if (!clamp) return { x: worldX, y: worldY }; return { x: Math.min(bounds.xMax, Math.max(bounds.xMin, worldX)), y: Math.min(bounds.yMax, Math.max(bounds.yMin, worldY)), }; } // =========================================================================== // Public API — path buffer (queue only; nothing is sent until runPath) // =========================================================================== /** Clear the path buffer. Returns this for chaining. */ startPath() { this._clearResumeState(); this._path = []; return this; } /** * Queue pen-up travel to (x, y) mm. * @param {number} x * @param {number} y * @param {number} [pressure] 0–100, mapped from penUpGcode S to penDownGcode S. Default 0 (up). */ move(x, y, pressure) { const cmd = { type: 'move', x, y }; if (pressure != null) cmd.pressure = this._clampPressure(pressure); this._path.push(cmd); return this; } /** * Queue pen-down stroke to (x, y) mm. * @param {number} x * @param {number} y * @param {number} [pressure] 0–100, mapped from penUpGcode S to penDownGcode S. Default 100 (down). */ draw(x, y, pressure) { const cmd = { type: 'draw', x, y }; if (pressure != null) cmd.pressure = this._clampPressure(pressure); this._path.push(cmd); return this; } /** Queue a pause in milliseconds (sends G4 on Plot). */ wait(ms) { this._path.push({ type: 'wait', ms }); return this; } /** Queue a mid-path speed change (mm/min). Restored after runPath. */ speed(travel, drawRate) { this._path.push({ type: 'speed', travel: travel != null ? this._clampFeed(travel) : undefined, draw: drawRate != null ? this._clampFeed(drawRate) : undefined, }); return this; } /** * Run the path buffer, or an explicit command array. * @param {Array|boolean} commandsOrTestOnly * @param {boolean} [testOnly=false] true = preview only (no serial) */ async runPath(commandsOrTestOnly, testOnly = false) { let commands; if (Array.isArray(commandsOrTestOnly)) { commands = commandsOrTestOnly; } else { testOnly = commandsOrTestOnly ?? false; commands = this._path; } await this._executePath(commands, testOnly); } /** * Estimated duration of the current path (or commands) in ms. * Uses setSpeed, queued speed()/wait(), and timingMultiplier. */ estimatePathMs(commands) { return this._estimatePathDurationMs(commands ?? this._path, this.x, this.y); } /** Abort the current runPath after the in-flight command's `ok`. */ stopPath() { this._clearResumeState(); this._abortJob(); } /** Pause the current runPath after the in-flight segment. */ pausePlot() { if (this._job !== 'running') return; this._setJob('paused'); this.onLog('plot paused'); } /** Continue a paused runPath (including after Stop or a timeout). */ resumePlot() { if (this._job !== 'paused') return; this._setJob('running'); this.onLog('plot resumed'); } /** * Stop the current Test/Plot and reset the preview to the start. * Test pose returns to where the path began. Live Plot only stops sending * (the machine stays where it is). */ abortToStart() { this._clearResumeState(); this._wantPathStartReset = true; this._pathRunId += 1; if (this._job === 'running' || this._job === 'paused') this._abortJob(); this._applyPathStartReset(); this.onLog('drawing cleared'); } _applyPathStartReset() { this.x = this._pathStartX; this.y = this._pathStartY; this.isPenDown = false; this.penPressure = 0; this.plotProgress = 0; this.previewPath = []; this._path = []; this._wantPathStartReset = false; this._plotTestOnly = false; this._setJob('idle'); this._redrawSketch(); this._updatePlotButtons(); } /** * First press: pause after the current stroke (safe to Resume). * Second press: stop sending more moves. The in-flight G1 still finishes * (Marlin has no emergency parser). Do not send M410 — it cannot interrupt * a running move, and on a polargraph it desyncs belt lengths. */ async stopPlot() { if (this._job === 'paused') { this._abortJob(); this.onLog( 'plot aborted — motors finish the in-flight move (unplug power for a hard stop)' ); return; } if (this._job === 'running') { this.pausePlot(); this.onLog('plot stopped after current move — resume to continue'); return; } if (this._okWaiter) { this._cancelPendingOk(); this.onLog('serial wait cancelled'); this._syncStatus(); } } // =========================================================================== // Public API — serial connection // =========================================================================== /** Open Web Serial, enable steppers, pen up, fetch M665 bounds, M114. */ async connect() { if (!('serial' in navigator)) { throw new Error('Web Serial is not supported in this browser. Use Chrome or Edge.'); } if (this._link === 'connected') return; let port; try { port = await this._acquirePort(); await this._openPort(port); } catch (err) { this.onLog('! ' + err.message); port = await navigator.serial.requestPort(); await this._openPort(port); } this.port = port; this._bindSerialEvents(port); this.writer = this.port.writable.getWriter(); this._startReadLoop(); this._startKeepalive(); this._link = 'connected'; this.onStatusChange('connected'); try { await this.send('M17', { allowWithoutHome: true }); await this.penUp(true); await this.send('M665', { allowWithoutHome: true }); await this._syncPoseFromMachine(); if (this._interrupted) { this.onLog( `saved path ${this._interrupted.index}/${this._interrupted.commands.length} — Plot resumes` ); this.statusText = 'Connected — Plot to resume'; this._updatePlotButtons(); } } catch (err) { if (err.name === 'AbortError') return; throw err; } } async _acquirePort() { const existing = await navigator.serial.getPorts(); if (existing.length >= 1) return existing[0]; return navigator.serial.requestPort(); } async _openPort(port) { const opened = port.open({ baudRate: this.baudRate, bufferSize: 65536, flowControl: 'none', }); const timedOut = this._delay(3000).then(() => { const err = new Error( 'serial open timeout — port still held after reload. Unplug USB or close other serial tabs.' ); err.name = 'TimeoutError'; throw err; }); try { await Promise.race([opened, timedOut]); if (typeof port.setSignals === 'function') { try { await port.setSignals({ dataTerminalReady: true, requestToSend: false }); } catch (_) { /* some stacks ignore control lines */ } } } catch (err) { try { await port.close(); } catch (_) { /* ignore */ } throw err; } } /** Close the serial port. */ async disconnect() { if (this._link === 'disconnected' && !this.port) return; this.readLoopActive = false; this._stopKeepalive(); this._cancelPendingOk(); this._rxBuf = ''; const reader = this.reader; const writer = this.writer; const port = this.port; this.reader = null; this.writer = null; this.port = null; if (reader) { try { await reader.cancel(); } catch (_) { /* already closed */ } try { reader.releaseLock(); } catch (_) { /* ignore */ } } if (writer) { try { writer.releaseLock(); } catch (_) { /* ignore */ } } if (port) { try { await port.close(); } catch (_) { /* ignore */ } } this._link = 'disconnected'; this.onStatusChange('disconnected'); } /** Best-effort close for page reload (must not await — Mac keeps the USB lock otherwise). */ _releaseSerialNow() { if (!this.port && this._link === 'disconnected') return; this.readLoopActive = false; this._stopKeepalive(); this._cancelPendingOk(); this._rxBuf = ''; const reader = this.reader; const writer = this.writer; const port = this.port; this.reader = null; this.writer = null; this.port = null; this._link = 'disconnected'; try { reader?.cancel(); } catch (_) { /* ignore */ } try { reader?.releaseLock(); } catch (_) { /* ignore */ } try { writer?.releaseLock(); } catch (_) { /* ignore */ } try { port?.close(); } catch (_) { /* ignore */ } } _writeLine(command, quiet = false) { if (!this.writer) { return Promise.reject(new Error('Not connected to plotter')); } const bytes = new TextEncoder().encode(command + '\n'); return new Promise((resolve, reject) => { let done = false; const timer = setTimeout(() => { if (done) return; done = true; const err = new Error('serial write timeout — USB port likely gone'); err.name = 'TimeoutError'; reject(err); }, WallPlotter.WRITE_TIMEOUT_MS); this.writer.write(bytes).then( () => { if (done) return; done = true; clearTimeout(timer); if (!quiet) this.onLog('→ ' + command); resolve(); }, (err) => { if (done) return; done = true; clearTimeout(timer); reject(err); } ); }); } _bindSerialEvents(port) { if (this._serialDisconnectBound) { navigator.serial.removeEventListener('disconnect', this._serialDisconnectBound); } this._serialDisconnectBound = (event) => { if (event.target === port || event.target === this.port) { this._onSerialGone('device unplugged or re-enumerated'); } }; navigator.serial.addEventListener('disconnect', this._serialDisconnectBound); if (port && typeof port.addEventListener === 'function') { port.addEventListener('disconnect', () => { this._onSerialGone('device unplugged or re-enumerated'); }); } } _onSerialGone(reason) { if (this._link === 'disconnected') return; this._stashInterruptedPath(); this.onLog('! USB serial lost — ' + reason); this.onLog( `kept pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} — reconnect will ask Marlin (M114)` ); this._abortJob(); this._releaseSerialNow(); this.statusText = this._interrupted ? 'USB disconnected — reconnect, then Plot to resume' : 'USB disconnected — reconnect'; this._updatePlotButtons(); this._redrawSketch(); } _stashInterruptedPath() { if (this._job === 'idle' && !this._pathLoopActive) return; const commands = this.previewPath; if (!commands.length) return; if (this.plotProgress >= commands.length) return; this._interrupted = { commands, index: this.plotProgress, testOnly: this._plotTestOnly, startedAt: this._plotStartedAt, pausedAccum: this._pausedAccumMs, estimateMs: this._plotEstimateMs, }; this.onLog( `plot paused at ${this.plotProgress}/${commands.length} — Plot resumes this path` ); this._persistResumeState(); } _resumeInterruptedPath() { const hit = this._interrupted; if (!hit) return false; this._interrupted = null; this.onLog( `resuming path at ${hit.index}/${hit.commands.length}` ); Promise.resolve( this._executePath(hit.commands, hit.testOnly, hit.index, hit) ).catch((err) => { this.statusText = 'Error: ' + err.message; this.onLog('! ' + err.message); }); return true; } _installUnloadHandler() { if (this._unloadBound) return; this._unloadBound = true; const release = () => { this._stashInterruptedPath(); this._persistResumeState(true); this._releaseSerialNow(); }; window.addEventListener('pagehide', release); window.addEventListener('beforeunload', release); window.addEventListener('unload', release); window.addEventListener('pageshow', (event) => { if (event.persisted) this._releaseSerialNow(); }); } _resumeSnapshot() { if (this._interrupted) return this._interrupted; if (this._job === 'idle' || this._plotTestOnly) return null; if (!this.previewPath.length || this.plotProgress >= this.previewPath.length) { return null; } return { commands: this.previewPath, index: this.plotProgress, testOnly: false, startedAt: this._plotStartedAt, pausedAccum: this._pausedAccumMs, estimateMs: this._plotEstimateMs, x: this.x, y: this.y, }; } _persistResumeState(force = false) { const snap = this._resumeSnapshot(); if (!snap || snap.testOnly) { if (force) this._clearResumeState(); return; } const now = performance.now(); if (!force && now - (this._lastResumePersistAt || 0) < 800) return; this._lastResumePersistAt = now; try { sessionStorage.setItem( WallPlotter.RESUME_STORAGE_KEY, JSON.stringify({ index: snap.index, testOnly: false, startedAt: snap.startedAt, pausedAccum: snap.pausedAccum, estimateMs: snap.estimateMs, x: snap.x ?? this.x, y: snap.y ?? this.y, commands: snap.commands, }) ); } catch (_) { /* quota / private mode */ } } _loadResumeState() { let raw = null; try { raw = sessionStorage.getItem(WallPlotter.RESUME_STORAGE_KEY); } catch (_) { return; } if (!raw) return; try { const snap = JSON.parse(raw); if (!Array.isArray(snap.commands) || !snap.commands.length) return; const index = Math.max(0, Math.min(snap.index ?? 0, snap.commands.length)); if (index >= snap.commands.length) { this._clearResumeState(); return; } this.previewPath = snap.commands; this.plotProgress = index; this._plotEstimateMs = snap.estimateMs ?? 0; this._plotStartedAt = snap.startedAt ?? 0; this._pausedAccumMs = snap.pausedAccum ?? 0; if (Number.isFinite(snap.x)) this.x = snap.x; if (Number.isFinite(snap.y)) this.y = snap.y; this._interrupted = { commands: snap.commands, index, testOnly: false, startedAt: this._plotStartedAt, pausedAccum: this._pausedAccumMs, estimateMs: this._plotEstimateMs, x: this.x, y: this.y, }; } catch (_) { this._clearResumeState(); } } _clearResumeState() { this._interrupted = null; try { sessionStorage.removeItem(WallPlotter.RESUME_STORAGE_KEY); } catch (_) { /* ignore */ } } /** * Write one G-code line; by default wait for Marlin ok. * @param {string} line * @param {{allowWithoutHome?: boolean, waitOk?: boolean, timeoutMs?: number}} [options] */ async send(line, options = {}) { const queued = this._sendChain.then(() => this._sendNow(line, options)); this._sendChain = queued.catch(() => {}); return queued; } async _sendNow(line, options = {}) { if (!this.connected || !this.writer) { throw new Error('Not connected to plotter'); } const command = line.trim(); if (!command) return; if ( !options.allowWithoutHome && !this.homeSet && this._isCoordinateMove(command) ) { this._showHomeRequiredWarning(); return; } this._lastSendAt = performance.now(); const waitOk = options.waitOk !== false; const quiet = options.quiet === true || /^M400\b/i.test(command); const okPromise = waitOk ? this._waitForOk(options.timeoutMs ?? WallPlotter.OK_TIMEOUT_MS, { command, waitLogAfterMs: options.waitLogAfterMs, quiet, }) : null; try { const written = this._writeLine(command, quiet); if (okPromise) { await Promise.all([ written.catch((err) => { this._cancelPendingOk(); throw err; }), okPromise, ]); } else { await written; } } catch (err) { if (err.name === 'AbortError') throw err; if (/USB|serial write timeout|NetworkError/i.test(err.message)) { this._onSerialGone(err.message); } const timeoutErr = new Error(err.message); timeoutErr.name = 'TimeoutError'; this.onLog('! ' + err.message); if (this._job === 'idle') this.statusText = 'Error: ' + err.message; throw timeoutErr; } } // =========================================================================== // Public API — immediate machine commands // =========================================================================== /** * Lift pen (pressure 0). Pass true to always send (used by P and connect). * @param {boolean} [force=false] */ async penUp(force = false) { await this._applyPenPressure(0, { force, wait: true }); } /** * Lower pen (pressure 100). Pass true to always send. * @param {boolean} [force=false] */ async penDown(force = false) { await this._applyPenPressure(100, { force, wait: true }); } /** * Set servo pressure immediately (0 = penUpGcode S, 100 = penDownGcode S). * @param {number} pressure */ async setPressure(pressure) { await this._applyPenPressure(this._clampPressure(pressure), { force: true, wait: true }); } /** * Pen up (or optional pressure) and travel to (x, y) mm immediately. Requires home. * @param {number} x * @param {number} y * @param {number} [pressure] 0–100. Default 0 (up). */ async moveTo(x, y, pressure) { if (!this._requireHome()) return; const startX = this.x; const startY = this.y; await this._applyPenPressure(pressure != null ? this._clampPressure(pressure) : 0, { wait: true, }); await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateTravel}`); this.x = x; this.y = y; await this._waitForMotionComplete( Math.hypot((x ?? 0) - (startX ?? 0), (y ?? 0) - (startY ?? 0)), this.feedRateTravel ); } /** * Pen down (or optional pressure) and draw to (x, y) mm immediately. Requires home. * @param {number} x * @param {number} y * @param {number} [pressure] 0–100. Default 100 (down). */ async drawTo(x, y, pressure) { if (!this._requireHome()) return; const startX = this.x; const startY = this.y; await this._applyPenPressure(pressure != null ? this._clampPressure(pressure) : 100, { wait: true, }); await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateDraw}`); this.x = x; this.y = y; await this._waitForMotionComplete( Math.hypot((x ?? 0) - (startX ?? 0), (y ?? 0) - (startY ?? 0)), this.feedRateDraw ); } async _waitForMotionComplete(distanceMm, feedRateMmPerMin) { const distance = Number.isFinite(distanceMm) ? Math.max(0, distanceMm) : 0; const speedMmPerS = Math.max(1, Number(feedRateMmPerMin) || 1) / 60; const etaMs = Math.round((distance / speedMmPerS) * 1000); await this.send('M400', { timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, etaMs + 15000), // M400 legitimately blocks until mechanics are done moving. waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, etaMs + 1500), }); } /** 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._bboxRunning) { this.onLog('! cannot set home while travelling the bounding box'); return; } if (this._job !== 'idle') { this.onLog('! cannot set home while plotting — resume or abort first'); this.statusText = 'Finish or abort plot before H'; return; } if (this._okWaiter) { this.onLog('! plotter busy — wait for ok before setting home'); this.statusText = 'Waiting for plotter…'; return; } await this.send('G92 X0 Y0'); try { await this.send('M114', { timeoutMs: 3000, allowWithoutHome: true }); } catch (_) { /* position report optional */ } this.x = 0; this.y = 0; this.homeSet = true; this._homeWarning = null; this._syncStatus(); } /** * Ask Marlin where it thinks it is. USB re-enum often keeps G92; * only the polargraph boot pose (belts at max length, Y ≈ -2600) means unhomed. */ async _syncPoseFromMachine() { const beforeX = this.x; const beforeY = this.y; try { await this.send('M114', { timeoutMs: 3000, allowWithoutHome: true }); } catch (_) { /* keep last browser pose */ } if (this._isUnhomedBootPose(this.x, this.y)) { this.homeSet = false; this._homeWarning = 'Home not set — jog to origin and press H'; this.onLog( `Marlin at boot pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} — press H` ); return; } this.homeSet = true; this._homeWarning = null; this.onLog( `restored Marlin pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} (was ${beforeX.toFixed(1)}, ${beforeY.toFixed(1)})` ); this._syncStatus(); } /** Polargraph default after reset: equal max belts → X≈0, Y≈-2600. */ _isUnhomedBootPose(x, y) { const span = (this.machineXMax - this.machineXMin) / 2; const maxBelt = 3500; const expectedY = this.machineYMax - Math.sqrt(Math.max(0, maxBelt * maxBelt - span * span)); return Math.abs(x) < 20 && Math.abs(y - expectedY) < 80; } // =========================================================================== // Internals — feed helpers // =========================================================================== _clampFeed(value) { const n = Number(value); if (!Number.isFinite(n)) return WallPlotter.MIN_FEED_MM_MIN; return Math.min( WallPlotter.MAX_FEED_MM_MIN, Math.max(WallPlotter.MIN_FEED_MM_MIN, n) ); } _clampPressure(value) { const n = Number(value); if (!Number.isFinite(n)) return 0; return Math.round(Math.min(100, Math.max(0, n))); } /** Effective 0–100 pressure for a path command (move default 0, draw default 100). */ _cmdPressure(cmd) { if (cmd.pressure != null) return this._clampPressure(cmd.pressure); return cmd.type === 'draw' ? 100 : 0; } _parsePenS(gcode) { const m = String(gcode ?? '').match(/S(-?\d+(?:\.\d+)?)/i); return m ? Number(m[1]) : null; } _penSForPressure(pressure) { const t = this._clampPressure(pressure) / 100; const upS = this._parsePenS(this.penUpGcode) ?? 90; const downS = this._parsePenS(this.penDownGcode) ?? 50; return upS + (downS - upS) * t; } _penGcodeForPressure(pressure) { const p = this._clampPressure(pressure); if (p <= 0) return this.penUpGcode; if (p >= 100) return this.penDownGcode; const s = Math.round(this._penSForPressure(p)); const template = this.penDownGcode || this.penUpGcode || 'M280 P0 S0'; if (/S-?\d+(?:\.\d+)?/i.test(template)) { return template.replace(/S-?\d+(?:\.\d+)?/i, `S${s}`); } return `${template} S${s}`.trim(); } /** * Send interpolated servo G-code. 0 = penUpGcode S, 100 = penDownGcode S. * @param {number} pressure * @param {{force?: boolean, wait?: boolean}} [options] */ async _applyPenPressure(pressure, options = {}) { const force = options.force === true; const wait = options.wait !== false; const next = this._clampPressure(pressure); if (!force && this.penPressure === next) { this.isPenDown = next > 0; return; } await this.send(this._penGcodeForPressure(next)); if (wait) { await this._delay(next === 0 ? this.penUpDelayMs : this.penDownDelayMs); } this.penPressure = next; this.isPenDown = next > 0; } // =========================================================================== // Internals — path execution & timing // =========================================================================== async _executePath(commands, testOnly = false, fromIndex = 0, timing = null) { if (!testOnly && !this._requireHome()) return; const runId = ++this._pathRunId; if (this._pathLoopActive) this._abortJob(); while (this._pathLoopActive) { if (runId !== this._pathRunId) return; await this._delay(16); } if (runId !== this._pathRunId) return; this._interrupted = null; this._pathLoopActive = true; this._plotTestOnly = testOnly; this.previewPath = commands; if (fromIndex === 0 && !timing) { this._pathStartX = this.x; this._pathStartY = this.y; } const startAt = Math.max(0, Math.min(fromIndex, commands.length)); this.plotProgress = startAt; if (timing) { this._plotStartedAt = timing.startedAt ?? performance.now(); this._pausedAccumMs = timing.pausedAccum ?? 0; this._plotEstimateMs = timing.estimateMs ?? this._estimatePathDurationMs(commands, this.x, this.y); } else { this._plotStartedAt = performance.now(); this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y); this._pausedAccumMs = 0; } this._lastSegmentMs = 0; this._setJob('running'); const savedFeedTravel = this.feedRateTravel; const savedFeedDraw = this.feedRateDraw; for (let i = 0; i < startAt; i++) { const cmd = commands[i]; if (cmd.type === 'speed') { if (cmd.travel != null) this.feedRateTravel = cmd.travel; if (cmd.draw != null) this.feedRateDraw = cmd.draw; } } let px = this.x; let py = this.y; const stopped = () => this._job === 'aborting' || runId !== this._pathRunId; try { for (let i = startAt; i < commands.length; i++) { if (stopped()) break; await this._waitIfPaused(); if (stopped()) break; const cmd = commands[i]; if (cmd.type === 'speed') { if (cmd.travel != null) this.feedRateTravel = cmd.travel; if (cmd.draw != null) this.feedRateDraw = cmd.draw; this.plotProgress = i + 1; this._persistResumeState(); this._redrawSketch(); continue; } try { if (cmd.type === 'wait') { const ms = Math.max(0, cmd.ms ?? 0); if (!testOnly && ms > 0) { await this.send(`G4 P${Math.round(ms)}`, { timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, ms + 5000), waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, ms + 1500), }); } await this._holdPosition(ms); } else { const duration = this._segmentDurationMs(px, py, cmd); this._lastSegmentMs = duration; if (testOnly) { await this._animatePosition(px, py, cmd, duration); } else { await Promise.all([ this._sendSegmentAndWait(cmd), this._animatePosition(px, py, cmd, duration), ]); } } } catch (err) { if (err.name === 'AbortError' || stopped()) break; if (err.name === 'TimeoutError') { if (this._job === 'running') this.pausePlot(); this.onLog('segment skipped after timeout — resume continues from here'); px = this.x; py = this.y; await this._waitIfPaused(); if (stopped()) break; this.plotProgress = i + 1; this._persistResumeState(); continue; } throw err; } if (stopped()) break; if (cmd.type === 'move' || cmd.type === 'draw') { px = cmd.x; py = cmd.y; } this.plotProgress = i + 1; this._persistResumeState(); } } catch (err) { if (err.name !== 'AbortError' && err.name !== 'TimeoutError') { /* send() already logged serial errors */ } } finally { this.feedRateTravel = savedFeedTravel; this.feedRateDraw = savedFeedDraw; if (!testOnly && this.connected) { try { await this.penUp(); } catch (_) { /* port may be gone */ } } else { this.isPenDown = false; this.penPressure = 0; } this._plotTestOnly = false; this._pathLoopActive = false; if (this._wantPathStartReset || this.previewPath.length === 0) { this._applyPathStartReset(); } else { this._setJob('idle'); if (this.plotProgress >= commands.length) this._clearResumeState(); } } } /** Segment wait ms: (distance/feed + pen delay) × timingMultiplier. */ _segmentDurationMs(fromX, fromY, cmd) { const isMove = cmd.type === 'move'; const dist = Math.hypot(cmd.x - fromX, cmd.y - fromY); const rate = isMove ? this.feedRateTravel : this.feedRateDraw; const motionMs = dist > 0 && rate > 0 ? (dist / rate) * 60000 : 0; const nextPressure = this._cmdPressure(cmd); let penMs = 0; if (nextPressure !== this.penPressure) { penMs = nextPressure === 0 ? this.penUpDelayMs : 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; const savedPressure = this.penPressure; 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.penPressure = this._cmdPressure(cmd); this.isPenDown = this.penPressure > 0; px = cmd.x; py = cmd.y; } } finally { this.feedRateTravel = savedTravel; this.feedRateDraw = savedDraw; this.isPenDown = savedPen; this.penPressure = savedPressure; } return total; } async _holdPosition(ms) { if (ms <= 0) return; let elapsed = 0; let last = performance.now(); while (elapsed < ms) { if (this._job === 'aborting') return; if (this._job === 'paused') { await this._waitIfPaused(); last = performance.now(); if (this._job === 'aborting') return; } const now = performance.now(); elapsed += now - last; last = now; this._redrawSketch(); await this._delay(this.previewFrameMs); } } async _animatePosition(fromX, fromY, cmd, duration) { if (this._plotTestOnly) { this.penPressure = this._cmdPressure(cmd); this.isPenDown = this.penPressure > 0; } const waitMs = Math.max(duration, 1); let elapsed = 0; let last = performance.now(); while (true) { if (this._job === 'aborting') break; if (this._plotTestOnly && this._job === 'paused') { await this._waitIfPaused(); last = performance.now(); if (this._job === 'aborting') break; } const now = performance.now(); elapsed += now - last; last = now; const t = Math.min(1, Math.max(0, elapsed / waitMs)); this.x = fromX + (cmd.x - fromX) * t; this.y = fromY + (cmd.y - fromY) * t; this._redrawSketch(); if (t >= 1) break; await this._delay(this.previewFrameMs); } if (this._job !== 'aborting') { this.x = cmd.x; this.y = cmd.y; } } /** Wait while paused. Live plots lift the pen until the next segment. */ async _waitIfPaused() { if (this._job !== 'paused') return; if (!this._plotTestOnly && this.connected && this.isPenDown) { try { await this.penUp(); } catch (_) { /* port may be busy */ } } while (this._job === 'paused') { this._redrawSketch(); await this._delay(this.previewFrameMs); } } _plotElapsedMs() { let elapsed = performance.now() - this._plotStartedAt - this._pausedAccumMs; if (this.isPaused && this._pauseStartedAt) { elapsed -= performance.now() - this._pauseStartedAt; } return Math.max(0, elapsed); } async _sendSegment(cmd) { if (!this._requireHome()) return; await this._applyPenPressure(this._cmdPressure(cmd), { wait: false }); const feed = cmd.type === 'move' ? this.feedRateTravel : this.feedRateDraw; // 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${feed}`); } async _sendSegmentAndWait(cmd) { await this._sendSegment(cmd); const motionMs = Math.round(this._lastSegmentMs); await this.send('M400', { timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, motionMs + 20000), // M400 is supposed to last as long as the move. Don't call that "stuck". waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, motionMs + 1500), }); } // =========================================================================== // Internals — serial I/O // =========================================================================== _waitForOk(timeoutMs, options = {}) { const command = options.command || ''; const quiet = options.quiet === true; const waitLogAfterMs = quiet ? Math.max(options.waitLogAfterMs ?? WallPlotter.OK_WAIT_LOG_MS, 8000) : options.waitLogAfterMs ?? WallPlotter.OK_WAIT_LOG_MS; const waitLabel = command && !quiet ? ` (${command})` : ''; return new Promise((resolve, reject) => { if (this._okWaiter) { reject(new Error('serial already waiting for ok')); return; } let settled = false; let timer = null; let waitLogTimer = null; let waitLogInterval = null; const finish = (fn, arg) => { if (settled) return; settled = true; if (this._okWaiter === entry) this._okWaiter = null; clearTimeout(timer); clearTimeout(waitLogTimer); if (waitLogInterval != null) clearInterval(waitLogInterval); fn(arg); }; const armTimer = () => { clearTimeout(timer); timer = setTimeout(() => { const err = new Error( `timeout waiting for Marlin ok${waitLabel} (${Math.round(timeoutMs / 1000)}s silent)` ); err.name = 'TimeoutError'; entry.reject(err); }, timeoutMs); }; const entry = { resolve: () => { finish(resolve); this._syncStatus(); }, reject: (err) => finish(reject, err), bump: () => { if (!settled) armTimer(); }, }; this._okWaiter = entry; armTimer(); waitLogTimer = setTimeout(() => { if (settled) return; const started = performance.now(); this.onLog(`… waiting for Marlin ok${waitLabel}`); this.statusText = 'Waiting for plotter…'; this._redrawSketch(); waitLogInterval = setInterval(() => { const s = Math.round((performance.now() - started) / 1000); this.onLog(`… still waiting for Marlin ok${waitLabel} (${s}s)`); }, WallPlotter.OK_WAIT_PING_MS); }, waitLogAfterMs); }); } _cancelPendingOk() { const waiter = this._okWaiter; if (!waiter) return; const err = new Error('aborted'); err.name = 'AbortError'; waiter.reject(err); } _startKeepalive() { this._stopKeepalive(); this._keepaliveTimer = setInterval(() => { this._keepaliveTick(); }, WallPlotter.KEEPALIVE_MS); } _stopKeepalive() { if (this._keepaliveTimer != null) { clearInterval(this._keepaliveTimer); this._keepaliveTimer = null; } } _keepaliveTick() { if (this._link !== 'connected' || !this.writer) return; if (this._okWaiter || this._job === 'aborting') return; if (performance.now() - this._lastSendAt < WallPlotter.KEEPALIVE_MS) return; this.send('M400', { quiet: true, allowWithoutHome: true, timeoutMs: 4000, waitLogAfterMs: 10000, }).catch(() => {}); } _startReadLoop() { this.readLoopActive = true; const plotter = this; const loop = async () => { while (plotter.port?.readable && plotter.readLoopActive) { plotter.reader = plotter.port.readable.getReader(); try { while (plotter.readLoopActive) { const { value, done } = await plotter.reader.read(); if (done) { if (plotter.readLoopActive) { plotter._onSerialGone('serial stream closed'); } break; } plotter._rxBuf += new TextDecoder().decode(value); plotter._drainRxBuffer(); } } catch (err) { if (plotter.readLoopActive) { plotter._onSerialGone(err.message || 'serial read error'); } } finally { try { plotter.reader.releaseLock(); } catch (_) { /* ignore */ } plotter.reader = null; } } }; loop(); } _drainRxBuffer() { const lines = this._rxBuf.split(/\r?\n/); this._rxBuf = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (trimmed) this._handleResponse(trimmed); } // USB-CDC sometimes delivers a complete "ok" without a trailing newline // until the next chunk. Don't leave that sitting in the buffer. const rest = this._rxBuf.trim(); if (/^(echo:\s*)?ok\b/i.test(rest)) { this._handleResponse(rest); this._rxBuf = ''; } } _handleResponse(line) { if (/busy:\s*processing/i.test(line)) { if (this._okWaiter && typeof this._okWaiter.bump === 'function') { this._okWaiter.bump(); } const now = performance.now(); if (now - this._lastBusyLogAt > 4000) { this._lastBusyLogAt = now; this.onLog('← busy (move still running)'); } return; } const isOk = /^(echo:\s*)?ok\b/i.test(line); if (!isOk) this.onLog('← ' + line); if (isOk) { const waiter = this._okWaiter; if (waiter) waiter.resolve(); } this._parseMachineBounds(line); if (line.startsWith('X:')) { const match = line.match(/X:([-\d.]+)\s+Y:([-\d.]+)/); if (match) { this.x = parseFloat(match[1]); this.y = parseFloat(match[2]); } } } /** Parse polargraph M665 report → machineX/Y min/max. */ _parseMachineBounds(line) { if (!/\bM665\b/i.test(line)) return; const l = line.match(/\bL(-?[\d.]+)/i); const r = line.match(/\bR(-?[\d.]+)/i); const t = line.match(/\bT(-?[\d.]+)/i); const b = line.match(/\bB(-?[\d.]+)/i); if (!l || !r || !t || !b) return; this.machineXMin = parseFloat(l[1]); this.machineXMax = parseFloat(r[1]); this.machineYMax = parseFloat(t[1]); this.machineYMin = parseFloat(b[1]); this.onLog( `machine bounds X ${this.machineXMin}…${this.machineXMax} Y ${this.machineYMin}…${this.machineYMax}` ); } _isCoordinateMove(command) { return /^G[01]\b/i.test(command) && /\b[XY]/i.test(command); } _isMachineIdle() { return this._job === 'idle' && !this._okWaiter && !this._bboxRunning; } _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') { if (!this._isLivePlot()) return false; if (this._job === 'paused') this.resumePlot(); else if (this._job === 'running') this.pausePlot(); return false; } if (k === 'a') { if (!this._isLivePlot() && !this._hasSavedPlot()) return false; this._abortLivePlot(); return false; } if (k === 't') { if (this._isLivePlot() || this._bboxRunning) return false; this._onTestButton(); return false; } if (k === 'b') { if (this._isLivePlot() || this._bboxRunning) return false; this._onBoundsButton(); return false; } if (!this._keysEnabled || !this.connected) return; if (k === 'h') { await this.setHomeHere(); return false; } if (k === '0') { if (!this._isMachineIdle()) return false; await this.goToZero(); return false; } if (k === 'p') { if (this.isPenDown) await this.penUp(true); else await this.penDown(true); return false; } if (k === '+' || k === '=') { this.stepSize = Math.min(this.stepSize + 5, 100); return false; } if (k === '-') { this.stepSize = Math.max(this.stepSize - 5, 1); return false; } } _handleContinuousJog() { if (!this.connected || this._jogInFlight || !this._isMachineIdle()) return; if (!this.homeSet) return; const draw = keyIsDown(SHIFT); const s = this.stepSize; let dx = 0; let dy = 0; if (keyIsDown(LEFT_ARROW)) dx -= s; if (keyIsDown(RIGHT_ARROW)) dx += s; if (keyIsDown(UP_ARROW)) dy += s; if (keyIsDown(DOWN_ARROW)) dy -= s; if (dx === 0 && dy === 0) { this._lastJogTime = 0; return; } const now = millis(); if (this._lastJogTime > 0 && now - this._lastJogTime < this.jogIntervalMs) return; this._lastJogTime = now; this._jogInFlight = true; this._jogBy(dx, dy, draw) .catch(() => {}) .finally(() => { this._jogInFlight = false; }); } async _jogBy(dx, dy, draw = false) { try { if (!this._requireHome()) return; if (draw) { await this.drawTo(this.x + dx, this.y + dy); return; } await this.penUp(); const nx = this.x + dx; const ny = this.y + dy; await this.send( `G1 X${this._fmt(nx)} Y${this._fmt(ny)} F${this.feedRateTravel}` ); this.x = nx; this.y = ny; } catch (err) { if (err.name === 'AbortError') return; throw err; } } async _toggleConnection() { if (this._link === 'connecting') return; try { if (this.connected) await this.disconnect(); else { this._link = 'connecting'; this._syncStatus(); this._updatePlotButtons(); await this.connect(); } } catch (err) { if (err.name === 'AbortError') return; this._link = 'disconnected'; this.statusText = 'Error: ' + err.message; this.onLog('! ' + err.message); } finally { if (this._link === 'connecting') this._link = 'disconnected'; this._updatePlotButtons(); } } _onConnectButton() { if (this._link !== 'disconnected') return; this._toggleConnection(); } _isLivePlot() { return this._job !== 'idle' && !this._plotTestOnly; } _hasSavedPlot() { return !!(this._interrupted && !this._interrupted.testOnly); } _hasDrawnSketch() { return this.previewPath.length > 0; } _abortLivePlot() { if (this._hasSavedPlot() && this._job === 'idle') { this._clearResumeState(); this.previewPath = []; this._path = []; this.plotProgress = 0; this.onLog('saved plot discarded — Test this sketch before Plot'); this._syncStatus(); this._updatePlotButtons(); this._redrawSketch(); return; } if (!this._isLivePlot()) return; this._clearResumeState(); this._abortJob(); this.onLog( 'plot aborted — motors finish the in-flight move (unplug power for a hard stop)' ); } async _plotExistingPath() { const commands = this.previewPath; if (!commands.length || !this.connected) return; if (this._pathLoopActive) this._abortJob(); while (this._pathLoopActive) await this._delay(16); await this._executePath(commands, false); } _onPlotButton() { if (this._bboxRunning) return; if (this._isLivePlot() && this._job === 'paused') { this.resumePlot(); return; } if (this._isLivePlot() && this._job === 'running') { this.pausePlot(); return; } if (this._job === 'aborting') return; if (this._job !== 'idle' && this._plotTestOnly) { this._plotExistingPath(); return; } if (this._job !== 'idle' || !this.connected) return; if (this._interrupted && !this._interrupted.testOnly) { this._resumeInterruptedPath(); return; } this._plotExistingPath(); } _previewBounds() { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; let n = 0; for (const cmd of this.previewPath) { if (cmd.type !== 'move' && cmd.type !== 'draw') continue; minX = Math.min(minX, cmd.x); maxX = Math.max(maxX, cmd.x); minY = Math.min(minY, cmd.y); maxY = Math.max(maxY, cmd.y); n += 1; } if (!n) return null; return { minX, minY, maxX, maxY }; } async _onBoundsButton() { if (this._isLivePlot() || this._bboxRunning) return; if (!this.connected || !this._requireHome()) return; const box = this._previewBounds(); if (!box) return; if (this._job !== 'idle' && this._plotTestOnly) { this._abortJob(); while (this._pathLoopActive) await this._delay(16); } if (this._job !== 'idle') return; this._bboxRunning = true; this._updatePlotButtons(); const { minX, minY, maxX, maxY } = box; this.onLog( `bounding box ${minX.toFixed(0)},${minY.toFixed(0)} … ${maxX.toFixed(0)},${maxY.toFixed(0)} mm (pen up)` ); try { await this.penUp(); const corners = [ { type: 'move', x: minX, y: minY }, { type: 'move', x: maxX, y: minY }, { type: 'move', x: maxX, y: maxY }, { type: 'move', x: minX, y: maxY }, { type: 'move', x: minX, y: minY }, ]; let px = this.x; let py = this.y; this.statusText = 'Bounding box (pen up)'; for (const cmd of corners) { const duration = this._segmentDurationMs(px, py, cmd); this._lastSegmentMs = duration; await Promise.all([ this._sendSegmentAndWait(cmd), this._animatePosition(px, py, cmd, duration), ]); px = cmd.x; py = cmd.y; } } catch (err) { if (err.name !== 'AbortError') this.onLog('! ' + err.message); } finally { this._bboxRunning = false; this._syncStatus(); this._updatePlotButtons(); } } _onTestButton() { if (this._isLivePlot() || this._bboxRunning) return; this._restartPath('test'); } _restartPath(mode) { this._clearResumeState(); if (this._pathLoopActive) this._abortJob(); this._triggerSketchFn(mode); } _updatePlotButtons() { const livePlot = this._isLivePlot(); const savedPlot = this._hasSavedPlot(); const sketchVisible = this._hasDrawnSketch() || livePlot || savedPlot; if (this._connectBtn) { if (this.connected) this._connectBtn.hide(); else this._connectBtn.show(); this._connectBtn.elt.disabled = this._link === 'connecting'; } if (this._testBtn) { if (livePlot) this._testBtn.hide(); else this._testBtn.show(); this._testBtn.html(this._testLabel); this._testBtn.elt.disabled = this._bboxRunning; } if (this._plotBtn) { if (this.connected && sketchVisible) this._plotBtn.show(); else this._plotBtn.hide(); let label = this._plotLabel; if (this._job === 'paused' && livePlot) label = this._resumeLabel; else if (savedPlot && this._job === 'idle') label = this._resumeLabel; else if (livePlot && (this._job === 'running' || this._job === 'aborting')) { label = this._pauseLabel; } this._plotBtn.html(label); this._plotBtn.elt.disabled = this._job === 'aborting' || this._bboxRunning; } if (this._bboxBtn) { if (sketchVisible && !livePlot) this._bboxBtn.show(); else this._bboxBtn.hide(); this._bboxBtn.elt.disabled = this._bboxRunning || !this.connected || !this.homeSet; } if (this._abortBtn) { if (livePlot || savedPlot) this._abortBtn.show(); else this._abortBtn.hide(); this._abortBtn.elt.disabled = this._job === 'aborting'; } } _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() { let style = document.getElementById('wallplotter-btn-styles'); if (!style) { style = document.createElement('style'); style.id = 'wallplotter-btn-styles'; document.head.appendChild(style); } style.textContent = ` #wallplotter-controls { position: absolute; top: 2.35rem; right: 0.75rem; z-index: 20; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; margin: 0; max-width: calc(100vw - 1.5rem); } #wallplotter-controls button, #wallplotter-log-toggle { 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, #wallplotter-log-toggle:hover { background: #35353c; border-color: #666; color: #eee; } #wallplotter-controls button:disabled { opacity: 0.4; cursor: not-allowed; color: #888; } #wallplotter-controls button:disabled:hover { background: rgba(42, 42, 48, 0.9); border-color: #444; color: #888; } #wallplotter-log { position: absolute; right: 0.75rem; bottom: 0.75rem; z-index: 20; box-sizing: border-box; width: min(40rem, calc(100vw - 1.5rem)); pointer-events: auto; background: #141418; border: 1px solid #333; border-radius: 6px; margin: 0; overflow: hidden; } #wallplotter-log.collapsed { width: auto; background: transparent; border: none; } #wallplotter-log-header { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.35rem 0.4rem 0.35rem 0.65rem; } #wallplotter-log.collapsed #wallplotter-log-header { padding: 0; } #wallplotter-log-title { color: #ccc; font-family: system-ui, sans-serif; font-size: 0.75rem; letter-spacing: 0.04em; } #wallplotter-log.collapsed #wallplotter-log-title { display: none; } #wallplotter-log-body { max-height: 38vh; overflow: auto; user-select: text; -webkit-user-select: text; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.4; color: #c8c8d0; background: #141418; border: none; margin: 0; padding: 0 0.65rem 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } #wallplotter-log.collapsed #wallplotter-log-body { display: none; } `; } // =========================================================================== // 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._drawSketchBounds(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(); } _drawSketchBounds(layout) { const box = this._previewBounds(); if (!box) return; const tl = this._worldToScreen(box.minX, box.maxY, layout); const br = this._worldToScreen(box.maxX, box.minY, layout); noFill(); stroke(90, 200, 255, 180); strokeWeight(1); drawingContext.setLineDash([6, 5]); rectMode(CORNER); rect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); drawingContext.setLineDash([]); } _drawPreviewPath(layout) { if (!this.previewPath.length) return; let px = null; let py = null; for (let i = 0; i < this.previewPath.length; i++) { const cmd = this.previewPath[i]; if (cmd.type !== 'move' && cmd.type !== 'draw') continue; const p = this._worldToScreen(cmd.x, cmd.y, layout); const isFuture = (this.isPlotting || !!this._interrupted) && i >= this.plotProgress; if (px !== null && py !== null) { if (cmd.type === 'draw') { const pressure = this._cmdPressure(cmd); stroke(255, 200, 60, isFuture ? 60 : 255); strokeWeight(0.6 + (pressure / 100) * 1.8); 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); const moving = this.isPlotting || this._bboxRunning; 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, moving ? 18 : 12); if (moving) { 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)'} ${this.penPressure}%`, 16, 72 ); text( `speed travel ${this.feedRateTravel} draw ${this.feedRateDraw} mm/min ×${this.timingMultiplier}`, 16, 92 ); text(`home ${this.homeSet ? 'set' : 'not set'}`, 16, 112); if (this.connected) { text( `machine X ${this.machineXMin.toFixed(0)}…${this.machineXMax.toFixed(0)} Y ${this.machineYMin.toFixed(0)}…${this.machineYMax.toFixed(0)} mm`, 16, 132 ); } else { const cm = this.previewSizeMm / 10; text(`preview ${cm} × ${cm} cm (connect for machine bounds)`, 16, 132); } if (this.isPlotting || this._interrupted) { fill(180, 220, 255); const label = this._interrupted && this._job === 'idle' ? 'interrupted' : this._job === 'paused' ? 'paused' : this._job === 'aborting' ? 'stopped' : 'plotting'; text( `${label} ${this.plotProgress}/${this.previewPath.length}`, 16, 152 ); const elapsedMs = this._plotElapsedMs(); const remainMs = Math.max(0, this._plotEstimateMs - elapsedMs); text( `elapsed ${this._formatDuration(elapsedMs)} est ${this._formatDuration(this._plotEstimateMs)} remain ${this._formatDuration(remainMs)}`, 16, 172 ); text( `seg wait ${this._lastSegmentMs.toFixed(0)} ms ×${this.timingMultiplier}`, 16, 192 ); fill(220); } if (this.connected && !this.homeSet && this._homeWarning) { fill(255, 180, 80); text(`! ${this._homeWarning}`, 16, this.isPlotting ? 212 : 152); fill(220); } if (this._keysEnabled) { textAlign(LEFT, BOTTOM); const help = [ '← → ↑ ↓ move (hold for continuous)', 'Shift+arrow draw (pen down)', '+ / - step size', 'H set current pos as home (G92) — required first', '0 go to origin (0, 0)', 'P toggle pen (test up/down angles)', 'T run test (preview)', 'B travel drawing bounds (pen up)', 'A abort plot (in-flight stroke still finishes)', 'S pause / resume plot', 'C connect / disconnect', ]; for (let i = 0; i < help.length; i++) { text(help[i], 16, height - 16 - i * 18); } } } _ensureLogOverlay() { this._injectPlotButtonStyles(); let wrap = document.getElementById('wallplotter-log'); let body = document.getElementById('wallplotter-log-body'); const header = document.getElementById('wallplotter-log-header'); if (wrap && body && header) return body; if (wrap) wrap.remove(); wrap = document.createElement('div'); wrap.id = 'wallplotter-log'; wrap.setAttribute('aria-label', 'Serial log'); const headerEl = document.createElement('div'); headerEl.id = 'wallplotter-log-header'; const title = document.createElement('span'); title.id = 'wallplotter-log-title'; title.textContent = 'Serial'; const toggle = document.createElement('button'); toggle.id = 'wallplotter-log-toggle'; toggle.type = 'button'; toggle.textContent = 'Hide'; toggle.setAttribute('aria-expanded', 'true'); toggle.addEventListener('click', () => { const collapsed = wrap.classList.toggle('collapsed'); toggle.textContent = collapsed ? 'serial-log' : 'Hide'; toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); }); body = document.createElement('pre'); body.id = 'wallplotter-log-body'; headerEl.appendChild(title); headerEl.appendChild(toggle); wrap.appendChild(headerEl); wrap.appendChild(body); document.body.appendChild(wrap); return body; } _syncLogOverlay() { const el = this._ensureLogOverlay(); const wrap = document.getElementById('wallplotter-log'); const text = this.logLines.join('\n'); if (el.textContent !== text) { el.textContent = text; el.scrollTop = el.scrollHeight; } if (wrap) wrap.style.display = this.logLines.length ? 'block' : 'none'; } // =========================================================================== // Internals — small utilities // =========================================================================== _fmt(value) { return Number(value).toFixed(3); } _formatDuration(ms) { const s = Math.max(0, ms) / 1000; if (s < 60) return `${s.toFixed(1)} s`; const totalSec = Math.round(s); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const sec = totalSec % 60; if (h > 0) { return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } return `${m}:${String(sec).padStart(2, '0')}`; } _delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } _redrawSketch() { if (typeof redraw === 'function') redraw(); } }