From joints to hand and back

A robot arm is a chain: a base, a link, a joint, the next link, the next joint, and at the end a tool. Such a sequence of rigid links connected by joints is a kinematic chain. Moving one joint moves everything after it, which is why the position of the tool depends on all the joint angles together (the parts are described on Anatomy of a Robot).

Kinematics is the geometry of that chain, without forces or masses: where the parts are and how fast they move. It answers two questions that a robot controller asks over and over while the robot moves:

  • Forward: the joints are at these angles; where is the hand?
  • Inverse: the hand must go there; what must the joint angles be?

In a 3D library such as Three.js you can build the chain as a hierarchy of objects: each child's rotation is relative to its parent, so turning the shoulder carries the forearm along, exactly like a real arm. The simulation further down computes the positions directly with the formulas of this page instead.

Joint space and task space

The two questions live in two different spaces. The joint space of a robot is the set of all its joint values, one coordinate per joint: (θ1, θ2) for a two-link arm, six angles for a six-axis arm. The task space (or Cartesian space) is where the work happens: the x, y, z and orientation of the tool in a coordinate frame.

Industrial robots let you choose the space for each motion. A joint move (for example MoveJ in ABB's RAPID or PTP in KUKA's KRL) interpolates the joint angles: fast and free of singularity problems, but the tool follows a curve you did not draw. A linear move (MoveL, LIN) keeps the tool on a straight line, which needs inverse kinematics at every step and can fail near the places described under singularities. See Industrial Robots and Cobots in Practice.

Forward kinematics

Forward kinematics computes the pose of the end of the chain from the joint values. Take a planar arm with two links of length L_1 and L_2, the shoulder at the origin, and two revolute joints: \theta_1 measured from the x-axis, \theta_2 measured from the direction of the first link.

A two-link arm seen from above. The shoulder sits at the center of a ring-shaped workspace. Link 1, of length L1, leaves the shoulder at angle theta 1 from the x-axis. Link 2, of length L2, leaves the elbow at angle theta 2 from the dashed extension of link 1 and ends at the hand. The reachable workspace is the ring between an inner circle of radius 0.3 and an outer circle of radius 1.7. x y θ1 θ2 L1 L2 hand shoulder elbow inner edge r = 0.3 outer edge r = 1.7
The arm of the simulation below (L1 = 1.0, L2 = 0.7) at θ1 = 30° and θ2 = 45°: the hand is at (1.047, 1.176). The shaded ring is everything the hand can reach.

The elbow is at distance L_1 from the shoulder in direction \theta_1. The forearm points in direction \theta_1 + \theta_2, because the elbow angle is measured from the first link, not from the x-axis. Add the two vectors:

\begin{aligned} x &= L_1 \cos \theta_1 + L_2 \cos(\theta_1 + \theta_2) \\ y &= L_1 \sin \theta_1 + L_2 \sin(\theta_1 + \theta_2) \end{aligned}

Worked example with L1 = 1.0, L2 = 0.7, θ1 = 30°, θ2 = 45°:

  1. Elbow: (1.0 × cos 30°, 1.0 × sin 30°) = (0.866, 0.500).
  2. Forearm direction: 30° + 45° = 75°. Forearm vector: (0.7 × cos 75°, 0.7 × sin 75°) = (0.181, 0.676).
  3. Hand: (0.866 + 0.181, 0.500 + 0.676) = (1.047, 1.176).

A Python version of the simulation's forwardKinematics, with the same link lengths:

import math

L1, L2 = 1.0, 0.7          # link lengths, as in the simulation below

def forward_kinematics(t1, t2):
    """Elbow and hand position for joint angles t1, t2 (radians)."""
    elbow = (L1 * math.cos(t1), L1 * math.sin(t1))
    hand = (elbow[0] + L2 * math.cos(t1 + t2),
            elbow[1] + L2 * math.sin(t1 + t2))
    return elbow, hand

for deg1, deg2 in [(0, 0), (90, 0), (30, 45), (30, -45), (0, 180)]:
    _, (x, y) = forward_kinematics(math.radians(deg1), math.radians(deg2))
    print(f"theta1 {deg1:4}  theta2 {deg2:4}  ->  hand ({x:.3f}, {y:.3f})")
theta1    0  theta2    0  ->  hand (1.700, 0.000)
theta1   90  theta2    0  ->  hand (0.000, 1.700)
theta1   30  theta2   45  ->  hand (1.047, 1.176)
theta1   30  theta2  -45  ->  hand (1.542, 0.319)
theta1    0  theta2  180  ->  hand (0.300, 0.000)

The first two lines are the arm stretched out along x and along y, 1.7 from the shoulder. The last is the arm folded back on itself: the hand comes back to 0.3. Forward kinematics never fails: any pair of angles gives exactly one hand position. The trigonometry behind it is on Sine, cosine and tangent.

The reachable workspace

The distance from shoulder to hand is largest when the arm is stretched (\theta_2 = 0) and smallest when it is folded (\theta_2 = 180°). In between, every distance is possible, in every direction, because \theta_1 can turn the whole arm around. So the workspace (see Anatomy of a Robot) of a two-link arm with full joint ranges is a ring:

|L_1 - L_2| \le \sqrt{x^2 + y^2} \le L_1 + L_2

For the simulation's arm that is 0.3 to 1.7. A real arm has joint limits, cables and its own base in the way, so its workspace is a ring with pieces cut out; vendors print it as a drawing in the datasheet.

import math

L1, L2 = 1.0, 0.7
INNER, OUTER = abs(L1 - L2), L1 + L2           # 0.3 and 1.7

def reachable(x, y):
    return INNER - 1e-9 <= math.hypot(x, y) <= OUTER + 1e-9

