Initial commit

This commit is contained in:
PROFERIS - Mi³osz Stocki
2026-07-06 11:34:03 +02:00
commit 67b4eda6aa
12 changed files with 2735 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* WijiBoard Home Section
* web/js/sections/home.js
*
* A minimal placeholder home section.
*/
import BLE from '../ble.js';
const HomeSection = {
_cleanup: [],
mount(container) {
container.innerHTML = `
<div class="section-page fade-in">
<div class="section-header">
<h2>WijiBoard</h2>
<p>WiFi Spirit Board — SCARA arm controller. Select a section from the sidebar to get started.</p>
</div>
<div class="grid-2" style="margin-bottom:16px">
<!-- Status card -->
<div class="card">
<div class="card-header">
<span class="card-title">Connection</span>
</div>
<div class="status-row" style="margin-bottom:10px">
<span class="status-label">BLE</span>
<span class="status-value" id="home-ble-status">
${BLE.isConnected() ? `${BLE.getDeviceName()}` : 'Not connected'}
</span>
</div>
<p style="font-size:0.8rem;color:var(--text-muted)">
Use the <strong style="color:var(--text-secondary)">Connect</strong> button
in the top bar to pair with the WijiBoard via Bluetooth.
The device name is <code style="color:var(--accent-blue)">WijiBoard</code>.
</p>
</div>
<!-- About card -->
<div class="card">
<div class="card-header">
<span class="card-title">About</span>
</div>
<p style="font-size:0.82rem;line-height:1.8;color:var(--text-secondary)">
This dashboard controls a two-arm SCARA robot that moves a planchette
across a spirit board. It uses <strong style="color:var(--text-primary)">Web Bluetooth</strong>
to communicate with an ESP32-S3 running two 28BYJ-48 stepper motors
with AccelStepper.
</p>
</div>
</div>
<!-- Sections overview -->
<div class="card">
<div class="card-header">
<span class="card-title">Sections</span>
</div>
<div class="grid-2">
<button class="sidebar-nav-item" data-route="stepper-test"
onclick="document.getElementById('nav-stepper-test').click()"
style="padding:14px 16px;background:var(--surface-2)">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
<div>
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Stepper Test</div>
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Manually jog each motor, visualize arm position</div>
</div>
</button>
<div class="sidebar-nav-item" style="padding:14px 16px;background:var(--surface-2);opacity:0.45;cursor:default">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<div>
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Board Control</div>
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Coming soon</div>
</div>
</div>
</div>
</div>
</div>
`;
// Keep home BLE status label in sync
const statusEl = container.querySelector('#home-ble-status');
const update = () => {
if (statusEl) statusEl.textContent = BLE.isConnected()
? `${BLE.getDeviceName()}`
: 'Not connected';
};
BLE.on('connected', update);
BLE.on('disconnected', update);
this._cleanup = [
() => BLE.off('connected', update),
() => BLE.off('disconnected', update),
];
},
unmount() {
this._cleanup.forEach(fn => fn());
this._cleanup = [];
},
};
export default HomeSection;
+766
View File
@@ -0,0 +1,766 @@
/**
* WijiBoard Stepper Test Section
* web/js/sections/stepper-test.js
*
* 5-bar parallel linkage visualiser + IK-based click-to-move.
*
* Mechanism geometry (from PositionControl.cpp):
* Motor 1 at (-12.9, 0) mm Motor 2 at (+12.9, 0) mm
* Proximal links: l1 = 85 mm Distal links: l2 = 110 mm
*
* IK flow: click XY → IK.solve(x,y) → {θ1, θ2} → steps → BLE
* FK flow: steps → radians → IK.forward(θ1, θ2) → draw SVG
*/
import BLE from '../ble.js';
import IK from '../kinematics.js';
import UI from '../ui.js';
// ── SVG viewport ──────────────────────────────────────────────────
// We map the real workspace onto the SVG canvas.
// Real workspace: X ∈ [-155, 155], Y ∈ [-35, 150]
// We add some margin and invert Y (SVG y grows downward).
const SV = {
W: 480, H: 380,
// World→SVG transform: scale + offset so the board fits nicely
SCALE: 1.35, // px per mm
// Origin (world 0,0) maps to this SVG point:
OX: 240, // ≈ centre horizontally
OY: 295, // base line — motors sit here
// Convenience: world mm → SVG px
wx(worldX) { return this.OX + worldX * this.SCALE; },
wy(worldY) { return this.OY - worldY * this.SCALE; },
// SVG px → world mm
svgToWorldX(sx) { return (sx - this.OX) / this.SCALE; },
svgToWorldY(sy) { return -(sy - this.OY) / this.SCALE; },
};
// ── Arm length in px (for readable references) ────────────────────
const PX_L1 = IK.ARM.l1 * SV.SCALE;
const PX_L2 = IK.ARM.l2 * SV.SCALE;
const PX_D2 = IK.ARM.d2 * SV.SCALE;
// ── Section state ─────────────────────────────────────────────────
let steps1 = 0;
let steps2 = 0;
let eventCleanup = [];
// ── SVG element refs ──────────────────────────────────────────────
let svgEl;
// Real arm elements
let arm1Prox, arm1Dist, arm2Prox, arm2Dist;
let elbow1, elbow2, endEff, coordLabel, xhH, xhV;
// Ghost arm
let ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist;
let ghostElbow1, ghostElbow2, ghostEnd, ghostLabel;
// Target marker
let targetMarker;
// ── Readout refs ──────────────────────────────────────────────────
let elCurrX, elCurrY, elTheta1, elTheta2, elSteps1, elSteps2;
let simBadge;
// ─────────────────────────────────────────────────────────────────
// SVG rendering helpers
// ─────────────────────────────────────────────────────────────────
function setLine(el, x1, y1, x2, y2) {
el.setAttribute('x1', x1); el.setAttribute('y1', y1);
el.setAttribute('x2', x2); el.setAttribute('y2', y2);
}
function setCircle(el, cx, cy) {
el.setAttribute('cx', cx); el.setAttribute('cy', cy);
}
/** Render the 5-bar arm from two motor angles. */
function renderArm(theta1, theta2, isGhost = false) {
const { elbow1: e1, elbow2: e2, endX, endY } = IK.forward(theta1, theta2);
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);
// 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(ghost2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
setLine(ghost2Dist, elbow2sx, elbow2sy, endsx, endsy);
setCircle(ghostElbow1, elbow1sx, elbow1sy);
setCircle(ghostElbow2, elbow2sx, elbow2sy);
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(arm2Prox, motor2sx, motor2sy, elbow2sx, elbow2sy);
setLine(arm2Dist, elbow2sx, elbow2sy, endsx, endsy);
setCircle(elbow1, elbow1sx, elbow1sy);
setCircle(elbow2, elbow2sx, elbow2sy);
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);
coordLabel.textContent = `(${endX.toFixed(1)}, ${endY.toFixed(1)}) mm`;
updateReadouts(theta1, theta2, endX, endY);
}
return { endsx, endsy };
}
function renderArmFromSteps() {
const t1 = IK.stepsToRad(steps1);
const t2 = IK.stepsToRad(steps2);
renderArm(t1, t2);
}
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 (elTheta1) elTheta1.textContent = IK.radToDeg(t1).toFixed(1) + '°';
if (elTheta2) elTheta2.textContent = IK.radToDeg(t2).toFixed(1) + '°';
if (elSteps1) elSteps1.textContent = steps1;
if (elSteps2) elSteps2.textContent = steps2;
}
// ── Ghost arm show/hide ───────────────────────────────────────────
function showGhost(theta1, theta2, labelText) {
const ghosts = [ghost1Prox, ghost1Dist, ghost2Prox, ghost2Dist,
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]
.forEach(el => el.style.display = 'none');
}
// ── Target marker ─────────────────────────────────────────────────
function showTargetMarker(sx, sy) {
targetMarker.setAttribute('cx', sx);
targetMarker.setAttribute('cy', sy);
targetMarker.style.display = '';
targetMarker.classList.remove('target-pulse');
void targetMarker.offsetWidth; // force reflow
targetMarker.classList.add('target-pulse');
}
// ─────────────────────────────────────────────────────────────────
// IK movement
// ─────────────────────────────────────────────────────────────────
async function moveToXY(targetX, targetY) {
// Check workspace limits first
const wsCheck = IK.checkWorkspace(targetX, targetY);
if (!wsCheck.ok) {
UI.log(`${wsCheck.reason}`, 'warn');
return false;
}
const { theta1, theta2 } = IK.solve(targetX, targetY);
const newSteps1 = IK.radToSteps(theta1);
const newSteps2 = IK.radToSteps(theta2);
const delta1 = newSteps1 - steps1;
const delta2 = newSteps2 - steps2;
steps1 = newSteps1;
steps2 = newSteps2;
renderArm(theta1, theta2);
const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`;
const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`;
if (BLE.isConnected()) {
try {
await BLE.write(cmd1);
await BLE.write(cmd2);
} catch (e) {
UI.log(`BLE error: ${e.message}`, 'error');
}
} else {
UI.log(`[sim] IK→ θ1=${IK.radToDeg(theta1).toFixed(1)}° θ2=${IK.radToDeg(theta2).toFixed(1)}° | ${cmd1} ${cmd2}`, 'info');
}
return true;
}
// ─────────────────────────────────────────────────────────────────
// Manual jog
// ─────────────────────────────────────────────────────────────────
async function jogMotor(motor, delta) {
if (motor === 1) steps1 += delta;
else steps2 += delta;
renderArmFromSteps();
const cmd = `S${motor}${delta >= 0 ? '+' : ''}${delta}`;
BLE.isConnected()
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
: UI.log(`[sim] ${cmd}`, 'info');
}
async function zeroMotor(motor) {
if (motor === 1) steps1 = 0;
else steps2 = 0;
renderArmFromSteps();
const cmd = `HOME${motor}`;
BLE.isConnected()
? await BLE.write(cmd).catch(e => UI.log(e.message, 'error'))
: UI.log(`[sim] ${cmd}`, 'info');
}
// ─────────────────────────────────────────────────────────────────
// SVG workspace zones (pre-computed)
// ─────────────────────────────────────────────────────────────────
function buildZones() {
const L = IK.LIMITS;
// Outer bounding box rect
const bx = SV.wx(L.X_MIN);
const by = SV.wy(L.Y_MAX);
const bw = (L.X_MAX - L.X_MIN) * SV.SCALE;
const bh = (L.Y_MAX - L.Y_MIN) * SV.SCALE;
// Mechanism exclusion box
const ex = SV.wx(-L.BOX_HALF_W);
const ey = SV.wy(L.BOX_Y_MAX);
const ew = L.BOX_HALF_W * 2 * SV.SCALE;
const eh = (L.BOX_Y_MAX - L.BOX_Y_MIN) * SV.SCALE;
// Motor positions
const m1x = SV.wx(-IK.ARM.d2);
const m2x = SV.wx( IK.ARM.d2);
const my = SV.wy(0);
// Scale ruler
const r0x = SV.wx(0); const r0y = SV.wy(-20);
const r1x = SV.wx(50);
return `
<!-- Board boundary (outer workspace) -->
<rect x="${bx}" y="${by}" width="${bw}" height="${bh}"
fill="rgba(68,138,255,0.04)"
stroke="rgba(68,138,255,0.30)" stroke-width="1.5" stroke-dasharray="6 4"
rx="4"/>
<!-- Mechanism exclusion zone -->
<rect x="${ex}" y="${ey}" width="${ew}" height="${eh}"
fill="rgba(255,82,82,0.12)"
stroke="rgba(255,82,82,0.55)" stroke-width="1.5"
rx="3"/>
<text x="${SV.OX}" y="${ey + eh/2 + 4}" text-anchor="middle"
font-size="8" fill="rgba(255,82,82,0.7)" font-family="monospace">
NO-GO ZONE
</text>
<!-- Axes -->
<line x1="${SV.wx(-160)}" y1="${my}" x2="${SV.wx(160)}" y2="${my}"
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
<line x1="${SV.OX}" y1="${SV.wy(-40)}" x2="${SV.OX}" y2="${SV.wy(155)}"
stroke="rgba(255,255,255,0.07)" stroke-width="1"/>
<text x="${SV.wx(163)}" y="${my + 4}" font-size="8"
fill="rgba(255,255,255,0.2)" font-family="monospace">+X</text>
<text x="${SV.OX + 4}" y="${SV.wy(158)}" font-size="8"
fill="rgba(255,255,255,0.2)" font-family="monospace">+Y</text>
<!-- Scale ruler: 50 mm -->
<line x1="${r0x}" y1="${r0y}" x2="${r1x}" y2="${r0y}"
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<line x1="${r0x}" y1="${r0y - 4}" x2="${r0x}" y2="${r0y + 4}"
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<line x1="${r1x}" y1="${r0y - 4}" x2="${r1x}" y2="${r0y + 4}"
stroke="rgba(255,255,255,0.25)" stroke-width="1"/>
<text x="${(r0x + r1x)/2}" y="${r0y + 12}" text-anchor="middle"
font-size="8" fill="rgba(255,255,255,0.3)" font-family="monospace">50 mm</text>
<!-- Motor markers -->
<circle cx="${m1x}" cy="${my}" r="5"
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
<circle cx="${m2x}" cy="${my}" r="5"
fill="#448aff" stroke="#0a0e18" stroke-width="1.5"/>
<text x="${m1x}" y="${my + 16}" text-anchor="middle"
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M1</text>
<text x="${m2x}" y="${my + 16}" text-anchor="middle"
font-size="7.5" fill="rgba(68,138,255,0.7)" font-family="monospace">M2</text>
`;
}
// ─────────────────────────────────────────────────────────────────
// HTML template
// ─────────────────────────────────────────────────────────────────
function buildHTML() {
return `
<style>
.test-grid {
display: grid;
grid-template-columns: 1fr 260px;
gap: 16px;
margin-bottom: 16px;
align-items: start;
}
@media (max-width: 900px) { .test-grid { grid-template-columns: 1fr; } }
#scara-svg { cursor: crosshair; display: block; width: 100%; }
@keyframes targetPulse {
0% { r: 5px; opacity: 1; }
100% { r: 20px; opacity: 0; }
}
.target-pulse { animation: targetPulse 0.45s ease-out forwards; }
.pos-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px;
}
.pos-box {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 7px 8px; text-align: center;
}
.pos-val { font-size:1.05rem; font-weight:700; font-variant-numeric:tabular-nums; letter-spacing:-0.02em; }
.pos-val.xy { color: var(--accent-green); }
.pos-val.ang { color: var(--accent-blue); }
.pos-val.stp { color: var(--text-secondary); font-size:0.88rem; }
.pos-sub { font-size:0.62rem; font-weight:600; text-transform:uppercase;
letter-spacing:0.09em; color:var(--text-muted); margin-top:2px; }
.jog-compact { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.jog-motor { background:var(--surface-2); border:1px solid var(--border);
border-radius:var(--radius-md); padding:12px;
display:flex; flex-direction:column; gap:8px; }
.jog-title { font-size:0.72rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.09em; color:var(--text-muted); }
.quick-steps { display:flex; gap:4px; flex-wrap:wrap; }
.quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem;
border-radius:var(--radius-xs); }
</style>
<div class="section-page fade-in">
<div class="section-header">
<h2>Stepper Test</h2>
<p>Click anywhere on the SCARA map to move via IK. Red zone = mechanism exclusion. Dashed box = board limits.</p>
</div>
<div class="test-grid">
<!-- ── SCARA map ─────────────────────────────────────── -->
<div class="card" style="padding:12px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<span class="card-title">SCARA Map — click to move</span>
<span id="sim-badge" style="font-size:0.67rem;padding:3px 8px;border-radius:100px;
background:rgba(255,202,40,0.12);color:var(--accent-amber);
border:1px solid rgba(255,202,40,0.25);font-weight:600">SIMULATION</span>
</div>
<svg id="scara-svg" viewBox="0 0 ${SV.W} ${SV.H}" xmlns="http://www.w3.org/2000/svg">
<!-- Background grid -->
<defs>
<pattern id="grid-pat" width="27" height="27" patternUnits="userSpaceOnUse"
patternTransform="translate(${SV.OX % 27},${SV.OY % 27})">
<path d="M 27 0 L 0 0 0 27" fill="none"
stroke="rgba(255,255,255,0.035)" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="${SV.W}" height="${SV.H}" fill="#070a0f"/>
<rect width="${SV.W}" height="${SV.H}" fill="url(#grid-pat)"/>
<!-- Workspace zones (board boundary, exclusion zone, axes) -->
${buildZones()}
<!-- ── Ghost arm (hover preview) ─────────────────── -->
<line id="ghost-1p" style="display:none"
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
<line id="ghost-1d" style="display:none"
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
<line id="ghost-2p" style="display:none"
stroke="rgba(68,138,255,0.20)" stroke-width="3.5" stroke-linecap="round"/>
<line id="ghost-2d" style="display:none"
stroke="rgba(68,138,255,0.16)" stroke-width="2.5" stroke-linecap="round"/>
<circle id="ghost-e1" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
<circle id="ghost-e2" r="4" style="display:none" fill="rgba(160,196,255,0.22)"/>
<circle id="ghost-end" r="5" style="display:none" fill="rgba(0,230,118,0.22)"/>
<text id="ghost-label" font-size="9" fill="rgba(0,230,118,0.6)"
font-family="monospace" style="display:none"/>
<!-- ── Real arm ──────────────────────────────────── -->
<!-- Arm 1: proximal + distal -->
<line id="arm1-prox"
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(0)}"
x2="${SV.wx(-IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
stroke="#448aff" stroke-width="5" stroke-linecap="round"/>
<line id="arm1-dist"
x1="${SV.wx(-IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
stroke="#7eb8f7" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
<!-- Arm 2: proximal + distal -->
<line id="arm2-prox"
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(0)}"
x2="${SV.wx(IK.ARM.d2)}" y2="${SV.wy(IK.ARM.l1)}"
stroke="#f7a03c" stroke-width="5" stroke-linecap="round"/>
<line id="arm2-dist"
x1="${SV.wx(IK.ARM.d2)}" y1="${SV.wy(IK.ARM.l1)}"
x2="${SV.wx(0)}" y2="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
stroke="#ffd08a" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="6 3"/>
<!-- Elbow joints -->
<circle id="elbow1" r="5" fill="#a0c4ff" stroke="#0a0e18" stroke-width="1.5"
cx="${SV.wx(-IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
<circle id="elbow2" r="5" fill="#ffd08a" stroke="#0a0e18" stroke-width="1.5"
cx="${SV.wx( IK.ARM.d2)}" cy="${SV.wy(IK.ARM.l1)}"/>
<!-- End effector -->
<circle id="end-eff" r="7"
cx="${SV.wx(0)}" cy="${SV.wy(IK.ARM.l1 + IK.ARM.l2)}"
fill="var(--accent-green)" stroke="#0a0e18" stroke-width="1.5"/>
<!-- Crosshair -->
<line id="xh-h" stroke="rgba(0,230,118,0.5)" stroke-width="1"
x1="0" y1="0" x2="0" y2="0"/>
<line id="xh-v" stroke="rgba(0,230,118,0.5)" stroke-width="1"
x1="0" y1="0" x2="0" y2="0"/>
<!-- Target marker -->
<circle id="target-marker" r="5" style="display:none"
fill="none" stroke="var(--accent-green)" stroke-width="1.5"/>
<!-- Coordinate label -->
<text id="coord-label"
x="${SV.OX}" y="${SV.H - 8}"
text-anchor="middle" font-size="10"
fill="rgba(0,230,118,0.65)" font-family="monospace">
(0.0, 0.0) mm
</text>
</svg>
<div style="display:flex;gap:16px;font-size:0.70rem;color:var(--text-muted);margin-top:6px;flex-wrap:wrap">
<span style="color:rgba(68,138,255,0.8)">━ M1 (left)</span>
<span style="color:rgba(247,160,60,0.8)">━ M2 (right)</span>
<span style="color:rgba(255,82,82,0.7)">▪ NO-GO zone</span>
<span style="color:rgba(68,138,255,0.5)">╌ board limits</span>
<span>l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm d=${IK.ARM.d}mm</span>
</div>
</div>
<!-- ── Right: controls ──────────────────────────────── -->
<div style="display:flex;flex-direction:column;gap:12px">
<!-- Current position -->
<div class="card">
<div class="card-header"><span class="card-title">Current Position</span></div>
<div class="pos-grid">
<div class="pos-box">
<div class="pos-val xy" id="curr-x">0.0</div>
<div class="pos-sub">X (mm)</div>
</div>
<div class="pos-box">
<div class="pos-val xy" id="curr-y">0.0</div>
<div class="pos-sub">Y (mm)</div>
</div>
<div class="pos-box">
<div class="pos-val ang" id="curr-t1">0.0°</div>
<div class="pos-sub">θ1 (M1)</div>
</div>
<div class="pos-box">
<div class="pos-val ang" id="curr-t2">0.0°</div>
<div class="pos-sub">θ2 (M2)</div>
</div>
<div class="pos-box">
<div class="pos-val stp" id="curr-s1">0</div>
<div class="pos-sub">M1 steps</div>
</div>
<div class="pos-box">
<div class="pos-val stp" id="curr-s2">0</div>
<div class="pos-sub">M2 steps</div>
</div>
</div>
</div>
<!-- Move to XY -->
<div class="card">
<div class="card-header"><span class="card-title">Move to XY</span></div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="display:flex;gap:6px;align-items:center">
<label class="form-label" style="width:18px">X</label>
<input class="form-input" type="number" id="input-x"
value="0" step="5" style="flex:1;text-align:center"/>
<span class="form-label">mm</span>
</div>
<div style="display:flex;gap:6px;align-items:center">
<label class="form-label" style="width:18px">Y</label>
<input class="form-input" type="number" id="input-y"
value="100" step="5" style="flex:1;text-align:center"/>
<span class="form-label">mm</span>
</div>
<button class="btn btn-success btn-full" id="btn-goto">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
<span class="btn-text">Move to XY</span>
</button>
</div>
</div>
<!-- Motion settings -->
<div class="card">
<div class="card-header"><span class="card-title">Motion</span></div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="form-group">
<label class="form-label">Speed (steps/s)</label>
<div class="range-row">
<input class="form-range" type="range" id="speed-slider"
min="100" max="1000" step="50" value="600"/>
<span class="range-value" id="speed-val">600</span>
</div>
</div>
<div class="form-group">
<label class="form-label">Accel (steps/s²)</label>
<div class="range-row">
<input class="form-range" type="range" id="accel-slider"
min="50" max="500" step="25" value="100"/>
<span class="range-value" id="accel-val">100</span>
</div>
</div>
</div>
</div>
</div><!-- /right panel -->
</div><!-- /test-grid -->
<!-- Manual jog -->
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<span class="card-title">Manual Jog</span>
<span style="font-size:0.72rem;color:var(--text-muted)">Incremental step control — bypasses IK</span>
</div>
<div class="jog-compact">
<!-- Motor 1 -->
<div class="jog-motor">
<div class="jog-title" style="color:rgba(68,138,255,0.9)">Motor 1 — Left</div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-ccw">◀ CCW</button>
<input class="form-input" type="number" id="jog-steps-1"
value="50" min="1" max="2048"
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
<button class="btn btn-ghost btn-sm" style="flex:1" id="s1-cw">CW ▶</button>
</div>
<div class="quick-steps">
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="${n}">+${n}</button>`).join('')}
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="1" data-steps="-${n}">-${n}</button>`).join('')}
</div>
<button class="btn btn-danger btn-sm btn-full" id="s1-zero">Zero M1</button>
</div>
<!-- Motor 2 -->
<div class="jog-motor">
<div class="jog-title" style="color:rgba(247,160,60,0.9)">Motor 2 — Right</div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-ccw">◀ CCW</button>
<input class="form-input" type="number" id="jog-steps-2"
value="50" min="1" max="2048"
style="width:60px;padding:5px 3px;text-align:center;font-size:0.82rem"/>
<button class="btn btn-ghost btn-sm" style="flex:1" id="s2-cw">CW ▶</button>
</div>
<div class="quick-steps">
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="${n}">+${n}</button>`).join('')}
${[10,50,100,512].map(n=>`<button class="btn btn-ghost" data-motor="2" data-steps="-${n}">-${n}</button>`).join('')}
</div>
<button class="btn btn-danger btn-sm btn-full" id="s2-zero">Zero M2</button>
</div>
</div>
</div>
<!-- Event log -->
<div class="card">
<div class="log-header">
<span class="log-title">Event Log</span>
<button class="log-clear-btn" id="clear-log-btn">Clear</button>
</div>
<div class="log-console" id="log-console" aria-live="polite"></div>
</div>
</div>
`;
}
// ─────────────────────────────────────────────────────────────────
// Section lifecycle
// ─────────────────────────────────────────────────────────────────
const StepperTestSection = {
mount(container) {
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');
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-end');
ghostLabel = document.getElementById('ghost-label');
targetMarker= document.getElementById('target-marker');
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');
elSteps2 = document.getElementById('curr-s2');
simBadge = document.getElementById('sim-badge');
// ── SVG coordinate helpers ───────────────────────────────────
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);
return { wx: SV.svgToWorldX(sx), wy: SV.svgToWorldY(sy), sx, sy };
}
// ── SVG hover ───────────────────────────────────────────────
svgEl.addEventListener('mousemove', (e) => {
const { wx, wy } = pointerToWorld(e);
const ws = IK.checkWorkspace(wx, wy);
if (ws.ok) {
const { theta1, theta2 } = IK.solve(wx, wy);
showGhost(theta1, theta2, `(${wx.toFixed(0)},${wy.toFixed(0)})`);
svgEl.style.cursor = 'crosshair';
} else {
hideGhost();
svgEl.style.cursor = 'not-allowed';
}
});
svgEl.addEventListener('mouseleave', () => {
hideGhost();
svgEl.style.cursor = 'crosshair';
});
// ── SVG click ───────────────────────────────────────────────
svgEl.addEventListener('click', async (e) => {
const { wx, wy, sx, sy } = pointerToWorld(e);
const ws = IK.checkWorkspace(wx, wy);
if (!ws.ok) {
UI.log(`${ws.reason}`, 'warn');
return;
}
showTargetMarker(sx, sy);
const xi = document.getElementById('input-x');
const yi = document.getElementById('input-y');
if (xi) xi.value = wx.toFixed(1);
if (yi) yi.value = wy.toFixed(1);
await moveToXY(wx, wy);
});
// ── Move to XY button ────────────────────────────────────────
document.getElementById('btn-goto').addEventListener('click', async () => {
const x = parseFloat(document.getElementById('input-x').value);
const y = parseFloat(document.getElementById('input-y').value);
if (isNaN(x) || isNaN(y)) { UI.log('Invalid XY.', 'warn'); return; }
await moveToXY(x, y);
});
// ── 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-ccw').addEventListener('click', () => jogMotor(1, -gs(1)));
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));
// ── Quick-step buttons ────────────────────────────────────────
container.querySelectorAll('[data-motor][data-steps]').forEach(btn => {
btn.addEventListener('click', () =>
jogMotor(parseInt(btn.dataset.motor, 10), parseInt(btn.dataset.steps, 10)));
});
// ── Speed / Accel sliders ─────────────────────────────────────
let spdT, accT;
document.getElementById('speed-slider').addEventListener('input', (e) => {
document.getElementById('speed-val').textContent = e.target.value;
clearTimeout(spdT);
spdT = setTimeout(() => {
const cmd = `SPD:${e.target.value}`;
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
}, 400);
});
document.getElementById('accel-slider').addEventListener('input', (e) => {
document.getElementById('accel-val').textContent = e.target.value;
clearTimeout(accT);
accT = setTimeout(() => {
const cmd = `ACC:${e.target.value}`;
BLE.isConnected() ? BLE.write(cmd).catch(() => {}) : UI.log(`[sim] ${cmd}`, 'info');
}, 400);
});
// ── Log clear ─────────────────────────────────────────────────
document.getElementById('clear-log-btn').addEventListener('click', UI.clearLog);
// ── BLE badge ─────────────────────────────────────────────────
function updateBadge() {
if (!simBadge) return;
const live = BLE.isConnected();
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)';
}
BLE.on('connected', updateBadge);
BLE.on('disconnected', updateBadge);
updateBadge();
// ── BLE NOTIFY position feedback ──────────────────────────────
function onBLEStatus(e) {
const msg = e.detail;
if (msg.startsWith('P:')) {
const [s1, s2] = msg.slice(2).split(',').map(Number);
steps1 = s1; steps2 = s2;
renderArmFromSteps();
}
}
document.addEventListener('ble:status', onBLEStatus);
eventCleanup = [
() => BLE.off('connected', updateBadge),
() => BLE.off('disconnected', updateBadge),
() => document.removeEventListener('ble:status', onBLEStatus),
];
// ── Initial render ─────────────────────────────────────────────
renderArmFromSteps();
UI.log('Stepper Test ready — 5-bar parallel IK active.', 'success');
UI.log(`Arm: d=${IK.ARM.d}mm l1=${IK.ARM.l1}mm l2=${IK.ARM.l2}mm`, 'info');
if (!BLE.isConnected()) {
UI.log('Simulation mode — BLE commands logged but not sent.', 'warn');
}
},
unmount() {
eventCleanup.forEach(fn => fn());
eventCleanup = [];
},
};
export default StepperTestSection;