commit e293b2f434dc53d406d04d48666c5dda7a3bc804 Author: Martin Fuchs Date: Fri Aug 14 18:36:23 2026 +0200 Add WallPlotter p5 library with zigzag example. Standalone package for Marlin polargraph control over Web Serial, including API docs and a runnable zigzag sketch. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9751508 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +Thumbs.db +*.log +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..64ae48a --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# p5-wallplotter + +p5.js library for a Marlin polargraph (wall plotter) over Web Serial, plus a zigzag example. + +``` +p5-wallplotter/ +├── lib/ +│ ├── WallPlotter.js # library +│ └── README.md # public API +└── examples/ + └── zickzack/ # example sketch + ├── index.html + └── sketch.js +``` + +## Run the example + +Chrome or Edge. Serve over localhost (Web Serial does not work from `file://`). + +```bash +cd p5-wallplotter +python -m http.server 8080 +``` + +Open http://localhost:8080/examples/zickzack/ + +Press **C** to connect, **H** to set home, then **Test** (preview) or **Plot** (send G-code). + +## Use in a sketch + +```html + + + +``` + +```javascript +let wp; + +function setup() { + createCanvas(windowWidth, windowHeight).parent('sketch-host'); + wp = new WallPlotter(); + wp.setSpeed(3000, 1500); + wp.enableKeyInputs(); + wp.enablePlotButtons(); +} + +function draw() { + background(28, 28, 32); + wp.showInfos(); +} + +async function plot() { + await drawArt(false); +} + +async function test() { + await drawArt(true); +} + +async function drawArt(testOnly) { + wp.startPath(); + wp.move(-200, -200); + wp.draw(200, -200); + await wp.runPath(testOnly); +} +``` + +Full API: [lib/README.md](lib/README.md). + +Baud **115200**. Pen servo: `M280 P0 S90` up, `M280 P0 S50` down. Coordinates in mm, origin at home (`G92`). diff --git a/examples/zickzack/index.html b/examples/zickzack/index.html new file mode 100644 index 0000000..15ffd0a --- /dev/null +++ b/examples/zickzack/index.html @@ -0,0 +1,60 @@ + + + + + + Wall Plotter — zigzag + + + + + + +

Wall Plotter — zigzag example

+
+