for x, y in [(1.05, 1.18), (0.0, -1.7), (2.1, 0.0), (0.2, 0.1)]:
    print(f"({x:5}, {y:5})  |p| = {math.hypot(x, y):.3f}  reachable: {reachable(x, y)}")
( 1.05,  1.18)  |p| = 1.580  reachable: True
(  0.0,  -1.7)  |p| = 1.700  reachable: True
(  2.1,   0.0)  |p| = 2.100  reachable: False
(  0.2,   0.1)  |p| = 0.224  reachable: False

The tiny tolerance of 1e-9 matters at the edges: in floating point, abs(1.0 - 0.7) is 0.30000000000000004, so a point exactly 0.3 from the shoulder would fail a strict comparison (see comparing floats). The simulation's inverseKinematics uses the same tolerance.

Inverse kinematics

Inverse kinematics (IK) computes the joint values that put the end of the chain at a wanted pose. It is what a robot needs most often: a camera finds a part at (x, y), and the controller must work out the joint angles to get the gripper there.

For the two-link arm there is a closed-form solution. Look at the triangle shoulder–elbow–hand: its sides are L_1, L_2 and the distance r = \sqrt{x^2 + y^2}. The law of cosines connects them, and solving it for the elbow angle gives

\cos \theta_2 = \frac{x^2 + y^2 - L_1^2 - L_2^2}{2 L_1 L_2}, \qquad \theta_2 = \pm \arccos(\cos \theta_2)

If the right-hand side is above 1 or below −1, the target is outside the ring and there is no solution. Otherwise there are two elbow angles, one positive and one negative. For each, the shoulder angle is the direction to the target minus the angle the forearm adds:

\theta_1 = \operatorname{atan2}(y, x) - \operatorname{atan2}(L_2 \sin \theta_2,\; L_1 + L_2 \cos \theta_2)

atan2(y, x) is the angle of the point (x, y) with the correct quadrant (see the angle of a point); a plain arctangent of y / x would confuse opposite directions.

The two solutions are called elbow up and elbow down. Both put the hand at the same point; the elbow sits on one side or the other of the line from shoulder to target. The simulation follows the textbook convention: elbow up is the solution with \theta_2 \le 0.

import math

L1, L2 = 1.0, 0.7

def forward_kinematics(t1, t2):
    return (L1 * math.cos(t1) + L2 * math.cos(t1 + t2),
            L1 * math.sin(t1) + L2 * math.sin(t1 + t2))

def inverse_kinematics(x, y, elbow_up=True):
    """Joint angles (radians) that put the hand at (x, y), or None."""
    c2 = (x * x + y * y - L1 * L1 - L2 * L2) / (2 * L1 * L2)
    if abs(c2) > 1 + 1e-9:
        return None                                  # out of reach
    c2 = max(-1.0, min(1.0, c2))
    t2 = (-1 if elbow_up else 1) * math.acos(c2)
    t1 = math.atan2(y, x) - math.atan2(L2 * math.sin(t2), L1 + L2 * math.cos(t2))
    return t1, t2

target = forward_kinematics(math.radians(30), math.radians(45))
print("target:", tuple(round(v, 4) for v in target))

for elbow_up in (False, True):
    t1, t2 = inverse_kinematics(*target, elbow_up)
    back = forward_kinematics(t1, t2)
    print(f"elbow {'up  ' if elbow_up else 'down'}: theta1 {math.degrees(t1):6.2f}"
          f"  theta2 {math.degrees(t2):6.2f}  -> {tuple(round(v, 4) for v in back)}")

print("(2.1, 0):", inverse_kinematics(2.1, 0.0))
target: (1.0472, 1.1761)
elbow down: theta1  30.00  theta2  45.00  -> (1.0472, 1.1761)
elbow up  : theta1  66.64  theta2 -45.00  -> (1.0472, 1.1761)
(2.1, 0): None

The pose θ1 = 30°, θ2 = 45° is the elbow-down solution for its own hand position; the elbow-up solution reaches the same point with θ1 = 66.64° and θ2 = −45°. Feeding the angles back through forward kinematics lands on the target both times, which is the standard test for any IK code.

How many solutions?

  • None: the target is out of reach.
  • One: the target is exactly on the outer edge (stretched) or, here, the inner edge (folded); the two elbows coincide.
  • Two: anywhere inside the ring, elbow up and elbow down.
  • Infinitely many: when the arm has more joints than the task needs. A seven-axis arm can move its elbow while the hand stays still, and a two-link arm with equal links can reach its own shoulder with any θ1.

For arms with more joints the closed-form route gets long or does not exist, and a six-axis arm has several elbow and wrist combinations for one pose. Such arms are often solved numerically: start from the current angles and improve them step by step using the Jacobian (described below), with methods such as the Jacobian transpose, the pseudo-inverse or damped least squares. Libraries such as MoveIt 2 and KDL do this for you (see ROS 2); a controller typically prefers the solution closest to the current pose, so the arm does not swing its elbow over to the other side.

Try it: move the arm

The simulation below draws the two-link arm of this page (L1 = 1.0, L2 = 0.7, in scene units) from above, with the workspace ring as two dashed circles. It is a small model of the geometry, not of a real robot: there are no motors, masses or joint limits.

How to use it

Press Play first. Keys only reach the simulation after you click in the frame once or Tab to the drawing. The frame follows your browser's light or dark mode, not the theme switch of this site.

Mouse or touch Keyboard
Click or drag on the drawing to set a target (this switches to inverse mode) Tab to the drawing, then the arrow keys move the target by 0.05 (with Shift: 0.01)
Sliders θ1 and θ2 (switch to forward mode) The sliders work with the arrow keys, Page Up/Down and Home as usual
Sliders target x and target y E toggles the elbow (the letter E, on any keyboard layout)
Buttons Elbow and Reset; radio buttons Forward and Inverse All buttons and radio buttons can be reached with Tab

