1714 lines
50 KiB
JavaScript
1714 lines
50 KiB
JavaScript
/**
|
||
* WallPlotter — p5.js helper for a Marlin polargraph over Web Serial.
|
||
*
|
||
* Coordinates are millimetres. Origin is machine home (G92). Y positive is up.
|
||
* Requires Chrome/Edge and a page served on localhost or HTTPS.
|
||
*
|
||
* Typical sketch flow:
|
||
* wp = new WallPlotter({ ... });
|
||
* wp.enableKeyInputs();
|
||
* wp.enablePlotButtons();
|
||
* // in draw(): wp.showInfos();
|
||
* // in plot()/test():
|
||
* wp.startPath().move(...).draw(...);
|
||
* await wp.runPath(testOnly);
|
||
*/
|
||
class WallPlotter {
|
||
// ---------------------------------------------------------------------------
|
||
// Static constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
static VERSION = '2.2.5';
|
||
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.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;
|
||
|
||
// --- Live machine state ---
|
||
this.connected = false;
|
||
this.homeSet = false;
|
||
this.isPenDown = false;
|
||
this.x = 0;
|
||
this.y = 0;
|
||
this.statusText = 'Disconnected';
|
||
this.logLines = [];
|
||
|
||
// --- Path / plot progress ---
|
||
this._path = [];
|
||
this.previewPath = [];
|
||
this.isPlotting = false;
|
||
this.isPaused = false;
|
||
this.plotProgress = 0;
|
||
this._plotTestOnly = false;
|
||
this._pathAbort = false;
|
||
this._pathRunId = 0;
|
||
this._pendingAfterAbort = null;
|
||
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 = [];
|
||
this._sendChain = Promise.resolve();
|
||
this._okIgnoreUntil = 0;
|
||
this._machineBusy = false;
|
||
this._lastBusyLogAt = 0;
|
||
|
||
// --- 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);
|
||
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';
|
||
this._updatePlotButtons();
|
||
userOnStatusChange(status);
|
||
};
|
||
|
||
this._unloadBound = false;
|
||
this._installUnloadHandler();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Public API — setup / UI
|
||
// ===========================================================================
|
||
|
||
/** Enable keyboard jog, home, pen toggle, connect (C), test (T), stop (S). Call from setup(). */
|
||
enableKeyInputs() {
|
||
if (this._keysEnabled) return;
|
||
this._keysEnabled = true;
|
||
this._prevKeyPressed = window.keyPressed;
|
||
const plotter = this;
|
||
window.keyPressed = async function () {
|
||
const handled = await plotter._handleKeyPressed();
|
||
if (handled === false) return false;
|
||
if (plotter._prevKeyPressed) return plotter._prevKeyPressed.apply(this, arguments);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Add Connect / 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;
|
||
this._injectPlotButtonStyles();
|
||
|
||
const parentId = options.parentId ?? 'wallplotter-controls';
|
||
let parent = document.getElementById(parentId);
|
||
// Prefer inserting controls into the page body so absolute positioning is viewport-relative
|
||
if (!parent) {
|
||
parent = document.createElement('div');
|
||
parent.id = parentId;
|
||
document.body.appendChild(parent);
|
||
}
|
||
|
||
this._connectLabel = options.connectLabel ?? 'Connect';
|
||
this._plotLabel = options.plotLabel ?? 'Plot';
|
||
this._pauseLabel = options.pauseLabel ?? 'Pause';
|
||
this._resumeLabel = options.resumeLabel ?? 'Resume';
|
||
this._testLabel = options.testLabel ?? 'Test';
|
||
this._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._testBtn = testBtn;
|
||
this._updatePlotButtons();
|
||
}
|
||
|
||
/** Draw overlay + HUD. Call every frame from draw(). */
|
||
showInfos() {
|
||
textFont('monospace', 14);
|
||
this._drawPlotArea();
|
||
this._drawHud();
|
||
if (this._keysEnabled && !this.isPlotting) this._handleContinuousJog();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Public API — speed
|
||
// ===========================================================================
|
||
|
||
/**
|
||
* Set feed rates in mm/min (G-code F and Test timing). Clamped 1–10000.
|
||
* @param {number|{travel?: number, draw?: number}} travel
|
||
* @param {number} [draw]
|
||
*/
|
||
setSpeed(travel, draw) {
|
||
if (typeof travel === 'object' && travel !== null) {
|
||
if (travel.travel != null) this.feedRateTravel = this._clampFeed(travel.travel);
|
||
if (travel.draw != null) this.feedRateDraw = this._clampFeed(travel.draw);
|
||
return;
|
||
}
|
||
this.feedRateTravel = this._clampFeed(travel);
|
||
if (draw != null) this.feedRateDraw = this._clampFeed(draw);
|
||
}
|
||
|
||
/**
|
||
* Scale Test preview waits only (Plot is paced by Marlin M400).
|
||
* @param {number} mult 1 = match setSpeed; 2 = twice as slow
|
||
*/
|
||
setTimingMultiplier(mult) {
|
||
this.timingMultiplier = Math.max(0.1, Number(mult) || 1);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 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',
|
||
travel: travel != null ? this._clampFeed(travel) : undefined,
|
||
draw: drawRate != null ? this._clampFeed(drawRate) : undefined,
|
||
});
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Run the path buffer, or an explicit command array.
|
||
* @param {Array|boolean} commandsOrTestOnly
|
||
* @param {boolean} [testOnly=false] true = preview only (no serial)
|
||
*/
|
||
async runPath(commandsOrTestOnly, testOnly = false) {
|
||
let commands;
|
||
if (Array.isArray(commandsOrTestOnly)) {
|
||
commands = commandsOrTestOnly;
|
||
} else {
|
||
testOnly = commandsOrTestOnly ?? false;
|
||
commands = this._path;
|
||
}
|
||
await this._executePath(commands, testOnly);
|
||
}
|
||
|
||
/**
|
||
* Estimated duration of the current path (or commands) in ms.
|
||
* Uses setSpeed, queued speed()/wait(), and timingMultiplier.
|
||
*/
|
||
estimatePathMs(commands) {
|
||
return this._estimatePathDurationMs(commands ?? this._path, this.x, this.y);
|
||
}
|
||
|
||
/** Abort the current runPath (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;
|
||
}
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* First press: pause after the current stroke (safe to Resume).
|
||
* Second press: stop sending more moves. The in-flight G1 still finishes
|
||
* (Marlin has no emergency parser). Do not send M410 — it cannot interrupt
|
||
* a running move, and on a polargraph it desyncs belt lengths.
|
||
*/
|
||
async stopPlot() {
|
||
const hadWait = this._okResolvers.length > 0;
|
||
|
||
if (this.isPlotting && this.isPaused) {
|
||
this.stopPath();
|
||
this.statusText = 'Stopped — current stroke may still finish';
|
||
this.onLog(
|
||
'plot aborted — motors finish the in-flight move (unplug power for a hard stop)'
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (this.isPlotting) {
|
||
this.pausePlot();
|
||
this.onLog('plot stopped after current move — resume to continue');
|
||
return;
|
||
}
|
||
|
||
if (hadWait) {
|
||
this._cancelPendingOk();
|
||
this.onLog('serial wait cancelled');
|
||
this.statusText = this.connected ? 'Connected' : 'Disconnected';
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Public API — serial connection
|
||
// ===========================================================================
|
||
|
||
/** Open Web Serial, enable steppers, pen up, fetch M665 bounds, M114. */
|
||
async connect() {
|
||
if (!('serial' in navigator)) {
|
||
throw new Error('Web Serial is not supported in this browser. Use Chrome or Edge.');
|
||
}
|
||
if (this.connected) return;
|
||
|
||
let port;
|
||
try {
|
||
port = await this._acquirePort();
|
||
await this._openPort(port);
|
||
} catch (err) {
|
||
this.onLog('! ' + err.message);
|
||
port = await navigator.serial.requestPort();
|
||
await this._openPort(port);
|
||
}
|
||
this.port = port;
|
||
|
||
this.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');
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
async _acquirePort() {
|
||
const existing = await navigator.serial.getPorts();
|
||
if (existing.length === 1) return existing[0];
|
||
return navigator.serial.requestPort();
|
||
}
|
||
|
||
async _openPort(port) {
|
||
const opened = port.open({ baudRate: this.baudRate });
|
||
const timedOut = this._delay(3000).then(() => {
|
||
const err = new Error(
|
||
'serial open timeout — port still held after reload. Unplug USB or close other serial tabs.'
|
||
);
|
||
err.name = 'TimeoutError';
|
||
throw err;
|
||
});
|
||
try {
|
||
await Promise.race([opened, timedOut]);
|
||
} catch (err) {
|
||
try {
|
||
await port.close();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
if (typeof port.forget === 'function') {
|
||
try {
|
||
await port.forget();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** Close the serial port. */
|
||
async disconnect() {
|
||
if (!this.connected && !this.port) return;
|
||
|
||
this.readLoopActive = false;
|
||
this._cancelPendingOk();
|
||
this._rxBuf = '';
|
||
this._machineBusy = false;
|
||
|
||
const reader = this.reader;
|
||
const writer = this.writer;
|
||
const port = this.port;
|
||
this.reader = null;
|
||
this.writer = null;
|
||
this.port = null;
|
||
|
||
if (reader) {
|
||
try {
|
||
await reader.cancel();
|
||
} catch (_) {
|
||
/* already closed */
|
||
}
|
||
try {
|
||
reader.releaseLock();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
if (writer) {
|
||
try {
|
||
writer.releaseLock();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
if (port) {
|
||
try {
|
||
await port.close();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
this.connected = false;
|
||
this.homeSet = false;
|
||
this._homeWarning = null;
|
||
this.onStatusChange('disconnected');
|
||
}
|
||
|
||
/** Best-effort close for page reload (must not await — Mac keeps the USB lock otherwise). */
|
||
_releaseSerialNow() {
|
||
if (!this.port && !this.connected) return;
|
||
this.readLoopActive = false;
|
||
this._cancelPendingOk();
|
||
this._rxBuf = '';
|
||
this._machineBusy = false;
|
||
const reader = this.reader;
|
||
const writer = this.writer;
|
||
const port = this.port;
|
||
this.reader = null;
|
||
this.writer = null;
|
||
this.port = null;
|
||
this.connected = false;
|
||
this.homeSet = false;
|
||
try {
|
||
reader?.cancel();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
reader?.releaseLock();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
writer?.releaseLock();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
try {
|
||
port?.close();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
_installUnloadHandler() {
|
||
if (this._unloadBound) return;
|
||
this._unloadBound = true;
|
||
const release = () => this._releaseSerialNow();
|
||
window.addEventListener('pagehide', release);
|
||
window.addEventListener('beforeunload', release);
|
||
window.addEventListener('unload', release);
|
||
window.addEventListener('pageshow', (event) => {
|
||
if (event.persisted) this._releaseSerialNow();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Write one G-code line; by default wait for Marlin ok.
|
||
* @param {string} line
|
||
* @param {{allowWithoutHome?: boolean, waitOk?: boolean, timeoutMs?: number}} [options]
|
||
*/
|
||
async send(line, options = {}) {
|
||
const queued = this._sendChain.then(() => this._sendNow(line, options));
|
||
this._sendChain = queued.catch(() => {});
|
||
return queued;
|
||
}
|
||
|
||
async _sendNow(line, options = {}) {
|
||
if (!this.connected || !this.writer) {
|
||
throw new Error('Not connected to plotter');
|
||
}
|
||
|
||
const command = line.trim();
|
||
if (!command) return;
|
||
|
||
if (
|
||
!options.allowWithoutHome &&
|
||
!this.homeSet &&
|
||
this._isCoordinateMove(command)
|
||
) {
|
||
this._showHomeRequiredWarning();
|
||
return;
|
||
}
|
||
|
||
const waitOk = options.waitOk !== false;
|
||
const okPromise = waitOk
|
||
? this._waitForOk(options.timeoutMs ?? WallPlotter.OK_TIMEOUT_MS, {
|
||
command,
|
||
waitLogAfterMs: options.waitLogAfterMs,
|
||
})
|
||
: null;
|
||
try {
|
||
const written = this.writer.write(
|
||
new TextEncoder().encode(command + '\n')
|
||
);
|
||
if (okPromise) {
|
||
await Promise.all([
|
||
written.then(() => this.onLog('→ ' + command)),
|
||
okPromise,
|
||
]);
|
||
} else {
|
||
await written;
|
||
this.onLog('→ ' + command);
|
||
}
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') throw err;
|
||
this._okIgnoreUntil = performance.now() + 400;
|
||
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. Refused while plotting or waiting for Marlin. */
|
||
async setHomeHere() {
|
||
if (this.isPlotting) {
|
||
this.onLog('! cannot set home while plotting — resume or abort first');
|
||
this.statusText = 'Finish or abort plot before H';
|
||
return;
|
||
}
|
||
if (!this._isMachineIdle()) {
|
||
this.onLog('! plotter busy — wait for ok before setting home');
|
||
this.statusText = 'Waiting for plotter…';
|
||
return;
|
||
}
|
||
await this.send('G92 X0 Y0');
|
||
try {
|
||
await this.send('M114', { timeoutMs: 3000, allowWithoutHome: true });
|
||
} catch (_) {
|
||
/* position report optional */
|
||
}
|
||
this.x = 0;
|
||
this.y = 0;
|
||
this.homeSet = true;
|
||
this._homeWarning = null;
|
||
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;
|
||
}
|
||
|
||
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') break;
|
||
if (err.name === 'TimeoutError') {
|
||
if (!this.isPaused) this.pausePlot();
|
||
this.onLog('segment skipped after timeout — resume continues from here');
|
||
px = this.x;
|
||
py = this.y;
|
||
await this._waitIfPaused();
|
||
if (this._pathAbort || runId !== this._pathRunId) break;
|
||
if (cmd.type === 'move' || cmd.type === 'draw') {
|
||
px = this.x;
|
||
py = this.y;
|
||
}
|
||
this.plotProgress = i + 1;
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
if (this._pathAbort || runId !== this._pathRunId) break;
|
||
|
||
if (this._freezeMotion) {
|
||
px = this.x;
|
||
py = this.y;
|
||
await this._waitIfPaused();
|
||
this._freezeMotion = false;
|
||
if (this._pathAbort || runId !== this._pathRunId) break;
|
||
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;
|
||
if (!testOnly && this.connected) {
|
||
try {
|
||
await this.penUp();
|
||
} catch (_) {
|
||
/* port may be gone */
|
||
}
|
||
} else {
|
||
this.isPenDown = false;
|
||
}
|
||
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) {
|
||
if (this._plotTestOnly) 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;
|
||
}
|
||
}
|
||
|
||
/** 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);
|
||
const motionMs = Math.round(this._lastSegmentMs);
|
||
await this.send('M400', {
|
||
timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, motionMs + 20000),
|
||
// M400 is supposed to last as long as the move. Don't call that "stuck".
|
||
waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, motionMs + 1500),
|
||
});
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Internals — serial I/O
|
||
// ===========================================================================
|
||
|
||
_waitForOk(timeoutMs, options = {}) {
|
||
const command = options.command || '';
|
||
const waitLogAfterMs = options.waitLogAfterMs ?? WallPlotter.OK_WAIT_LOG_MS;
|
||
const waitLabel = command ? ` (${command})` : '';
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let settled = false;
|
||
let timer = null;
|
||
let waitLogTimer = null;
|
||
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);
|
||
fn(arg);
|
||
};
|
||
|
||
const armTimer = () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => {
|
||
const err = new Error(
|
||
`timeout waiting for Marlin ok${waitLabel} (${Math.round(timeoutMs / 1000)}s silent)`
|
||
);
|
||
err.name = 'TimeoutError';
|
||
entry.reject(err);
|
||
}, timeoutMs);
|
||
};
|
||
|
||
const entry = {
|
||
resolve: () => {
|
||
if (this.statusText === 'Waiting for plotter…') {
|
||
this.statusText =
|
||
this._statusBeforeOkWait ??
|
||
(this.connected ? 'Connected' : 'Disconnected');
|
||
this._statusBeforeOkWait = null;
|
||
}
|
||
finish(resolve);
|
||
},
|
||
reject: (err) => finish(reject, err),
|
||
bump: () => {
|
||
if (!settled) armTimer();
|
||
},
|
||
};
|
||
|
||
armTimer();
|
||
|
||
waitLogTimer = setTimeout(() => {
|
||
if (settled) return;
|
||
const started = performance.now();
|
||
this.onLog(`… waiting for Marlin ok${waitLabel}`);
|
||
if (this.statusText !== 'Waiting for plotter…') {
|
||
this._statusBeforeOkWait = this.statusText;
|
||
}
|
||
this.statusText = 'Waiting for plotter…';
|
||
this._redrawSketch();
|
||
waitLogInterval = setInterval(() => {
|
||
const s = Math.round((performance.now() - started) / 1000);
|
||
this.onLog(`… still waiting for Marlin ok${waitLabel} (${s}s)`);
|
||
}, WallPlotter.OK_WAIT_PING_MS);
|
||
}, waitLogAfterMs);
|
||
|
||
this._okResolvers.push(entry);
|
||
});
|
||
}
|
||
|
||
_cancelPendingOk() {
|
||
this._okIgnoreUntil = performance.now() + 400;
|
||
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);
|
||
plotter._drainRxBuffer();
|
||
}
|
||
} catch (err) {
|
||
if (plotter.readLoopActive) {
|
||
plotter.onLog('serial read error: ' + err.message);
|
||
}
|
||
} finally {
|
||
try {
|
||
plotter.reader.releaseLock();
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
plotter.reader = null;
|
||
}
|
||
}
|
||
};
|
||
loop();
|
||
}
|
||
|
||
_drainRxBuffer() {
|
||
const lines = this._rxBuf.split(/\r?\n/);
|
||
this._rxBuf = lines.pop();
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (trimmed) this._handleResponse(trimmed);
|
||
}
|
||
// USB-CDC sometimes delivers a complete "ok" without a trailing newline
|
||
// until the next chunk. Don't leave that sitting in the buffer.
|
||
const rest = this._rxBuf.trim();
|
||
if (/^(echo:\s*)?ok\b/i.test(rest)) {
|
||
this._handleResponse(rest);
|
||
this._rxBuf = '';
|
||
}
|
||
}
|
||
|
||
_handleResponse(line) {
|
||
if (/busy:\s*processing/i.test(line)) {
|
||
this._machineBusy = true;
|
||
for (const entry of this._okResolvers) {
|
||
if (typeof entry.bump === 'function') entry.bump();
|
||
}
|
||
const now = performance.now();
|
||
if (now - this._lastBusyLogAt > 4000) {
|
||
this._lastBusyLogAt = now;
|
||
this.onLog('← busy (move still running)');
|
||
}
|
||
return;
|
||
}
|
||
this.onLog('← ' + line);
|
||
if (/^(echo:\s*)?ok\b/i.test(line)) {
|
||
this._machineBusy = false;
|
||
const next = this._okResolvers.shift();
|
||
if (next) {
|
||
next.resolve();
|
||
} else if (performance.now() < this._okIgnoreUntil) {
|
||
this.onLog('← ok ignored (stale, no waiter)');
|
||
}
|
||
}
|
||
this._parseMachineBounds(line);
|
||
if (line.startsWith('X:')) {
|
||
const match = line.match(/X:([-\d.]+)\s+Y:([-\d.]+)/);
|
||
if (match) {
|
||
this.x = parseFloat(match[1]);
|
||
this.y = parseFloat(match[2]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Parse polargraph M665 report → machineX/Y min/max. */
|
||
_parseMachineBounds(line) {
|
||
if (!/\bM665\b/i.test(line)) return;
|
||
const l = line.match(/\bL(-?[\d.]+)/i);
|
||
const r = line.match(/\bR(-?[\d.]+)/i);
|
||
const t = line.match(/\bT(-?[\d.]+)/i);
|
||
const b = line.match(/\bB(-?[\d.]+)/i);
|
||
if (!l || !r || !t || !b) return;
|
||
this.machineXMin = parseFloat(l[1]);
|
||
this.machineXMax = parseFloat(r[1]);
|
||
this.machineYMax = parseFloat(t[1]);
|
||
this.machineYMin = parseFloat(b[1]);
|
||
this.onLog(
|
||
`machine bounds X ${this.machineXMin}…${this.machineXMax} Y ${this.machineYMin}…${this.machineYMax}`
|
||
);
|
||
}
|
||
|
||
_isCoordinateMove(command) {
|
||
return /^G[01]\b/i.test(command) && /\b[XY]/i.test(command);
|
||
}
|
||
|
||
_isMachineIdle() {
|
||
return this._okResolvers.length === 0 && !this._machineBusy;
|
||
}
|
||
|
||
_showHomeRequiredWarning() {
|
||
const msg = 'Home not set — press H before sending moves';
|
||
this._homeWarning = msg;
|
||
this.statusText = msg;
|
||
this.onLog('! ' + msg);
|
||
}
|
||
|
||
_requireHome() {
|
||
if (this.homeSet) {
|
||
this._homeWarning = null;
|
||
return true;
|
||
}
|
||
this._showHomeRequiredWarning();
|
||
return false;
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Internals — keyboard, jog, plot buttons
|
||
// ===========================================================================
|
||
|
||
async _handleKeyPressed() {
|
||
const k = key.toLowerCase();
|
||
|
||
if (k === 'c') {
|
||
await this._toggleConnection();
|
||
return false;
|
||
}
|
||
if (k === 's') {
|
||
await this.stopPlot();
|
||
return false;
|
||
}
|
||
if (k === 't') {
|
||
this._onTestButton();
|
||
return false;
|
||
}
|
||
|
||
if (!this._keysEnabled || !this.connected) return;
|
||
|
||
if (k === 'h') {
|
||
await this.setHomeHere();
|
||
return false;
|
||
}
|
||
if (k === '0') {
|
||
if (this.isPlotting || !this._isMachineIdle()) return false;
|
||
await this.goToZero();
|
||
return false;
|
||
}
|
||
if (k === 'p') {
|
||
if (this.isPenDown) await this.penUp(true);
|
||
else await this.penDown(true);
|
||
return false;
|
||
}
|
||
if (k === '+' || k === '=') {
|
||
this.stepSize = Math.min(this.stepSize + 5, 100);
|
||
return false;
|
||
}
|
||
if (k === '-') {
|
||
this.stepSize = Math.max(this.stepSize - 5, 1);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
_handleContinuousJog() {
|
||
if (!this.connected || this._jogInFlight || this.isPlotting) return;
|
||
if (!this.homeSet || !this._isMachineIdle()) return;
|
||
|
||
const draw = keyIsDown(SHIFT);
|
||
const s = this.stepSize;
|
||
let dx = 0;
|
||
let dy = 0;
|
||
if (keyIsDown(LEFT_ARROW)) dx -= s;
|
||
if (keyIsDown(RIGHT_ARROW)) dx += s;
|
||
if (keyIsDown(UP_ARROW)) dy += s;
|
||
if (keyIsDown(DOWN_ARROW)) dy -= s;
|
||
|
||
if (dx === 0 && dy === 0) {
|
||
this._lastJogTime = 0;
|
||
return;
|
||
}
|
||
|
||
const now = millis();
|
||
if (this._lastJogTime > 0 && now - this._lastJogTime < this.jogIntervalMs) return;
|
||
this._lastJogTime = now;
|
||
|
||
this._jogInFlight = true;
|
||
this._jogBy(dx, dy, draw)
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
this._jogInFlight = false;
|
||
});
|
||
}
|
||
|
||
async _jogBy(dx, dy, draw = false) {
|
||
try {
|
||
if (!this._requireHome()) return;
|
||
if (draw) {
|
||
await this.drawTo(this.x + dx, this.y + dy);
|
||
return;
|
||
}
|
||
await this.penUp();
|
||
const nx = this.x + dx;
|
||
const ny = this.y + dy;
|
||
await this.send(
|
||
`G1 X${this._fmt(nx)} Y${this._fmt(ny)} F${this.feedRateTravel}`
|
||
);
|
||
this.x = nx;
|
||
this.y = ny;
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') return;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async _toggleConnection() {
|
||
if (this._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 {
|
||
xMin: this.machineXMin,
|
||
xMax: this.machineXMax,
|
||
yMin: this.machineYMin,
|
||
yMax: this.machineYMax,
|
||
preview: false,
|
||
};
|
||
}
|
||
const half = this.previewSizeMm / 2;
|
||
return {
|
||
xMin: -half,
|
||
xMax: half,
|
||
yMin: -half,
|
||
yMax: half,
|
||
preview: true,
|
||
};
|
||
}
|
||
|
||
_getPlotLayout() {
|
||
const margin = 60;
|
||
const size = Math.min(width, height) - margin * 2;
|
||
const b = this._getDisplayBounds();
|
||
const worldW = Math.max(1, b.xMax - b.xMin);
|
||
const worldH = Math.max(1, b.yMax - b.yMin);
|
||
return {
|
||
cx: width / 2,
|
||
cy: height / 2 - 20,
|
||
scale: Math.min(size / worldW, size / worldH),
|
||
worldCx: 0,
|
||
worldCy: 0,
|
||
};
|
||
}
|
||
|
||
_worldToScreen(worldX, worldY, layout) {
|
||
return {
|
||
x: (worldX - layout.worldCx) * layout.scale,
|
||
y: -(worldY - layout.worldCy) * layout.scale,
|
||
};
|
||
}
|
||
|
||
_drawPlotArea() {
|
||
const layout = this._getPlotLayout();
|
||
const bounds = this._getDisplayBounds();
|
||
|
||
push();
|
||
translate(layout.cx, layout.cy);
|
||
|
||
const tl = this._worldToScreen(bounds.xMin, bounds.yMax, layout);
|
||
const br = this._worldToScreen(bounds.xMax, bounds.yMin, layout);
|
||
const w = br.x - tl.x;
|
||
const h = br.y - tl.y;
|
||
|
||
noFill();
|
||
rectMode(CORNER);
|
||
strokeWeight(1);
|
||
if (bounds.preview) {
|
||
stroke(90, 90, 110);
|
||
drawingContext.setLineDash([8, 6]);
|
||
} else {
|
||
stroke(70);
|
||
drawingContext.setLineDash([]);
|
||
}
|
||
rect(tl.x, tl.y, w, h);
|
||
drawingContext.setLineDash([]);
|
||
|
||
stroke(60);
|
||
line(tl.x, 0, br.x, 0);
|
||
line(0, tl.y, 0, br.y);
|
||
|
||
this._drawBoundSizeLabels(bounds, tl, br, w, h);
|
||
|
||
this._drawPreviewPath(layout);
|
||
this._drawGondola(layout);
|
||
|
||
pop();
|
||
}
|
||
|
||
_drawBoundSizeLabels(bounds, tl, br, w, h) {
|
||
const widthCm = (bounds.xMax - bounds.xMin) / 10;
|
||
const heightCm = (bounds.yMax - bounds.yMin) / 10;
|
||
const fmtCm = (cm) => (Number.isInteger(cm) ? `${cm}` : cm.toFixed(1));
|
||
const widthLabel = `${fmtCm(widthCm)} cm · ${Math.round(Math.abs(w))} px`;
|
||
const heightLabel = `${fmtCm(heightCm)} cm · ${Math.round(Math.abs(h))} px`;
|
||
|
||
noStroke();
|
||
fill(bounds.preview ? 140 : 160);
|
||
textFont('monospace', 12);
|
||
textAlign(CENTER, TOP);
|
||
text(widthLabel, (tl.x + br.x) / 2, br.y + 8);
|
||
|
||
push();
|
||
translate(br.x + 8, (tl.y + br.y) / 2);
|
||
rotate(HALF_PI);
|
||
textAlign(CENTER, BOTTOM);
|
||
text(heightLabel, 0, 0);
|
||
pop();
|
||
}
|
||
|
||
_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;
|
||
}
|
||
}
|
||
|
||
_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();
|
||
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(
|
||
`${this.isPaused ? 'paused' : 'plotting'} ${this.plotProgress}/${this.previewPath.length}`,
|
||
16,
|
||
152
|
||
);
|
||
const elapsedMs = this._plotElapsedMs();
|
||
const remainMs = Math.max(0, this._plotEstimateMs - elapsedMs);
|
||
text(
|
||
`elapsed ${this._formatDuration(elapsedMs)} est ${this._formatDuration(this._plotEstimateMs)} remain ${this._formatDuration(remainMs)}`,
|
||
16,
|
||
172
|
||
);
|
||
text(
|
||
`seg wait ${this._lastSegmentMs.toFixed(0)} ms ×${this.timingMultiplier}`,
|
||
16,
|
||
192
|
||
);
|
||
fill(220);
|
||
}
|
||
|
||
if (this.connected && !this.homeSet && this._homeWarning) {
|
||
fill(255, 180, 80);
|
||
text(`! ${this._homeWarning}`, 16, this.isPlotting ? 212 : 152);
|
||
fill(220);
|
||
}
|
||
|
||
if (this._keysEnabled) {
|
||
textAlign(LEFT, BOTTOM);
|
||
const help = [
|
||
'← → ↑ ↓ move (hold for continuous)',
|
||
'Shift+arrow draw (pen down)',
|
||
'+ / - step size',
|
||
'H set current pos as home (G92) — required first',
|
||
'0 go to origin (0, 0)',
|
||
'P toggle pen (test up/down angles)',
|
||
'T run test (preview)',
|
||
'S stop sending (in-flight stroke still finishes)',
|
||
'C connect / disconnect',
|
||
];
|
||
for (let i = 0; i < help.length; i++) {
|
||
text(help[i], 16, height - 16 - i * 18);
|
||
}
|
||
}
|
||
|
||
textAlign(RIGHT, BOTTOM);
|
||
fill(140);
|
||
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));
|
||
}
|
||
|
||
_redrawSketch() {
|
||
if (typeof redraw === 'function') redraw();
|
||
}
|
||
}
|