devol.dev

Inverse kinematics

Forward kinematics is arithmetic. Given the joint angles, the end effector is wherever the trigonometry says it is, and there is exactly one answer.

Inverse kinematics runs the other direction, and it is not arithmetic. Given a point in space, what joint angles put the hand there? For a two-link planar arm: usually two sets, sometimes one, often none.

Planar manipulator / 2-DOF

Inverse kinematics, two ways

Drag the target. The solver finds both joint configurations that put the end effector there. Change the link lengths and watch the reachable workspace open a hole in the middle.

Theta 1
Theta 2
Target
Reach

The two branches

Drag the target anywhere inside the shaded region. The solver returns both configurations: solid is elbow-down, ghost is elbow-up. Both put the end effector on exactly the same point.

The elbow angle comes from the law of cosines on the shoulder-elbow-hand triangle:

cosθ2=r2L12L222L1L2\cos\theta_2 = \frac{r^2 - L_1^2 - L_2^2}{2 L_1 L_2}

acos returns a magnitude, not a sign. That missing sign is the whole story: ±θ₂ are both valid, each implying a different shoulder angle. Two solutions, one target.

Why it matters that there are two

If you are writing the controller, you have to pick one, and the choice is not cosmetic.

Elbow-up sweeps a different volume than elbow-down. Put a shelf above the arm and one branch collides while the other does not. Joint limits may rule out a branch entirely. And you cannot switch branches mid-path without passing through a singularity, so a real trajectory has to commit to a branch and stay there, or plan an explicit reconfiguration.

Turn on the trace and drag in a slow circle. Watch how far the joints travel for a small movement near the workspace edge. That ratio is what bites you in practice.

The hole in the middle

Set the two links to different lengths and a dead zone opens around the shoulder. The arm cannot fold tighter than |L₁ − L₂|, so there is a disc of space, right next to the base, that it can never reach.

Now drag the sliders until both links are equal. The hole closes completely. An arm with equal links can touch its own shoulder; an arm with unequal links has a permanent blind spot at close range. That is a real design constraint, and it is visible in about four seconds of dragging.

Singularities

Push the target all the way out until the arm is straight. The ghost and the solid arm converge: the two solutions have merged into one. That is a singularity. The elbow angle is zero, the arm has lost a degree of freedom, and the manipulability measure

w=L1L2sinθ2w = |L_1 L_2 \sin\theta_2|

has gone to zero along with it. Near that boundary, moving the hand outward by a millimetre demands an enormous joint rotation. Controllers that naively invert the Jacobian here produce commanded velocities no motor can deliver.

The same thing happens at the inner boundary, where the arm is folded back on itself.

The code

The same solver in C++, returning both branches rather than picking one, because picking one is a decision about your workspace and not about the mathematics:

// Shoulder at the origin, +x right, +y up, angles in radians and
// counter-clockwise positive. theta1 is measured from +x; theta2 is
// measured relative to link 1, not from +x.
constexpr double kEps = 1e-9;

struct Vec2 { double x, y; };

struct Angles { double t1, t2; };

// The reachable annulus. The outer radius is the obvious one. The inner
// radius is the fold limit: the arm cannot bring its hand closer to the
// shoulder than |l1 - l2|, so unequal links leave a disc around the base
// that nothing can reach. Equal links make it zero and the hole closes.
struct Workspace { double inner, outer; };

Workspace workspace(double l1, double l2) {
    return {std::abs(l1 - l2), l1 + l2};
}

struct Solution {
    bool reachable;     // was the target inside the annulus as given
    double r;           // radius of the target as given
    Workspace ws;
    Vec2 clamped;       // the target after clamping into the annulus
    Angles down, up;    // both branches, always; the caller picks
};

// Forward kinematics, for the round trip. One answer, no branches.
struct Pose { Vec2 elbow, hand; };

Pose fk(double t1, double t2, double l1, double l2) {
    const double ex = l1 * std::cos(t1);
    const double ey = l1 * std::sin(t1);
    return {{ex, ey},
            {ex + l2 * std::cos(t1 + t2), ey + l2 * std::sin(t1 + t2)}};
}

Solution ik(double x, double y, double l1, double l2) {
    const Workspace ws = workspace(l1, l2);

    const double r = std::hypot(x, y);
    const bool reachable = r <= ws.outer + kEps && r >= ws.inner - kEps;

    // Clamp the target radius into the annulus, keeping its direction, so
    // an unreachable request still yields the nearest pose the arm can
    // actually hold. Report it through `reachable` rather than silently
    // pretending the point was fine.
    double cr = std::clamp(r, ws.inner, ws.outer);
    Vec2 c;
    if (r < kEps) {
        // Degenerate: the target sits exactly on the shoulder, so it has
        // no direction. Pick +x to give the atan2 below something defined.
        cr = ws.inner;
        c = {ws.inner, 0.0};
    } else {
        const double k = cr / r;
        c = {x * k, y * k};
    }

    // Law of cosines on the shoulder-elbow-hand triangle. Both clamps
    // above earn their place here: divided through unguarded, a target
    // past l1 + l2 or inside |l1 - l2| drives the quotient outside
    // [-1, 1], acos returns NaN, and the NaN propagates through every
    // angle to the renderer, which draws nothing and says nothing. The
    // arm goes blank in the two places the post is about.
    const double cos2 =
        std::clamp((cr * cr - l1 * l1 - l2 * l2) / (2 * l1 * l2), -1.0, 1.0);
    const double t2mag = std::acos(cos2);

    // acos returns a magnitude. The sign it threw away is the second
    // solution, and each sign implies its own shoulder angle.
    auto branch = [&](double sign) -> Angles {
        const double t2 = sign * t2mag;
        const double t1 = std::atan2(c.y, c.x) -
                          std::atan2(l2 * std::sin(t2), l1 + l2 * std::cos(t2));
        return {t1, t2};
    };

    return {reachable, r, ws, c, branch(1.0), branch(-1.0)};
}

// Yoshikawa's manipulability, which for a planar 2R is just this. It goes
// to zero exactly where the two branches meet, and it is the number to
// watch before inverting a Jacobian near either boundary.
double manipulability(double t2, double l1, double l2) {
    return std::abs(l1 * l2 * std::sin(t2));
}