From 228cbf232952c1bea7397b3105d08df8a7f1c452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PROFERIS=20-=20Mi=C2=B3osz=20Stocki?= Date: Tue, 7 Jul 2026 11:54:50 +0200 Subject: [PATCH] Sequence editor. Layout fixes --- TODO.md | 2 +- gemini.md | 15 +- index.html | 19 +- web/assets/backgrounds/default/sequences.json | 35 + .../backgrounds/portrait-right/sequences.json | 14 + web/assets/backgrounds/sequence_spec.md | 54 ++ web/js/quick-sequences.js | 66 ++ web/js/sections/board-control.js | 22 + web/js/sections/home.js | 12 +- web/js/sections/input-text.js | 30 +- web/js/sections/sequence-editor.js | 738 ++++++++++++++++++ web/js/sections/stepper-test.js | 114 +-- web/js/sequence-runner.js | 106 +++ web/styles/components.css | 10 + 14 files changed, 1162 insertions(+), 75 deletions(-) create mode 100644 web/assets/backgrounds/default/sequences.json create mode 100644 web/assets/backgrounds/portrait-right/sequences.json create mode 100644 web/assets/backgrounds/sequence_spec.md create mode 100644 web/js/quick-sequences.js create mode 100644 web/js/sections/sequence-editor.js create mode 100644 web/js/sequence-runner.js diff --git a/TODO.md b/TODO.md index 718a519..3016503 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -- Sequence editor (sequences stored in sequences.json up to boards and in browser local storage for individual boards, manual XY input) +- Sequence editor (sequences stored in sequences.json up to boards and in browser local storage for individual boards, manual XY input). Fully stateless Both of these should also have effects like: Normal movement, erratic, jumpy, random - No Disable steppers but - Idle timeout diff --git a/gemini.md b/gemini.md index 70b3132..e22d3e8 100644 --- a/gemini.md +++ b/gemini.md @@ -374,6 +374,11 @@ The `?v=Date.now()` on imports handles this automatically on page load. - Text input to spell words automatically - Delay, Speed, and Acceleration sliders - Position overlay marker on background +- [x] Sequence Editor section (`sequence-editor.js`) & Runner: + - Vertical list builder for custom sequences (Spots or X/Y). + - JSON export to persist sequences to `sequences.json`. + - Quick Sequences UI in `stepper-test`, `board-control`, and `input-text`. + - Shared `sequence-runner.js` to handle asynchronous execution and delays. - [x] Firmware (`src/main.cpp`) — AccelStepper + BLE command parser + position NOTIFY --- @@ -393,10 +398,11 @@ The router currently only has three routes. These sections need to be created/ex - Requires background `spots.json` to have `"hasAlphabet": true`. - Persisted Speed, Acceleration, and Delay settings. -### `sequence-editor` section (not started) -- Record and replay a sequence of moves -- Save/load sequences to localStorage -- Simple list UI: add step, delete step, reorder, run +### `sequence-editor` section (implemented) +- Record and replay a sequence of moves. +- Draft sequences auto-save to `localStorage`. +- Simple list UI: add Spot step, add XY step, delete step, reorder, play sequence. +- Export as JSON to paste into `web/assets/backgrounds//sequences.json`. To add a new section: 1. Create `web/js/sections/.js` exporting `{ mount(el), unmount() }` @@ -421,6 +427,7 @@ To add a new section: | **`fivebarIKGame.js` is reference only** | The original site's game uses a different angle convention (absolute degrees, different home, with `ikElbowSigns` tracking). We use the C++ IK formula instead because it directly produces step deltas from home. `fivebarIKGame.js` is kept in the repo for reference and understanding, not imported. | | **Hash-based routing** | Keeps the SPA working from `file://` and simple static servers without needing a history API setup | | **Persistent Homing State** | The UI uses `localStorage.getItem('wiji_homed')` to track if the arm has been homed during the user's ongoing interaction. This avoids forcing the user to re-home every time they switch tabs or pages. Sending `HOMEALL` sets this to `true`. | +| **Stateless Sequence Storage** | To remain serverless, sequences are stored per-background in `web/assets/backgrounds//sequences.json`. The Sequence Editor generates the JSON payload, which the user manually copy-pastes into the file to persist. Sequences marked `"favorite": true` appear automatically in Quick Sequences cards across the app. | --- diff --git a/index.html b/index.html index 3630e05..7597861 100644 --- a/index.html +++ b/index.html @@ -46,28 +46,27 @@ Input Text + + + Sequence Editor + + Stepper Test - +
-
- - -
-
+
+
+ Loading sequences... +
+
+ + `; +} + +export async function initQuickSequences(bgName, getSpots, getSteps, onStepComplete) { + const listEl = document.getElementById('quick-sequences-list'); + const stopBtn = document.getElementById('btn-stop-sequence'); + if (!listEl) return; + + try { + const res = await fetch(`web/assets/backgrounds/${bgName}/sequences.json?v=${Date.now()}`); + if (!res.ok) throw new Error('Not found'); + const sequences = await res.json(); + const favorites = sequences.filter(s => s.favorite); + + if (favorites.length === 0) { + listEl.innerHTML = `
No favorite sequences found.
`; + return; + } + + listEl.innerHTML = favorites.map(seq => ` +
+ ${seq.name} + +
+ `).join(''); + + // Attach listeners + listEl.querySelectorAll('.btn-play-seq').forEach(btn => { + btn.addEventListener('click', async () => { + const seq = favorites.find(s => s.id === btn.dataset.seqId); + if (seq) { + stopBtn.style.display = 'block'; + const { steps1, steps2 } = getSteps(); + await SequenceRunner.run(seq, getSpots(), steps1, steps2, onStepComplete); + stopBtn.style.display = 'none'; + } + }); + }); + + stopBtn.addEventListener('click', () => { + SequenceRunner.stop(); + stopBtn.style.display = 'none'; + }); + + } catch (e) { + listEl.innerHTML = `
No sequences.json found for this background.
`; + } +} diff --git a/web/js/sections/board-control.js b/web/js/sections/board-control.js index 459cd03..46c4a90 100644 --- a/web/js/sections/board-control.js +++ b/web/js/sections/board-control.js @@ -2,6 +2,7 @@ import BLE from '../ble.js'; import UI from '../ui.js'; import IK from '../kinematics.js'; import Settings from '../settings.js'; +import { buildQuickSequencesHTML, initQuickSequences } from '../quick-sequences.js'; let eventCleanup = []; let isHomed = false; @@ -147,6 +148,8 @@ function buildHTML() { + ${buildQuickSequencesHTML()} + @@ -317,6 +320,15 @@ export default { currentBg = bgSelect.value; localStorage.setItem('wiji_bg', currentBg); loadSpots(); + initQuickSequences( + currentBg, + () => spots, + () => ({ steps1, steps2 }), + (s1, s2) => { + steps1 = s1; steps2 = s2; + updatePositionReadout(); + } + ); }); } @@ -399,6 +411,16 @@ export default { updatePositionReadout(); } + initQuickSequences( + currentBg, + () => spots, + () => ({ steps1, steps2 }), + (s1, s2) => { + steps1 = s1; steps2 = s2; + updatePositionReadout(); + } + ); + eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus)); }, diff --git a/web/js/sections/home.js b/web/js/sections/home.js index 1f0e5e7..ebff574 100644 --- a/web/js/sections/home.js +++ b/web/js/sections/home.js @@ -81,9 +81,19 @@ const HomeSection = { + + + ${buildQuickSequencesHTML()} + @@ -362,6 +365,15 @@ export default { currentBg = bgSelect.value; localStorage.setItem('wiji_input_bg', currentBg); loadSpots(); + initQuickSequences( + currentBg, + () => spots, + () => ({ steps1, steps2 }), + (s1, s2) => { + steps1 = s1; steps2 = s2; + updatePosMarker(); + } + ); }); } @@ -384,7 +396,7 @@ export default { sDelay.addEventListener('input', (e) => { Settings.delay = parseInt(e.target.value); - document.getElementById('delay-val').textContent = (Settings.delay/1000).toFixed(1); + document.getElementById('delay-val').textContent = Settings.delay; }); // ── Homing Logic ────────────────────────────────────────── @@ -450,6 +462,16 @@ export default { updatePosMarker(); } + initQuickSequences( + currentBg, + () => spots, + () => ({ steps1, steps2 }), + (s1, s2) => { + steps1 = s1; steps2 = s2; + updatePosMarker(); + } + ); + eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus)); }, diff --git a/web/js/sections/sequence-editor.js b/web/js/sections/sequence-editor.js new file mode 100644 index 0000000..4ae6aa7 --- /dev/null +++ b/web/js/sections/sequence-editor.js @@ -0,0 +1,738 @@ +import BLE from '../ble.js'; +import UI from '../ui.js'; +import IK from '../kinematics.js'; +import Settings from '../settings.js'; +import SequenceRunner from '../sequence-runner.js'; + +let eventCleanup = []; +let isHomed = false; +let spots = []; +let bounds = { xMin: -150, xMax: 150, yMin: -30, yMax: 145 }; +let currentBg = localStorage.getItem('wiji_bg') || 'default'; +let steps1 = 0; +let steps2 = 0; +let hasAlphabet = false; +let savedBrowserSequences = []; +let bgSequences = []; +let sequence = { + id: "my_custom_sequence", + name: "My Sequence", + favorite: false, + steps: [] +}; + +function syncStateFromStorage() { + isHomed = localStorage.getItem('wiji_homed') === 'true'; + steps1 = isHomed ? parseInt(localStorage.getItem('wiji_steps1') || IK.ARM.HOME_STEPS.m1) : IK.ARM.HOME_STEPS.m1; + steps2 = isHomed ? parseInt(localStorage.getItem('wiji_steps2') || IK.ARM.HOME_STEPS.m2) : IK.ARM.HOME_STEPS.m2; + + const savedSeq = localStorage.getItem('wiji_sequence_draft'); + if (savedSeq) { + try { + sequence = JSON.parse(savedSeq); + } catch(e) {} + } + const savedSeqs = localStorage.getItem('wiji_saved_sequences'); + if (savedSeqs) { + try { + savedBrowserSequences = JSON.parse(savedSeqs); + } catch(e) {} + } +} + +function saveSteps() { + if (isHomed) { + localStorage.setItem('wiji_steps1', steps1); + localStorage.setItem('wiji_steps2', steps2); + } +} + +function saveSequenceDraft() { + localStorage.setItem('wiji_sequence_draft', JSON.stringify(sequence)); + updateJSONPreview(); +} + +function updateJSONPreview() { + const el = document.getElementById('json-preview'); + if (el) { + el.value = JSON.stringify([sequence], null, 2); + } +} + +function saveToBrowser() { + const existingIdx = savedBrowserSequences.findIndex(s => s.id === sequence.id); + if (existingIdx >= 0) { + savedBrowserSequences[existingIdx] = JSON.parse(JSON.stringify(sequence)); + } else { + savedBrowserSequences.push(JSON.parse(JSON.stringify(sequence))); + } + localStorage.setItem('wiji_saved_sequences', JSON.stringify(savedBrowserSequences)); + renderSavedSequences(); + UI.log(`Saved '${sequence.name}' to browser storage`, 'success'); +} + +function buildHTML() { + return ` + + +
+
+