In inverse mode the θ sliders follow the arm, so you can read the angles; touching one of them goes back to forward mode. Switching to inverse mode keeps the arm where it is: the Elbow button always shows the elbow the arm has (the start pose, θ2 = 45°, is elbow down), and the arm only swings over to the other solution when you press Elbow. The readout shows the angles, the hand position, the reach and, in inverse mode, the target and its distance |p| from the shoulder. The drawing needs WebGL; without it the frame shows a message instead.

<main class="sim">
  <div class="stage">
    <canvas
      id="view"
      tabindex="0"
      aria-label="Two-link robot arm. Click or drag to set a target; arrow keys move the target."
    ></canvas>
    <p id="status" class="status" aria-live="polite"></p>
  </div>

  <div class="panel">
    <fieldset class="modes">
      <legend>Mode</legend>
      <label
        ><input type="radio" name="mode" id="mode-fk" checked /> Forward</label
      >
      <label><input type="radio" name="mode" id="mode-ik" /> Inverse</label>
    </fieldset>

    <div id="fk-controls" class="group">
      <label for="theta1"
        >θ1 shoulder <output id="theta1-out">30°</output></label
      >
      <input
        type="range"
        id="theta1"
        min="-180"
        max="180"
        step="1"
        value="30"
      />
      <label for="theta2">θ2 elbow <output id="theta2-out">45°</output></label>
      <input
        type="range"
        id="theta2"
        min="-180"
        max="180"
        step="1"
        value="45"
      />
    </div>

    <div id="ik-controls" class="group">
      <label for="target-x"
        >target x <output id="target-x-out">1.05</output></label
      >
      <input
        type="range"
        id="target-x"
        min="-2"
        max="2"
        step="0.01"
        value="1.05"
      />
      <label for="target-y"
        >target y <output id="target-y-out">1.18</output></label
      >
      <input
        type="range"
        id="target-y"
        min="-2"
        max="2"
        step="0.01"
        value="1.18"
      />
      <button id="elbow" type="button">Elbow: down</button>
    </div>

    <button id="reset" type="button">Reset</button>

    <pre id="readout" class="readout" aria-live="off"></pre>
  </div>
</main>
/* All colors in one place. script.js reads --paper, --ink, --grid,
   --soft and --accent from here, so the drawing follows dark mode too. */
:root {
  --paper: #ffffff;
  --ink: #111111;
  --muted: #666666;
  --grid: #e4e4e4;
  --soft: #b8b8b8;
  --accent: #677449;
  --font: system-ui, -apple-system, "Segoe UI", sans-serif;
  --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root {
    --paper: #161616;
    --ink: #ededed;
    --muted: #a3a3a3;
    --grid: #2a2a2a;
    --soft: #5a5a5a;
    --accent: #a6b082;
    color-scheme: dark;
  }
}

html,
body {
  height: 100%;
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font: 13px/1.4 var(--font);
}

/* Drawing on the left, controls on the right; stacked on a phone. */
.sim {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 15rem;
  height: 100%;
}

.stage {
  position: relative;
  min-height: 0;
}

#view {
  display: block;
  width: 100%;
  height: 100%;
  cursor: crosshair;
  touch-action: none; /* a finger drags the target, not the page */
}

#view:focus-visible {
  outline: 1px solid var(--ink);
  outline-offset: -4px;
}

.status {
  position: absolute;
  left: 12px;
  top: 8px;
  margin: 0;
  font: 12px/1.4 var(--mono);
  color: var(--accent);
  pointer-events: none;
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px 14px;
  overflow-y: auto;
  border-left: 1px solid var(--ink);
}

.modes {
  display: flex;
  gap: 14px;
  margin: 0;
  padding: 0;
  border: 0;
}

.modes legend {
  float: left;
  margin-right: 4px;
  color: var(--muted);
}

.group {
  display: grid;
  gap: 4px;
  padding-top: 10px;
  border-top: 1px solid var(--grid);
}

.group label {
  display: flex;
  justify-content: space-between;
}

output {
  font-family: var(--mono);
}

input[type="range"] {
  width: 100%;
  margin: 0 0 6px;
  accent-color: var(--accent);
}

input[type="radio"] {
  accent-color: var(--accent);
}

button {
  padding: 5px 10px;
  font: inherit;
  color: var(--ink);
  background: transparent;
  border: 1px solid var(--ink);
  border-radius: 0;
  cursor: pointer;
}

button:hover {
  background: var(--grid);
}

button:focus-visible,
input:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 2px;
}

.readout {
  margin: 0;
  padding: 8px;
  font: 12px/1.5 var(--mono);
  white-space: pre;
  border: 1px solid var(--grid);
}

.note {
  margin: 0;
  padding: 24px;
  color: var(--muted);
}

@media (max-width: 560px) {
  .sim {
    grid-template-columns: 1fr;
    grid-template-rows: minmax(240px, 60vh) auto;
    height: auto;
  }

  .panel {
    border-left: 0;
    border-top: 1px solid var(--ink);
  }
}
import * as THREE from "three";

// ---- ROBOT MATH (pure functions: no Three.js, no DOM) ----
// A planar arm with two links: the shoulder sits at the origin, the elbow
// at the end of link 1, the hand (end effector) at the end of link 2.
// Angles are in radians; θ1 is measured from the x-axis, θ2 from link 1.

const L1 = 1.0; // length of link 1 (upper arm)
const L2 = 0.7; // length of link 2 (forearm)
const VIEW_HALF = 1.95; // the drawing shows at least -1.95..1.95 both ways
const JOINT_RATE = 8; // how fast the joints catch up (higher = snappier)
const MAX_STEP = 0.05; // longest time step we accept, in seconds
const DEG = 180 / Math.PI;

