Files
ESP32-WijiBoard/web/js/sections/stepper-test.js
T
PROFERIS - Mi³osz Stocki ae0341dc87 kine
2026-07-08 16:38:38 +02:00

814 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WijiBoard Stepper Test Section
* web/js/sections/stepper-test.js
*
* 5-bar parallel linkage visualiser + IK-based click-to-move.
*
* Mechanism geometry (corrected from PositionControl.cpp bug):
* Motor 1 at (-12.9, 0) mm Motor 2 at (+12.9, 0) mm ← NOTE: M1 is on the LEFT
* 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 = [];
function syncStateFromStorage() {
const 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;
// Auto-migrate the old buggy negative home position to the new positive one
if (steps1 === -1024) {
steps1 = 1024;
localStorage.setItem('wiji_steps1', 1024);
}
}
function saveSteps() {
if (localStorage.getItem('wiji_homed') === 'true') {
localStorage.setItem('wiji_steps1', steps1);
localStorage.setItem('wiji_steps2', steps2);
}
}
// ── 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);
// Motor base SVG positions — M1 at -d2, M2 at +d2 (hardware 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);
// 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;
saveSteps();
}
// ── 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.radToStepsClosest(theta1, steps1);
const newSteps2 = IK.radToStepsClosest(theta2, steps2);
const cmd = `X:${newSteps1},${newSteps2}`;
if (BLE.isConnected()) {
try {
await BLE.write(cmd);
// Do not update steps1/steps2 here; let the P: notify handler update the UI smoothly
} catch (e) {
UI.log(`BLE error: ${e.message}`, 'error');
}
} else {
steps1 = newSteps1;
steps2 = newSteps2;
renderArm(theta1, theta2);
const simMsg = `[sim] IK→ θ1=${IK.radToDeg(theta1).toFixed(1)}° θ2=${IK.radToDeg(theta2).toFixed(1)}° | ${cmd}`;
console.log(simMsg);
UI.log(simMsg, 'info');
}
return true;
}
// ─────────────────────────────────────────────────────────────────
// Manual jog
// ─────────────────────────────────────────────────────────────────
async function jogMotor(motor, delta) {
const cmd = `S${motor}${delta >= 0 ? '+' : ''}${delta}`;
if (BLE.isConnected()) {
await BLE.write(cmd).catch(e => UI.log(e.message, 'error'));
} else {
if (motor === 1) steps1 += delta;
else steps2 += delta;
renderArmFromSteps();
console.log(`[sim] ${cmd}`);
UI.log(`[sim] ${cmd}`, 'info');
}
}
async function zeroMotor(motor) {
const cmd = motor === 'ALL' ? 'HOMEALL' : `HOME${motor}`;
if (BLE.isConnected()) {
await BLE.write(cmd).catch(e => UI.log(e.message, 'error'));
} else {
if (motor === 'ALL') {
steps1 = IK.ARM.HOME_STEPS.m1;
steps2 = IK.ARM.HOME_STEPS.m2;
localStorage.setItem('wiji_homed', 'true');
} else if (motor === 1) {
steps1 = IK.ARM.HOME_STEPS.m1;
} else {
steps2 = IK.ARM.HOME_STEPS.m2;
}
renderArmFromSteps();
console.log(`[sim] ${cmd}`);
UI.log(`[sim] ${cmd}`, 'info');
}
}
// ─────────────────────────────────────────────────────────────────
// Manual jog
// ─────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────
// 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 (uses updated LIMITS fields)
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 — 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);
// 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); }
@media (max-width: 600px) {
.jog-compact { grid-template-columns: 1fr; }
}
</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 (M1 at -d2) -->
<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 (M2 at +d2) -->
<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="2000" 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="2000" 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">Home 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">Home M2</button>
</div>
</div>
<div style="margin-top: 12px;">
<button class="btn btn-danger btn-full" id="sall-zero" style="padding: 10px; font-weight: bold; font-size: 0.9rem;">HOME BOTH MOTORS</button>
</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) {
syncStateFromStorage();
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-e1');
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));
document.getElementById('sall-zero').addEventListener('click', () => zeroMotor('ALL'));
// ── 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;