Files
ESP32-WijiBoard/web/js/kinematics.js
T
PROFERIS - Mi³osz Stocki 67b4eda6aa Initial commit
2026-07-06 11:34:03 +02:00

262 lines
9.4 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 Kinematics (exact port of PositionControl.cpp)
* web/js/kinematics.js
*
* ── Mechanism: symmetric 5-bar parallel linkage ─────────────────
*
* END EFFECTOR (x, y)
* / \
* L2 (110) L2 (110)
* / \
* ELBOW1 ELBOW2
* \ /
* L1 (85) L1 (85)
* \ /
* MOTOR1 (-d2,0) MOTOR2 (+d2,0)
* | |
* [===BASE===] (d=25.8 mm wide)
* d2=12.9 mm
*
* Both motors are mounted in the centre mechanism box.
* Motor 1 is at (-d2, 0), Motor 2 is at (+d2, 0).
* Each motor drives a proximal arm (l1). The distal arms (l2)
* connect the elbows to the shared end-effector.
*
* IK: given target (x, y), solve θ1 and θ2 independently:
* Motor 1 sees the target at (x - d2, y) from its pivot.
* Motor 2 sees the target at (x + d2, y) from its pivot.
* Each uses the standard 2-link IK (law of cosines).
*
* Source: lib/Position/PositionControl.cpp (nerd-sniped/WijiBoard)
*/
// ── Exact constants from PositionControl.cpp ─────────────────────
const ARM = {
d: 25.8, // full motor separation (mm)
d2: 12.9, // half motor separation — motor 1 at (-d2,0), motor 2 at (+d2,0)
l1: 85.0, // proximal link length (mm)
l2: 110.0, // distal link length (mm)
STEPS_PER_REV: 2048,
STEP_ANGLE_DEG: 360 / 2048, // ≈ 0.17578125°
};
// ── Workspace limits ──────────────────────────────────────────────
const LIMITS = {
// Outer bounding box (the board surface)
X_MIN: -150,
X_MAX: 150,
Y_MIN: -30, // numbers sit slightly below Y=0
Y_MAX: 145,
// Centre exclusion zone (the motor/mechanism box)
// Motors are at ±12.9 mm; box is a bit larger to account for the housing
BOX_HALF_W: 22, // ±22 mm in X (tune to real hardware)
BOX_HALF_H: 22, // 0..22 mm in Y (box sits above the base line)
BOX_Y_MIN: -5,
BOX_Y_MAX: 22,
};
// ── Letter / number position lookup table ────────────────────────
// Directly ported from PositionControl.cpp
const LOOKUP_TABLE = {
'Q': { x: -66.5, y: 91.6 },
'W': { x: 70.8, y: 92.0 },
'E': { x: -41.4, y: 125.5 },
'R': { x: -44.0, y: 96.0 },
'T': { x: 1.3, y: 97.8 },
'Y': { x: 117.8, y: 91.7 },
'U': { x: 22.5, y: 98.3 },
'I': { x: 53.5, y: 124.5 },
'O': { x: -112.0, y: 82.0 },
'P': { x: -90.0, y: 89.0 },
'A': { x: -143.0, y: 99.0 },
'S': { x: -19.8, y: 98.0 },
'D': { x: -68.5, y: 121.7 },
'F': { x: -19.5, y: 127.0 },
'G': { x: 4.0, y: 128.4 },
'H': { x: 31.3, y: 127.3 },
'J': { x: 72.5, y: 121.0 },
'K': { x: 93.0, y: 116.8 },
'L': { x: 114.3, y: 110.2 },
'Z': { x: 130.0, y: 75.0 },
'X': { x: 95.9, y: 87.3 },
'C': { x: -94.0, y: 116.0 },
'V': { x: 44.5, y: 97.0 },
'B': { x: -119.0, y: 109.0 },
'N': { x: -135.4, y: 75.1 },
'M': { x: 132.0, y: 100.5 },
'+': { x: -110.0, y: 1.0 },
'-': { x: 110.0, y: 1.0 },
'*': { x: 110.0, y: -24.0 },
',': { x: -110.0, y: -24.0 },
'0': { x: -130.4, y: 44.2 },
'1': { x: -101.1, y: 53.3 },
'2': { x: -71.5, y: 60.4 },
'3': { x: -41.7, y: 65.2 },
'4': { x: -13.0, y: 66.5 },
'5': { x: 16.3, y: 68.0 },
'6': { x: 45.8, y: 64.0 },
'7': { x: 75.7, y: 61.1 },
'8': { x: 103.6, y: 53.8 },
'9': { x: 132.1, y: 45.0 },
};
// ── IK solver — exact port of calculateInverseKinematics() ───────
/**
* Compute motor angles for a given target end-effector position.
* Exactly mirrors the C++ implementation in PositionControl.cpp.
*
* @param {number} x Target X in mm (origin = midpoint between motors)
* @param {number} y Target Y in mm
* @returns {{ theta1: number, theta2: number, reachable: boolean }}
* theta1 / theta2 in RADIANS (motor 1 = left, motor 2 = right)
*/
function solve(x, y) {
const { d2, l1, l2 } = ARM;
// ── Motor 1 (left pivot at -d2, 0) ───────────────────────────
const xmd = x - d2; // target X relative to left motor
const s = Math.sqrt(xmd * xmd + y * y); // distance: left motor → target
const cosW1 = (l2 * l2 - s * s - l1 * l1) / (-2 * l1 * s);
if (cosW1 < -1 || cosW1 > 1) return { theta1: 0, theta2: 0, reachable: false };
const q = Math.atan2(y, xmd);
const w1 = Math.acos(cosW1);
const theta1 = q - w1;
// ── Motor 2 (right pivot at +d2, 0) ──────────────────────────
const xpd = x + d2; // target X relative to right motor
const t = Math.sqrt(xpd * xpd + y * y); // distance: right motor → target
const cosW2 = (l2 * l2 - t * t - l1 * l1) / (-2 * l1 * t);
if (cosW2 < -1 || cosW2 > 1) return { theta1: 0, theta2: 0, reachable: false };
const r = Math.atan2(y, xpd);
const w2 = Math.acos(cosW2);
const theta2 = r + w2;
return { theta1, theta2, reachable: true };
}
// ── Forward kinematics ───────────────────────────────────────────
/**
* Given motor angles, compute elbow and end-effector positions.
* The end-effector is found as the intersection of the two distal
* link circles — this is the FK complement to the 5-bar IK above.
*
* In practice for visualisation we just re-derive the elbow
* positions from each motor.
*
* @param {number} theta1 Motor 1 angle (radians)
* @param {number} theta2 Motor 2 angle (radians)
* @returns {{
* elbow1: {x,y}, elbow2: {x,y},
* endX: number, endY: number
* }}
*/
function forward(theta1, theta2) {
const { d2, l1, l2 } = ARM;
// Elbow 1 (tip of motor 1's proximal link)
const e1x = -d2 + l1 * Math.cos(theta1);
const e1y = l1 * Math.sin(theta1);
// Elbow 2 (tip of motor 2's proximal link)
const e2x = d2 + l1 * Math.cos(theta2);
const e2y = l1 * Math.sin(theta2);
// End-effector: intersection of circle(elbow1, l2) and circle(elbow2, l2)
// Use the same approach as the IK: each distal link points from its elbow to the EE.
// For visualisation accuracy, reconstruct EE by reversing the IK:
// From IK: theta1 = q - w1 → q = atan2(y, x - d2)
// We know theta1 and the elbow position, so EE is at l2 along some direction.
// Simplest: use circle-circle intersection of the two elbow-radius-l2 circles.
const dx = e2x - e1x;
const dy = e2y - e1y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1e-6 || dist > 2 * l2) {
// Degenerate / unreachable — just average the two elbows
return {
elbow1: { x: e1x, y: e1y },
elbow2: { x: e2x, y: e2y },
endX: (e1x + e2x) / 2,
endY: (e1y + e2y) / 2,
};
}
const a = dist / 2;
const h = Math.sqrt(l2 * l2 - a * a);
const mx = (e1x + e2x) / 2;
const my = (e1y + e2y) / 2;
// Two intersection candidates — pick the one with higher Y (the "up" configuration)
const px1 = mx + h * (dy / dist);
const py1 = my - h * (dx / dist);
const px2 = mx - h * (dy / dist);
const py2 = my + h * (dx / dist);
const { endX, endY } = py1 > py2
? { endX: px1, endY: py1 }
: { endX: px2, endY: py2 };
return {
elbow1: { x: e1x, y: e1y },
elbow2: { x: e2x, y: e2y },
endX, endY,
};
}
// ── Workspace check ───────────────────────────────────────────────
/**
* Check whether a point is inside the valid workspace.
* @param {number} x
* @param {number} y
* @returns {{ ok: boolean, reason: string }}
*/
function checkWorkspace(x, y) {
// Outer bounding box
if (x < LIMITS.X_MIN || x > LIMITS.X_MAX)
return { ok: false, reason: `X=${x.toFixed(1)} outside board limits [${LIMITS.X_MIN}, ${LIMITS.X_MAX}]` };
if (y < LIMITS.Y_MIN || y > LIMITS.Y_MAX)
return { ok: false, reason: `Y=${y.toFixed(1)} outside board limits [${LIMITS.Y_MIN}, ${LIMITS.Y_MAX}]` };
// Centre exclusion zone (mechanism box)
if (
x > -LIMITS.BOX_HALF_W && x < LIMITS.BOX_HALF_W &&
y > LIMITS.BOX_Y_MIN && y < LIMITS.BOX_Y_MAX
) {
return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is inside the mechanism exclusion zone` };
}
// IK reachability
const { reachable } = solve(x, y);
if (!reachable) return { ok: false, reason: `(${x.toFixed(1)}, ${y.toFixed(1)}) is outside arm reach` };
return { ok: true, reason: '' };
}
// ── Lookup ────────────────────────────────────────────────────────
function lookup(char) {
return LOOKUP_TABLE[char.toUpperCase()] ?? null;
}
// ── Unit converters ───────────────────────────────────────────────
function stepsToRad(steps) {
return (steps / ARM.STEPS_PER_REV) * 2 * Math.PI;
}
function radToSteps(rad) {
return Math.round((rad / (2 * Math.PI)) * ARM.STEPS_PER_REV);
}
function stepsToDeg(steps) {
return steps * ARM.STEP_ANGLE_DEG;
}
function radToDeg(rad) {
return rad * 180 / Math.PI;
}
export default {
ARM, LIMITS, LOOKUP_TABLE,
solve, forward, checkWorkspace, lookup,
stepsToRad, radToSteps, stepsToDeg, radToDeg,
};