/** Where the elbow and the hand are for joint angles t1, t2. */
function forwardKinematics(t1, t2, l1 = L1, l2 = L2) {
  const elbow = { x: l1 * Math.cos(t1), y: l1 * Math.sin(t1) };
  const hand = {
    x: elbow.x + l2 * Math.cos(t1 + t2),
    y: elbow.y + l2 * Math.sin(t1 + t2),
  };
  return { elbow, hand };
}

/** The hand can reach a ring: from |l1 - l2| to l1 + l2 from the shoulder. */
function reachLimits(l1 = L1, l2 = L2) {
  return { inner: Math.abs(l1 - l2), outer: l1 + l2 };
}

/**
 * Inverse kinematics in closed form (law of cosines).
 * Returns { t1, t2 }, or null when (x, y) is out of reach.
 * Elbow up: t2 <= 0, so the elbow lies to the left of the line from the
 * shoulder to the target; elbow down is the mirror image.
 */
function inverseKinematics(x, y, elbowUp = true, l1 = L1, l2 = L2) {
  const c2 = (x * x + y * y - l1 * l1 - l2 * l2) / (2 * l1 * l2);
  if (c2 > 1 + 1e-9 || c2 < -1 - 1e-9) return null;
  const t2 = (elbowUp ? -1 : 1) * Math.acos(Math.min(1, Math.max(-1, c2)));
  const t1 =
    Math.atan2(y, x) - Math.atan2(l2 * Math.sin(t2), l1 + l2 * Math.cos(t2));
  return { t1: wrapAngle(t1), t2: wrapAngle(t2) };
}

/** The reachable point closest to (x, y): pulled onto the ring if needed. */
function nearestReachable(x, y, l1 = L1, l2 = L2) {
  const { inner, outer } = reachLimits(l1, l2);
  const r = Math.hypot(x, y);
  if (r >= inner && r <= outer) return { x, y };
  const angle = r > 0 ? Math.atan2(y, x) : 0; // at the shoulder: point right
  const radius = r < inner ? inner : outer;
  return { x: radius * Math.cos(angle), y: radius * Math.sin(angle) };
}

/** An angle brought into -π..π. */
function wrapAngle(a) {
  const turn = 2 * Math.PI;
  const wrapped = a - turn * Math.floor((a + Math.PI) / turn);
  return wrapped === -Math.PI ? Math.PI : wrapped;
}

/** Moves an angle part of the way to its target, the short way round.
 *  The share depends on time, not frames: same motion at 60 or 144 Hz. */
function easeAngle(current, target, dt, rate = JOINT_RATE) {
  const share = 1 - Math.exp(-rate * dt);
  return wrapAngle(current + wrapAngle(target - current) * share);
}

/** Points (x, y, x, y, ...) on an arc around (cx, cy), from angle start
 *  over a signed sweep. Used for the joint angle marks. */
function arcPoints(cx, cy, r, start, sweep, count) {
  const points = [];
  for (let i = 0; i < count; i++) {
    const a = start + (sweep * i) / (count - 1);
    points.push(cx + r * Math.cos(a), cy + r * Math.sin(a));
  }
  return points;
}

/** An orthographic view that shows -half..half both ways on any canvas. */
function fitView(width, height, half = VIEW_HALF) {
  const aspect = width / Math.max(height, 1);
  const w = aspect >= 1 ? half * aspect : half;
  const h = aspect >= 1 ? half : half / aspect;
  return { left: -w, right: w, top: h, bottom: -h };
}

/** A pixel on the canvas (0,0 = top left) to a point in the world. */
function screenToWorld(px, py, width, height, view) {
  return {
    x: view.left + (px / width) * (view.right - view.left),
    y: view.top - (py / height) * (view.top - view.bottom),
  };
}

/** The text of the readout panel. */
function readoutText(t1, t2, target, mode, elbowUp) {
  const { hand } = forwardKinematics(t1, t2);
  const { inner, outer } = reachLimits();
  const lines = [
    `θ1     ${(t1 * DEG).toFixed(1).padStart(7)}°`,
    `θ2     ${(t2 * DEG).toFixed(1).padStart(7)}°`,
    `hand x ${hand.x.toFixed(3).padStart(7)}`,
    `hand y ${hand.y.toFixed(3).padStart(7)}`,
    `reach  ${inner.toFixed(2)} … ${outer.toFixed(2)}`,
  ];
  if (mode === "ik") {
    const r = Math.hypot(target.x, target.y);
    lines.push(`target ${target.x.toFixed(2)}, ${target.y.toFixed(2)}`);
    lines.push(`|p|    ${r.toFixed(3).padStart(7)}`);
    lines.push(`elbow  ${elbowUp ? "up" : "down"}`);
  }
  return lines.join("\n");
}
// ---- END OF ROBOT MATH ----

// ---- Page elements ----
const $ = (id) => document.getElementById(id);
const canvas = $("view");
const statusLine = $("status");
const sliders = {
  theta1: $("theta1"),
  theta2: $("theta2"),
  x: $("target-x"),
  y: $("target-y"),
};

// ---- State ----
const START = { t1: 30 / DEG, t2: 45 / DEG };
let mode = "fk"; // "fk" = sliders set the angles, "ik" = a target sets them
let elbowUp = START.t2 <= 0; // the start pose is elbow down
let angles = { t1: START.t1, t2: START.t2 }; // what the arm shows now
let goal = { t1: START.t1, t2: START.t2 }; // where the joints are heading
let target = forwardKinematics(START.t1, START.t2).hand;
let view = fitView(1, 1);

