814 lines
37 KiB
JavaScript
814 lines
37 KiB
JavaScript
/**
|
||
* 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;
|