/** * 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, };