Drag the red target and watch the arm follow. Each link can rotate freely at its base joint. The solver uses Cyclic Coordinate Descent (CCD): it sweeps from the tip backwards, rotating each joint just enough to bring the end-effector closer to the target.
<!-- Robot arm IK demo — drag the red crosshair target to move the arm -->
<div class="toolbar">
<label>Links: <input id="link-count" type="range" min="2" max="8" value="4"> <span id="link-val">4</span></label>
<button id="reset-btn" type="button">Reset</button>
</div>
<canvas id="canvas" width="480" height="340"></canvas>
<div class="status" id="status">Drag the red target to move the arm. Adjust the slider to change the number of links.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f5f7fa; }
.toolbar {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
padding: .45rem .6rem; background: #fff; border-bottom: 1px solid #dde3ea;
}
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 90px; cursor: pointer; accent-color: #1d3557; }
button {
font: 600 13px system-ui; padding: .35rem .8rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 6px; cursor: pointer;
}
canvas { display: block; background: #fff; border-top: none; touch-action: none; cursor: crosshair; }
.status { font-size: .85rem; color: #555; padding: .35rem .6rem; min-height: 1.6em; background: #fff; border-top: 1px solid #eee; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Inverse Kinematics via Cyclic Coordinate Descent (CCD). */
/* Each iteration sweeps from tip to base. */
/* Each joint rotates to point its sub-chain toward the target. */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const linkInput = document.getElementById('link-count');
const linkVal = document.getElementById('link-val');
const W = canvas.width, H = canvas.height;
/* Scene constants */
const LINK_LEN = 50;
const BASE = { x: W / 2, y: H - 40 };
const MAX_ITER = 40;
const THRESHOLD = 2;
let numLinks = 4;
let angles = []; // joint angles, relative to previous link
let target = { x: BASE.x + 80, y: BASE.y - 160 };
let dragging = false;
function initAngles() {
angles = [];
for (let i = 0; i < numLinks; i++) angles.push(-Math.PI / 2);
}
/* Forward kinematics: returns all joint positions given current angles. */
function forwardKinematics() {
const pts = [{ x: BASE.x, y: BASE.y }];
let cumAngle = 0;
for (let i = 0; i < numLinks; i++) {
cumAngle += angles[i];
const prev = pts[pts.length - 1];
pts.push({
x: prev.x + LINK_LEN * Math.cos(cumAngle),
y: prev.y + LINK_LEN * Math.sin(cumAngle)
});
}
return pts;
}
/* CCD solver: iterates until the tip reaches the target or MAX_ITER is hit. */
function solveCCD() {
for (let iter = 0; iter < MAX_ITER; iter++) {
const pts = forwardKinematics();
const tip = pts[pts.length - 1];
const dx = tip.x - target.x, dy = tip.y - target.y;
if (Math.sqrt(dx * dx + dy * dy) < THRESHOLD) break;
/* Sweep from tip joint back to root */
for (let j = numLinks - 1; j >= 0; j--) {
const pivot = pts[j];
const ptsNow = forwardKinematics();
const tipNow = ptsNow[ptsNow.length - 1];
const toTip = Math.atan2(tipNow.y - pivot.y, tipNow.x - pivot.x);
const toTarget = Math.atan2(target.y - pivot.y, target.x - pivot.x);
let delta = toTarget - toTip;
/* Wrap angle to [-PI, PI] */
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
angles[j] += delta;
}
}
}
/* Main render: solve IK, then paint the arm. */
function draw() {
ctx.clearRect(0, 0, W, H);
solveCCD();
const pts = forwardKinematics();
/* Draw links with a gradient hue */
ctx.lineWidth = 8;
ctx.lineCap = 'round';
for (let i = 0; i < pts.length - 1; i++) {
const t = i / (pts.length - 2);
ctx.strokeStyle = `hsl(${210 + t * 60}, 60%, ${45 + t * 15}%)`;
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
/* Draw joint circles */
for (let i = 0; i < pts.length - 1; i++) {
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, i === 0 ? 9 : 6, 0, 2 * Math.PI);
ctx.fillStyle = i === 0 ? '#1d3557' : '#457b9d';
ctx.fill();
}
/* Draw end-effector (tip) */
const tip = pts[pts.length - 1];
ctx.beginPath();
ctx.arc(tip.x, tip.y, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#2a9d8f';
ctx.fill();
/* Draw target crosshair */
ctx.beginPath();
ctx.arc(target.x, target.y, 10, 0, 2 * Math.PI);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(target.x - 13, target.y);
ctx.lineTo(target.x + 13, target.y);
ctx.moveTo(target.x, target.y - 13);
ctx.lineTo(target.x, target.y + 13);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.stroke();
/* Update status bar */
const dx = tip.x - target.x, dy = tip.y - target.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < THRESHOLD) {
statusEl.textContent = 'Target reached!';
} else {
statusEl.textContent = i18nFmt('Distance to target: %d% px', { d: Math.round(dist) });
}
}
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
function onDown(e) {
e.preventDefault();
const p = getPos(e);
const dx = p.x - target.x, dy = p.y - target.y;
if (Math.sqrt(dx * dx + dy * dy) < 20) dragging = true;
}
function onMove(e) {
e.preventDefault();
if (!dragging) return;
const p = getPos(e);
target.x = Math.max(0, Math.min(W, p.x));
target.y = Math.max(0, Math.min(H, p.y));
draw();
}
function onUp() { dragging = false; }
canvas.addEventListener('mousedown', onDown);
canvas.addEventListener('mousemove', onMove);
canvas.addEventListener('mouseup', onUp);
canvas.addEventListener('touchstart', onDown, { passive: false });
canvas.addEventListener('touchmove', onMove, { passive: false });
canvas.addEventListener('touchend', onUp);
linkInput.addEventListener('input', () => {
numLinks = +linkInput.value;
linkVal.textContent = numLinks;
initAngles();
draw();
});
document.getElementById('reset-btn').addEventListener('click', () => {
target = { x: BASE.x + 80, y: BASE.y - 160 };
initAngles();
draw();
});
initAngles();
draw();
<!-- Robot arm IK demo — drag the red crosshair target to move the arm -->
<div class="toolbar">
<label>Links: <input id="link-count" type="range" min="2" max="8" value="4"> <span id="link-val">4</span></label>
<button id="reset-btn" type="button">Reset</button>
</div>
<canvas id="canvas" width="480" height="340"></canvas>
<div class="status" id="status">Drag the red target to move the arm. Adjust the slider to change the number of links.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #f5f7fa; }
.toolbar {
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
padding: .45rem .6rem; background: #fff; border-bottom: 1px solid #dde3ea;
}
label { font-size: .85rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 90px; cursor: pointer; accent-color: #1d3557; }
button {
font: 600 13px system-ui; padding: .35rem .8rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 6px; cursor: pointer;
}
canvas { display: block; background: #fff; border-top: none; touch-action: none; cursor: crosshair; }
.status { font-size: .85rem; color: #555; padding: .35rem .6rem; min-height: 1.6em; background: #fff; border-top: 1px solid #eee; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Inverse Kinematics via Cyclic Coordinate Descent (CCD). */
/* Each iteration sweeps from tip to base. */
/* Each joint rotates to point its sub-chain toward the target. */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const linkInput = document.getElementById('link-count');
const linkVal = document.getElementById('link-val');
const W = canvas.width, H = canvas.height;
/* Scene constants */
const LINK_LEN = 50;
const BASE = { x: W / 2, y: H - 40 };
const MAX_ITER = 40;
const THRESHOLD = 2;
let numLinks = 4;
let angles = []; // joint angles, relative to previous link
let target = { x: BASE.x + 80, y: BASE.y - 160 };
let dragging = false;
function initAngles() {
angles = [];
for (let i = 0; i < numLinks; i++) angles.push(-Math.PI / 2);
}
/* Forward kinematics: returns all joint positions given current angles. */
function forwardKinematics() {
const pts = [{ x: BASE.x, y: BASE.y }];
let cumAngle = 0;
for (let i = 0; i < numLinks; i++) {
cumAngle += angles[i];
const prev = pts[pts.length - 1];
pts.push({
x: prev.x + LINK_LEN * Math.cos(cumAngle),
y: prev.y + LINK_LEN * Math.sin(cumAngle)
});
}
return pts;
}
/* CCD solver: iterates until the tip reaches the target or MAX_ITER is hit. */
function solveCCD() {
for (let iter = 0; iter < MAX_ITER; iter++) {
const pts = forwardKinematics();
const tip = pts[pts.length - 1];
const dx = tip.x - target.x, dy = tip.y - target.y;
if (Math.sqrt(dx * dx + dy * dy) < THRESHOLD) break;
/* Sweep from tip joint back to root */
for (let j = numLinks - 1; j >= 0; j--) {
const pivot = pts[j];
const ptsNow = forwardKinematics();
const tipNow = ptsNow[ptsNow.length - 1];
const toTip = Math.atan2(tipNow.y - pivot.y, tipNow.x - pivot.x);
const toTarget = Math.atan2(target.y - pivot.y, target.x - pivot.x);
let delta = toTarget - toTip;
/* Wrap angle to [-PI, PI] */
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
angles[j] += delta;
}
}
}
/* Main render: solve IK, then paint the arm. */
function draw() {
ctx.clearRect(0, 0, W, H);
solveCCD();
const pts = forwardKinematics();
/* Draw links with a gradient hue */
ctx.lineWidth = 8;
ctx.lineCap = 'round';
for (let i = 0; i < pts.length - 1; i++) {
const t = i / (pts.length - 2);
ctx.strokeStyle = `hsl(${210 + t * 60}, 60%, ${45 + t * 15}%)`;
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
/* Draw joint circles */
for (let i = 0; i < pts.length - 1; i++) {
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, i === 0 ? 9 : 6, 0, 2 * Math.PI);
ctx.fillStyle = i === 0 ? '#1d3557' : '#457b9d';
ctx.fill();
}
/* Draw end-effector (tip) */
const tip = pts[pts.length - 1];
ctx.beginPath();
ctx.arc(tip.x, tip.y, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#2a9d8f';
ctx.fill();
/* Draw target crosshair */
ctx.beginPath();
ctx.arc(target.x, target.y, 10, 0, 2 * Math.PI);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(target.x - 13, target.y);
ctx.lineTo(target.x + 13, target.y);
ctx.moveTo(target.x, target.y - 13);
ctx.lineTo(target.x, target.y + 13);
ctx.strokeStyle = '#e63946';
ctx.lineWidth = 1.5;
ctx.stroke();
/* Update status bar */
const dx = tip.x - target.x, dy = tip.y - target.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < THRESHOLD) {
statusEl.textContent = 'Target reached!';
} else {
statusEl.textContent = i18nFmt('Distance to target: %d% px', { d: Math.round(dist) });
}
}
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
function onDown(e) {
e.preventDefault();
const p = getPos(e);
const dx = p.x - target.x, dy = p.y - target.y;
if (Math.sqrt(dx * dx + dy * dy) < 20) dragging = true;
}
function onMove(e) {
e.preventDefault();
if (!dragging) return;
const p = getPos(e);
target.x = Math.max(0, Math.min(W, p.x));
target.y = Math.max(0, Math.min(H, p.y));
draw();
}
function onUp() { dragging = false; }
canvas.addEventListener('mousedown', onDown);
canvas.addEventListener('mousemove', onMove);
canvas.addEventListener('mouseup', onUp);
canvas.addEventListener('touchstart', onDown, { passive: false });
canvas.addEventListener('touchmove', onMove, { passive: false });
canvas.addEventListener('touchend', onUp);
linkInput.addEventListener('input', () => {
numLinks = +linkInput.value;
linkVal.textContent = numLinks;
initAngles();
draw();
});
document.getElementById('reset-btn').addEventListener('click', () => {
target = { x: BASE.x + 80, y: BASE.y - 160 };
initAngles();
draw();
});
initAngles();
draw();
Notice what happens with a long chain: there are infinitely many valid configurations, and CCD picks one by a greedy sweep. A short chain that can't quite reach the target will hover as close as it can — the solver converges on the nearest point, not failure. This asymmetry — easy to verify a solution, hard to find one globally optimal — is the heart of the inverse kinematics problem.
Comments
Loading comments...