/** * WallPlotter — p5-friendly library for Marlin polargraph control via Web Serial. * * Provides moveTo (pen up, rapid travel) and drawTo (pen down, draw move). * Optional: enableKeyInputs() and showInfos() for built-in jog UI. * Requires Chrome/Edge and a page served over localhost or HTTPS. */ class WallPlotter { static VERSION = 'wait-ok+M400'; static MAX_FEED_MM_MIN = 10000; static MIN_FEED_MM_MIN = 1; /** Offline overlay size (mm) — 150×150 cm centred on origin */ static PREVIEW_SIZE_MM = 1500; constructor(options = {}) { this.baudRate = options.baudRate ?? 115200; this.stepSize = options.stepSize ?? 10; this.feedRateTravel = this._clampFeed(options.feedRateTravel ?? 1500); this.feedRateDraw = this._clampFeed(options.feedRateDraw ?? 300); this.penUpGcode = options.penUpGcode ?? 'M280 P0 S90'; this.penDownGcode = options.penDownGcode ?? 'M280 P0 S50'; this.penUpDelayMs = options.penUpDelayMs ?? 150; this.penDownDelayMs = options.penDownDelayMs ?? 50; this.machineXMin = options.machineXMin ?? -830; this.machineXMax = options.machineXMax ?? 830; this.machineYMin = options.machineYMin ?? -1200; this.machineYMax = options.machineYMax ?? 800; this.previewSizeMm = options.previewSizeMm ?? WallPlotter.PREVIEW_SIZE_MM; this.jogIntervalMs = options.jogIntervalMs ?? 150; this.maxLogLines = options.maxLogLines ?? 8; this.port = null; this.writer = null; this.reader = null; this.readLoopActive = false; this.connected = false; this.homeSet = false; this.isPenDown = false; this.x = 0; this.y = 0; this.statusText = 'Press C to connect'; this.logLines = []; this.drawBounds = { left: null, right: null, top: null, bottom: null }; this.previewPath = []; this.isPlotting = false; this.plotProgress = 0; this.previewMinDelayMs = options.previewMinDelayMs ?? 30; this.previewMaxDelayTravelMs = options.previewMaxDelayTravelMs ?? options.previewMaxDelayMs ?? 120000; this.previewMaxDelayDrawMs = options.previewMaxDelayDrawMs ?? options.previewMaxDelayMs ?? 120000; this.previewFrameMs = options.previewFrameMs ?? 16; this.timingMultiplier = options.timingMultiplier ?? 1; this._keysEnabled = false; this._lastJogTime = 0; this._jogInFlight = false; this._prevKeyPressed = null; this._homeWarning = null; this._plotButtonsEnabled = false; this._path = []; this._plotTestOnly = false; this._pathAbort = false; this._pathRunId = 0; this._pendingAfterAbort = null; this._plotBtn = null; this._testBtn = null; this._plotLabel = 'Plot'; this._testLabel = 'Test'; this._stopLabel = 'Stop'; this._lastTestBtnClick = 0; this._plotStartedAt = 0; this._plotEstimateMs = 0; this._lastSegmentMs = 0; this._rxBuf = ''; this._okResolvers = []; const userOnLog = options.onLog ?? (() => {}); this.onLog = (line) => { this.logLines.push(line); if (this.logLines.length > this.maxLogLines) this.logLines.shift(); userOnLog(line); }; const userOnStatusChange = options.onStatusChange ?? (() => {}); this.onStatusChange = (status) => { if (status === 'connected') this.statusText = 'Connected'; else if (status === 'disconnected') this.statusText = 'Disconnected'; userOnStatusChange(status); }; } enableKeyInputs() { if (this._keysEnabled) return; this._keysEnabled = true; this._prevKeyPressed = window.keyPressed; const plotter = this; window.keyPressed = async function () { const handled = await plotter._handleKeyPressed(); if (handled === false) return false; if (plotter._prevKeyPressed) return plotter._prevKeyPressed.apply(this, arguments); }; } enablePlotButtons(options = {}) { if (this._plotButtonsEnabled) return; this._plotButtonsEnabled = true; this._injectPlotButtonStyles(); const parentId = options.parentId ?? 'wallplotter-controls'; let parent = document.getElementById(parentId); if (!parent) { parent = document.createElement('div'); parent.id = parentId; const host = document.getElementById('sketch-host'); if (host?.parentNode) host.parentNode.insertBefore(parent, host); else document.body.appendChild(parent); } const plotter = this; this._plotLabel = options.plotLabel ?? 'Plot'; this._testLabel = options.testLabel ?? 'Test'; this._stopLabel = options.stopLabel ?? 'Stop'; const plotBtn = createButton(this._plotLabel); plotBtn.parent(parent); plotBtn.mousePressed(() => plotter._onPlotButton()); const testBtn = createButton(this._testLabel); testBtn.parent(parent); testBtn.mousePressed(() => plotter._onTestButton()); this._plotBtn = plotBtn; this._testBtn = testBtn; } _updatePlotButtons() { if (!this._testBtn) return; if (this.isPlotting && this._plotTestOnly) { this._testBtn.html(this._stopLabel); } else { this._testBtn.html(this._testLabel); } } stopPath() { this._pathAbort = true; } /** Abort path; if a live plot is running, also quick-stop Marlin (M410). */ async stopPlot() { if (!this.isPlotting) return; this.stopPath(); if (this.connected && !this._plotTestOnly) { try { await this.send('M410', { waitOk: false, allowWithoutHome: true }); } catch (_) { /* port may be busy */ } } this.statusText = 'Stopped'; this.onLog('plot stopped'); } _restartPathAfterAbort(mode) { this._pendingAfterAbort = mode; this._pathAbort = true; } _onTestButton() { const now = millis(); if (this.isPlotting && this._plotTestOnly) { if (now - this._lastTestBtnClick < 450) { this._restartPathAfterAbort('test'); } else { this.stopPath(); } this._lastTestBtnClick = now; return; } this._lastTestBtnClick = now; if (this.isPlotting) { this._restartPathAfterAbort('test'); return; } this._triggerSketchFn('test'); } _onPlotButton() { if (this.isPlotting) { this._restartPathAfterAbort('plot'); return; } this._triggerSketchFn('plot'); } _injectPlotButtonStyles() { if (document.getElementById('wallplotter-btn-styles')) return; const style = document.createElement('style'); style.id = 'wallplotter-btn-styles'; style.textContent = ` #wallplotter-controls { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; } #wallplotter-controls button { background: #2a2a30; border: 1px solid #444; border-radius: 6px; color: #ddd; cursor: pointer; font-family: system-ui, sans-serif; font-size: 0.9rem; padding: 0.4rem 1rem; } #wallplotter-controls button:hover { background: #35353c; border-color: #666; } `; document.head.appendChild(style); } _triggerSketchFn(name) { const fn = window[name]; if (typeof fn !== 'function') { this.onLog(`! sketch has no ${name}() function`); return; } Promise.resolve(fn()).catch((err) => { this.statusText = 'Error: ' + err.message; this.onLog('! ' + err.message); }); } showInfos() { textFont('monospace', 14); this._drawPlotArea(); this._drawHud(); if (this._keysEnabled && !this.isPlotting) this._handleContinuousJog(); } /** * Set travel/draw feed rates in mm/min (G-code F and wait/preview timing). * Use setTimingMultiplier() if the machine is slower or faster than this estimate. * @param {number|{travel?: number, draw?: number}} travel * @param {number} [draw] */ setSpeed(travel, draw) { if (typeof travel === 'object' && travel !== null) { if (travel.travel != null) this.feedRateTravel = this._clampFeed(travel.travel); if (travel.draw != null) this.feedRateDraw = this._clampFeed(travel.draw); return; } this.feedRateTravel = this._clampFeed(travel); if (draw != null) this.feedRateDraw = this._clampFeed(draw); } _clampFeed(value) { const n = Number(value); if (!Number.isFinite(n)) return WallPlotter.MIN_FEED_MM_MIN; return Math.min(WallPlotter.MAX_FEED_MM_MIN, Math.max(WallPlotter.MIN_FEED_MM_MIN, n)); } /** * Scale segment wait/preview duration. Use when the machine is slower than * the speed-based estimate (e.g. 2 = wait twice as long, 0.5 = half as long). * @param {number} mult */ setTimingMultiplier(mult) { this.timingMultiplier = Math.max(0.1, Number(mult) || 1); } /** * Begin a new path (clears the internal path buffer). * Add segments with move(), draw(), wait(), speed(), then runPath(testOnly). */ startPath() { this._path = []; return this; } move(x, y) { this._path.push({ type: 'move', x, y }); return this; } draw(x, y) { this._path.push({ type: 'draw', x, y }); return this; } wait(ms) { this._path.push({ type: 'wait', ms }); return this; } speed(travel, drawRate) { this._path.push({ type: 'speed', travel: travel != null ? this._clampFeed(travel) : undefined, draw: drawRate != null ? this._clampFeed(drawRate) : undefined, }); return this; } /** * Run a plot path. Pass an array, or call runPath(testOnly) after startPath(). * @param {Array|boolean} commandsOrTestOnly - path array, or testOnly if using startPath() * @param {boolean} [testOnly] - if true, preview only (no serial commands) */ async runPath(commandsOrTestOnly, testOnly = false) { let commands; if (Array.isArray(commandsOrTestOnly)) { commands = commandsOrTestOnly; } else { testOnly = commandsOrTestOnly ?? false; commands = this._path; } await this._executePath(commands, testOnly); } /** * Estimated duration of the current path buffer (or `commands`) in ms, * using setSpeed / queued speed() and wait() plus timingMultiplier. */ estimatePathMs(commands) { const cmds = commands ?? this._path; return this._estimatePathDurationMs(cmds, this.x, this.y); } _estimatePathDurationMs(commands, fromX, fromY) { const savedTravel = this.feedRateTravel; const savedDraw = this.feedRateDraw; const savedPen = this.isPenDown; let px = fromX; let py = fromY; let total = 0; try { for (const cmd of commands) { if (cmd.type === 'speed') { if (cmd.travel != null) this.feedRateTravel = cmd.travel; if (cmd.draw != null) this.feedRateDraw = cmd.draw; continue; } if (cmd.type === 'wait') { total += Math.max(0, cmd.ms ?? 0); continue; } if (cmd.type !== 'move' && cmd.type !== 'draw') continue; total += this._segmentDurationMs(px, py, cmd); this.isPenDown = cmd.type === 'draw'; px = cmd.x; py = cmd.y; } } finally { this.feedRateTravel = savedTravel; this.feedRateDraw = savedDraw; this.isPenDown = savedPen; } return total; } _formatDuration(ms) { const s = Math.max(0, ms) / 1000; if (s < 60) return `${s.toFixed(1)} s`; const totalSec = Math.round(s); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const sec = totalSec % 60; const mmss = `${m}:${String(sec).padStart(2, '0')}`; return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss; } async _executePath(commands, testOnly = false) { const runId = ++this._pathRunId; this._pathAbort = false; this._plotTestOnly = testOnly; this.previewPath = commands; if (!testOnly && !this._requireHome()) { this._plotTestOnly = false; return; } this.isPlotting = true; this.plotProgress = 0; this._plotStartedAt = performance.now(); this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y); this._lastSegmentMs = 0; this._updatePlotButtons(); const savedFeedTravel = this.feedRateTravel; const savedFeedDraw = this.feedRateDraw; let px = this.x; let py = this.y; try { for (let i = 0; i < commands.length; i++) { if (this._pathAbort || runId !== this._pathRunId) break; const cmd = commands[i]; if (cmd.type === 'speed') { if (cmd.travel != null) this.feedRateTravel = cmd.travel; if (cmd.draw != null) this.feedRateDraw = cmd.draw; this.plotProgress = i + 1; this._redrawSketch(); continue; } if (cmd.type === 'wait') { const ms = max(0, cmd.ms ?? 0); if (!testOnly && ms > 0) await this.send(`G4 P${Math.round(ms)}`); await this._holdPosition(ms); if (this._pathAbort || runId !== this._pathRunId) break; this.plotProgress = i + 1; continue; } const fromX = px; const fromY = py; const duration = this._segmentDurationMs(fromX, fromY, cmd); this._lastSegmentMs = duration; if (testOnly) { await this._animatePosition(fromX, fromY, cmd, duration); } else { const motion = this._sendSegmentAndWait(cmd); const anim = this._animatePosition(fromX, fromY, cmd, duration); await Promise.all([motion, anim]); } if (this._pathAbort || runId !== this._pathRunId) break; px = cmd.x; py = cmd.y; this.plotProgress = i + 1; } } finally { this.feedRateTravel = savedFeedTravel; this.feedRateDraw = savedFeedDraw; this.isPlotting = false; this._plotTestOnly = false; this._updatePlotButtons(); this._redrawSketch(); const next = this._pendingAfterAbort; this._pendingAfterAbort = null; if (next === 'test') this._triggerSketchFn('test'); else if (next === 'plot') this._triggerSketchFn('plot'); } } /** ms to wait for one segment: (distance/feed + pen delay) × multiplier */ _segmentDurationMs(fromX, fromY, cmd) { const isMove = cmd.type === 'move'; const dist = Math.hypot(cmd.x - fromX, cmd.y - fromY); const rate = isMove ? this.feedRateTravel : this.feedRateDraw; const motionMs = dist > 0 && rate > 0 ? (dist / rate) * 60000 : 0; let penMs = 0; if (isMove && this.isPenDown) penMs = this.penUpDelayMs; if (!isMove && !this.isPenDown) penMs = this.penDownDelayMs; const raw = motionMs + penMs; const floored = Math.max(raw, this.previewMinDelayMs); return floored * this.timingMultiplier; } async _holdPosition(ms) { if (ms <= 0) return; const start = performance.now(); while (performance.now() - start < ms) { if (this._pathAbort) return; this._redrawSketch(); await this._delay(this.previewFrameMs); } } async _animatePosition(fromX, fromY, cmd, duration) { this.isPenDown = cmd.type === 'draw'; const start = performance.now(); const waitMs = Math.max(duration, 1); while (true) { if (this._pathAbort) { const t = Math.min(1, Math.max(0, (performance.now() - start) / waitMs)); this.x = fromX + (cmd.x - fromX) * t; this.y = fromY + (cmd.y - fromY) * t; return; } const t = Math.min(1, Math.max(0, (performance.now() - start) / waitMs)); this.x = fromX + (cmd.x - fromX) * t; this.y = fromY + (cmd.y - fromY) * t; this._redrawSketch(); if (t >= 1) break; await this._delay(this.previewFrameMs); } this.x = cmd.x; this.y = cmd.y; } async _sendSegment(cmd) { if (!this._requireHome()) return; if (cmd.type === 'move') { if (this.isPenDown) { await this.send(this.penUpGcode); this.isPenDown = false; } await this.send( // G1 not G0: Marlin G0_FEEDRATE overrides F unless VARIABLE_G0_FEEDRATE is enabled `G1 X${this._fmt(cmd.x)} Y${this._fmt(cmd.y)} F${this.feedRateTravel}` ); } else { if (!this.isPenDown) { await this.send(this.penDownGcode); this.isPenDown = true; } await this.send( `G1 X${this._fmt(cmd.x)} Y${this._fmt(cmd.y)} F${this.feedRateDraw}` ); } } /** Send the segment, then M400 so Marlin finishes the move before we continue. */ async _sendSegmentAndWait(cmd) { await this._sendSegment(cmd); await this.send('M400'); } _redrawSketch() { if (typeof redraw === 'function') redraw(); } async connect() { if (!('serial' in navigator)) { throw new Error('Web Serial is not supported in this browser. Use Chrome or Edge.'); } if (this.connected) return; this.port = await navigator.serial.requestPort(); await this.port.open({ baudRate: this.baudRate }); this.writer = this.port.writable.getWriter(); this._startReadLoop(); this.connected = true; this.homeSet = false; this._homeWarning = 'Home not set — jog to origin and press H'; this.onStatusChange('connected'); await this.send('M17'); await this.penUp(true); await this.send('M665'); // polargraph draw area → updates machine bounds await this.send('M114'); } async disconnect() { if (!this.connected) return; this.readLoopActive = false; while (this._okResolvers.length) { const next = this._okResolvers.shift(); if (next) next.resolve(); } this._rxBuf = ''; if (this.reader) { try { await this.reader.cancel(); } catch (_) { /* port may already be closed */ } this.reader = null; } if (this.writer) { try { this.writer.releaseLock(); } catch (_) { /* ignore */ } this.writer = null; } if (this.port) { try { await this.port.close(); } catch (_) { /* ignore */ } this.port = null; } this.connected = false; this.homeSet = false; this._homeWarning = null; this.onStatusChange('disconnected'); } async send(line, options = {}) { if (!this.connected || !this.writer) { throw new Error('Not connected to plotter'); } const command = line.trim(); if (!command) return; if ( !options.allowWithoutHome && !this.homeSet && this._isCoordinateMove(command) ) { this._showHomeRequiredWarning(); return; } const waitOk = options.waitOk !== false; const okPromise = waitOk ? this._waitForOk(options.timeoutMs ?? 300000) : null; const payload = new TextEncoder().encode(command + '\n'); await this.writer.write(payload); this.onLog('→ ' + command); if (okPromise) await okPromise; } _waitForOk(timeoutMs) { return new Promise((resolve, reject) => { const entry = { resolve: () => { clearTimeout(timer); resolve(); } }; const timer = setTimeout(() => { const idx = this._okResolvers.indexOf(entry); if (idx >= 0) this._okResolvers.splice(idx, 1); reject(new Error('timeout waiting for Marlin ok')); }, timeoutMs); this._okResolvers.push(entry); }); } _isCoordinateMove(command) { return /^G[01]\b/i.test(command) && /\b[XY]/i.test(command); } _showHomeRequiredWarning() { const msg = 'Home not set — press H before sending moves'; this._homeWarning = msg; this.statusText = msg; this.onLog('! ' + msg); } _requireHome() { if (this.homeSet) { this._homeWarning = null; return true; } this._showHomeRequiredWarning(); return false; } async penUp(force = false) { if (!force && !this.isPenDown) return; await this.send(this.penUpGcode); await this._delay(this.penUpDelayMs); this.isPenDown = false; } async penDown(force = false) { if (!force && this.isPenDown) return; await this.send(this.penDownGcode); await this._delay(this.penDownDelayMs); this.isPenDown = true; } async moveTo(x, y) { if (!this._requireHome()) return; await this.penUp(); await this.send( `G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateTravel}` ); this.x = x; this.y = y; } async goToZero() { await this.moveTo(0, 0); } async drawTo(x, y) { if (!this._requireHome()) return; await this.penDown(); await this.send( `G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateDraw}` ); this.x = x; this.y = y; } async step(dx, dy, draw = false) { if (!this._requireHome()) return; const nx = this.x + dx; const ny = this.y + dy; if (draw) { await this.drawTo(nx, ny); } else { await this.moveTo(nx, ny); } } async setHomeHere() { await this.send('G92 X0 Y0'); this.x = 0; this.y = 0; this.homeSet = true; this._homeWarning = null; if (this.connected) this.statusText = 'Connected'; } async _jogBy(dx, dy, draw = false) { if (draw) { if (!this._requireHome()) return; await this.drawTo(this.x + dx, this.y + dy); return; } await this.penUp(); const nx = this.x + dx; const ny = this.y + dy; await this.send( `G1 X${this._fmt(nx)} Y${this._fmt(ny)} F${this.feedRateTravel}`, { allowWithoutHome: true } ); this.x = nx; this.y = ny; } async _handleKeyPressed() { const k = key.toLowerCase(); if (k === 'c') { await this._toggleConnection(); return false; } if (k === 's') { if (this.isPlotting) await this.stopPlot(); return false; } if (!this._keysEnabled || !this.connected) return; if (k === 'h') { await this.setHomeHere(); return false; } if (k === '0') { await this.goToZero(); return false; } if (k === 'p') { if (this.isPenDown) await this.penUp(true); else await this.penDown(true); return false; } if (k === '+' || k === '=') { this.stepSize = min(this.stepSize + 5, 100); return false; } if (k === '-') { this.stepSize = max(this.stepSize - 5, 1); return false; } if (k === 'l') { if (!this._requireHome()) return false; this.drawBounds.left = this.x; return false; } if (k === 'r') { if (!this._requireHome()) return false; this.drawBounds.right = this.x; return false; } if (k === 't') { if (!this._requireHome()) return false; this.drawBounds.top = this.y; return false; } if (k === 'b') { if (!this._requireHome()) return false; this.drawBounds.bottom = this.y; return false; } } _handleContinuousJog() { if (!this.connected || this._jogInFlight) return; const draw = keyIsDown(SHIFT); const s = this.stepSize; let dx = 0; let dy = 0; if (keyIsDown(LEFT_ARROW)) dx -= s; if (keyIsDown(RIGHT_ARROW)) dx += s; if (keyIsDown(UP_ARROW)) dy += s; if (keyIsDown(DOWN_ARROW)) dy -= s; if (dx === 0 && dy === 0) { this._lastJogTime = 0; return; } const now = millis(); if (this._lastJogTime > 0 && now - this._lastJogTime < this.jogIntervalMs) return; this._lastJogTime = now; this._jogInFlight = true; this._jogBy(dx, dy, draw).finally(() => { this._jogInFlight = false; }); } async _toggleConnection() { try { if (this.connected) { await this.disconnect(); } else { this.statusText = 'Connecting…'; await this.connect(); } } catch (err) { this.statusText = 'Error: ' + err.message; this.onLog('! ' + err.message); } } _getDisplayBounds() { if (this.connected) { return { xMin: this.machineXMin, xMax: this.machineXMax, yMin: this.machineYMin, yMax: this.machineYMax, preview: false, }; } const half = this.previewSizeMm / 2; return { xMin: -half, xMax: half, yMin: -half, yMax: half, preview: true, }; } _getPlotLayout() { const margin = 60; const size = min(width, height) - margin * 2; const b = this._getDisplayBounds(); const worldW = Math.max(1, b.xMax - b.xMin); const worldH = Math.max(1, b.yMax - b.yMin); // Fit full display rectangle; centre the view on work origin (0,0) const scale = min(size / worldW, size / worldH); return { cx: width / 2, cy: height / 2 - 20, scale, worldCx: 0, worldCy: 0, }; } _worldToScreen(worldX, worldY, layout) { return { x: (worldX - layout.worldCx) * layout.scale, y: -(worldY - layout.worldCy) * layout.scale, }; } _getEffectiveDrawBounds() { const { left, right, top, bottom } = this.drawBounds; if (left === null && right === null && top === null && bottom === null) { return null; } return { left: left ?? this.machineXMin, right: right ?? this.machineXMax, top: top ?? this.machineYMax, bottom: bottom ?? this.machineYMin, }; } _formatBound(value) { return value === null ? '—' : value.toFixed(1); } _getDrawSize() { const b = this._getEffectiveDrawBounds(); if (!b) return null; const w = b.right - b.left; const h = b.top - b.bottom; if (w <= 0 || h <= 0) return null; return { w, h }; } _drawPlotArea() { const layout = this._getPlotLayout(); const bounds = this._getDisplayBounds(); push(); translate(layout.cx, layout.cy); const tl = this._worldToScreen(bounds.xMin, bounds.yMax, layout); const br = this._worldToScreen(bounds.xMax, bounds.yMin, layout); const w = br.x - tl.x; const h = br.y - tl.y; noFill(); rectMode(CORNER); strokeWeight(1); if (bounds.preview) { stroke(90, 90, 110); drawingContext.setLineDash([8, 6]); } else { stroke(70); drawingContext.setLineDash([]); } rect(tl.x, tl.y, w, h); drawingContext.setLineDash([]); stroke(60); line(tl.x, 0, br.x, 0); line(0, tl.y, 0, br.y); // Size labels in cm along bottom and right edges const widthCm = (bounds.xMax - bounds.xMin) / 10; const heightCm = (bounds.yMax - bounds.yMin) / 10; const widthLabel = Number.isInteger(widthCm) ? `${widthCm} cm` : `${widthCm.toFixed(1)} cm`; const heightLabel = Number.isInteger(heightCm) ? `${heightCm} cm` : `${heightCm.toFixed(1)} cm`; noStroke(); fill(bounds.preview ? 140 : 160); textFont('monospace', 12); textAlign(CENTER, TOP); text(widthLabel, (tl.x + br.x) / 2, br.y + 8); push(); translate(br.x + 8, (tl.y + br.y) / 2); rotate(HALF_PI); textAlign(CENTER, BOTTOM); text(heightLabel, 0, 0); pop(); const rectBounds = this._getEffectiveDrawBounds(); if (rectBounds) { const dtl = this._worldToScreen(rectBounds.left, rectBounds.top, layout); const dbr = this._worldToScreen(rectBounds.right, rectBounds.bottom, layout); noFill(); stroke(120, 220, 120); strokeWeight(2); rect(dtl.x, dtl.y, dbr.x - dtl.x, dbr.y - dtl.y); } this._drawPreviewPath(layout); const pos = this._worldToScreen(this.x, this.y, layout); if (this.isPenDown) { stroke(255, 90, 90); fill(255, 90, 90); } else { stroke(90, 200, 255); fill(90, 200, 255); } strokeWeight(2); circle(pos.x, pos.y, this.isPlotting ? 18 : 12); if (this.isPlotting) { noFill(); stroke(255, 255, 255, 120); strokeWeight(1); circle(pos.x, pos.y, 26); } pop(); } _drawPreviewPath(layout) { if (!this.previewPath.length) return; let px = null; let py = null; for (let i = 0; i < this.previewPath.length; i++) { const cmd = this.previewPath[i]; if (cmd.type !== 'move' && cmd.type !== 'draw') continue; const p = this._worldToScreen(cmd.x, cmd.y, layout); const isFuture = this.isPlotting && i >= this.plotProgress; if (px !== null && py !== null) { if (cmd.type === 'draw') { stroke(255, 200, 60, isFuture ? 60 : 255); strokeWeight(2); line(px, py, p.x, p.y); } else { stroke(100, 100, 120, isFuture ? 60 : 200); strokeWeight(1); drawingContext.setLineDash([4, 4]); line(px, py, p.x, p.y); drawingContext.setLineDash([]); } } px = p.x; py = p.y; } } _drawHud() { fill(220); noStroke(); textAlign(LEFT, TOP); text(this.statusText, 16, 16); fill(140); text(`lib ${WallPlotter.VERSION}`, 16, 32); fill(220); text( `pos X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} step ${this.stepSize} mm`, 16, 52 ); text(`pen ${this.isPenDown ? 'down (drawing)' : 'up (travel)'}`, 16, 72); text( `speed travel ${this.feedRateTravel} draw ${this.feedRateDraw} mm/min ×${this.timingMultiplier}`, 16, 92 ); text(`home ${this.homeSet ? 'set' : 'not set'}`, 16, 112); if (this.connected) { text( `machine X ${this.machineXMin.toFixed(0)}…${this.machineXMax.toFixed(0)} Y ${this.machineYMin.toFixed(0)}…${this.machineYMax.toFixed(0)} mm`, 16, 132 ); } else { const cm = this.previewSizeMm / 10; text(`preview ${cm} × ${cm} cm (connect for machine bounds)`, 16, 132); } if (this.isPlotting) { fill(180, 220, 255); text( `plotting ${this.plotProgress}/${this.previewPath.length}`, 16, 152 ); const elapsedMs = performance.now() - this._plotStartedAt; const remainMs = Math.max(0, this._plotEstimateMs - elapsedMs); text( `elapsed ${this._formatDuration(elapsedMs)} est ${this._formatDuration(this._plotEstimateMs)} remain ${this._formatDuration(remainMs)}`, 16, 172 ); text( `seg wait ${this._lastSegmentMs.toFixed(0)} ms ×${this.timingMultiplier}`, 16, 192 ); fill(220); } if (this.connected && !this.homeSet && this._homeWarning) { fill(255, 180, 80); text(`! ${this._homeWarning}`, 16, this.isPlotting ? 212 : 152); fill(220); } text( `bounds L ${this._formatBound(this.drawBounds.left)} R ${this._formatBound(this.drawBounds.right)} T ${this._formatBound(this.drawBounds.top)} B ${this._formatBound(this.drawBounds.bottom)}`, 16, this.isPlotting ? 232 : 172 ); const size = this._getDrawSize(); const areaY = this.isPlotting ? 252 : 192; if (size) { text(`area ${size.w.toFixed(1)} × ${size.h.toFixed(1)} mm`, 16, areaY); } else if ( this.drawBounds.left !== null || this.drawBounds.right !== null || this.drawBounds.top !== null || this.drawBounds.bottom !== null ) { text('area (mark opposite edges for size)', 16, areaY); } if (this._keysEnabled) { textAlign(LEFT, BOTTOM); const help = [ 'L R T B mark left / right / top / bottom bound', '← → ↑ ↓ move (hold for continuous)', 'Shift+arrow draw (pen down)', '+ / - step size', 'H set current pos as home (G92) — required first', '0 go to origin (0, 0)', 'P toggle pen (test up/down angles)', 'S stop plot / test', 'C connect / disconnect', ]; for (let i = 0; i < help.length; i++) { text(help[i], 16, height - 16 - i * 18); } } textAlign(RIGHT, TOP); fill(140); for (let i = 0; i < this.logLines.length; i++) { text(this.logLines[i], width - 16, 16 + i * 16); } } _fmt(value) { return Number(value).toFixed(3); } _delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } _startReadLoop() { this.readLoopActive = true; const plotter = this; const loop = async () => { while (plotter.port?.readable && plotter.readLoopActive) { plotter.reader = plotter.port.readable.getReader(); try { while (plotter.readLoopActive) { const { value, done } = await plotter.reader.read(); if (done) break; plotter._rxBuf += new TextDecoder().decode(value); const lines = plotter._rxBuf.split(/\r?\n/); plotter._rxBuf = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (trimmed) plotter._handleResponse(trimmed); } } } catch (err) { if (plotter.readLoopActive) plotter.onLog('serial read error: ' + err.message); } finally { try { plotter.reader.releaseLock(); } catch (_) { /* ignore */ } plotter.reader = null; } } }; loop(); } _handleResponse(line) { this.onLog('← ' + line); if (/^ok\b/i.test(line)) { const next = this._okResolvers.shift(); if (next) next.resolve(); } this._parseMachineBounds(line); if (line.startsWith('X:')) { const match = line.match(/X:([-\d.]+)\s+Y:([-\d.]+)/); if (match) { this.x = parseFloat(match[1]); this.y = parseFloat(match[2]); } } } /** Parse Marlin polargraph M665 report: M665 S… L… R… T… B… H… */ _parseMachineBounds(line) { if (!/\bM665\b/i.test(line)) return; const l = line.match(/\bL(-?[\d.]+)/i); const r = line.match(/\bR(-?[\d.]+)/i); const t = line.match(/\bT(-?[\d.]+)/i); const b = line.match(/\bB(-?[\d.]+)/i); if (!l || !r || !t || !b) return; this.machineXMin = parseFloat(l[1]); this.machineXMax = parseFloat(r[1]); this.machineYMax = parseFloat(t[1]); this.machineYMin = parseFloat(b[1]); this.onLog( `machine bounds X ${this.machineXMin}…${this.machineXMax} Y ${this.machineYMin}…${this.machineYMax}` ); } }