diff --git a/.gitignore b/.gitignore
index 9751508..8b41f66 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ Thumbs.db
*.log
.idea/
.vscode/
+unterricht
diff --git a/README.md b/README.md
index 21be2c1..9e2e2da 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ p5.js library for a Marlin polargraph (wall plotter) over Web Serial, plus a zig
```
p5-wallplotter/
├── tutorial.md # setup + plotter + G-code
+├── unterricht/ # three workshop assignments
├── hardware.md # motors, board, geometry
├── gcode.md # G-code used by this setup
├── plotter.svg # devices + signal path
diff --git a/examples/live/index.html b/examples/live/index.html
new file mode 100644
index 0000000..6f90a94
--- /dev/null
+++ b/examples/live/index.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ Wall Plotter — mouse
+
+
+
+
+
+
+ Wall Plotter — mouse
+
+
+
diff --git a/examples/live/sketch.js b/examples/live/sketch.js
new file mode 100644
index 0000000..92bd25f
--- /dev/null
+++ b/examples/live/sketch.js
@@ -0,0 +1,79 @@
+// Live-Maus: Jeder Klick schickt eine Zielposition. Der Plotter
+// fährt erst dorthin (moveTo wartet auf Bewegungsende). Der nächste Klick wird
+// erst danach ausgeführt.
+// waitUntilArrived: true = Klicks während der Fahrt ignorieren.
+// false = Klicks dürfen sich stapeln (send-Kette in der Library).
+
+let wp;
+let live = false;
+let busy = false;
+const waitUntilArrived = true;
+
+function setup() {
+ const canvas = createCanvas(windowWidth, windowHeight);
+ canvas.parent('sketch-host');
+
+ wp = new WallPlotter({
+ penUpGcode: 'M280 P0 S120',
+ penDownGcode: 'M280 P0 S30',
+ penUpDelayMs: 250,
+ penDownDelayMs: 150,
+ });
+ wp.setSpeed(3000, 3000);
+ wp.enableKeyInputs();
+ wp.enablePlotButtons();
+}
+
+// Jeden Frame: Overlay zeichnen und die Maus umranden.
+function draw() {
+ background(28, 28, 32);
+ wp.showInfos();
+
+ noFill();
+ stroke(live ? color(230, 50, 50) : color(220));
+ strokeWeight(2);
+ circle(mouseX, mouseY, 16);
+
+ if (live) {
+ noStroke();
+ fill(230, 50, 50);
+ textSize(12);
+ textAlign(LEFT, CENTER);
+ text(busy ? 'LIVE BUSY' : 'LIVE', mouseX + 14, mouseY);
+ }
+}
+
+// Fenstergrösse geändert: Canvas anpassen.
+function windowResized() {
+ resizeCanvas(windowWidth, windowHeight);
+}
+
+// Taste L: Live-Modus ein- oder ausschalten.
+function keyPressed() {
+ if (key !== 'l' && key !== 'L') return;
+ live = !live;
+}
+
+// Klick (nur im Live-Modus): Gondel fährt zu dieser Millimeter-Position, Stift oben.n
+async function mousePressed() {
+ if (!live || !wp.connected || !wp.homeSet) return;
+ if (waitUntilArrived && busy) return;
+
+ busy = true;
+ try {
+ // Maus-Pixel auf dem Overlay -> Plotter-Koordinaten
+ // Die Library nutzt den aktuellen Overlay-Layout-Massstab und begrenzt
+ // standardmässig auf die Maschinen-Grenzen (inkl. korrekter Y-Ausrichtung).
+ const p = wp.screenToPlot(mouseX, mouseY);
+ await wp.moveTo(p.x, p.y);
+
+ console.log('moveTo', p.x, p.y);
+ } finally {
+ busy = false;
+ }
+}
+
+// Von den Plot-Buttons erwartet; hier ungenutzt (kein gepufferter Pfad).
+async function plot() {}
+
+async function test() {}
diff --git a/lib/WallPlotter.js b/lib/WallPlotter.js
index 2ef8c3b..74ce8fa 100644
--- a/lib/WallPlotter.js
+++ b/lib/WallPlotter.js
@@ -320,6 +320,38 @@ class WallPlotter {
this.timingMultiplier = Math.max(0.1, Number(mult) || 1);
}
+ /**
+ * Convert canvas pixel coordinates to plot coordinates in mm.
+ * By default this uses machine bounds so live-click targeting stays stable.
+ * @param {number} px
+ * @param {number} py
+ * @param {{clamp?: boolean, useDisplayBounds?: boolean}} [options]
+ */
+ screenToPlot(px, py, options = {}) {
+ const layout = this._getPlotLayout();
+ const bounds =
+ options.useDisplayBounds === true
+ ? this._getDisplayBounds()
+ : {
+ xMin: this.machineXMin,
+ xMax: this.machineXMax,
+ yMin: this.machineYMin,
+ yMax: this.machineYMax,
+ };
+
+ const scale = Math.max(layout.scale || 0, 1e-9);
+ const worldX = (px - layout.cx) / scale + layout.worldCx;
+ const worldY = -(py - layout.cy) / scale + layout.worldCy;
+ const clamp = options.clamp !== false;
+
+ if (!clamp) return { x: worldX, y: worldY };
+
+ return {
+ x: Math.min(bounds.xMax, Math.max(bounds.xMin, worldX)),
+ y: Math.min(bounds.yMax, Math.max(bounds.yMin, worldY)),
+ };
+ }
+
// ===========================================================================
// Public API — path buffer (queue only; nothing is sent until runPath)
// ===========================================================================
@@ -929,19 +961,42 @@ class WallPlotter {
/** Pen up and travel to (x, y) mm immediately. Requires home. */
async moveTo(x, y) {
if (!this._requireHome()) return;
+ const startX = this.x;
+ const startY = this.y;
await this.penUp();
await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateTravel}`);
this.x = x;
this.y = y;
+ await this._waitForMotionComplete(
+ Math.hypot((x ?? 0) - (startX ?? 0), (y ?? 0) - (startY ?? 0)),
+ this.feedRateTravel
+ );
}
/** Pen down and draw to (x, y) mm immediately. Requires home. */
async drawTo(x, y) {
if (!this._requireHome()) return;
+ const startX = this.x;
+ const startY = this.y;
await this.penDown();
await this.send(`G1 X${this._fmt(x)} Y${this._fmt(y)} F${this.feedRateDraw}`);
this.x = x;
this.y = y;
+ await this._waitForMotionComplete(
+ Math.hypot((x ?? 0) - (startX ?? 0), (y ?? 0) - (startY ?? 0)),
+ this.feedRateDraw
+ );
+ }
+
+ async _waitForMotionComplete(distanceMm, feedRateMmPerMin) {
+ const distance = Number.isFinite(distanceMm) ? Math.max(0, distanceMm) : 0;
+ const speedMmPerS = Math.max(1, Number(feedRateMmPerMin) || 1) / 60;
+ const etaMs = Math.round((distance / speedMmPerS) * 1000);
+ await this.send('M400', {
+ timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, etaMs + 15000),
+ // M400 legitimately blocks until mechanics are done moving.
+ waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, etaMs + 1500),
+ });
}
/** Travel to (0, 0). */
diff --git a/lib/doc.md b/lib/doc.md
index 09e6ddb..7cad72e 100644
--- a/lib/doc.md
+++ b/lib/doc.md
@@ -184,13 +184,21 @@ Write one G-code line and wait for Marlin `ok`.
Send pen G-code and wait the pen delay. By default, no-op if already in that state. Pass `true` to always send (used by `P` and on connect) so you can test the configured angles.
+### `screenToPlot(px, py, options?)`
+
+Convert canvas pixel coordinates to plot coordinates in mm.
+
+- `px`, `py` (number): screen/canvas pixel coordinates.
+- `options.clamp` (boolean, default `true`): clamp result to bounds.
+- `options.useDisplayBounds` (boolean, default `false`): use current display bounds (preview when disconnected) instead of machine bounds.
+
### `moveTo(x, y)`
-Pen up, travel to `(x, y)` mm immediately (not queued). Requires home.
+Pen up, travel to `(x, y)` mm immediately (not queued), then wait for motion completion (`M400`). Requires home.
### `drawTo(x, y)`
-Pen down, draw to `(x, y)` mm immediately. Requires home.
+Pen down, draw to `(x, y)` mm immediately, then wait for motion completion (`M400`). Requires home.
### `goToZero()`