Sequence Editor

+

Build and test custom sequences. Export JSON to save them permanently.

+
+ +
+ +
+ +
+
+ Sequence Steps +
+ + + +
+
+ + +
+ + + +
+ + +
+ +
+ + +
+
Add Step
+ +
+ + + +
+ +
+ +
+ + + + +
+ + + + +
+
+ + +
+
+ JSON Export + +
+ +
+ Copy this JSON array into web/assets/backgrounds/${currentBg}/sequences.json to persist it. +
+
+
+ + +
+ + +
+
+ System Status +
+
X (mm)
0.0
+
Y (mm)
0.0
+
+
+
+ ${isHomed ? '✓ HOMED' : '⚠ NOT HOMED! Movement Locked.'} +
+ +
+ + +
+
Motion Settings
+ +
+ + +
Provides available spots for the editor.
+
+ +
+
+ + ${Settings.speed} +
+ + +
+ + ${Settings.accel} +
+ + +
+ + ${Settings.delay} +
+ +
+
+ + +
+
Saved Sequences
+
+ +
+
+
+
+
+ `; +} + +function updatePositionReadout() { + const t1 = IK.stepsToRad(steps1); + const t2 = IK.stepsToRad(steps2); + const { endX, endY } = IK.forward(t1, t2); + const px = document.getElementById('pos-x'); + const py = document.getElementById('pos-y'); + if (px) px.textContent = isFinite(endX) ? endX.toFixed(1) : '---'; + if (py) py.textContent = isFinite(endY) ? endY.toFixed(1) : '---'; +} + +function updateHomingUI() { + const banner = document.getElementById('homing-banner'); + const btn = document.getElementById('btn-force-home'); + if (!banner || !btn) return; + + if (isHomed) { + banner.className = 'homing-banner homed'; + banner.textContent = '✓ HOMED'; + btn.textContent = 'FORCE HOME ALL'; + } else { + banner.className = 'homing-banner not-homed'; + banner.textContent = '⚠ NOT HOMED! Movement Locked.'; + btn.textContent = 'HOME ALL MOTORS'; + } +} + +function renderStepsList() { + const container = document.getElementById('steps-list'); + if (!container) return; + + if (sequence.steps.length === 0) { + container.innerHTML = `
No steps added yet.
`; + return; + } + + container.innerHTML = sequence.steps.map((step, idx) => { + let label = ''; + let details = ''; + if (step.type === 'spot') { + label = `Spot: ${step.id}`; + } else { + label = `XY: (${step.x}, ${step.y})`; + } + + if (step.delay !== undefined) { + details = `Delay: ${step.delay}ms`; + } + + return ` +
+
+ #${idx+1} + ${label} ${details} +
+
+ + + +
+
+ `; + }).join(''); + + // Attach listeners + container.querySelectorAll('.btn-move-up').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx); + if (idx > 0) { + [sequence.steps[idx - 1], sequence.steps[idx]] = [sequence.steps[idx], sequence.steps[idx - 1]]; + saveSequenceDraft(); + renderStepsList(); + } + }); + }); + + container.querySelectorAll('.btn-move-down').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx); + if (idx < sequence.steps.length - 1) { + [sequence.steps[idx + 1], sequence.steps[idx]] = [sequence.steps[idx], sequence.steps[idx + 1]]; + saveSequenceDraft(); + renderStepsList(); + } + }); + }); + + container.querySelectorAll('.btn-delete-step').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx); + sequence.steps.splice(idx, 1); + saveSequenceDraft(); + renderStepsList(); + }); + }); +} + +async function loadSpots() { + try { + const res = await fetch(`web/assets/backgrounds/${currentBg}/spots.json?v=${Date.now()}`); + const data = await res.json(); + if (Array.isArray(data)) { + spots = data; + hasAlphabet = false; + } else { + spots = data.spots || []; + if (data.bounds) bounds = data.bounds; + hasAlphabet = !!data.hasAlphabet; + } + + document.getElementById('text-input-row').style.display = hasAlphabet ? 'grid' : 'none'; + document.getElementById('text-input-divider').style.display = hasAlphabet ? 'block' : 'none'; + + // Populate spot dropdown + const select = document.getElementById('editor-spot-select'); + if (select) { + select.innerHTML = '' + spots.map(s => + `` + ).join(''); + } + + updateJSONPreview(); + await loadBgSequences(); + } catch(e) { + UI.log('Failed to load spots.json', 'error'); + } +} + +async function loadBgSequences() { + try { + const res = await fetch(`web/assets/backgrounds/${currentBg}/sequences.json?v=${Date.now()}`); + if (res.ok) { + bgSequences = await res.json(); + } else { + bgSequences = []; + } + } catch (e) { + bgSequences = []; + } + renderSavedSequences(); +} + +function renderSavedSequences() { + const container = document.getElementById('saved-seq-list'); + if (!container) return; + + let html = ''; + + if (bgSequences.length > 0) { + html += `
From sequences.json
`; + html += bgSequences.map((s, idx) => ` +
+ ${s.name} + +
+ `).join(''); + } + + if (savedBrowserSequences.length > 0) { + html += `
From Browser Storage
`; + html += savedBrowserSequences.map((s, idx) => ` +
+ ${s.name} +
+ + +
+
+ `).join(''); + } + + if (!html) { + html = `
No saved sequences
`; + } + + container.innerHTML = html; + + container.querySelectorAll('.btn-load-seq').forEach(btn => { + btn.addEventListener('click', () => { + const source = btn.dataset.source; + const idx = parseInt(btn.dataset.idx); + const srcObj = source === 'bg' ? bgSequences[idx] : savedBrowserSequences[idx]; + if (srcObj) { + sequence = JSON.parse(JSON.stringify(srcObj)); + document.getElementById('seq-id').value = sequence.id; + document.getElementById('seq-name').value = sequence.name; + const favCheckbox = document.getElementById('seq-favorite'); + if (favCheckbox) favCheckbox.checked = !!sequence.favorite; + saveSequenceDraft(); + renderStepsList(); + UI.log(`Loaded sequence '${sequence.name}'`, 'info'); + } + }); + }); + + container.querySelectorAll('.btn-delete-seq').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx); + if (confirm('Delete this sequence from browser storage?')) { + savedBrowserSequences.splice(idx, 1); + localStorage.setItem('wiji_saved_sequences', JSON.stringify(savedBrowserSequences)); + renderSavedSequences(); + } + }); + }); +} + +async function sendSettings() { + if (!BLE.isConnected()) return; + try { + await BLE.write(`SPD:${Settings.speed}`); + await BLE.write(`ACC:${Settings.accel}`); + } catch (e) { + console.error('Failed to send settings', e); + } +} + +export default { + mount(container) { + syncStateFromStorage(); + container.innerHTML = buildHTML(); + + updateHomingUI(); + loadSpots(); + renderStepsList(); + + // ── Input bindings for metadata ────────────────────────────── + document.getElementById('seq-id').addEventListener('input', (e) => { + sequence.id = e.target.value; + saveSequenceDraft(); + }); + document.getElementById('seq-name').addEventListener('input', (e) => { + sequence.name = e.target.value; + saveSequenceDraft(); + }); + const favCheckbox = document.getElementById('seq-favorite'); + if (favCheckbox) { + favCheckbox.addEventListener('change', (e) => { + sequence.favorite = e.target.checked; + saveSequenceDraft(); + }); + } + + // ── Background Selection ────────────────────────────────── + const bgSelect = document.getElementById('bg-select'); + if (bgSelect) { + bgSelect.value = currentBg; + bgSelect.addEventListener('change', () => { + currentBg = bgSelect.value; + localStorage.setItem('wiji_bg', currentBg); + loadSpots(); + }); + } + + // ── Settings Sliders ────────────────────────────────────── + const sSpeed = document.getElementById('slider-speed'); + const sAccel = document.getElementById('slider-accel'); + const sDelay = document.getElementById('slider-delay'); + + sSpeed.addEventListener('input', (e) => { + Settings.speed = parseInt(e.target.value); + document.getElementById('speed-val').textContent = Settings.speed; + sendSettings(); + }); + + sAccel.addEventListener('input', (e) => { + Settings.accel = parseInt(e.target.value); + document.getElementById('accel-val').textContent = Settings.accel; + sendSettings(); + }); + + sDelay.addEventListener('input', (e) => { + Settings.delay = parseInt(e.target.value); + document.getElementById('delay-val').textContent = Settings.delay; + }); + + // ── Add Steps Logic ─────────────────────────────────────── + document.getElementById('btn-add-spot').addEventListener('click', () => { + const select = document.getElementById('editor-spot-select'); + const delayInput = document.getElementById('editor-spot-delay'); + const spotId = select.value; + const delay = parseInt(delayInput.value); + + if (spotId) { + const step = { type: 'spot', id: spotId }; + if (!isNaN(delay) && delay >= 0) step.delay = delay; + sequence.steps.push(step); + saveSequenceDraft(); + renderStepsList(); + select.value = ""; + delayInput.value = ""; + } + }); + + document.getElementById('btn-add-xy').addEventListener('click', () => { + const xInput = document.getElementById('editor-x'); + const yInput = document.getElementById('editor-y'); + const delayInput = document.getElementById('editor-xy-delay'); + + const x = parseFloat(xInput.value); + const y = parseFloat(yInput.value); + const delay = parseInt(delayInput.value); + + if (!isNaN(x) && !isNaN(y)) { + const step = { type: 'xy', x, y }; + if (!isNaN(delay) && delay >= 0) { + step.delay = delay; + } + sequence.steps.push(step); + saveSequenceDraft(); + renderStepsList(); + xInput.value = ""; + yInput.value = ""; + delayInput.value = ""; + } + }); + + document.getElementById('btn-add-text').addEventListener('click', () => { + const textInput = document.getElementById('editor-text'); + const delayInput = document.getElementById('editor-text-delay'); + const text = textInput.value.trim().toUpperCase(); + const delay = parseInt(delayInput.value); + + if (text) { + const chars = Array.from(text); + let addedCount = 0; + for (const char of chars) { + const spot = spots.find(s => + (s.label && s.label.toUpperCase() === char) || + (s.id && s.id.toUpperCase() === char) + ); + if (spot) { + const step = { type: 'spot', id: spot.id }; + if (!isNaN(delay) && delay >= 0) step.delay = delay; + sequence.steps.push(step); + addedCount++; + } + } + if (addedCount > 0) { + saveSequenceDraft(); + renderStepsList(); + UI.log(`Added ${addedCount} letter spots`, 'success'); + } else { + UI.log('No spots found for entered text', 'warn'); + } + textInput.value = ""; + delayInput.value = ""; + } + }); + + document.getElementById('btn-clear-seq').addEventListener('click', () => { + if (confirm('Are you sure you want to clear this sequence?')) { + sequence.steps = []; + saveSequenceDraft(); + renderStepsList(); + } + }); + + document.getElementById('btn-save-browser').addEventListener('click', () => { + saveToBrowser(); + }); + + // ── Sequence Execution ──────────────────────────────────── + const btnPlay = document.getElementById('btn-play-editor'); + const btnStop = document.getElementById('btn-stop-editor'); + + btnPlay.addEventListener('click', async () => { + if (!isHomed) { + UI.log('Cannot move: System is not homed!', 'error'); + return; + } + if (sequence.steps.length === 0) return; + + btnPlay.style.display = 'none'; + btnStop.style.display = 'inline-block'; + + await sendSettings(); + await SequenceRunner.run(sequence, spots, steps1, steps2, (s1, s2) => { + steps1 = s1; steps2 = s2; + updatePositionReadout(); + }); + + btnPlay.style.display = 'inline-block'; + btnStop.style.display = 'none'; + }); + + btnStop.addEventListener('click', () => { + SequenceRunner.stop(); + }); + + // ── Homing Logic ────────────────────────────────────────── + document.getElementById('btn-force-home').addEventListener('click', async () => { + isHomed = true; + localStorage.setItem('wiji_homed', 'true'); + updateHomingUI(); + + steps1 = IK.ARM.HOME_STEPS.m1; + steps2 = IK.ARM.HOME_STEPS.m2; + saveSteps(); + updatePositionReadout(); + + UI.log('Homing all motors...', 'info'); + if (BLE.isConnected()) { + try { + await BLE.write('HOMEALL'); + } catch (e) { + UI.log(e.message, 'error'); + } + } else { + UI.log('[sim] HOMEALL', 'info'); + } + }); + + // ── Sync Steps from BLE ─────────────────────────────────── + const onBLEStatus = (e) => { + const msg = e.detail; + if (msg.startsWith('P:')) { + const [s1, s2] = msg.slice(2).split(',').map(Number); + steps1 = s1; steps2 = s2; + saveSteps(); + updatePositionReadout(); + } + }; + document.addEventListener('ble:status', onBLEStatus); + + if (BLE.isConnected()) { + BLE.write('POS').catch(() => {}); + sendSettings(); + } else { + updatePositionReadout(); + } + + eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus)); + }, + + unmount() { + SequenceRunner.stop(); + eventCleanup.forEach(fn => fn()); + eventCleanup = []; + } +}; diff --git a/web/js/sections/stepper-test.js b/web/js/sections/stepper-test.js index 71fecc5..a58242a 100644 --- a/web/js/sections/stepper-test.js +++ b/web/js/sections/stepper-test.js @@ -13,8 +13,8 @@ */ import BLE from '../ble.js'; -import IK from '../kinematics.js'; -import UI from '../ui.js'; +import IK from '../kinematics.js'; +import UI from '../ui.js'; // ── SVG viewport ────────────────────────────────────────────────── // We map the real workspace onto the SVG canvas. @@ -96,37 +96,37 @@ function renderArm(theta1, theta2, isGhost = false) { // Motor base SVG positions — M1 at +d2, M2 at -d2 (PositionControl.cpp convention) const motor1sx = SV.wx(+IK.ARM.d2); const motor1sy = SV.wy(0); const motor2sx = SV.wx(-IK.ARM.d2); const motor2sy = SV.wy(0); - const elbow1sx = SV.wx(e1.x); const elbow1sy = SV.wy(e1.y); - const elbow2sx = SV.wx(e2.x); const elbow2sy = SV.wy(e2.y); - const endsx = SV.wx(endX); const endsy = SV.wy(endY); + const elbow1sx = SV.wx(e1.x); const elbow1sy = SV.wy(e1.y); + const elbow2sx = SV.wx(e2.x); const elbow2sy = SV.wy(e2.y); + const endsx = SV.wx(endX); const endsy = SV.wy(endY); // Guard: forward() can return NaN for degenerate angles – skip render if (!isFinite(endX) || !isFinite(endY)) return { endsx: SV.OX, endsy: SV.OY }; if (isGhost) { setLine(ghost1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy); - setLine(ghost1Dist, elbow1sx, elbow1sy, endsx, endsy); + setLine(ghost1Dist, elbow1sx, elbow1sy, endsx, endsy); setLine(ghost2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy); - setLine(ghost2Dist, elbow2sx, elbow2sy, endsx, endsy); + setLine(ghost2Dist, elbow2sx, elbow2sy, endsx, endsy); setCircle(ghostElbow1, elbow1sx, elbow1sy); setCircle(ghostElbow2, elbow2sx, elbow2sy); - setCircle(ghostEnd, endsx, endsy); + setCircle(ghostEnd, endsx, endsy); ghostLabel.setAttribute('x', endsx + 10); ghostLabel.setAttribute('y', endsy); } else { setLine(arm1Prox, motor1sx, motor1sy, elbow1sx, elbow1sy); - setLine(arm1Dist, elbow1sx, elbow1sy, endsx, endsy); + setLine(arm1Dist, elbow1sx, elbow1sy, endsx, endsy); setLine(arm2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy); - setLine(arm2Dist, elbow2sx, elbow2sy, endsx, endsy); + setLine(arm2Dist, elbow2sx, elbow2sy, endsx, endsy); setCircle(elbow1, elbow1sx, elbow1sy); setCircle(elbow2, elbow2sx, elbow2sy); - setCircle(endEff, endsx, endsy); + setCircle(endEff, endsx, endsy); // Crosshairs xhH.setAttribute('x1', endsx - 13); xhH.setAttribute('y1', endsy); xhH.setAttribute('x2', endsx + 13); xhH.setAttribute('y2', endsy); - xhV.setAttribute('x1', endsx); xhV.setAttribute('y1', endsy - 13); - xhV.setAttribute('x2', endsx); xhV.setAttribute('y2', endsy + 13); + xhV.setAttribute('x1', endsx); xhV.setAttribute('y1', endsy - 13); + xhV.setAttribute('x2', endsx); xhV.setAttribute('y2', endsy + 13); coordLabel.textContent = `(${endX.toFixed(1)}, ${endY.toFixed(1)}) mm`; updateReadouts(theta1, theta2, endX, endY); @@ -142,8 +142,8 @@ function renderArmFromSteps() { } function updateReadouts(t1, t2, ex, ey) { - if (elCurrX) elCurrX.textContent = isFinite(ex) ? ex.toFixed(1) : '–'; - if (elCurrY) elCurrY.textContent = isFinite(ey) ? ey.toFixed(1) : '–'; + if (elCurrX) elCurrX.textContent = isFinite(ex) ? ex.toFixed(1) : '–'; + if (elCurrY) elCurrY.textContent = isFinite(ey) ? ey.toFixed(1) : '–'; if (elTheta1) elTheta1.textContent = IK.radToDeg(t1).toFixed(1) + '°'; if (elTheta2) elTheta2.textContent = IK.radToDeg(t2).toFixed(1) + '°'; if (elSteps1) elSteps1.textContent = steps1; @@ -154,14 +154,14 @@ function updateReadouts(t1, t2, ex, ey) { // ── Ghost arm show/hide ─────────────────────────────────────────── function showGhost(theta1, theta2, labelText) { const ghosts = [ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist, - ghostElbow1, ghostElbow2, ghostEnd, ghostLabel]; + ghostElbow1, ghostElbow2, ghostEnd, ghostLabel]; ghosts.forEach(el => el.style.display = ''); renderArm(theta1, theta2, true); ghostLabel.textContent = labelText; } function hideGhost() { [ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist, - ghostElbow1, ghostElbow2, ghostEnd, ghostLabel] + ghostElbow1, ghostElbow2, ghostEnd, ghostLabel] .forEach(el => el.style.display = 'none'); } @@ -220,7 +220,7 @@ async function moveToXY(targetX, targetY) { async function jogMotor(motor, delta) { if (motor === 1) steps1 += delta; - else steps2 += delta; + else steps2 += delta; renderArmFromSteps(); const cmd = `S${motor}${delta >= 0 ? '+' : ''}${delta}`; BLE.isConnected() @@ -270,7 +270,7 @@ function buildZones() { // Motor positions — M1 at +d2, M2 at -d2 const m1x = SV.wx(+IK.ARM.d2); const m2x = SV.wx(-IK.ARM.d2); - const my = SV.wy(0); + const my = SV.wy(0); // Scale ruler const r0x = SV.wx(0); const r0y = SV.wy(-20); @@ -288,7 +288,7 @@ function buildZones() { fill="rgba(255,82,82,0.12)" stroke="rgba(255,82,82,0.55)" stroke-width="1.5" rx="3"/> - NO-GO ZONE @@ -310,7 +310,7 @@ function buildZones() { stroke="rgba(255,255,255,0.25)" stroke-width="1"/> - 50 mm @@ -372,7 +372,9 @@ function buildHTML() { .quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem; border-radius:var(--radius-xs); } - + @media (max-width: 600px) { + .jog-compact { grid-template-columns: 1fr; } + }
@@ -586,8 +588,8 @@ function buildHTML() {
- ${[10,50,100,512].map(n=>``).join('')} - ${[10,50,100,512].map(n=>``).join('')} + ${[10, 50, 100, 512].map(n => ``).join('')} + ${[10, 50, 100, 512].map(n => ``).join('')}
@@ -602,8 +604,8 @@ function buildHTML() {
- ${[10,50,100,512].map(n=>``).join('')} - ${[10,50,100,512].map(n=>``).join('')} + ${[10, 50, 100, 512].map(n => ``).join('')} + ${[10, 50, 100, 512].map(n => ``).join('')}
@@ -635,30 +637,30 @@ const StepperTestSection = { container.innerHTML = buildHTML(); // ── Cache refs ────────────────────────────────────────────── - svgEl = document.getElementById('scara-svg'); - arm1Prox = document.getElementById('arm1-prox'); - arm1Dist = document.getElementById('arm1-dist'); - arm2Prox = document.getElementById('arm2-prox'); - arm2Dist = document.getElementById('arm2-dist'); - elbow1 = document.getElementById('elbow1'); - elbow2 = document.getElementById('elbow2'); - endEff = document.getElementById('end-eff'); - coordLabel= document.getElementById('coord-label'); - xhH = document.getElementById('xh-h'); - xhV = document.getElementById('xh-v'); + svgEl = document.getElementById('scara-svg'); + arm1Prox = document.getElementById('arm1-prox'); + arm1Dist = document.getElementById('arm1-dist'); + arm2Prox = document.getElementById('arm2-prox'); + arm2Dist = document.getElementById('arm2-dist'); + elbow1 = document.getElementById('elbow1'); + elbow2 = document.getElementById('elbow2'); + endEff = document.getElementById('end-eff'); + coordLabel = document.getElementById('coord-label'); + xhH = document.getElementById('xh-h'); + xhV = document.getElementById('xh-v'); - ghost1Prox = document.getElementById('ghost-1p'); - ghost1Dist = document.getElementById('ghost-1d'); - ghost2Prox = document.getElementById('ghost-2p'); - ghost2Dist = document.getElementById('ghost-2d'); + ghost1Prox = document.getElementById('ghost-1p'); + ghost1Dist = document.getElementById('ghost-1d'); + ghost2Prox = document.getElementById('ghost-2p'); + ghost2Dist = document.getElementById('ghost-2d'); ghostElbow1 = document.getElementById('ghost-e1'); ghostElbow2 = document.getElementById('ghost-e2'); - ghostEnd = document.getElementById('ghost-e1'); - ghostLabel = document.getElementById('ghost-label'); - targetMarker= document.getElementById('target-marker'); + ghostEnd = document.getElementById('ghost-e1'); + ghostLabel = document.getElementById('ghost-label'); + targetMarker = document.getElementById('target-marker'); - elCurrX = document.getElementById('curr-x'); - elCurrY = document.getElementById('curr-y'); + elCurrX = document.getElementById('curr-x'); + elCurrY = document.getElementById('curr-y'); elTheta1 = document.getElementById('curr-t1'); elTheta2 = document.getElementById('curr-t2'); elSteps1 = document.getElementById('curr-s1'); @@ -669,7 +671,7 @@ const StepperTestSection = { function pointerToWorld(e) { const rect = svgEl.getBoundingClientRect(); const sx = (e.clientX - rect.left) * (SV.W / rect.width); - const sy = (e.clientY - rect.top) * (SV.H / rect.height); + const sy = (e.clientY - rect.top) * (SV.H / rect.height); return { wx: SV.svgToWorldX(sx), wy: SV.svgToWorldY(sy), sx, sy }; } @@ -717,9 +719,9 @@ const StepperTestSection = { // ── Jog CW / CCW ──────────────────────────────────────────── const gs = (n) => parseInt(document.getElementById(`jog-steps-${n}`)?.value ?? '50', 10) || 50; - document.getElementById('s1-cw') .addEventListener('click', () => jogMotor(1, +gs(1))); + document.getElementById('s1-cw').addEventListener('click', () => jogMotor(1, +gs(1))); document.getElementById('s1-ccw').addEventListener('click', () => jogMotor(1, -gs(1))); - document.getElementById('s2-cw') .addEventListener('click', () => jogMotor(2, +gs(2))); + document.getElementById('s2-cw').addEventListener('click', () => jogMotor(2, +gs(2))); document.getElementById('s2-ccw').addEventListener('click', () => jogMotor(2, -gs(2))); document.getElementById('s1-zero').addEventListener('click', () => zeroMotor(1)); document.getElementById('s2-zero').addEventListener('click', () => zeroMotor(2)); @@ -738,7 +740,7 @@ const StepperTestSection = { clearTimeout(spdT); spdT = setTimeout(() => { const cmd = `SPD:${e.target.value}`; - BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info'); + BLE.isConnected() ? BLE.write(cmd).catch(() => { }) : UI.log(`[sim] ${cmd}`, 'info'); }, 400); }); document.getElementById('accel-slider').addEventListener('input', (e) => { @@ -746,7 +748,7 @@ const StepperTestSection = { clearTimeout(accT); accT = setTimeout(() => { const cmd = `ACC:${e.target.value}`; - BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info'); + BLE.isConnected() ? BLE.write(cmd).catch(() => { }) : UI.log(`[sim] ${cmd}`, 'info'); }, 400); }); @@ -757,12 +759,12 @@ const StepperTestSection = { function updateBadge() { if (!simBadge) return; const live = BLE.isConnected(); - simBadge.textContent = live ? 'LIVE' : 'SIMULATION'; + simBadge.textContent = live ? 'LIVE' : 'SIMULATION'; simBadge.style.background = live ? 'rgba(0,230,118,0.12)' : 'rgba(255,202,40,0.12)'; - simBadge.style.color = live ? 'var(--accent-green)' : 'var(--accent-amber)'; - simBadge.style.border = live ? '1px solid rgba(0,230,118,0.3)' : '1px solid rgba(255,202,40,0.25)'; + simBadge.style.color = live ? 'var(--accent-green)' : 'var(--accent-amber)'; + simBadge.style.border = live ? '1px solid rgba(0,230,118,0.3)' : '1px solid rgba(255,202,40,0.25)'; } - BLE.on('connected', updateBadge); + BLE.on('connected', updateBadge); BLE.on('disconnected', updateBadge); updateBadge(); @@ -778,7 +780,7 @@ const StepperTestSection = { document.addEventListener('ble:status', onBLEStatus); eventCleanup = [ - () => BLE.off('connected', updateBadge), + () => BLE.off('connected', updateBadge), () => BLE.off('disconnected', updateBadge), () => document.removeEventListener('ble:status', onBLEStatus), ]; diff --git a/web/js/sequence-runner.js b/web/js/sequence-runner.js new file mode 100644 index 0000000..303b12d --- /dev/null +++ b/web/js/sequence-runner.js @@ -0,0 +1,106 @@ +import BLE from './ble.js'; +import UI from './ui.js'; +import IK from './kinematics.js'; +import Settings from './settings.js'; + +let isRunning = false; + +function delayMs(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export default { + get isRunning() { return isRunning; }, + stop() { isRunning = false; }, + + async run(sequence, spotsData, currentSteps1, currentSteps2, onStepComplete) { + if (isRunning) { + UI.log('A sequence is already running.', 'warn'); + return false; + } + + isRunning = true; + UI.log(`Running sequence: ${sequence.name}`, 'info'); + + let steps1 = currentSteps1; + let steps2 = currentSteps2; + + for (let i = 0; i < sequence.steps.length; i++) { + if (!isRunning) { + UI.log('Sequence stopped.', 'info'); + break; + } + + const step = sequence.steps[i]; + let targetX, targetY, label; + + if (step.type === 'spot') { + const spot = spotsData.find(s => s.id === step.id); + if (!spot) { + UI.log(`Sequence error: Spot '${step.id}' not found. Skipping.`, 'error'); + continue; + } + targetX = spot.x; + targetY = spot.y; + label = spot.label || spot.id; + } else if (step.type === 'xy') { + targetX = step.x; + targetY = step.y; + label = `(${targetX}, ${targetY})`; + } else { + UI.log(`Unknown step type: ${step.type}`, 'error'); + continue; + } + + const wsCheck = IK.checkWorkspace(targetX, targetY); + if (!wsCheck.ok) { + UI.log(`⛔ ${wsCheck.reason} [${label}]`, 'error'); + continue; // or break? let's continue + } + + const res = IK.solve(targetX, targetY); + if (!res.reachable || IK.armsCrossed(res.theta1, res.theta2)) { + UI.log(`Target ${label} is unreachable or crosses arms.`, 'error'); + continue; + } + + const newSteps1 = IK.radToSteps(res.theta1); + const newSteps2 = IK.radToSteps(res.theta2); + const delta1 = newSteps1 - steps1; + const delta2 = newSteps2 - steps2; + + steps1 = newSteps1; + steps2 = newSteps2; + + if (onStepComplete) { + onStepComplete(steps1, steps2); + } + + const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`; + const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`; + + UI.log(`Seq step: ${label}`, 'info'); + + if (BLE.isConnected()) { + try { + await BLE.write(cmd1); + await BLE.write(cmd2); + } catch (e) { + UI.log(`BLE error: ${e.message}`, 'error'); + isRunning = false; + return false; + } + } else { + console.log(`[sim] ${cmd1} ${cmd2}`); + } + + // Wait for delay + const waitTime = typeof step.delay === 'number' ? step.delay : Settings.delay; + await delayMs(waitTime); + } + + isRunning = false; + UI.log(`Sequence completed.`, 'success'); + return true; + } +}; diff --git a/web/styles/components.css b/web/styles/components.css index bb877ed..d11b15a 100644 --- a/web/styles/components.css +++ b/web/styles/components.css @@ -58,6 +58,14 @@ gap: 10px; } +@media (max-width: 480px) { + .topbar { padding: 0 10px; gap: 8px; } + .topbar-brand { gap: 6px; font-size: 0.9rem; } + .topbar-actions { gap: 6px; } + #ble-label { display: none; } + .topbar-brand .brand-icon { width: 24px; height: 24px; } +} + /* ── Hamburger button ────────────────────────────────────────── */ .hamburger { display: flex; @@ -447,6 +455,8 @@ letter-spacing: 0.08em; } .form-input { + width: 100%; + min-width: 0; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);