/**
* 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.radToStepsAbsolute(theta1, 1);
const newSteps2 = IK.radToStepsAbsolute(theta2, 2);
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');
const warningEl = document.getElementById('home-warning');
if (warningEl) warningEl.style.display = localStorage.getItem('wiji_homed') === 'true' ? 'none' : 'block';
}
}
// ─────────────────────────────────────────────────────────────────
// 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 `
Click anywhere on the SCARA map to move via IK. Red zone = mechanism exclusion. Dashed box = board limits.