Log in
web/app.js 361 lines · 11.3 KB · javascript
1
import { handlers, NextDraw, plotters, realClock, STOP_CODES, WebSerialTransport,
2
  webSerialSupported } from '../src/index.js';
3
import { EBBSimulator } from '../src/sim/ebb-sim.js';
4
import { joinPolylines, svgToPolylines } from '../src/svg.js';
5
6
const $ = (sel) => document.querySelector(sel);
7
const $$ = (sel) => [...document.querySelectorAll(sel)];
8
const SVG_NS = 'http://www.w3.org/2000/svg';
9
const SETTINGS_KEY = 'dotter.settings';
10
const SETTING_NAMES = ['model', 'handling', 'speedPendown', 'speedPenup', 'accel', 'penPosUp', 'penPosDown',
11
  'penRateLower', 'penRateRaise', 'homing'];
12
13
let nd = null; // Connected NextDraw, if any
14
let sim = null; // Simulator, when connected to one instead of USB
15
let busy = false;
16
let job = null; // { polylines, dryRun }
17
18
// --- Logging & status ---
19
20
function log(message, isError = false) {
21
  const li = document.createElement('li');
22
  li.textContent = message;
23
  if (isError) li.className = 'err';
24
  $('#log').prepend(li);
25
}
26
27
function setStatus() {
28
  const el = $('#status');
29
  if (!nd) {
30
    el.textContent = 'Not connected';
31
    el.className = 'status off';
32
  } else {
33
    const who = sim ? 'Simulator' : (nd.nickname || nd.params.modelName);
34
    el.textContent = `${who} · fw ${nd.fwVersion ?? '?'}${busy ? ' · busy' : ''}`;
35
    el.className = `status on${busy ? ' busy' : ''}`;
36
  }
37
  $('#connect').hidden = Boolean(nd);
38
  $('#simulate').hidden = Boolean(nd);
39
  $('#disconnect').hidden = !nd;
40
  for (const b of $$('.needs-conn')) b.disabled = !nd || busy;
41
  $('#plot').disabled = !nd || busy || !job || job.dryRun?.violations.length > 0;
42
  $('#stop').disabled = !busy;
43
  $('#disconnect').disabled = busy;
44
}
45
46
/** Run one machine operation at a time; report errors to the log. */
47
async function run(label, fn) {
48
  if (busy) return;
49
  busy = true;
50
  setStatus();
51
  try {
52
    await fn();
53
  } catch (e) {
54
    log(`${label}: ${e.message ?? e}`, true);
55
  } finally {
56
    busy = false;
57
    setStatus();
58
    drawCarriage();
59
    if (sim) drawInk(sim.ink);
60
  }
61
}
62
63
// --- Drawing the bed ---
64
65
function setBed() {
66
  const model = plotters[Number(readSettings().model)];
67
  const svg = $('#bed');
68
  svg.setAttribute('viewBox', `-0.2 -0.2 ${model.travelX + 0.4} ${model.travelY + 0.4}`);
69
  $('#bed-area').setAttribute('width', model.travelX);
70
  $('#bed-area').setAttribute('height', model.travelY);
71
}
72
73
function polylineElements(group, polylines) {
74
  group.replaceChildren(...polylines.map((pts) => {
75
    const el = document.createElementNS(SVG_NS, 'polyline');
76
    el.setAttribute('points', pts.map(([x, y]) => `${x.toFixed(4)},${y.toFixed(4)}`).join(' '));
77
    return el;
78
  }));
79
}
80
81
function drawPlan(polylines) {
82
  polylineElements($('#layer-plan'), polylines);
83
}
84
85
function drawInk(polylines) {
86
  polylineElements($('#layer-ink'), polylines);
87
}
88
89
function drawCarriage() {
90
  if (!nd || nd.pen.phys.xpos == null) {
91
    $('#pos').textContent = '–';
92
    return;
93
  }
94
  const { xpos, ypos } = nd.pen.phys;
95
  $('#carriage').setAttribute('cx', xpos);
96
  $('#carriage').setAttribute('cy', ypos);
97
  $('#pos').textContent = `${(xpos * 25.4).toFixed(1)}, ${(ypos * 25.4).toFixed(1)} mm`;
98
}
99
100
// --- Settings ---
101
102
function readSettings() {
103
  const form = $('#settings');
104
  const out = {};
105
  for (const name of SETTING_NAMES) {
106
    const input = form.elements[name];
107
    out[name] = input.type === 'checkbox' ? input.checked : Number(input.value);
108
  }
109
  return out;
110
}
111
112
function applySettings(target) {
113
  Object.assign(target.options, readSettings());
114
}
115
116
function initSettings() {
117
  const form = $('#settings');
118
  const defaults = new NextDraw().options;
119
  let saved = {};
120
  try {
121
    saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
122
  } catch {
123
    saved = {};
124
  }
125
  plotters.forEach((p, i) => {
126
    if (i > 0) form.elements.model.add(new Option(p.modelName, i));
127
  });
128
  handlers.forEach((h, i) => {
129
    if (i > 0) form.elements.handling.add(new Option(h.name, i));
130
  });
131
  for (const name of SETTING_NAMES) {
132
    const input = form.elements[name];
133
    const value = saved[name] ?? defaults[name];
134
    if (input.type === 'checkbox') input.checked = Boolean(value);
135
    else input.value = value;
136
  }
137
  const refresh = () => {
138
    for (const input of form.querySelectorAll('input[type=range]')) {
139
      input.previousElementSibling.textContent = `${input.value}%`;
140
    }
141
    try {
142
      localStorage.setItem(SETTINGS_KEY, JSON.stringify(readSettings()));
143
    } catch {
144
      // Storage unavailable; settings just won't persist.
145
    }
146
    setBed();
147
  };
148
  form.addEventListener('input', () => {
149
    refresh();
150
    if (job) dryRun();
151
  });
152
  form.addEventListener('submit', (e) => {
153
    e.preventDefault();
154
    run('Apply settings', async () => {
155
      const modelChanged = Number(form.elements.model.value) !== nd.options.model;
156
      applySettings(nd);
157
      if (modelChanged) log('Model changed: disconnect and reconnect so homing uses the new travel limits.', true);
158
      await nd.update();
159
      log('Settings applied.');
160
    });
161
  });
162
  refresh();
163
}
164
165
// --- Connecting ---
166
167
async function connectWith(transport, clock) {
168
  const isSim = clock !== realClock;
169
  const machine = new NextDraw({ transport, clock, log: (m) => log(m) });
170
  machine.interactive();
171
  applySettings(machine);
172
  log(isSim ? 'Connecting to simulator…' : 'Connecting…');
173
  const ok = await machine.connect();
174
  if (!ok) {
175
    const code = machine.plotStatus.stopped;
176
    log(`Connection failed${code ? `: ${STOP_CODES[code] ?? code}` : ''}. ${machine.machine.err ?? ''}`, true);
177
    await machine.disconnect().catch(() => {});
178
    return;
179
  }
180
  nd = machine;
181
  log(`Connected: ${nd.params.modelName}, firmware ${nd.fwVersion}. Homed.`);
182
}
183
184
/** Virtual time that still lets the page repaint during long simulations. */
185
function yieldingClock() {
186
  let t = 0;
187
  let last = performance.now();
188
  return {
189
    now: () => t,
190
    async sleep(ms) {
191
      t += Math.max(0, ms);
192
      if (performance.now() - last > 30) {
193
        last = performance.now();
194
        await new Promise((r) => setTimeout(r, 0));
195
      }
196
    },
197
  };
198
}
199
200
$('#connect').addEventListener('click', () => run('Connect', async () => {
201
  let transport;
202
  try {
203
    transport = await WebSerialTransport.request();
204
  } catch {
205
    return; // User closed the chooser.
206
  }
207
  sim = null;
208
  await connectWith(transport, realClock);
209
}));
210
211
$('#simulate').addEventListener('click', () => run('Simulator', async () => {
212
  const model = plotters[Number(readSettings().model)];
213
  const clock = yieldingClock(); // Shared, so the simulated FIFO advances with the driver's time.
214
  sim = new EBBSimulator({ clock, travelX: model.travelX, travelY: model.travelY, startX: 3, startY: 2 });
215
  await connectWith(sim, clock);
216
  if (!nd) sim = null;
217
}));
218
219
$('#disconnect').addEventListener('click', () => run('Disconnect', async () => {
220
  await nd.disconnect();
221
  nd = null;
222
  sim = null;
223
  log('Disconnected.');
224
}));
225
226
if (!webSerialSupported()) {
227
  $('#unsupported').hidden = false;
228
  $('#connect').disabled = true;
229
}
230
navigator.serial?.addEventListener('disconnect', () => {
231
  if (nd && !sim) {
232
    log('NextDraw was unplugged.', true);
233
    nd.requestPause();
234
    nd = null;
235
    setStatus();
236
  }
237
});
238
239
// --- Pen, motors, jog ---
240
241
const actions = {
242
  penup: () => nd.penup(),
243
  pendown: () => nd.pendown(),
244
  cycle: () => nd.cyclePen(),
245
  home: () => nd.walkHome(),
246
  rehome: async () => {
247
    if (await nd.rehome()) log('Homed.');
248
    else log('Homing failed.', true);
249
  },
250
  disable: async () => {
251
    await nd.disableXY();
252
    log('Motors off. Re-home before moving again.');
253
  },
254
};
255
256
for (const b of $$('[data-action]')) {
257
  b.addEventListener('click', () => run(b.textContent, actions[b.dataset.action]));
258
}
259
260
for (const b of $$('[data-jog]')) {
261
  b.addEventListener('click', () => run('Jog', async () => {
262
    const [dx, dy] = b.dataset.jog.split(',').map(Number);
263
    const step = Number($('#jog-step').value);
264
    await nd.move(dx * step, dy * step);
265
  }));
266
}
267
268
// --- Plotting ---
269
270
async function loadFile(file) {
271
  const text = await file.text();
272
  const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
273
  if (doc.querySelector('parsererror') || doc.documentElement.localName !== 'svg') {
274
    throw new Error('Not a valid SVG file.');
275
  }
276
  const result = svgToPolylines(doc, { tolerance: 0.002, autoRotate: $('#rotate').checked });
277
  const ox = Number($('#offset-x').value) || 0;
278
  const oy = Number($('#offset-y').value) || 0;
279
  let polylines = result.polylines.map((p) => p.map(([x, y]) => [x + ox, y + oy]));
280
  if ($('#join').checked) polylines = joinPolylines(polylines);
281
  return { polylines, name: file.name, size: [result.width, result.height], rotated: result.rotated };
282
}
283
284
/** Plot the job on a fresh simulated machine: time estimate plus safety check. */
285
async function dryRun() {
286
  if (!job) return;
287
  const settings = readSettings();
288
  const model = plotters[settings.model];
289
  const clock = yieldingClock();
290
  const s = new EBBSimulator({ clock, travelX: model.travelX, travelY: model.travelY });
291
  const machine = new NextDraw({ transport: s, clock, log: () => {} });
292
  machine.interactive();
293
  Object.assign(machine.options, settings);
294
  $('#plot-info').textContent = 'Checking plot in the simulator…';
295
  const ok = await machine.connect();
296
  const result = ok ? await machine.plotPolylines(job.polylines) : null;
297
  job.dryRun = { violations: s.violations, timeMs: clock.now(), warnings: [...machine.warnings], ok };
298
  const mins = Math.floor(clock.now() / 60000);
299
  const secs = Math.round((clock.now() % 60000) / 1000);
300
  const parts = [`${job.name}: ${job.polylines.length} paths, ${job.size[0].toFixed(2)} × ${job.size[1].toFixed(2)} in` +
301
    `${job.rotated ? ' (rotated)' : ''}.`];
302
  if (!ok || result.stopped) {
303
    parts.push('Simulation failed; not plotting.');
304
    job.dryRun.violations = [...s.violations, { kind: 'sim', detail: 'simulation failed' }];
305
  } else if (s.violations.length) {
306
    parts.push(`Safety check failed (${s.violations[0].kind}: ${s.violations[0].detail}). Plot disabled.`);
307
  } else {
308
    parts.push(`Estimated time ${mins}m ${secs}s. Simulator check passed.`);
309
  }
310
  if (machine.warnings.has('bounds')) parts.push('Parts outside the travel area will be clipped.');
311
  $('#plot-info').textContent = parts.join(' ');
312
  setStatus();
313
}
314
315
async function reloadJob() {
316
  const file = $('#file').files[0];
317
  if (!file) return;
318
  try {
319
    job = await loadFile(file);
320
  } catch (e) {
321
    job = null;
322
    $('#plot-info').textContent = e.message;
323
    setStatus();
324
    return;
325
  }
326
  drawPlan(job.polylines);
327
  drawInk([]);
328
  await dryRun();
329
}
330
331
for (const id of ['#file', '#rotate', '#join', '#offset-x', '#offset-y']) {
332
  $(id).addEventListener('change', reloadJob);
333
}
334
335
$('#plot').addEventListener('click', () => run('Plot', async () => {
336
  const progress = $('#progress');
337
  progress.hidden = false;
338
  progress.value = 0;
339
  if (!sim) drawInk([]);
340
  log(`Plotting ${job.name}…`);
341
  const started = performance.now();
342
  const result = await nd.plotPolylines(job.polylines, {
343
    onProgress: (done, total) => {
344
      progress.value = done / total;
345
      drawCarriage();
346
      if (sim) drawInk(sim.ink);
347
      else drawInk(job.polylines.slice(0, done));
348
    },
349
  });
350
  progress.hidden = true;
351
  if (result.stopped) log(`Plot stopped: ${STOP_CODES[result.stopped] ?? result.stopped}.`, true);
352
  else log(`Plot complete in ${((performance.now() - started) / 1000).toFixed(0)} s.`);
353
}));
354
355
$('#stop').addEventListener('click', () => {
356
  nd?.requestPause();
357
  log('Stopping…');
358
});
359
360
initSettings();
361
setStatus();