+ 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 new file mode 100644 index 0000000..96a2308 --- /dev/null +++ b/examples/zickzack/sketch.js @@ -0,0 +1,52 @@ +let wp; + +let area = 400; + +function setup() { + const canvas = createCanvas(windowWidth, windowHeight); + canvas.parent('sketch-host'); + + wp = new WallPlotter(); + wp.setSpeed(10000, 10000); + wp.setTimingMultiplier(1); // tune if machine is slower than speed estimate, the higher the slower + wp.enableKeyInputs(); + wp.enablePlotButtons(); +} + +function draw() { + background(28, 28, 32); + wp.showInfos(); +} + +function windowResized() { + resizeCanvas(windowWidth, windowHeight); +} + +async function plot() { + await drawArt(false); +} + +async function test() { + await drawArt(true); +} + +async function drawArt(testOnly) { + wp.startPath(); + + + wp.move(-area, -area); + wp.wait(1000 * 10); + + let step = area / 20; + + for (let y = -area; y < area; y+=step){ + for (let x = -area; x < area; x+=step){ + if(random(1) < 0.8){ + wp.move(x, y); + wp.draw(x, y+step); + } + } + } + + await wp.runPath(testOnly); +} diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 0000000..d2959d8 --- /dev/null +++ b/lib/README.md @@ -0,0 +1,215 @@ +# WallPlotter library + +p5 helper for a Marlin polargraph over Web Serial. Coordinates are **mm**. Origin is machine home (`G92`). Y positive is up. Chrome or Edge, page on localhost or HTTPS. + +Load from this package: + +```html + +``` + +Example sketch: `examples/zickzack/`. + +--- + +## `new WallPlotter(options?)` + +All options are optional. + +| Option | Default | What it is | +|--------|---------|------------| +| `baudRate` | `115200` | Serial baud (must match Marlin) | +| `stepSize` | `10` | Keyboard jog step, mm | +| `feedRateTravel` | `1500` | Pen-up speed, **mm/min** (G-code `F`) | +| `feedRateDraw` | `300` | Pen-down speed, **mm/min** | +| `penUpGcode` | `M280 P0 S90` | Command to lift pen | +| `penDownGcode` | `M280 P0 S50` | Command to lower pen | +| `penUpDelayMs` | `150` | Pause after pen up, ms | +| `penDownDelayMs` | `50` | Pause after pen down, ms | +| `machineXMin` / `machineXMax` | `-850` / `850` | Overlay X extents, mm | +| `machineYMin` / `machineYMax` | `-1000` / `1000` | Overlay Y extents, mm | +| `jogIntervalMs` | `150` | Repeat delay while holding arrows, ms | +| `maxLogLines` | `8` | Serial lines shown on the HUD | +| `previewMinDelayMs` | `30` | Floor for preview segment wait, ms | +| `previewFrameMs` | `16` | Preview animation frame time, ms | +| `timingMultiplier` | `1` | Scale **Test** preview waits (`2` = twice as slow) | +| `onLog(line)` | no-op | Extra callback for each log line | +| `onStatusChange(status)` | no-op | `'connected'` or `'disconnected'` | + +--- + +## Setup / UI + +### `enableKeyInputs()` + +Keyboard controls. Call from `setup()`. No arguments. + +| Key | Action | +|-----|--------| +| `C` | Connect / disconnect | +| `H` | Set home here (`G92 X0 Y0`) — required before Plot | +| `0` | Travel to `(0, 0)` | +| `P` | Toggle pen | +| `← → ↑ ↓` | 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. + +| Option | Default | What it is | +|--------|---------|------------| +| `parentId` | `'wallplotter-controls'` | DOM id for the button row | +| `plotLabel` | `'Plot'` | Plot button text | +| `testLabel` | `'Test'` | Test button text | +| `stopLabel` | `'Stop'` | Test button while a test is running | + +### `showInfos()` + +Draw HUD, path preview, bounds, and key help. Call every frame from `draw()`. Also runs held-arrow jogging when keys are enabled. + +--- + +## Speed + +### `setSpeed(travel, draw)` + +Feed rates in **mm/min**. Used as G-code `F` and for Test preview timing. Clamped to **1–10000**. + +- `travel` (number): pen-up / `move()` speed. Or `{ travel, draw }`. +- `draw` (number, optional): pen-down / `draw()` speed. Omit to leave draw rate unchanged. + +Practical range about **100–10000**. Values above **10000** are clamped. Do not use `0`. + +```javascript +wp.setSpeed(6000, 2500); +wp.setSpeed({ travel: 6000, draw: 2500 }); +``` + +### `setTimingMultiplier(mult)` + +Scales **Test** preview waits only. Plot is paced by Marlin (`ok` + `M400`). + +- `mult` (number): `1` = match `setSpeed` estimate, `2` = twice as slow, `0.5` = twice as fast. Clamped to ≥ `0.1`. + +--- + +## Path (buffered drawing) + +`move` / `draw` / `wait` / `speed` only **queue**. Nothing is sent until `runPath`. + +### `startPath()` + +Clear the path buffer. Returns `this`. + +### `move(x, y)` + +Queue pen-up travel to `(x, y)` mm. Returns `this`. + +### `draw(x, y)` + +Queue pen-down stroke to `(x, y)` mm. Returns `this`. + +### `wait(ms)` + +Queue a pause. `ms` is milliseconds. On Plot, sends `G4 P`. Returns `this`. + +### `speed(travel, drawRate)` + +Queue a mid-path speed change (mm/min, clamped 1–10000). Either argument may be omitted. Restored after `runPath`. Returns `this`. + +### `runPath(testOnly)` / `runPath(commands, testOnly?)` + +Run the path. **async**. + +- `testOnly` (boolean): `true` = preview only, no serial. `false` = send G-code. Home must be set for Plot. +- `commands` (array, optional): explicit list instead of the `startPath()` buffer. + +Plot: wait for Marlin `ok`, then `M400` (move finished). Test: animate using distance / `setSpeed` × `timingMultiplier`. + +```javascript +wp.startPath(); +wp.move(-200, -200); +wp.draw(200, -200); +wp.wait(500); +await wp.runPath(false); // plot +await wp.runPath(true); // test +``` + +Sketch: `plot()` → `runPath(false)`, `test()` → `runPath(true)`. + +### `estimatePathMs(commands?)` + +Returns estimated duration in **milliseconds** for the current path buffer, or for `commands` if given. Uses `setSpeed`, queued `speed()`, `wait()`, and `timingMultiplier`. The HUD shows elapsed / est / remain while plotting. + +### `stopPath()` + +Abort the current `runPath`. No arguments. + +--- + +## Serial / machine + +All **async**. Coordinate moves on Plot require home (`H`) first. + +### `connect()` + +Web Serial port picker, enable steppers (`M17`), pen up, `M114`. + +### `disconnect()` + +Close the serial port. + +### `send(line, options?)` + +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`. + +### `penUp()` / `penDown()` + +Send pen G-code and wait the pen delay. No-op if already in that state. + +### `moveTo(x, y)` + +Pen up, travel to `(x, y)` mm immediately (not queued). Requires home. + +### `drawTo(x, y)` + +Pen down, draw to `(x, y)` mm immediately. Requires home. + +### `goToZero()` + +`moveTo(0, 0)`. + +### `step(dx, dy, draw?)` + +Relative move in mm. + +- `dx`, `dy` (number): delta mm +- `draw` (boolean, default `false`): `true` → `drawTo`, else `moveTo` + +### `setHomeHere()` + +`G92 X0 Y0`. Sets local `x,y` to 0 and marks home as set. + +--- + +## Fields (read) + +| Field | Meaning | +|-------|---------| +| `connected` | Serial open | +| `homeSet` | Home was set with `H` / `setHomeHere()` | +| `x`, `y` | Current position, mm | +| `isPenDown` | Pen state | +| `isPlotting` | `runPath` in progress | +| `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 new file mode 100644 index 0000000..9d8ddb8 --- /dev/null +++ b/lib/WallPlotter.js @@ -0,0 +1,1085 @@ +/** + * 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; + + 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 ?? -850; + this.machineXMax = options.machineXMax ?? 850; + this.machineYMin = options.machineYMin ?? -1000; + this.machineYMax = options.machineYMax ?? 1000; + 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; + } + + _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(); + 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() { + if (!this.isPenDown) return; + await this.send(this.penUpGcode); + await this._delay(this.penUpDelayMs); + this.isPenDown = false; + } + + async penDown() { + if (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 (!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(); + else await this.penDown(); + 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); + } + } + + _getPlotLayout() { + const margin = 60; + const size = min(width, height) - margin * 2; + const worldW = this.machineXMax - this.machineXMin; + const worldH = this.machineYMax - this.machineYMin; + const scale = min(size / worldW, size / worldH); + return { + cx: width / 2, + cy: height / 2 - 20, + scale, + worldCx: (this.machineXMin + this.machineXMax) / 2, + worldCy: (this.machineYMin + this.machineYMax) / 2, + }; + } + + _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(); + + push(); + translate(layout.cx, layout.cy); + + stroke(50); + strokeWeight(1); + const tl = this._worldToScreen(this.machineXMin, this.machineYMax, layout); + const br = this._worldToScreen(this.machineXMax, this.machineYMin, layout); + noFill(); + rectMode(CORNER); + rect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + + stroke(60); + line(tl.x, 0, br.x, 0); + line(0, tl.y, 0, br.y); + + 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.isPlotting) { + fill(180, 220, 255); + text( + `plotting ${this.plotProgress}/${this.previewPath.length}`, + 16, + 132 + ); + 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, + 152 + ); + text( + `seg wait ${this._lastSegmentMs.toFixed(0)} ms ×${this.timingMultiplier}`, + 16, + 172 + ); + fill(220); + } + + if (this.connected && !this.homeSet && this._homeWarning) { + fill(255, 180, 80); + text(`! ${this._homeWarning}`, 16, this.isPlotting ? 192 : 132); + 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 ? 212 : 152 + ); + + const size = this._getDrawSize(); + const areaY = this.isPlotting ? 232 : 172; + 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', + '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(); + } + 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]); + } + } + } +}