// ---- Renderer, scene and camera ----
let renderer;
try {
  renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
} catch (error) {
  statusLine.textContent = "WebGL is not available, so nothing can be drawn.";
  throw error;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10, 10);

// One material per role; applyColors() fills in the CSS colors.
const mat = {
  grid: new THREE.LineBasicMaterial(),
  axis: new THREE.LineBasicMaterial(),
  reach: new THREE.LineDashedMaterial({ dashSize: 0.05, gapSize: 0.04 }),
  ink: new THREE.LineBasicMaterial(),
  paper: new THREE.MeshBasicMaterial(),
  accent: new THREE.LineBasicMaterial(),
  accentFill: new THREE.MeshBasicMaterial(),
  dashed: new THREE.LineDashedMaterial({ dashSize: 0.04, gapSize: 0.04 }),
};

function readColors() {
  const style = getComputedStyle(document.documentElement);
  const pick = (name, fallback) =>
    style.getPropertyValue(name).trim() || fallback;
  return {
    paper: pick("--paper", "#ffffff"),
    ink: pick("--ink", "#111111"),
    grid: pick("--grid", "#e4e4e4"),
    soft: pick("--soft", "#b8b8b8"),
    accent: pick("--accent", "#b5441f"),
  };
}

function applyColors() {
  const c = readColors();
  scene.background = new THREE.Color(c.paper);
  mat.grid.color.set(c.grid);
  mat.axis.color.set(c.soft);
  mat.reach.color.set(c.soft);
  mat.ink.color.set(c.ink);
  mat.paper.color.set(c.paper);
  mat.accent.color.set(c.accent);
  mat.accentFill.color.set(c.accent);
  mat.dashed.color.set(c.accent);
}

// ---- Helpers that turn flat point lists into lines ----
function lineFrom(points, material, z = 0, closed = false) {
  const geometry = new THREE.BufferGeometry();
  const xyz = [];
  for (let i = 0; i < points.length; i += 2)
    xyz.push(points[i], points[i + 1], 0);
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(xyz, 3));
  const line = closed
    ? new THREE.LineLoop(geometry, material)
    : new THREE.Line(geometry, material);
  line.position.z = z;
  if (material.isLineDashedMaterial) line.computeLineDistances();
  return line;
}

/** Overwrites a line's points (same count) and tells Three.js. */
function setLine(line, points) {
  const position = line.geometry.attributes.position;
  for (let i = 0; i < points.length / 2; i++) {
    position.setXY(i, points[i * 2], points[i * 2 + 1]);
  }
  position.needsUpdate = true;
  line.geometry.computeBoundingSphere();
  if (line.material.isLineDashedMaterial) line.computeLineDistances();
}

// ---- The paper: hairline grid every 0.25, axes through the shoulder ----
const gridPoints = [];
for (let v = -6; v <= 6.001; v += 0.25) {
  gridPoints.push(v, -6, 0, v, 6, 0, -6, v, 0, 6, v, 0);
}
const gridGeometry = new THREE.BufferGeometry();
gridGeometry.setAttribute(
  "position",
  new THREE.Float32BufferAttribute(gridPoints, 3),
);
scene.add(new THREE.LineSegments(gridGeometry, mat.grid));
scene.add(lineFrom([-6, 0, 6, 0], mat.axis, 0.01));
scene.add(lineFrom([0, -6, 0, 6], mat.axis, 0.01));

// ---- The workspace: two dashed circles ----
const { inner, outer } = reachLimits();
for (const r of [inner, outer]) {
  scene.add(lineFrom(arcPoints(0, 0, r, 0, 2 * Math.PI, 129), mat.reach, 0.02));
}

// ---- The arm ----
/** A link: a rounded bar from (0,0) to (length,0), white with an outline. */
function makeLink(length, halfWidth, z) {
  const shape = new THREE.Shape();
  shape.absarc(length, 0, halfWidth, -Math.PI / 2, Math.PI / 2, false);
  shape.absarc(0, 0, halfWidth, Math.PI / 2, (3 * Math.PI) / 2, false);
  const group = new THREE.Group();
  const fill = new THREE.Mesh(new THREE.ShapeGeometry(shape, 12), mat.paper);
  const outline = shape.getPoints(12).flatMap((p) => [p.x, p.y]);
  group.add(fill, lineFrom(outline, mat.ink, 0.001, true));
  group.position.z = z;
  return group;
}

/** A pin joint: a small circle with a dot. */
function makeJoint(radius, z) {
  const group = new THREE.Group();
  group.add(new THREE.Mesh(new THREE.CircleGeometry(radius, 24), mat.paper));
  const ring = arcPoints(0, 0, radius, 0, 2 * Math.PI, 25);
  group.add(lineFrom(ring, mat.ink, 0.001));
  group.add(
    new THREE.Mesh(new THREE.CircleGeometry(radius * 0.3, 12), mat.accentFill),
  );
  group.position.z = z;
  return group;
}

// The base: a triangle on a hatched ground line, like a technical drawing.
const base = new THREE.Group();
base.add(lineFrom([0, 0, -0.09, -0.13, 0.09, -0.13], mat.ink, 0, true));
base.add(lineFrom([-0.16, -0.13, 0.16, -0.13], mat.ink));
for (let x = -0.14; x <= 0.15; x += 0.05) {
  base.add(lineFrom([x, -0.13, x - 0.04, -0.18], mat.ink));
}
base.position.z = 0.1;
scene.add(base);

