0
2-Link Arm: Inverse Kinematics
move cursor to target, click to flip elbow
idle
169 lines ยท vanilla
view source
// 2-Link planar arm with closed-form inverse kinematics.
// Anchored at canvas center. End effector tracks cursor.
// Given target (x, y) with r^2 = x^2 + y^2:
// theta2 = +/- acos((r^2 - L1^2 - L2^2) / (2 L1 L2))
// theta1 = atan2(y, x) - atan2(L2 sin(theta2), L1 + L2 cos(theta2))
// Two valid configs: "elbow up" (theta2 > 0) and "elbow down" (theta2 < 0).
// Both rendered at half alpha; primary is drawn fully. Click to toggle which
// is primary. When out of reachable annulus [|L1-L2|, L1+L2], arm stretches
// collinear toward the target and tints red.
let W = 0, H = 0;
let cx = 0, cy = 0;
let L1 = 0, L2 = 0;
let primaryUp = true; // true = elbow-up primary; false = elbow-down
let buttonRect = null; // { x, y, w, h }
function recompute(width, height) {
W = width;
H = height;
cx = W * 0.5;
cy = H * 0.55;
const m = Math.min(W, H);
L1 = 0.45 * m;
L2 = 0.35 * m;
buttonRect = { x: 12, y: 12, w: 160, h: 28 };
}
function init({ width, height, ctx }) {
recompute(width, height);
ctx.fillStyle = "#0a0c14";
ctx.fillRect(0, 0, W, H);
}
// Solve IK. Returns { theta1, theta2, reachable }.
// If unreachable, returns the collinear-clamped angle in theta1 and theta2 = 0.
function solveIK(dx, dy, elbowUp) {
const r2 = dx * dx + dy * dy;
const r = Math.sqrt(r2);
const rMin = Math.abs(L1 - L2);
const rMax = L1 + L2;
if (r > rMax + 1e-6 || r < rMin - 1e-6) {
// unreachable: collinear stretch toward target
const theta1 = Math.atan2(dy, dx);
// For r > rMax: arm fully extended, theta2 = 0 (collinear out).
// For r < rMin: arm fully folded, theta2 = pi (collinear back), but the
// visual is least ugly if we point both links toward the target โ use
// theta2 = 0 anchored along theta1, which lands beyond rMin but inside
// rMax. Either way, end effector won't reach.
return { theta1, theta2: 0, reachable: false };
}
// cos(theta2) clamp guards floating noise.
let c2 = (r2 - L1 * L1 - L2 * L2) / (2 * L1 * L2);
if (c2 > 1) c2 = 1;
if (c2 < -1) c2 = -1;
const s2mag = Math.sqrt(Math.max(0, 1 - c2 * c2));
const theta2 = (elbowUp ? +1 : -1) * Math.atan2(s2mag, c2);
const theta1 = Math.atan2(dy, dx) - Math.atan2(L2 * Math.sin(theta2), L1 + L2 * Math.cos(theta2));
return { theta1, theta2, reachable: true };
}
function drawArm(ctx, sol, alpha, isPrimary, unreachable) {
const baseR = unreachable ? 235 : 90;
const baseG = unreachable ? 70 : 170;
const baseB = unreachable ? 95 : 240;
const j1x = cx + L1 * Math.cos(sol.theta1);
const j1y = cy + L1 * Math.sin(sol.theta1);
const eeAng = sol.theta1 + sol.theta2;
const eex = j1x + L2 * Math.cos(eeAng);
const eey = j1y + L2 * Math.sin(eeAng);
ctx.lineCap = "round";
ctx.lineJoin = "round";
// upper arm
ctx.strokeStyle = `rgba(${baseR}, ${baseG}, ${baseB}, ${alpha})`;
ctx.lineWidth = isPrimary ? 9 : 6;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(j1x, j1y);
ctx.stroke();
// forearm
const fR = unreachable ? 235 : 140;
const fG = unreachable ? 110 : 210;
const fB = unreachable ? 130 : 255;
ctx.strokeStyle = `rgba(${fR}, ${fG}, ${fB}, ${alpha})`;
ctx.lineWidth = isPrimary ? 7 : 5;
ctx.beginPath();
ctx.moveTo(j1x, j1y);
ctx.lineTo(eex, eey);
ctx.stroke();
// elbow joint
ctx.fillStyle = `rgba(230, 230, 250, ${alpha})`;
ctx.beginPath();
ctx.arc(j1x, j1y, isPrimary ? 7 : 5, 0, Math.PI * 2);
ctx.fill();
// end effector
ctx.fillStyle = unreachable
? `rgba(255, 120, 130, ${alpha})`
: `rgba(255, 220, 130, ${alpha})`;
ctx.beginPath();
ctx.arc(eex, eey, isPrimary ? 6 : 4, 0, Math.PI * 2);
ctx.fill();
return { eex, eey };
}
function pointInRect(x, y, r) {
return r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
}
function tick({ ctx, width, height, input }) {
if (width !== W || height !== H) recompute(width, height);
// background
ctx.fillStyle = "#0a0c14";
ctx.fillRect(0, 0, W, H);
// faint grid
ctx.strokeStyle = "rgba(120,140,180,0.06)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x < W; x += 40) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for (let y = 0; y < H; y += 40) { ctx.moveTo(0, y); ctx.lineTo(0 + W, y); }
ctx.stroke();
// reachable annulus boundary
const rMin = Math.abs(L1 - L2);
const rMax = L1 + L2;
ctx.strokeStyle = "rgba(160, 200, 255, 0.18)";
ctx.setLineDash([6, 6]);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, rMax, 0, Math.PI * 2);
ctx.stroke();
if (rMin > 1) {
ctx.beginPath();
ctx.arc(cx, cy, rMin, 0, Math.PI * 2);
ctx.stroke();
}
ctx.setLineDash([]);
// target relative to shoulder
const tx = input.mouseX - cx;
const ty = input.mouseY - cy;
// Process clicks. Toggle elbow only when click is NOT on the button
// (button click also toggles, so a single handler is fine โ but we want
// both to work, so we just toggle on any click that is inside the canvas).
for (const c of input.consumeClicks()) {
// If user clicks the explicit button area, always toggle.
// If user clicks elsewhere on canvas, also toggle (per spec).
if (pointInRect(c.x, c.y, buttonRect)) {
primaryUp = !primaryUp;
} else {
primaryUp = !primaryUp;
}
}
const upSol = solveIK(tx, ty, true);
const downSol = solveIK(tx, ty, false);
const reachable = upSol.reachable && downSol.reachable;
// Draw non-primary at half alpha first, then primary on top.
let primaryEE;
if (primaryUp) {
drawArm(ctx, downSol, 0.35, false, !reachable);
primaryEE = drawArm(ctx, upSol, 1.0, true, !reachable);
} else {
drawArm(ctx, upSol, 0.35, false, !reachable);
primaryEE = drawArm(ctx, downSol, 1.0, true, !reachable);
}
// shoulder pivot
ctx.fillStyle = "rgba(220, 230, 255, 0.95)";
ctx.strokeStyle = "rgba(255, 255, 255, 0.6)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, 9, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// target crosshair on cursor (only meaningful inside canvas)
if (input.mouseX >= 0 && input.mouseY >= 0 && input.mouseX <= W && input.mouseY <= H) {
ctx.strokeStyle = reachable ? "rgba(255, 220, 130, 0.55)" : "rgba(255, 120, 130, 0.7)";
ctx.lineWidth = 1;
const s = 8;
ctx.beginPath();
ctx.moveTo(input.mouseX - s, input.mouseY);
ctx.lineTo(input.mouseX + s, input.mouseY);
ctx.moveTo(input.mouseX, input.mouseY - s);
ctx.lineTo(input.mouseX, input.mouseY + s);
ctx.stroke();
}
// Toggle button (top-left)
const b = buttonRect;
ctx.fillStyle = "rgba(30, 40, 60, 0.85)";
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeStyle = "rgba(160, 200, 255, 0.55)";
ctx.lineWidth = 1;
ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
ctx.fillStyle = "rgba(230, 240, 255, 0.95)";
ctx.font = "13px ui-monospace, monospace";
ctx.textBaseline = "middle";
ctx.fillText(`primary: elbow-${primaryUp ? "up" : "down"}`, b.x + 10, b.y + b.h / 2);
ctx.textBaseline = "alphabetic";
// HUD: angles in degrees of the primary solution
const sol = primaryUp ? upSol : downSol;
const t1deg = sol.theta1 * 180 / Math.PI;
const t2deg = sol.theta2 * 180 / Math.PI;
const r = Math.hypot(tx, ty);
ctx.fillStyle = "rgba(220, 230, 250, 0.92)";
ctx.font = "13px ui-monospace, monospace";
let hy = b.y + b.h + 18;
ctx.fillText(`theta1 = ${t1deg.toFixed(1)} deg`, 12, hy); hy += 16;
ctx.fillText(`theta2 = ${t2deg.toFixed(1)} deg`, 12, hy); hy += 16;
ctx.fillText(`L1 = ${L1.toFixed(0)} L2 = ${L2.toFixed(0)}`, 12, hy); hy += 16;
ctx.fillText(`r = ${r.toFixed(0)} / [${rMin.toFixed(0)}, ${rMax.toFixed(0)}]`, 12, hy); hy += 16;
if (!reachable) {
ctx.fillStyle = "rgba(255, 130, 140, 0.95)";
ctx.fillText("unreachable โ clamped collinear", 12, hy);
}
// legend
ctx.fillStyle = "rgba(200, 210, 230, 0.55)";
ctx.fillText("click anywhere to flip elbow", W - 240, H - 14);
}
Comments (0)
Log in to comment.