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