const link1 = makeLink(L1, 0.055, 0.2);
const link2 = makeLink(L2, 0.045, 0.3);
const shoulder = makeJoint(0.045, 0.25);
const elbowJoint = makeJoint(0.04, 0.35);
const hand = new THREE.Mesh(
  new THREE.CircleGeometry(0.035, 20),
  mat.accentFill,
);
hand.position.z = 0.4;
link1.name = "link1";
link2.name = "link2";
elbowJoint.name = "elbow";
hand.name = "hand";
scene.add(link1, link2, shoulder, elbowJoint, hand);

// Angle marks: θ1 from the x-axis, θ2 from the extension of link 1.
const ARC_POINTS = 33;
const blankArc = new Array(ARC_POINTS * 2).fill(0);
const arc1 = lineFrom(blankArc, mat.accent, 0.15);
const arc2 = lineFrom(blankArc, mat.accent, 0.15);
const extension = lineFrom([0, 0, 0, 0], mat.reach, 0.15);
scene.add(arc1, arc2, extension);

// The target (inverse mode): a ring with a cross, and a dashed line to it
// when the hand cannot get there.
const targetMark = new THREE.Group();
targetMark.add(lineFrom(arcPoints(0, 0, 0.06, 0, 2 * Math.PI, 25), mat.accent));
targetMark.add(lineFrom([-0.1, 0, 0.1, 0], mat.accent));
targetMark.add(lineFrom([0, -0.1, 0, 0.1], mat.accent));
targetMark.position.z = 0.5;
const missLine = lineFrom([0, 0, 0, 0], mat.dashed, 0.45);
targetMark.name = "target";
missLine.name = "miss";
scene.add(targetMark, missLine);
applyColors();

// ---- Fit the drawing to the canvas, also after a resize ----
function resize() {
  const width = canvas.clientWidth || window.innerWidth;
  const height = canvas.clientHeight || window.innerHeight;
  renderer.setSize(width, height, false);
  view = fitView(width, height);
  Object.assign(camera, view);
  camera.updateProjectionMatrix();
}
window.addEventListener("resize", resize);
resize();
const dark = window.matchMedia("(prefers-color-scheme: dark)");
dark.addEventListener("change", applyColors);

// ---- Controls ----
function setMode(next) {
  mode = next;
  $("mode-fk").checked = mode === "fk";
  $("mode-ik").checked = mode === "ik";
  if (mode === "ik") {
    // Start from where the hand is now, with the elbow it has now, so
    // nothing jumps.
    target = forwardKinematics(angles.t1, angles.t2).hand;
    setElbow(angles.t2 <= 0);
    syncTargetSliders();
  }
}

function syncTargetSliders() {
  sliders.x.value = target.x.toFixed(2);
  sliders.y.value = target.y.toFixed(2);
  $("target-x-out").textContent = target.x.toFixed(2);
  $("target-y-out").textContent = target.y.toFixed(2);
}

function syncAngleSliders(t1, t2) {
  const pairs = [
    [sliders.theta1, $("theta1-out"), t1],
    [sliders.theta2, $("theta2-out"), t2],
  ];
  for (const [slider, out, angle] of pairs) {
    const degrees = String(Math.round(angle * DEG));
    if (out.textContent === degrees + "°") continue; // unchanged
    slider.value = degrees;
    out.textContent = degrees + "°";
  }
}

function setTarget(x, y) {
  if (mode !== "ik") setMode("ik");
  const limit = 2;
  target = {
    x: Math.min(limit, Math.max(-limit, x)),
    y: Math.min(limit, Math.max(-limit, y)),
  };
  syncTargetSliders();
}

function onAngleSlider() {
  if (mode !== "fk") setMode("fk");
  goal = {
    t1: Number(sliders.theta1.value) / DEG,
    t2: Number(sliders.theta2.value) / DEG,
  };
  setElbow(goal.t2 <= 0);
  syncAngleSliders(goal.t1, goal.t2);
}
sliders.theta1.addEventListener("input", onAngleSlider);
sliders.theta2.addEventListener("input", onAngleSlider);
sliders.x.addEventListener("input", () =>
  setTarget(Number(sliders.x.value), target.y),
);
sliders.y.addEventListener("input", () =>
  setTarget(target.x, Number(sliders.y.value)),
);
$("mode-fk").addEventListener("change", () => {
  setMode("fk");
  onAngleSlider();
});
$("mode-ik").addEventListener("change", () => setMode("ik"));

function setElbow(up) {
  elbowUp = up;
  const button = $("elbow");
  button.textContent = "Elbow: " + (up ? "up" : "down");
}
function toggleElbow() {
  if (mode !== "ik") setMode("ik");
  setElbow(!elbowUp);
}
$("elbow").addEventListener("click", toggleElbow);

$("reset").addEventListener("click", () => {
  setMode("fk");
  syncAngleSliders(START.t1, START.t2);
  onAngleSlider();
});

// Mouse, pen or finger: press or drag on the drawing to place the target.
let dragging = false;
function pointerTarget(event) {
  const rect = canvas.getBoundingClientRect();
  const p = screenToWorld(
    event.clientX - rect.left,
    event.clientY - rect.top,
    rect.width,
    rect.height,
    view,
  );
  setTarget(p.x, p.y);
}
canvas.addEventListener("pointerdown", (event) => {
  dragging = true;
  canvas.setPointerCapture?.(event.pointerId);
  pointerTarget(event);
});
canvas.addEventListener("pointermove", (event) => {
  if (dragging) pointerTarget(event);
});
const stopDrag = () => (dragging = false);
canvas.addEventListener("pointerup", stopDrag);
canvas.addEventListener("pointercancel", stopDrag);

