From 606752fbeca18821691b73d8bb2fbac89b12c583 Mon Sep 17 00:00:00 2001 From: Martin Fuchs Date: Sun, 16 Aug 2026 15:54:30 +0200 Subject: [PATCH] stop and go --- examples/zickzack/index.html | 52 +- examples/zickzack/sketch.js | 11 +- lib/README.md | 22 +- lib/WallPlotter.js | 1631 +++++++++++++++++++++------------- 4 files changed, 1033 insertions(+), 683 deletions(-) diff --git a/examples/zickzack/index.html b/examples/zickzack/index.html index 5b66771..935047f 100644 --- a/examples/zickzack/index.html +++ b/examples/zickzack/index.html @@ -9,52 +9,44 @@ box-sizing: border-box; margin: 0; } + html, body { + width: 100%; + height: 100%; + overflow: hidden; background: #1c1c20; color: #ddd; font-family: system-ui, sans-serif; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - padding: 1rem; } h1 { - font-size: 1.1rem; + position: absolute; + top: 0.6rem; + right: 0.75rem; + z-index: 20; + font-size: 0.95rem; font-weight: 500; - margin-bottom: 0.75rem; + letter-spacing: 0.02em; opacity: 0.85; + pointer-events: none; + text-align: right; } - main { - border-radius: 8px; - overflow: hidden; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + #sketch-host { + position: absolute; + inset: 0; + z-index: 0; } - p.note { - max-width: 800px; - margin-top: 1rem; - font-size: 0.85rem; - line-height: 1.5; - opacity: 0.7; - } - code { - background: #2a2a30; - padding: 0.1em 0.35em; - border-radius: 4px; + #sketch-host canvas { + display: block; + width: 100% !important; + height: 100% !important; } - + -

Wall Plotter — zigzag example

+

Wall Plotter — zigzag

-

- Serve from the p5-wallplotter folder: - python -m http.server 8080 - then open http://localhost:8080/examples/zickzack/. - Chrome or Edge. Press C to connect, H to set home. -

diff --git a/examples/zickzack/sketch.js b/examples/zickzack/sketch.js index 31f857a..149dabb 100644 --- a/examples/zickzack/sketch.js +++ b/examples/zickzack/sketch.js @@ -42,14 +42,15 @@ async function drawArt(testOnly) { wp.move(-area, -area); //wp.wait(1000 * 10); - let step = area / 2; + let step = area / 6; for (let y = -area; y <= area; y+=step){ for (let x = -area; x <= area; x+=step){ - wp.move(x+step, y); - wp.draw(x, y+step); - } + //if(random(1) < 0.8){ + wp.draw(x, y); + wp.draw(x+(random(1)<0.9), y+random(step*1.5)); + //} + } } - await wp.runPath(testOnly); } diff --git a/lib/README.md b/lib/README.md index 1c4f874..8e9e58d 100644 --- a/lib/README.md +++ b/lib/README.md @@ -53,16 +53,20 @@ Keyboard controls. Call from `setup()`. No arguments. | `← → ↑ ↓` | Jog one step, pen up | | `Shift + arrows` | Jog one step, pen down | | `+` / `-` | Jog step size 1–100 mm | -| `L` `R` `T` `B` | Mark left / right / top / bottom bound at current position | ### `enablePlotButtons(options?)` -Adds Plot and Test buttons. Expects global `plot()` and `test()` in the sketch. +Adds Connect, Plot, and Test buttons. Expects global `plot()` and `test()` in the sketch. + +**Connect** is shown only while disconnected (same as key **C**). While a path is running, Plot becomes **Pause**; while paused it becomes **Resume**. Test becomes **Stop** during a test. | Option | Default | What it is | |--------|---------|------------| | `parentId` | `'wallplotter-controls'` | DOM id for the button row | -| `plotLabel` | `'Plot'` | Plot button text | +| `connectLabel` | `'Connect'` | Connect button text | +| `plotLabel` | `'Plot'` | Plot button text when idle | +| `pauseLabel` | `'Pause'` | Plot button while a path is running | +| `resumeLabel` | `'Resume'` | Plot button while paused | | `testLabel` | `'Test'` | Test button text | | `stopLabel` | `'Stop'` | Test button while a test is running | @@ -146,7 +150,13 @@ Returns estimated duration in **milliseconds** for the current path buffer, or f ### `stopPath()` / `stopPlot()` -`stopPath()` aborts the current `runPath`. `stopPlot()` also sends Marlin `M410` (quick stop) during a live plot. Key **S** calls `stopPlot()` while plotting. +`stopPath()` aborts the current `runPath` and unblocks any wait for Marlin `ok`. + +`stopPlot()` **halts and pauses** so **Resume** can continue from the current position (live plot: `M410` quick-stop, then `M114`). Key **S** and a Marlin `ok` timeout do the same. **S** / Stop again while paused aborts the run. + +### `pausePlot()` / `resumePlot()` + +**Pause** waits for the current segment to finish, then lifts the pen. **Stop** / timeout interrupt the current move immediately. Both leave the Plot button as **Resume**. Elapsed / remaining HUD time freezes while paused. --- @@ -169,7 +179,7 @@ Write one G-code line and wait for Marlin `ok`. - `line` (string): command without newline, e.g. `'M114'`. - `options.allowWithoutHome` (boolean): allow `G0`/`G1` before home. Default `false`. - `options.waitOk` (boolean): wait for `ok`. Default `true`. -- `options.timeoutMs` (number): `ok` timeout, default `300000`. +- `options.timeoutMs` (number): `ok` timeout. Default **15000**. `M400` / `G4` use a longer timeout based on the move or dwell. After 1s with no `ok`, the log shows `waiting for Marlin ok` and the HUD says **Waiting for plotter…**. During a plot, a timeout **pauses** so **Resume** can continue; **S** / Stop does the same. ### `penUp(force?)` / `penDown(force?)` @@ -209,7 +219,7 @@ Relative move in mm. | `x`, `y` | Current position, mm | | `isPenDown` | Pen state | | `isPlotting` | `runPath` in progress | +| `isPaused` | `runPath` is paused | | `feedRateTravel` / `feedRateDraw` | Current speeds, mm/min | | `timingMultiplier` | Preview wait scale | | `stepSize` | Jog step, mm | -| `drawBounds` | `{ left, right, top, bottom }` or `null` | diff --git a/lib/WallPlotter.js b/lib/WallPlotter.js index f894726..0016c61 100644 --- a/lib/WallPlotter.js +++ b/lib/WallPlotter.js @@ -1,79 +1,143 @@ /** - * WallPlotter — p5-friendly library for Marlin polargraph control via Web Serial. + * WallPlotter — p5.js helper for a Marlin polargraph over 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. + * 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 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; + // --------------------------------------------------------------------------- + // Static constants + // --------------------------------------------------------------------------- + static VERSION = '2.2.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; + + // --------------------------------------------------------------------------- + // Construction + // --------------------------------------------------------------------------- + + /** + * @param {object} [options] + * @param {number} [options.baudRate=115200] + * @param {number} [options.stepSize=10] Jog step in mm + * @param {number} [options.feedRateTravel=1500] Pen-up speed, mm/min + * @param {number} [options.feedRateDraw=300] Pen-down speed, mm/min + * @param {string} [options.penUpGcode] + * @param {string} [options.penDownGcode] + * @param {number} [options.penUpDelayMs=150] + * @param {number} [options.penDownDelayMs=50] + * @param {number} [options.machineXMin] Fallback overlay X until M665 + * @param {number} [options.machineXMax] + * @param {number} [options.machineYMin] + * @param {number} [options.machineYMax] + * @param {number} [options.previewSizeMm] Offline square size (mm) + * @param {number} [options.jogIntervalMs=150] + * @param {number} [options.maxLogLines=8] + * @param {number} [options.previewMinDelayMs=30] Floor for preview segment wait + * @param {number} [options.previewFrameMs=16] Preview animation frame time + * @param {number} [options.timingMultiplier=1] Scales Test preview waits + * @param {function} [options.onLog] + * @param {function} [options.onStatusChange] + */ constructor(options = {}) { + // --- Serial / pen / speeds --- this.baudRate = options.baudRate ?? 115200; - this.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.timingMultiplier = options.timingMultiplier ?? 1; + + // --- Machine / overlay extents (mm) --- this.machineXMin = options.machineXMin ?? -830; this.machineXMax = options.machineXMax ?? 830; this.machineYMin = options.machineYMin ?? -1200; this.machineYMax = options.machineYMax ?? 800; this.previewSizeMm = options.previewSizeMm ?? WallPlotter.PREVIEW_SIZE_MM; + + // --- Jog / UI --- + this.stepSize = options.stepSize ?? 10; this.jogIntervalMs = options.jogIntervalMs ?? 150; this.maxLogLines = options.maxLogLines ?? 8; + this.previewMinDelayMs = options.previewMinDelayMs ?? 30; + this.previewFrameMs = options.previewFrameMs ?? 16; - this.port = null; - this.writer = null; - this.reader = null; - this.readLoopActive = false; + // --- Live machine state --- this.connected = false; this.homeSet = false; this.isPenDown = false; this.x = 0; this.y = 0; - this.statusText = 'Press C to connect'; + this.statusText = 'Disconnected'; this.logLines = []; - this.drawBounds = { left: null, right: null, top: null, bottom: null }; + + // --- Path / plot progress --- + this._path = []; this.previewPath = []; this.isPlotting = false; + this.isPaused = 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._pausedAccumMs = 0; + this._pauseStartedAt = 0; + this._statusBeforePause = null; + this._statusBeforeOkWait = null; + this._freezeMotion = false; this._lastSegmentMs = 0; + + // --- Web Serial --- + this.port = null; + this.writer = null; + this.reader = null; + this.readLoopActive = false; this._rxBuf = ''; this._okResolvers = []; + // --- Keyboard / buttons --- + this._keysEnabled = false; + this._prevKeyPressed = null; + this._lastJogTime = 0; + this._jogInFlight = false; + this._homeWarning = null; + this._plotButtonsEnabled = false; + this._connectBtn = null; + this._plotBtn = null; + this._testBtn = null; + this._connectLabel = 'Connect'; + this._plotLabel = 'Plot'; + this._pauseLabel = 'Pause'; + this._resumeLabel = 'Resume'; + this._testLabel = 'Test'; + this._stopLabel = 'Stop'; + this._lastTestBtnClick = 0; + this._connecting = false; + + // --- Callbacks --- const userOnLog = options.onLog ?? (() => {}); this.onLog = (line) => { this.logLines.push(line); @@ -85,10 +149,16 @@ class WallPlotter { this.onStatusChange = (status) => { if (status === 'connected') this.statusText = 'Connected'; else if (status === 'disconnected') this.statusText = 'Disconnected'; + this._updatePlotButtons(); userOnStatusChange(status); }; } + // =========================================================================== + // Public API — setup / UI + // =========================================================================== + + /** Enable keyboard jog, home, pen toggle, connect (C), stop (S). Call from setup(). */ enableKeyInputs() { if (this._keysEnabled) return; this._keysEnabled = true; @@ -101,6 +171,11 @@ class WallPlotter { }; } + /** + * Add Connect / Plot / Test buttons. Expects global sketch functions plot() and test(). + * Connect is shown only while disconnected. Plot becomes Pause while running, Resume while paused. + * @param {{parentId?: string, connectLabel?: string, plotLabel?: string, pauseLabel?: string, resumeLabel?: string, testLabel?: string, stopLabel?: string}} [options] + */ enablePlotButtons(options = {}) { if (this._plotButtonsEnabled) return; this._plotButtonsEnabled = true; @@ -108,131 +183,39 @@ class WallPlotter { 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; - const host = document.getElementById('sketch-host'); - if (host?.parentNode) host.parentNode.insertBefore(parent, host); - else document.body.appendChild(parent); + document.body.appendChild(parent); } - const plotter = this; + this._connectLabel = options.connectLabel ?? 'Connect'; this._plotLabel = options.plotLabel ?? 'Plot'; + this._pauseLabel = options.pauseLabel ?? 'Pause'; + this._resumeLabel = options.resumeLabel ?? 'Resume'; this._testLabel = options.testLabel ?? 'Test'; this._stopLabel = options.stopLabel ?? 'Stop'; + const plotter = this; + const connectBtn = createButton(this._connectLabel); + connectBtn.parent(parent); + connectBtn.mousePressed(() => plotter._onConnectButton()); + this._connectBtn = connectBtn; + const plotBtn = createButton(this._plotLabel); plotBtn.parent(parent); plotBtn.mousePressed(() => plotter._onPlotButton()); + this._plotBtn = plotBtn; const testBtn = createButton(this._testLabel); testBtn.parent(parent); testBtn.mousePressed(() => plotter._onTestButton()); - - this._plotBtn = plotBtn; this._testBtn = testBtn; + this._updatePlotButtons(); } - _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); - }); - } - + /** Draw overlay + HUD. Call every frame from draw(). */ showInfos() { textFont('monospace', 14); this._drawPlotArea(); @@ -240,9 +223,12 @@ class WallPlotter { if (this._keysEnabled && !this.isPlotting) this._handleContinuousJog(); } + // =========================================================================== + // Public API — speed + // =========================================================================== + /** - * 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. + * 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] */ @@ -256,45 +242,43 @@ class WallPlotter { 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 + * 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); } - /** - * Begin a new path (clears the internal path buffer). - * Add segments with move(), draw(), wait(), speed(), then runPath(testOnly). - */ + // =========================================================================== + // Public API — path buffer (queue only; nothing is sent until runPath) + // =========================================================================== + + /** Clear the path buffer. Returns this for chaining. */ startPath() { this._path = []; return this; } + /** Queue pen-up travel to (x, y) mm. */ move(x, y) { this._path.push({ type: 'move', x, y }); return this; } + /** Queue pen-down stroke to (x, y) mm. */ draw(x, y) { this._path.push({ type: 'draw', x, y }); return this; } + /** Queue a pause in milliseconds (sends G4 on Plot). */ wait(ms) { this._path.push({ type: 'wait', ms }); return this; } + /** Queue a mid-path speed change (mm/min). Restored after runPath. */ speed(travel, drawRate) { this._path.push({ type: 'speed', @@ -305,9 +289,9 @@ class WallPlotter { } /** - * 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) + * 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; @@ -321,212 +305,80 @@ class WallPlotter { } /** - * Estimated duration of the current path buffer (or `commands`) in ms, - * using setSpeed / queued speed() and wait() plus timingMultiplier. + * Estimated duration of the current path (or commands) in ms. + * Uses setSpeed, queued speed()/wait(), and timingMultiplier. */ estimatePathMs(commands) { - const cmds = commands ?? this._path; - return this._estimatePathDurationMs(cmds, this.x, this.y); + return this._estimatePathDurationMs(commands ?? this._path, 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; + /** Abort the current runPath (browser side only) and unblock any `ok` wait. */ + stopPath() { + this._pathAbort = true; + this.isPaused = false; + this._freezeMotion = true; + this._cancelPendingOk(); + } + + /** Pause the current runPath after the in-flight segment. */ + pausePlot() { + if (!this.isPlotting || this.isPaused) return; + this.isPaused = true; + this._pauseStartedAt = performance.now(); + this._statusBeforePause = this.statusText; + this.statusText = 'Paused'; + this.onLog('plot paused'); + this._updatePlotButtons(); + this._redrawSketch(); + } + + /** Continue a paused runPath (including after Stop or a timeout). */ + resumePlot() { + if (!this.isPlotting || !this.isPaused) return; + if (this._pauseStartedAt) { + this._pausedAccumMs += performance.now() - this._pauseStartedAt; } - return total; + this._pauseStartedAt = 0; + this.isPaused = false; + this._freezeMotion = false; + if (this._statusBeforePause) this.statusText = this._statusBeforePause; + this._statusBeforePause = null; + this.onLog('plot resumed'); + this._updatePlotButtons(); + this._redrawSketch(); } - _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; - } + /** + * Halt the current move and pause so Resume can continue. + * If already paused, abort the run. Also cancels a stuck `ok` wait. + */ + async stopPlot() { + const hadWait = this._okResolvers.length > 0; - 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; + if (this.isPlotting && this.isPaused) { + this.stopPath(); + this.statusText = 'Stopped'; + this.onLog('plot aborted'); 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(); + if (this.isPlotting) { + await this._haltToPause('stopped'); + return; + } - 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'); + if (hadWait) { + this._cancelPendingOk(); + this.onLog('serial wait cancelled'); + this.statusText = this.connected ? 'Connected' : 'Disconnected'; } } - /** 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(); - } + // =========================================================================== + // 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.'); @@ -543,26 +395,30 @@ class WallPlotter { 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'); + try { + await this.send('M17'); + await this.penUp(true); + await this.send('M665'); + await this.send('M114'); + } catch (err) { + if (err.name === 'AbortError') return; + throw err; + } } + /** Close the serial port. */ async disconnect() { if (!this.connected) return; this.readLoopActive = false; - while (this._okResolvers.length) { - const next = this._okResolvers.shift(); - if (next) next.resolve(); - } + this._cancelPendingOk(); this._rxBuf = ''; + if (this.reader) { try { await this.reader.cancel(); } catch (_) { - /* port may already be closed */ + /* already closed */ } this.reader = null; } @@ -589,6 +445,11 @@ class WallPlotter { this.onStatusChange('disconnected'); } + /** + * Write one G-code line; by default wait for Marlin ok. + * @param {string} line + * @param {{allowWithoutHome?: boolean, waitOk?: boolean, timeoutMs?: number}} [options] + */ async send(line, options = {}) { if (!this.connected || !this.writer) { throw new Error('Not connected to plotter'); @@ -607,30 +468,558 @@ class WallPlotter { } 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); + const okPromise = waitOk + ? this._waitForOk(options.timeoutMs ?? WallPlotter.OK_TIMEOUT_MS) + : null; + await this.writer.write(new TextEncoder().encode(command + '\n')); this.onLog('→ ' + command); - if (okPromise) await okPromise; + if (!okPromise) return; + try { + await okPromise; + } catch (err) { + if (err.name === 'AbortError') throw err; + const timeoutErr = new Error(err.message); + timeoutErr.name = 'TimeoutError'; + this.onLog('! ' + err.message); + if (!this.isPlotting) this.statusText = 'Error: ' + err.message; + throw timeoutErr; + } } + // =========================================================================== + // Public API — immediate machine commands + // =========================================================================== + + /** + * Lift pen. Pass true to always send (used by P and connect). + * @param {boolean} [force=false] + */ + async penUp(force = false) { + if (!force && !this.isPenDown) return; + await this.send(this.penUpGcode); + await this._delay(this.penUpDelayMs); + this.isPenDown = false; + } + + /** + * Lower pen. Pass true to always send. + * @param {boolean} [force=false] + */ + async penDown(force = false) { + if (!force && this.isPenDown) return; + await this.send(this.penDownGcode); + await this._delay(this.penDownDelayMs); + this.isPenDown = true; + } + + /** Pen up and travel to (x, y) mm immediately. Requires home. */ + async moveTo(x, y) { + if (!this._requireHome()) return; + await this.penUp(); + await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateTravel}`); + this.x = x; + this.y = y; + } + + /** Pen down and draw to (x, y) mm immediately. Requires home. */ + async drawTo(x, y) { + if (!this._requireHome()) return; + await this.penDown(); + await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateDraw}`); + this.x = x; + this.y = y; + } + + /** Travel to (0, 0). */ + async goToZero() { + await this.moveTo(0, 0); + } + + /** + * Relative move from current position. + * @param {number} dx + * @param {number} dy + * @param {boolean} [draw=false] + */ + async step(dx, dy, draw = false) { + if (!this._requireHome()) return; + if (draw) await this.drawTo(this.x + dx, this.y + dy); + else await this.moveTo(this.x + dx, this.y + dy); + } + + /** G92 X0 Y0 — set current pose as home. */ + 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'; + } + + // =========================================================================== + // Internals — feed helpers + // =========================================================================== + + _clampFeed(value) { + const n = Number(value); + if (!Number.isFinite(n)) return WallPlotter.MIN_FEED_MM_MIN; + return Math.min( + WallPlotter.MAX_FEED_MM_MIN, + Math.max(WallPlotter.MIN_FEED_MM_MIN, n) + ); + } + + // =========================================================================== + // Internals — path execution & timing + // =========================================================================== + + async _executePath(commands, testOnly = false) { + const runId = ++this._pathRunId; + this._pathAbort = false; + this._plotTestOnly = testOnly; + this.previewPath = commands; + + if (!testOnly && !this._requireHome()) { + this._plotTestOnly = false; + return; + } + + this.isPlotting = true; + this.isPaused = false; + this.plotProgress = 0; + this._plotStartedAt = performance.now(); + this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y); + this._pausedAccumMs = 0; + this._pauseStartedAt = 0; + this._statusBeforePause = null; + this._freezeMotion = false; + this._lastSegmentMs = 0; + this._updatePlotButtons(); + + const savedFeedTravel = this.feedRateTravel; + const savedFeedDraw = this.feedRateDraw; + let px = this.x; + let py = this.y; + + try { + for (let i = 0; i < commands.length; i++) { + if (this._pathAbort || runId !== this._pathRunId) break; + await this._waitIfPaused(); + this._freezeMotion = false; + if (this._pathAbort || runId !== this._pathRunId) break; + const cmd = commands[i]; + + if (cmd.type === 'speed') { + if (cmd.travel != null) this.feedRateTravel = cmd.travel; + if (cmd.draw != null) this.feedRateDraw = cmd.draw; + this.plotProgress = i + 1; + this._redrawSketch(); + continue; + } + + let retry = false; + try { + if (cmd.type === 'wait') { + const ms = Math.max(0, cmd.ms ?? 0); + if (!testOnly && ms > 0) { + await this.send(`G4 P${Math.round(ms)}`, { + timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, ms + 5000), + }); + } + await this._holdPosition(ms); + } else { + const duration = this._segmentDurationMs(px, py, cmd); + this._lastSegmentMs = duration; + + if (testOnly) { + await this._animatePosition(px, py, cmd, duration); + } else { + await Promise.all([ + this._sendSegmentAndWait(cmd), + this._animatePosition(px, py, cmd, duration), + ]); + } + } + } catch (err) { + if (err.name === 'AbortError') { + retry = this.isPaused && !this._pathAbort; + if (!retry) break; + } else if (err.name === 'TimeoutError') { + if (!this.isPaused) await this._haltToPause('timeout'); + retry = !this._pathAbort; + if (!retry) break; + } else { + throw err; + } + } + + if (this._pathAbort || runId !== this._pathRunId) break; + + if (retry || this._freezeMotion) { + px = this.x; + py = this.y; + await this._waitIfPaused(); + this._freezeMotion = false; + if (this._pathAbort || runId !== this._pathRunId) break; + i -= 1; + continue; + } + + if (cmd.type === 'move' || cmd.type === 'draw') { + px = cmd.x; + py = cmd.y; + } + this.plotProgress = i + 1; + } + } catch (err) { + if (err.name !== 'AbortError' && err.name !== 'TimeoutError') { + /* send() already logged serial errors */ + } + } finally { + this.feedRateTravel = savedFeedTravel; + this.feedRateDraw = savedFeedDraw; + this.isPlotting = false; + this.isPaused = false; + this._pauseStartedAt = 0; + this._statusBeforePause = null; + this._plotTestOnly = false; + this._updatePlotButtons(); + this._redrawSketch(); + + const next = this._pendingAfterAbort; + this._pendingAfterAbort = null; + if (next === 'test') this._triggerSketchFn('test'); + else if (next === 'plot') this._triggerSketchFn('plot'); + } + } + + /** Segment wait ms: (distance/feed + pen delay) × timingMultiplier. */ + _segmentDurationMs(fromX, fromY, cmd) { + const isMove = cmd.type === 'move'; + const dist = Math.hypot(cmd.x - fromX, cmd.y - fromY); + const rate = isMove ? this.feedRateTravel : this.feedRateDraw; + const motionMs = dist > 0 && rate > 0 ? (dist / rate) * 60000 : 0; + let penMs = 0; + if (isMove && this.isPenDown) penMs = this.penUpDelayMs; + if (!isMove && !this.isPenDown) penMs = this.penDownDelayMs; + return Math.max(motionMs + penMs, this.previewMinDelayMs) * this.timingMultiplier; + } + + _estimatePathDurationMs(commands, fromX, fromY) { + const savedTravel = this.feedRateTravel; + const savedDraw = this.feedRateDraw; + const savedPen = this.isPenDown; + let px = fromX; + let py = fromY; + let total = 0; + + try { + for (const cmd of commands) { + if (cmd.type === 'speed') { + if (cmd.travel != null) this.feedRateTravel = cmd.travel; + if (cmd.draw != null) this.feedRateDraw = cmd.draw; + continue; + } + if (cmd.type === 'wait') { + total += Math.max(0, cmd.ms ?? 0); + continue; + } + if (cmd.type !== 'move' && cmd.type !== 'draw') continue; + total += this._segmentDurationMs(px, py, cmd); + this.isPenDown = cmd.type === 'draw'; + px = cmd.x; + py = cmd.y; + } + } finally { + this.feedRateTravel = savedTravel; + this.feedRateDraw = savedDraw; + this.isPenDown = savedPen; + } + return total; + } + + async _holdPosition(ms) { + if (ms <= 0) return; + let elapsed = 0; + let last = performance.now(); + while (elapsed < ms) { + if (this._pathAbort || this._freezeMotion) return; + if (this._plotTestOnly && this.isPaused) { + await this._waitIfPaused(); + last = performance.now(); + if (this._pathAbort) return; + } + const now = performance.now(); + elapsed += now - last; + last = now; + this._redrawSketch(); + await this._delay(this.previewFrameMs); + } + } + + async _animatePosition(fromX, fromY, cmd, duration) { + this.isPenDown = cmd.type === 'draw'; + const waitMs = Math.max(duration, 1); + let elapsed = 0; + let last = performance.now(); + + while (true) { + if (this._pathAbort || this._freezeMotion) break; + if (this._plotTestOnly && this.isPaused) { + await this._waitIfPaused(); + last = performance.now(); + if (this._pathAbort || this._freezeMotion) break; + } + const now = performance.now(); + elapsed += now - last; + last = now; + const t = Math.min(1, Math.max(0, elapsed / waitMs)); + this.x = fromX + (cmd.x - fromX) * t; + this.y = fromY + (cmd.y - fromY) * t; + this._redrawSketch(); + if (t >= 1) break; + await this._delay(this.previewFrameMs); + } + if (!this._pathAbort && !this._freezeMotion) { + this.x = cmd.x; + this.y = cmd.y; + } + } + + /** Interrupt the current move (M410) and pause so Resume can continue. */ + async _haltToPause(reason) { + if (!this.isPlotting || this.isPaused) return; + this._freezeMotion = true; + this._cancelPendingOk(); + this.isPaused = true; + this._pauseStartedAt = performance.now(); + this._statusBeforePause = this.connected ? 'Connected' : 'Disconnected'; + this.statusText = 'Paused'; + this.onLog( + reason === 'timeout' + ? 'plot paused after timeout — resume to continue' + : 'plot stopped — resume to continue' + ); + this._updatePlotButtons(); + this._redrawSketch(); + + if (this.connected && !this._plotTestOnly) { + try { + await this.send('M410', { waitOk: false, allowWithoutHome: true }); + } catch (_) { + /* port may be busy */ + } + try { + await this.send('M114', { + timeoutMs: 3000, + allowWithoutHome: true, + }); + } catch (_) { + /* USB may be dead */ + } + try { + await this.penUp(); + } catch (_) { + /* ignore */ + } + } + } + + /** Wait while paused. Live plots lift the pen until the next segment. */ + async _waitIfPaused() { + if (!this.isPaused) return; + + if (!this._plotTestOnly && this.connected && this.isPenDown) { + try { + await this.penUp(); + } catch (_) { + /* port may be busy */ + } + } + + while (this.isPaused && !this._pathAbort) { + this._redrawSketch(); + await this._delay(this.previewFrameMs); + } + } + + _plotElapsedMs() { + let elapsed = performance.now() - this._plotStartedAt - this._pausedAccumMs; + if (this.isPaused && this._pauseStartedAt) { + elapsed -= performance.now() - this._pauseStartedAt; + } + return Math.max(0, elapsed); + } + + async _sendSegment(cmd) { + if (!this._requireHome()) return; + if (cmd.type === 'move') { + if (this.isPenDown) { + await this.send(this.penUpGcode); + this.isPenDown = false; + } + // G1 not G0: Marlin G0_FEEDRATE overrides F unless VARIABLE_G0_FEEDRATE is on + await this.send( + `G1 X${this._fmt(cmd.x)} Y${this._fmt(cmd.y)} F${this.feedRateTravel}` + ); + } else { + if (!this.isPenDown) { + await this.send(this.penDownGcode); + this.isPenDown = true; + } + await this.send( + `G1 X${this._fmt(cmd.x)} Y${this._fmt(cmd.y)} F${this.feedRateDraw}` + ); + } + } + + async _sendSegmentAndWait(cmd) { + await this._sendSegment(cmd); + await this.send('M400', { + timeoutMs: Math.max( + WallPlotter.OK_TIMEOUT_MS, + Math.round(this._lastSegmentMs) + 20000 + ), + }); + } + + // =========================================================================== + // Internals — serial I/O + // =========================================================================== + _waitForOk(timeoutMs) { return new Promise((resolve, reject) => { - const entry = { - resolve: () => { - clearTimeout(timer); - resolve(); - } - }; - const timer = setTimeout(() => { + let settled = false; + let waitLogInterval = null; + + const finish = (fn, arg) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(waitLogTimer); + if (waitLogInterval != null) clearInterval(waitLogInterval); const idx = this._okResolvers.indexOf(entry); if (idx >= 0) this._okResolvers.splice(idx, 1); - reject(new Error('timeout waiting for Marlin ok')); + fn(arg); + }; + + const entry = { + resolve: () => { + if (this.statusText === 'Waiting for plotter…') { + this.statusText = + this._statusBeforeOkWait ?? + (this.connected ? 'Connected' : 'Disconnected'); + this._statusBeforeOkWait = null; + } + finish(resolve); + }, + reject: (err) => finish(reject, err), + }; + + const timer = setTimeout(() => { + entry.reject( + new Error( + `timeout waiting for Marlin ok (${Math.round(timeoutMs / 1000)}s)` + ) + ); }, timeoutMs); + + const waitLogTimer = setTimeout(() => { + if (settled) return; + this.onLog('… waiting for Marlin ok'); + if (this.statusText !== 'Waiting for plotter…') { + this._statusBeforeOkWait = this.statusText; + } + this.statusText = 'Waiting for plotter…'; + this._redrawSketch(); + waitLogInterval = setInterval(() => { + this.onLog('… still waiting for Marlin ok'); + }, WallPlotter.OK_WAIT_PING_MS); + }, WallPlotter.OK_WAIT_LOG_MS); + this._okResolvers.push(entry); }); } + _cancelPendingOk() { + const pending = this._okResolvers.slice(); + for (const entry of pending) { + const err = new Error('aborted'); + err.name = 'AbortError'; + entry.reject(err); + } + } + + _startReadLoop() { + this.readLoopActive = true; + const plotter = this; + + const loop = async () => { + while (plotter.port?.readable && plotter.readLoopActive) { + plotter.reader = plotter.port.readable.getReader(); + try { + while (plotter.readLoopActive) { + const { value, done } = await plotter.reader.read(); + if (done) break; + plotter._rxBuf += new TextDecoder().decode(value); + const lines = plotter._rxBuf.split(/\r?\n/); + plotter._rxBuf = lines.pop(); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) plotter._handleResponse(trimmed); + } + } + } catch (err) { + if (plotter.readLoopActive) { + plotter.onLog('serial read error: ' + err.message); + } + } finally { + try { + plotter.reader.releaseLock(); + } catch (_) { + /* ignore */ + } + plotter.reader = null; + } + } + }; + loop(); + } + + _handleResponse(line) { + 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 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); } @@ -651,80 +1040,9 @@ class WallPlotter { 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; - } + // =========================================================================== + // Internals — keyboard, jog, plot buttons + // =========================================================================== async _handleKeyPressed() { const k = key.toLowerCase(); @@ -733,9 +1051,8 @@ class WallPlotter { await this._toggleConnection(); return false; } - if (k === 's') { - if (this.isPlotting) await this.stopPlot(); + await this.stopPlot(); return false; } @@ -745,45 +1062,21 @@ class WallPlotter { 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); + this.stepSize = Math.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; + this.stepSize = Math.max(this.stepSize - 5, 1); return false; } } @@ -795,7 +1088,6 @@ class WallPlotter { 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; @@ -811,25 +1103,176 @@ class WallPlotter { this._lastJogTime = now; this._jogInFlight = true; - this._jogBy(dx, dy, draw).finally(() => { - this._jogInFlight = false; - }); + this._jogBy(dx, dy, draw) + .catch(() => {}) + .finally(() => { + this._jogInFlight = false; + }); } - async _toggleConnection() { + async _jogBy(dx, dy, draw = false) { try { - if (this.connected) { - await this.disconnect(); - } else { - this.statusText = 'Connecting…'; - await this.connect(); + 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; } catch (err) { - this.statusText = 'Error: ' + err.message; - this.onLog('! ' + err.message); + if (err.name === 'AbortError') return; + throw err; } } + async _toggleConnection() { + if (this._connecting) return; + try { + if (this.connected) await this.disconnect(); + else { + this._connecting = true; + this.statusText = 'Connecting…'; + this._updatePlotButtons(); + await this.connect(); + } + } catch (err) { + if (err.name === 'AbortError') return; + this.statusText = 'Error: ' + err.message; + this.onLog('! ' + err.message); + } finally { + this._connecting = false; + this._updatePlotButtons(); + } + } + + _onConnectButton() { + if (this.connected || this._connecting) return; + this._toggleConnection(); + } + + _onPlotButton() { + if (this.isPlotting) { + if (this.isPaused) this.resumePlot(); + else this.pausePlot(); + return; + } + if (!this.connected) return; + this._triggerSketchFn('plot'); + } + + _onTestButton() { + const now = millis(); + if (this.isPlotting && this._plotTestOnly) { + if (now - this._lastTestBtnClick < 450) this._restartPathAfterAbort('test'); + else this.stopPlot(); + this._lastTestBtnClick = now; + return; + } + this._lastTestBtnClick = now; + if (this.isPlotting) { + this._restartPathAfterAbort('test'); + return; + } + this._triggerSketchFn('test'); + } + + _restartPathAfterAbort(mode) { + this._pendingAfterAbort = mode; + this._pathAbort = true; + this.isPaused = false; + this._freezeMotion = true; + this._cancelPendingOk(); + } + + _updatePlotButtons() { + if (this._connectBtn) { + if (this.connected) this._connectBtn.hide(); + else this._connectBtn.show(); + this._connectBtn.elt.disabled = this._connecting; + } + if (this._plotBtn) { + let label = this._plotLabel; + if (this.isPlotting) { + label = this.isPaused ? this._resumeLabel : this._pauseLabel; + } + this._plotBtn.html(label); + this._plotBtn.elt.disabled = !this.connected && !this.isPlotting; + } + if (this._testBtn) { + this._testBtn.html( + this.isPlotting && this._plotTestOnly ? this._stopLabel : this._testLabel + ); + } + } + + _triggerSketchFn(name) { + const fn = window[name]; + if (typeof fn !== 'function') { + this.onLog(`! sketch has no ${name}() function`); + return; + } + Promise.resolve(fn()).catch((err) => { + this.statusText = 'Error: ' + err.message; + this.onLog('! ' + err.message); + }); + } + + _injectPlotButtonStyles() { + if (document.getElementById('wallplotter-btn-styles')) return; + const style = document.createElement('style'); + style.id = 'wallplotter-btn-styles'; + style.textContent = ` + #wallplotter-controls { + position: absolute; + top: 2.35rem; + right: 0.75rem; + z-index: 20; + display: flex; + gap: 0.5rem; + margin: 0; + } + #wallplotter-controls button { + background: rgba(42, 42, 48, 0.9); + border: 1px solid #444; + border-radius: 6px; + color: #ddd; + cursor: pointer; + font-family: system-ui, sans-serif; + font-size: 0.9rem; + line-height: 1; + padding: 0.4rem 1rem; + } + #wallplotter-controls button:hover { + background: #35353c; + border-color: #666; + color: #eee; + } + #wallplotter-controls button:disabled { + opacity: 0.4; + cursor: not-allowed; + color: #888; + } + #wallplotter-controls button:disabled:hover { + background: rgba(42, 42, 48, 0.9); + border-color: #444; + color: #888; + } + `; + document.head.appendChild(style); + } + + // =========================================================================== + // Internals — overlay / HUD drawing + // =========================================================================== + + /** Connected → Marlin bounds; offline → centred preview square. */ _getDisplayBounds() { if (this.connected) { return { @@ -852,16 +1295,14 @@ class WallPlotter { _getPlotLayout() { const margin = 60; - const size = min(width, height) - margin * 2; + 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); - // 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, + scale: Math.min(size / worldW, size / worldH), worldCx: 0, worldCy: 0, }; @@ -874,32 +1315,6 @@ class WallPlotter { }; } - _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(); @@ -929,56 +1344,33 @@ class WallPlotter { line(tl.x, 0, br.x, 0); line(0, tl.y, 0, br.y); - // Size labels in cm + screen pixels along bottom and right edges + this._drawBoundSizeLabels(bounds, tl, br, w, h); + + this._drawPreviewPath(layout); + this._drawGondola(layout); + + pop(); + } + + _drawBoundSizeLabels(bounds, tl, br, w, h) { const widthCm = (bounds.xMax - bounds.xMin) / 10; const heightCm = (bounds.yMax - bounds.yMin) / 10; - const widthPx = Math.round(Math.abs(w)); - const heightPx = Math.round(Math.abs(h)); const fmtCm = (cm) => (Number.isInteger(cm) ? `${cm}` : cm.toFixed(1)); - const widthLabel = `${fmtCm(widthCm)} cm · ${widthPx} px`; - const heightLabel = `${fmtCm(heightCm)} cm · ${heightPx} px`; + 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(); - - 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) { @@ -986,7 +1378,6 @@ class WallPlotter { 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; @@ -1011,6 +1402,25 @@ class WallPlotter { } } + _drawGondola(layout) { + const pos = this._worldToScreen(this.x, this.y, layout); + if (this.isPenDown) { + stroke(255, 90, 90); + fill(255, 90, 90); + } else { + stroke(90, 200, 255); + fill(90, 200, 255); + } + strokeWeight(2); + circle(pos.x, pos.y, this.isPlotting ? 18 : 12); + if (this.isPlotting) { + noFill(); + stroke(255, 255, 255, 120); + strokeWeight(1); + circle(pos.x, pos.y, 26); + } + } + _drawHud() { fill(220); noStroke(); @@ -1032,6 +1442,7 @@ class WallPlotter { 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`, @@ -1046,11 +1457,11 @@ class WallPlotter { if (this.isPlotting) { fill(180, 220, 255); text( - `plotting ${this.plotProgress}/${this.previewPath.length}`, + `${this.isPaused ? 'paused' : 'plotting'} ${this.plotProgress}/${this.previewPath.length}`, 16, 152 ); - const elapsedMs = performance.now() - this._plotStartedAt; + 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)}`, @@ -1071,36 +1482,16 @@ class WallPlotter { 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', + 'S halt plot (Resume to continue, S again to abort)', 'C connect / disconnect', ]; for (let i = 0; i < help.length; i++) { @@ -1108,84 +1499,40 @@ class WallPlotter { } } - textAlign(RIGHT, TOP); + textAlign(RIGHT, BOTTOM); fill(140); - for (let i = 0; i < this.logLines.length; i++) { - text(this.logLines[i], width - 16, 16 + i * 16); + const n = this.logLines.length; + for (let i = 0; i < n; i++) { + text(this.logLines[i], width - 16, height - 16 - (n - 1 - i) * 16); } } + // =========================================================================== + // Internals — small utilities + // =========================================================================== + _fmt(value) { return Number(value).toFixed(3); } + _formatDuration(ms) { + const s = Math.max(0, ms) / 1000; + if (s < 60) return `${s.toFixed(1)} s`; + const totalSec = Math.round(s); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const sec = totalSec % 60; + if (h > 0) { + return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; + } + return `${m}:${String(sec).padStart(2, '0')}`; + } + _delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } - _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}` - ); + _redrawSketch() { + if (typeof redraw === 'function') redraw(); } }