Log in
main 0 open branches 33 files Download ZIP Browse files
src/nextdraw.js 584 lines · 18.1 KB · javascript
1
// NextDraw: browser/JS port of the Bantam Tools NextDraw Python API
2
// (https://bantam.tools/nd_py). Async methods mirror the Python names in
3
// camelCase: connect, moveto, lineto, drawPath, penup, ... disconnect.
4
//
5
// Everything here talks to the machine through an EBB over a transport, so
6
// the same code runs against WebSerial or the EBB simulator in src/sim.
7
8
import { isClose } from './calc.js';
9
import { clipPolyline, clipSegment, pointInBounds, pointsNear } from './clip.js';
10
import { defaultOptions, defaultParams } from './config.js';
11
import * as control from './control.js';
12
import { EBB } from './ebb.js';
13
import { Homing } from './homing.js';
14
import { applyModelAndHandling } from './models.js';
15
import { computeSegment, copyPos, trajectory } from './motion.js';
16
import { PenHandler } from './pen.js';
17
18
export const realClock = {
19
  now: () => performance.now(),
20
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
21
};
22
23
// plot_status.stopped codes (negative while being handled, positive once stopped)
24
export const STOP_CODES = {
25
  101: 'Failed to connect',
26
  102: 'Stopped by pause button press',
27
  103: 'Stopped by software request',
28
  104: 'Lost USB connectivity',
29
  105: 'Power loss detected',
30
  106: 'Homing failed',
31
  107: 'Refused a motion command beyond the firmware step-rate limit',
32
};
33
34
function newStats() {
35
  return {
36
    upTravelInch: 0,
37
    downTravelInch: 0,
38
    ptEstimate: 0, // ms, preview estimate
39
    addDist(nd, inches) {
40
      if (nd.pen.phys.zUp) this.upTravelInch += inches;
41
      else this.downTravelInch += inches;
42
    },
43
  };
44
}
45
46
function newPlotStatus() {
47
  return {
48
    stopped: 0,
49
    button: false,
50
    limit: false,
51
    power: false,
52
    connection: false,
53
    monitor: false,
54
    buttonTimestamp: -Infinity,
55
    stats: newStats(),
56
  };
57
}
58
59
export class NextDraw {
60
  /**
61
   * @param {object} opts
62
   * @param {object} [opts.transport] transport to use in connect()
63
   * @param {object} [opts.clock] { now(), sleep(ms) }; defaults to real time
64
   * @param {function} [opts.log] message sink (default console.log)
65
   */
66
  constructor({ transport = null, clock = realClock, log = (m) => console.log(m) } = {}) {
67
    this.transport = transport;
68
    this.clock = clock;
69
    this.logFn = log;
70
    this.options = defaultOptions();
71
    this.params = defaultParams();
72
    this.machine = new EBB();
73
    this.pen = new PenHandler();
74
    this.homing = new Homing(this);
75
    this.plotStatus = newPlotStatus();
76
    this.warnings = new Set();
77
    this.bounds = [[0, 0], [0, 0]];
78
    this.connected = false;
79
    this.stepScale = 2 * this.params.nativeResFactor;
80
    this.speedPendown = 0;
81
    this.speedPenup = 0;
82
    this.useLayerSpeed = false;
83
    this.layerSpeedPendown = null;
84
    this.pauseRequested = false;
85
    // Set any of these true to throw instead of returning quietly.
86
    this.errors = { connect: false, button: false, keyboard: false, disconnect: false,
87
      power: false, homing: false, code: 0 };
88
    this.fwVersion = null;
89
    this.nickname = null;
90
    applyModelAndHandling(this, true);
91
  }
92
93
  log(message) {
94
    this.logFn(message);
95
  }
96
97
  warn(key) {
98
    if (this.warnings.has(key)) return;
99
    this.warnings.add(key);
100
    const text = {
101
      bounds: `Warning: motion was clipped at the travel limits of the ${this.params.modelName}.`,
102
      voltage: 'Warning: low or no motor power detected. Is the power supply connected?',
103
      homing_voltage: 'Automatic homing requires motor power. Check the power supply.',
104
    }[key];
105
    if (text) this.log(text);
106
  }
107
108
  // --- Context & settings ---
109
110
  /** Enter the interactive context with default options (Python: interactive()). */
111
  interactive() {
112
    this.options.units = 0;
113
    this.options.preview = false;
114
    this.options.mode = 'interactive';
115
  }
116
117
  requireInteractive() {
118
    if (this.options.mode !== 'interactive') throw new Error('Function only available in interactive mode.');
119
    if (!this.connected) {
120
      this.handleErrors();
121
      throw new Error('Not connected to NextDraw');
122
    }
123
  }
124
125
  /** Re-derive bounds and clamp options (nextdraw.update_options). */
126
  updateOptions() {
127
    if (this.options.model) {
128
      applyModelAndHandling(this);
129
      this.bounds = [[-1e-9, -1e-9], [this.params.travelX + 1e-9, this.params.travelY + 1e-9]];
130
    }
131
    const o = this.options;
132
    const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
133
    o.penPosUp = clamp(o.penPosUp, 0, 100);
134
    o.penPosDown = clamp(o.penPosDown, 0, 100);
135
    o.penRateRaise = clamp(o.penRateRaise, 1, 100);
136
    o.penRateLower = clamp(o.penRateLower, 1, 100);
137
    o.speedPendown = clamp(o.speedPendown, 1, 100);
138
    o.speedPenup = clamp(o.speedPenup, 1, 100);
139
    o.accel = clamp(o.accel, 1, 100);
140
  }
141
142
  /** Apply changed options while connected (Python: update()). */
143
  async update() {
144
    this.requireInteractive();
145
    this.updateOptions();
146
    await this.pen.servoInit(this);
147
    if (!this.machine.connected) return;
148
    await control.enableMotors(this);
149
    // A resolution change re-enables the motors, which zeroes the EBB step
150
    // counters and marks the machine un-homed. (The Python API leaves this to
151
    // the user.) Re-home so the counters and our position agree again.
152
    if (!(await this.machine.varRead(12))) {
153
      if (this.options.homing && this.params.autoHome) {
154
        this.log('Motor resolution changed; re-homing.');
155
        await this.homing.findHome();
156
        this.pen.turtle = copyPos(this.pen.phys);
157
        this.pen.turtle.zUp = true;
158
      } else {
159
        this.log('Motor resolution changed; position is no longer known. Move the carriage home and reconnect.');
160
      }
161
    }
162
  }
163
164
  // --- Connection ---
165
166
  /**
167
   * Open the connection, configure the pen servo, enable motors and home.
168
   * Returns true on success, false on failure.
169
   */
170
  async connect(transport = this.transport) {
171
    if (this.options.mode !== 'interactive') {
172
      this.log('Error: connect() called outside of interactive context.');
173
      return null;
174
    }
175
    if (!this.options.model) {
176
      this.log('Error: Plotter hardware model not selected.');
177
      return null;
178
    }
179
    if (!transport) throw new Error('No transport given to connect().');
180
    this.transport = transport;
181
    applyModelAndHandling(this, true);
182
    this.pauseRequested = false;
183
    this.plotStatus = newPlotStatus();
184
    this.warnings.clear();
185
186
    if (!(await this.serialConnect())) {
187
      this.log(`Failed to connect to ${this.params.modelName}. ${this.machine.err ?? ''}`.trim());
188
      if (this.errors.connect) throw new Error('Failed to connect to NextDraw');
189
      this.errors.code = this.plotStatus.stopped;
190
      return false;
191
    }
192
193
    await this.queryEbbVoltage();
194
    this.updateOptions();
195
196
    this.pen.turtle = copyPos(this.pen.phys);
197
    this.pen.turtle.zUp = true;
198
199
    await control.readStatusByte(this); // Clear any stale button press
200
    this.plotStatus = newPlotStatus();
201
202
    await this.pen.servoInit(this);
203
    await this.pen.raise(this);
204
    await control.enableMotors(this);
205
206
    if (!(await this.homing.findHome())) {
207
      this.handleErrors();
208
      return false;
209
    }
210
    this.pen.turtle = copyPos(this.pen.phys);
211
    this.pen.turtle.zUp = true;
212
    this.fwVersion = this.machine.version;
213
    this.nickname = this.machine.name;
214
    return true;
215
  }
216
217
  async serialConnect() {
218
    if (await this.machine.connect(this.transport)) {
219
      await control.connectSetup(this);
220
      this.connected = true;
221
      return true;
222
    }
223
    this.plotStatus.stopped = 101;
224
    return false;
225
  }
226
227
  async queryEbbVoltage() {
228
    await control.readStatusByte(this);
229
    if (this.plotStatus.power) {
230
      await this.machine.clearSteps();
231
      await this.machine.varWrite(0, 12);
232
      await this.machine.varWrite(0, 13);
233
      this.plotStatus.power = false;
234
    }
235
    this.plotStatus.button = false;
236
    this.plotStatus.limit = false;
237
    return control.queryVoltage(this);
238
  }
239
240
  /** Wait for motion to finish, then close the connection. */
241
  async disconnect() {
242
    if (this.machine.connected) await control.exhaustQueue(this);
243
    await this.machine.disconnect();
244
    this.connected = false;
245
  }
246
247
  // --- Errors & pausing ---
248
249
  /** Ask a running plot or move sequence to stop (like Ctrl-C in Python). */
250
  requestPause() {
251
    this.pauseRequested = true;
252
  }
253
254
  receivePauseRequest() {
255
    return this.pauseRequested;
256
  }
257
258
  handleErrors() {
259
    const code = this.plotStatus.stopped;
260
    this.errors.code = code;
261
    const e = this.errors;
262
    if ((code === 101 && e.connect) || (code === 102 && e.button) || (code === 103 && e.keyboard) ||
263
        (code === 104 && e.disconnect) || (code === 105 && e.power) || (code === 106 && e.homing)) {
264
      throw new Error(STOP_CODES[code]);
265
    }
266
  }
267
268
  /** Check for pause requests, button presses, power loss and lost USB. */
269
  async pauseCheck() {
270
    const ps = this.plotStatus;
271
    if (ps.stopped > 0) return;
272
    const button = await this.checkButton();
273
    if (this.receivePauseRequest()) ps.stopped = -103;
274
    if (ps.power) {
275
      ps.stopped = -105;
276
      this.log('Plot stopped because loss of power detected.');
277
      await this.machine.varWrite(0, 12);
278
      await this.machine.varWrite(0, 13);
279
    }
280
    if (button === -1) {
281
      this.log('Error: USB connection lost.');
282
      this.connected = false;
283
      ps.stopped = -104;
284
    }
285
    if (button === 1) {
286
      this.log('Stopped by button press.');
287
      ps.stopped = -102;
288
    }
289
    if (ps.stopped === -103) this.log('Stopped by request.');
290
    if (ps.stopped < 0) {
291
      await this.pen.raise(this);
292
      ps.stopped = -ps.stopped;
293
    }
294
  }
295
296
  /** 1 if the PRG button was pressed, 0 if not, -1 on USB error. Polls at most every 50 ms. */
297
  async checkButton() {
298
    if (this.options.preview) return 0;
299
    const ps = this.plotStatus;
300
    const now = this.clock.now();
301
    if (now - ps.buttonTimestamp > this.params.buttonInterval * 1000) {
302
      ps.buttonTimestamp = now;
303
      await control.readStatusByte(this);
304
    }
305
    if (ps.button) return 1;
306
    if (ps.connection) return -1;
307
    return 0;
308
  }
309
310
  // --- Motion primitives ---
311
312
  /** Point-to-point move with normal motion planning, starting and ending at rest. */
313
  async goToPosition(x, y, ignoreLimits = false, xyzPos = null) {
314
    const [moves] = computeSegment(this, [x, y, 0, 0, ignoreLimits], xyzPos);
315
    await control.feed(this, moves);
316
  }
317
318
  /** Plot one polyline (inches): pen-up travel to start, then a pen-down path. */
319
  async plotPolyline(vertices) {
320
    if (this.plotStatus.stopped || !vertices || vertices.length < 2) return;
321
    await this.pen.raise(this);
322
    const clamped = vertices.map(([x, y]) => [
323
      Math.max(0, Math.min(this.bounds[1][0], x)),
324
      Math.max(0, Math.min(this.bounds[1][1], y)),
325
    ]);
326
    await this.goToPosition(clamped[0][0], clamped[0][1]);
327
    await control.feed(this, trajectory(this, clamped));
328
  }
329
330
  toInches(v) {
331
    if (this.options.units === 1) return v / 2.54;
332
    if (this.options.units === 2) return v / 25.4;
333
    return v;
334
  }
335
336
  fromInches(v) {
337
    if (this.options.units === 1) return v * 2.54;
338
    if (this.options.units === 2) return v * 25.4;
339
    return v;
340
  }
341
342
  /**
343
   * Move the turtle to (x, y), clipping the actual motion to travel bounds.
344
   * Out-of-bounds portions are skipped with the pen raised.
345
   */
346
  async xyPlotSegment(relative, x, y) {
347
    this.requireInteractive();
348
    x = this.toInches(x);
349
    y = this.toInches(y);
350
    const turtle = this.pen.turtle;
351
    if (relative) {
352
      x += turtle.xpos;
353
      y += turtle.ypos;
354
    }
355
    const b = this.bounds;
356
    if (isClose(x, b[0][0], 2e-9)) x = b[0][0];
357
    if (isClose(x, b[1][0], 2e-9)) x = b[1][0];
358
    if (isClose(y, b[0][1], 2e-9)) y = b[0][1];
359
    if (isClose(y, b[1][1], 2e-9)) y = b[1][1];
360
361
    const start = [turtle.xpos, turtle.ypos];
362
    const target = [x, y];
363
    const [accept, seg] = clipSegment([start, target], b);
364
    if (accept && this.machine.connected) {
365
      if (!pointsNear(seg[0], start, 1e-9)) {
366
        if (this.params.autoClipLift && !turtle.zUp) {
367
          await this.pen.raise(this);
368
          turtle.zUp = false;
369
        }
370
        await this.goToPosition(seg[0][0], seg[0][1]);
371
      }
372
      if (!turtle.zUp) await this.pen.lower(this);
373
      await this.goToPosition(seg[1][0], seg[1][1]);
374
      if (!pointsNear(seg[1], target, 1e-9) && this.params.autoClipLift && !turtle.zUp) {
375
        await this.pen.raise(this);
376
        turtle.zUp = false;
377
      }
378
    }
379
    turtle.xpos = x;
380
    turtle.ypos = y;
381
    this.handleErrors();
382
  }
383
384
  /** Absolute move, keeping the current pen state. */
385
  goto(x, y) {
386
    return this.xyPlotSegment(false, x, y);
387
  }
388
389
  /** Absolute pen-up move. */
390
  async moveto(x, y) {
391
    this.requireInteractive();
392
    await this.pen.raise(this);
393
    this.pen.turtle.zUp = true;
394
    await this.xyPlotSegment(false, x, y);
395
  }
396
397
  /** Absolute pen-down move. */
398
  lineto(x, y) {
399
    this.pen.turtle.zUp = false;
400
    return this.xyPlotSegment(false, x, y);
401
  }
402
403
  /** Relative move, keeping the current pen state. */
404
  go(dx, dy) {
405
    return this.xyPlotSegment(true, dx, dy);
406
  }
407
408
  /** Relative pen-up move. */
409
  async move(dx, dy) {
410
    this.requireInteractive();
411
    await this.pen.raise(this);
412
    this.pen.turtle.zUp = true;
413
    await this.xyPlotSegment(true, dx, dy);
414
  }
415
416
  /** Relative pen-down move. */
417
  line(dx, dy) {
418
    this.pen.turtle.zUp = false;
419
    return this.xyPlotSegment(true, dx, dy);
420
  }
421
422
  async penup() {
423
    this.requireInteractive();
424
    await this.pen.raise(this);
425
    this.pen.turtle.zUp = true;
426
  }
427
428
  async pendown() {
429
    this.requireInteractive();
430
    this.pen.turtle.zUp = false;
431
    if (this.params.autoClipLift &&
432
        !pointInBounds([this.pen.turtle.xpos, this.pen.turtle.ypos], this.bounds)) {
433
      return; // Don't lower the pen outside the travel area.
434
    }
435
    await this.pen.lower(this);
436
  }
437
438
  /**
439
   * Draw a path through [[x, y], ...] (current units): pen-up move to the
440
   * first vertex, pen-down along the rest, then raise. Clipped at travel bounds.
441
   */
442
  async drawPath(vertices) {
443
    this.requireInteractive();
444
    if (vertices.length < 2 || this.plotStatus.stopped) return;
445
    const scaled = vertices.map(([x, y]) => [this.toInches(x), this.toInches(y)]);
446
    const last = scaled[scaled.length - 1];
447
    this.pen.turtle.xpos = last[0];
448
    this.pen.turtle.ypos = last[1];
449
    this.pen.turtle.zUp = true;
450
451
    const { paths } = clipPolyline(scaled, this.bounds, this.params.boundsTolerance);
452
    for (const path of paths) {
453
      if (this.plotStatus.stopped) break;
454
      await this.plotPolyline(path);
455
      this.handleErrors();
456
      await this.penup();
457
    }
458
    if (this.plotStatus.stopped) {
459
      this.pen.turtle = copyPos(this.pen.phys);
460
      this.pen.turtle.zUp = true;
461
    }
462
  }
463
464
  /** Hardware-timed delay in the motion queue. */
465
  async delay(ms) {
466
    this.requireInteractive();
467
    ms = Math.trunc(ms);
468
    if (ms > 0) await this.machine.timedPause(ms);
469
  }
470
471
  /** Wait until all queued motion has completed. */
472
  async block() {
473
    this.requireInteractive();
474
    await control.exhaustQueue(this);
475
  }
476
477
  currentPos() {
478
    return [this.fromInches(this.pen.phys.xpos), this.fromInches(this.pen.phys.ypos)];
479
  }
480
481
  turtlePos() {
482
    return [this.fromInches(this.pen.turtle.xpos), this.fromInches(this.pen.turtle.ypos)];
483
  }
484
485
  currentPen() {
486
    return this.pen.phys.zUp;
487
  }
488
489
  turtlePen() {
490
    return this.pen.turtle.zUp;
491
  }
492
493
  /** Raw EBB query. Bypasses all safety checks; use with great care. */
494
  async usbQuery(query) {
495
    this.requireInteractive();
496
    return this.machine.query(query);
497
  }
498
499
  /** Raw EBB command. Bypasses all safety checks; use with great care. */
500
  async usbCommand(command) {
501
    this.requireInteractive();
502
    await this.machine.query(command);
503
  }
504
505
  // --- Utilities (Python CLI "utility" mode equivalents) ---
506
507
  /** Return to the home corner at the current pen state's speed. */
508
  async walkHome() {
509
    this.requireInteractive();
510
    await this.pen.raise(this);
511
    await control.exhaustQueue(this);
512
    await control.writeStepOffsets(this, 0, 0);
513
    await this.homing.readPosition();
514
    await this.goToPosition(Math.min(this.pen.phys.xpos, 0.01), Math.min(this.pen.phys.ypos, 0.01), true);
515
    await this.homing.precisionMoveTo(0, 0);
516
    this.pen.turtle = copyPos(this.pen.phys);
517
    this.pen.turtle.zUp = true;
518
  }
519
520
  /** Disable (freewheel) the XY motors. Position is lost until re-homed. */
521
  async disableXY() {
522
    await this.machine.motorsDisable();
523
    await this.machine.varWrite(0, 12); // No longer homed
524
  }
525
526
  /** Force a fresh homing sequence. */
527
  async rehome() {
528
    this.requireInteractive();
529
    await this.pen.raise(this);
530
    await control.enableMotors(this);
531
    await this.machine.varWrite(0, 12);
532
    const ok = await this.homing.findHome();
533
    this.pen.turtle = copyPos(this.pen.phys);
534
    this.pen.turtle.zUp = true;
535
    return ok;
536
  }
537
538
  /** Lower the pen, pause half a second, raise it. */
539
  async cyclePen() {
540
    this.requireInteractive();
541
    await this.pen.cycle(this);
542
  }
543
544
  // --- Plotting ---
545
546
  /**
547
   * Plot a list of polylines given in inches from the home corner, like the
548
   * Python Plot context does for an SVG: plot each path, then return home.
549
   * onProgress(doneCount, total) is called after each path.
550
   */
551
  async plotPolylines(polylines, { onProgress = null } = {}) {
552
    this.requireInteractive();
553
    this.pauseRequested = false;
554
    this.plotStatus.stopped = 0;
555
    this.plotStatus.stats = newStats();
556
    await this.pen.raise(this);
557
    let done = 0;
558
    for (const polyline of polylines) {
559
      if (this.plotStatus.stopped) break;
560
      const { paths, outOfBounds } = clipPolyline(polyline, this.bounds, this.params.boundsTolerance);
561
      if (outOfBounds) this.warn('bounds');
562
      for (const path of paths) {
563
        if (this.plotStatus.stopped) break;
564
        await this.plotPolyline(path);
565
      }
566
      done += 1;
567
      onProgress?.(done, polylines.length);
568
    }
569
    await this.pen.raise(this);
570
    if (this.plotStatus.stopped === 0) {
571
      await this.goToPosition(Math.min(this.pen.phys.xpos, 0.1), Math.min(this.pen.phys.ypos, 0.1));
572
      await this.homing.precisionMoveTo(0, 0);
573
    }
574
    this.pen.turtle = copyPos(this.pen.phys);
575
    this.pen.turtle.zUp = true;
576
    const stopped = this.plotStatus.stopped;
577
    if (stopped) {
578
      // Allow further commands after a pause; the caller sees the code.
579
      this.plotStatus.stopped = 0;
580
      this.pauseRequested = false;
581
    }
582
    return { stopped, stats: this.plotStatus.stats };
583
  }
584
}