// Keyboard on the drawing: arrows move the target (Shift = finer), E flips
// the elbow. Arrow keys must not scroll the page. Arrows by position
// (event.code), letters by what the key types (event.key), so E works on
// every keyboard layout.
const ARROWS = {
  ArrowLeft: [-1, 0],
  ArrowRight: [1, 0],
  ArrowUp: [0, 1],
  ArrowDown: [0, -1],
};
canvas.addEventListener("keydown", (event) => {
  const arrow = ARROWS[event.code];
  if (arrow) {
    event.preventDefault();
    const step = event.shiftKey ? 0.01 : 0.05;
    if (mode !== "ik") setMode("ik");
    setTarget(target.x + arrow[0] * step, target.y + arrow[1] * step);
  } else if (event.key.toLowerCase() === "e") {
    toggleElbow();
  }
});

// ---- The animation loop ----
const timer = new THREE.Timer();
timer.connect(document);
let lastStatus = "";
let lastReadout = "";

function update(dt) {
  let reachable = true;
  if (mode === "ik") {
    const solution = inverseKinematics(target.x, target.y, elbowUp);
    reachable = solution !== null;
    // Out of reach: stretch toward the target as far as the arm goes.
    const near = nearestReachable(target.x, target.y);
    goal = solution || inverseKinematics(near.x, near.y, elbowUp);
  }
  angles = {
    t1: easeAngle(angles.t1, goal.t1, dt),
    t2: easeAngle(angles.t2, goal.t2, dt),
  };
  if (mode === "ik") syncAngleSliders(angles.t1, angles.t2);
  return reachable;
}

function draw(reachable) {
  const { t1, t2 } = angles;
  const { elbow, hand: tip } = forwardKinematics(t1, t2);
  link1.rotation.z = t1;
  link2.position.set(elbow.x, elbow.y, link2.position.z);
  link2.rotation.z = t1 + t2;
  elbowJoint.position.set(elbow.x, elbow.y, elbowJoint.position.z);
  hand.position.set(tip.x, tip.y, hand.position.z);

  setLine(arc1, arcPoints(0, 0, 0.32, 0, t1, ARC_POINTS));
  setLine(arc2, arcPoints(elbow.x, elbow.y, 0.24, t1, t2, ARC_POINTS));
  const ext = 0.32;
  setLine(extension, [
    elbow.x,
    elbow.y,
    elbow.x + ext * Math.cos(t1),
    elbow.y + ext * Math.sin(t1),
  ]);

  targetMark.visible = mode === "ik";
  targetMark.position.set(target.x, target.y, targetMark.position.z);
  missLine.visible = mode === "ik" && !reachable;
  if (missLine.visible) setLine(missLine, [tip.x, tip.y, target.x, target.y]);

  const r = Math.hypot(target.x, target.y);
  const { inner: rMin, outer: rMax } = reachLimits();
  let status = "";
  if (mode === "ik" && !reachable) {
    status =
      r > rMax
        ? `Out of reach: |p| = ${r.toFixed(2)} > ${rMax.toFixed(2)}`
        : `Out of reach: |p| = ${r.toFixed(2)} < ${rMin.toFixed(2)}`;
  }
  if (status !== lastStatus) statusLine.textContent = lastStatus = status;
  const text = readoutText(t1, t2, target, mode, elbowUp);
  if (text !== lastReadout) $("readout").textContent = lastReadout = text;
}

syncTargetSliders(); // the target starts where the hand is
renderer.setAnimationLoop((time) => {
  timer.update(time);
  const dt = Math.min(timer.getDelta(), MAX_STEP);
  draw(update(dt));
  renderer.render(scene, camera);
});

What to try

  • In forward mode, set θ1 to 30° and θ2 to 45° and compare the readout's hand position with the Python example above: (1.047, 1.176). The Elbow button reads "down": this pose is the elbow-down solution.
  • Set θ2 to 0. The arm is stretched and the hand sits on the outer circle, 1.7 from the shoulder. This is a singularity (see below): the hand cannot move straight outward at all.
  • Switch to inverse mode and drag the target outside the outer circle. The status line says "Out of reach" with the distance, a dashed line runs from the hand to the target, and the arm stretches toward the nearest point it can reach.
  • Put the target somewhere inside the ring and press Elbow (or E). The arm swings over to the other solution and ends with the hand on the same target; θ2 changes sign. Move the target to the left of the shoulder and the "up" solution hangs below the line to the target; it is the same solution, mirrored.
  • Move the target close to the inner circle. The arm folds until θ2 nears ±180°, the second singularity: small target moves now swing the joints a long way. Inside the inner circle the target is out of reach again.

The code below the frame is the complete simulation (HTML, CSS and JavaScript), ready to paste into a CodePen Pen; the HTML goes inside <body>. Its "ROBOT MATH" section at the top holds the pure functions this page explains: forwardKinematics (the forward kinematics example is a Python version of it), reachLimits, inverseKinematics (mirrored in the inverse kinematics example) and nearestReachable. It imports Three.js as import * as THREE from "three"; CodePen 2.0 adds Three.js for you. The simulation was built against Three.js r186 (three@0.186.1, as of October 2026). For CodePen's Classic editor, see If you use the Classic editor.

Denavit–Hartenberg parameters

For two links in a plane you can draw the triangle. For six links in space you need a system, and the classic one comes from Jacques Denavit and Richard Hartenberg ("A Kinematic Notation for Lower-Pair Mechanisms Based on Matrices", 1955). Denavit–Hartenberg parameters (DH parameters) describe each link of a serial arm with four numbers, after attaching a coordinate frame to every joint by a fixed set of rules:

  • \theta: the rotation about the joint's z-axis (the joint angle, for a revolute joint);
  • d: the offset along that z-axis (the joint value, for a prismatic joint);
  • a: the length of the link, along the new x-axis;
  • \alpha: the twist between this joint's z-axis and the next one, about the new x-axis.

Each row of the table becomes one homogeneous transform, and the pose of the tool is the product of all of them, from the base outward:

