| 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();
|