A_i = \begin{pmatrix} \cos \theta_i & -\sin \theta_i \cos \alpha_i & \sin \theta_i \sin \alpha_i & a_i \cos \theta_i \\ \sin \theta_i & \cos \theta_i \cos \alpha_i & -\cos \theta_i \sin \alpha_i & a_i \sin \theta_i \\ 0 & \sin \alpha_i & \cos \alpha_i & d_i \\ 0 & 0 & 0 & 1 \end{pmatrix}, \qquad T = A_1 A_2 \cdots A_n

The two-link arm is the simplest table there is: two rows with d = 0 and \alpha = 0, and the link lengths as a. The product gives the same hand position as the formulas above, plus the orientation of the hand (its x-axis points along the forearm, at 75°):

import math

L1, L2 = 1.0, 0.7

def dh(theta, d, a, alpha):
    """Standard Denavit-Hartenberg transform: Rz(theta) Tz(d) Tx(a) Rx(alpha)."""
    ct, st = math.cos(theta), math.sin(theta)
    ca, sa = math.cos(alpha), math.sin(alpha)
    return [[ct, -st * ca,  st * sa, a * ct],
            [st,  ct * ca, -ct * sa, a * st],
            [0,   sa,       ca,      d],
            [0,   0,        0,       1]]

def matmul(A, B):
    return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]

# The two-link arm as a DH table: one row (theta, d, a, alpha) per joint
t1, t2 = math.radians(30), math.radians(45)
table = [(t1, 0.0, L1, 0.0),
         (t2, 0.0, L2, 0.0)]

T = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
for row in table:
    T = matmul(T, dh(*row))

print("hand position:", [round(T[i][3], 4) + 0.0 for i in range(3)])
print("hand x-axis:  ", [round(T[i][0], 4) + 0.0 for i in range(3)])
hand position: [1.0472, 1.1761, 0.0]
hand x-axis:   [0.2588, 0.9659, 0.0]

The Jacobian: speeds instead of positions

Kinematics also answers a question about speed: if the joints turn at certain speeds, how fast does the hand move, and in which direction? The answer is a matrix, the Jacobian J(q): the matrix of partial derivatives of the hand position with respect to every joint value. It maps joint speeds \dot q to hand velocity \dot x, and it changes with the pose q:

\dot x = J(q) \, \dot q

Each column says how the hand moves when one joint turns and the others stand still (the derivative of the forward kinematics). For the two-link arm, with the shorthand s_{12} = \sin(\theta_1 + \theta_2) and c_{12} = \cos(\theta_1 + \theta_2):

J = \begin{pmatrix} -L_1 \sin \theta_1 - L_2 s_{12} & -L_2 s_{12} \\ L_1 \cos \theta_1 + L_2 c_{12} & L_2 c_{12} \end{pmatrix}, \qquad \det J = L_1 L_2 \sin \theta_2

Going the other way, from a wanted hand velocity to joint speeds, means solving J \dot q = \dot x. That is possible only while the determinant is not zero, and it becomes expensive in joint speed as the determinant shrinks. The example moves the hand straight outward, away from the shoulder, at 0.1 m/s and asks what the two joints must do as the arm straightens:

import math

L1, L2 = 1.0, 0.7

def jacobian(t1, t2):
    s1, c1 = math.sin(t1), math.cos(t1)
    s12, c12 = math.sin(t1 + t2), math.cos(t1 + t2)
    return [[-L1 * s1 - L2 * s12, -L2 * s12],
            [ L1 * c1 + L2 * c12,  L2 * c12]]

def joint_speeds(J, vx, vy):
    """Solve J q = v for the two joint speeds (rad/s)."""
    (a, b), (c, d) = J
    det = a * d - b * c
    return (d * vx - b * vy) / det, (a * vy - c * vx) / det, det

# Move the hand straight outward (away from the shoulder) at 0.1 m/s
for deg2 in (90, 45, 10, 2):
    t1, t2 = 0.0, math.radians(deg2)
    hx = L1 + L2 * math.cos(t2)
    hy = L2 * math.sin(t2)
    r = math.hypot(hx, hy)
    w1, w2, det = joint_speeds(jacobian(t1, t2), 0.1 * hx / r, 0.1 * hy / r)
    print(f"theta2 {deg2:3} deg  det J {det:.4f}  joint speeds "
          f"{math.degrees(w1):8.1f} {math.degrees(w2):8.1f} deg/s")
theta2  90 deg  det J 0.7000  joint speeds      3.3    -10.0 deg/s
theta2  45 deg  det J 0.4950  joint speeds      7.2    -18.2 deg/s
theta2  10 deg  det J 0.1216  joint speeds     32.8    -79.8 deg/s
theta2   2 deg  det J 0.0244  joint speeds    164.1   -398.6 deg/s

Singularities

A kinematic singularity is a pose in which the Jacobian loses rank (for a square Jacobian: its determinant is zero), so the hand cannot move in some direction no matter what the joints do. For the two-link arm, \det J = L_1 L_2 \sin \theta_2 is zero at θ2 = 0° (stretched) and θ2 = 180° (folded): at both, the hand can only move sideways, never along the arm.

The singular pose itself is rarely the problem; the neighborhood is. The example shows the joint speeds needed for a modest 0.1 m/s growing from a few degrees per second to almost 400°/s at θ2 = 2°. A real arm cannot do that, so the hand would leave its straight line, or the controller would command a dangerous whip-like motion. Industrial controllers therefore watch for singularities on linear moves and slow down, stop with an error or switch to a joint move. A six-axis arm has more of these poses than the two-link arm, for instance where two of its joint axes line up. Path planners keep a margin around them, and a cell is laid out so that the robot's normal work stays clear of them.