Joint angles that put a robot link at a target, and timed joint moves with their speeds, accelerations and peak torques.
How the precision is kept
Inverse kinematics is an iteration that stops at 1e-6 m and 1e-6 rad by default, and is reported as reached within ten times that. Trajectory profiles are closed forms in 64-bit. Peak torque is the largest of 51 samples along the move.
Calculations under this policy
9 calculations in 2 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
ik.jsShared shell, every app64-bit3 calculationsReviewed
How far a link is from its target in position and orientation.
e = (p_target − p, rotation vector of R_target Rᵀ)
Position error by subtraction; orientation error through the rotation vector, with its small-angle and near-180° branches.
ik.js
lines 8 to 14
/** Pose error of `link` (point `local`) against target {p?, R?}: [Δp (3), rotation vector of R_t·Rᵀ (3)] in world axes. */
export function poseError(robot, q, link, target, local = [0, 0, 0], base) {
const T = robot.pose(q, link, base), pw = M.T_apply(T, local), e = [];
if (target.p) e.push(...M.sub(target.p, pw));
if (target.R) e.push(...M.rotvec(M.mmul(target.R, M.mT(T.R))));
return e;
}
Inverse kinematics
64-bit
The joint values that bring a link to a target position, orientation or both.
Δq = Jᵀ (J Jᵀ + λ² I)⁻¹ e
Stops when the position error is at most 1e-6 m and the rotation error at most 1e-6 rad (defaults), or after 300 iterations, or when 12 tries of the damping give no improvement. The answer is flagged ok when it is within ten times the tolerances, so a returned pose can be up to 1e-5 m from the target and still count as reached. Steps are capped at 0.3 per iteration and joints are clamped to their limits.
ik.js
lines 17 to 55
/**
* ik(robot, link, target, q0, opts) → {ok, q, iterations, error: {pos, rot}, problems}
* target {p?: [x,y,z], R?: 3×3}: position only, orientation only, or both. opts: local, base, maxIter (300), tol (1e-6 m), rotTol (1e-6 rad),
* lambda (0.1), weightRot (1), stepMax (0.3 rad or m per iteration), limits (true).
*/
export function ik(robot, link, target, q0 = null, opts = {}) {
const { local = [0, 0, 0], base, maxIter = 300, tol = 1e-6, rotTol = 1e-6, weightRot = 1, stepMax = 0.3, limits = true } = opts;
let lambda = opts.lambda ?? 0.1;
const n = robot.dof, lim = robot.limits(), problems = [];
if (!robot.ok) return { ok: false, q: q0 || new Array(n).fill(0), iterations: 0, error: { pos: NaN, rot: NaN }, problems: robot.problems.slice() };
if (!target.p && !target.R) return { ok: false, q: q0 || new Array(n).fill(0), iterations: 0, error: { pos: NaN, rot: NaN }, problems: [M.problem('target', 'error', 'The target has neither a position nor an orientation.')] };
let q = limits ? clampQ(q0 || new Array(n).fill(0), lim) : (q0 || new Array(n).fill(0)).slice();
const rows = (target.p ? 3 : 0) + (target.R ? 3 : 0);
const errNorm = (e) => { let i = 0, pe = 0, re = 0; if (target.p) { pe = Math.hypot(e[0], e[1], e[2]); i = 3; } if (target.R) re = Math.hypot(e[i], e[i + 1], e[i + 2]); return { pos: pe, rot: re }; };
const weigh = (e) => { if (!target.R) return e; const o = e.slice(); const s = target.p ? 3 : 0; for (let k = 0; k < 3; k++) o[s + k] *= weightRot; return o; };
let e = poseError(robot, q, link, target, local, base), er = errNorm(e), cost = er.pos ** 2 + (weightRot * er.rot) ** 2, it = 0;
for (; it < maxIter; it++) {
if (er.pos <= tol && er.rot <= rotTol) break;
const Jf = robot.jacobian(q, link, local, base); // 6×n
let J = []; if (target.p) J.push(Jf[0], Jf[1], Jf[2]); if (target.R) for (const r of [3, 4, 5]) J.push(Jf[r].map((v) => v * weightRot));
let improved = false;
for (let tries = 0; tries < 12 && !improved; tries++) {
const A = M.zeros(rows, rows); for (let i = 0; i < rows; i++) for (let j = 0; j < rows; j++) { let s = 0; for (let k = 0; k < n; k++) s += J[i][k] * J[j][k]; A[i][j] = s + (i === j ? lambda * lambda : 0); }
const y = M.solve(A, weigh(e)); if (!y) { lambda *= 4; continue; }
let dq = new Array(n).fill(0); for (let k = 0; k < n; k++) for (let i = 0; i < rows; i++) dq[k] += J[i][k] * y[i];
const big = Math.max(...dq.map(Math.abs)); if (big > stepMax) dq = dq.map((v) => v * stepMax / big);
let qn = q.map((v, i) => v + dq[i]); if (limits) qn = clampQ(qn, lim);
const en = poseError(robot, qn, link, target, local, base), ern = errNorm(en), cn = ern.pos ** 2 + (weightRot * ern.rot) ** 2;
if (cn < cost) { q = qn; e = en; er = ern; cost = cn; lambda = Math.max(1e-4, lambda * 0.5); improved = true; } else lambda *= 3;
}
if (!improved) break; // stuck: a joint limit, a singularity or an unreachable target
}
const ok = er.pos <= tol * 10 && er.rot <= rotTol * 10;
if (!ok) {
const atLimit = limits ? robot.names.filter((_, i) => Math.abs(q[i] - lim[i].lower) < 1e-9 || Math.abs(q[i] - lim[i].upper) < 1e-9) : [];
problems.push(M.problem('ik.unreached', 'warning', `Inverse kinematics did not reach the target: ${er.pos.toExponential(2)} m and ${er.rot.toExponential(2)} rad short after ${it} iterations` + (atLimit.length ? `; joint ${atLimit.join(', ')} is at its limit.` : '. The target may be out of reach or at a singularity.'), [{ id: link }], { found: er, req: { pos: tol, rot: rotTol } }));
}
return { ok, q, iterations: it, error: er, problems };
}
Planar two-link arm, closed form
64-bit
Both elbow solutions of a two-link arm for a tip position.
cos q₂ = (x² + y² − l₁² − l₂²) ÷ (2 l₁ l₂); q₁ = atan2(y, x) − atan2(l₂ sin q₂, l₁ + l₂ cos q₂)
Closed form. The acos argument is clamped, and a value up to 1e-12 outside [−1, 1] is still accepted. Near full stretch acos is ill-conditioned, which is a property of the problem. A link length of zero divides by zero.
ik.js
lines 57 to 67
/** Closed-form IK of a planar two-link arm (links l1, l2) for a tip at (x, y): both elbow solutions [{q1, q2}] ([] when out of reach). */
export function ikPlanar2(l1, l2, x, y) {
const r2 = x * x + y * y, c2 = (r2 - l1 * l1 - l2 * l2) / (2 * l1 * l2);
if (c2 > 1 + 1e-12 || c2 < -1 - 1e-12) return [];
const out = [];
for (const s of [1, -1]) {
const q2 = s * Math.acos(Math.max(-1, Math.min(1, c2))), q1 = Math.atan2(y, x) - Math.atan2(l2 * Math.sin(q2), l1 + l2 * Math.cos(q2));
out.push({ q1, q2 });
}
return out;
}
traj.jsShared shell, every app64-bit6 calculationsReviewed
Position, speed and acceleration of a rest-to-rest move with a speed and an acceleration limit.
t_a = v ÷ a; T = 2 t_a + (d − v² ÷ a) ÷ v; short move: t_a = √(d ÷ a), T = 2 t_a
Closed forms. The deceleration phase is computed backward from the end, x = d − ½ a (T − t)², so the position at t = T is the distance itself.
traj.js
lines 10 to 26
/** 1-D rest-to-rest move of `delta` with peak speed vmax and acceleration amax: triangular when the distance is too short to reach vmax. */
export function trapezoid(delta, vmax, amax) {
const s = Math.sign(delta) || 1, d = Math.abs(delta);
if (!(vmax > 0) || !(amax > 0)) return { T: NaN, at: () => ({ x: NaN, v: NaN, a: NaN }) };
const ta = vmax / amax, da = vmax * vmax / amax;
let tA, tC, vp;
if (d >= da) { tA = ta; vp = vmax; tC = (d - da) / vmax; } else { tA = Math.sqrt(d / amax); vp = amax * tA; tC = 0; }
const T = 2 * tA + tC;
return { T, peak: vp * s, at(t) {
t = Math.min(Math.max(t, 0), T);
let x, v, a;
if (t < tA) { a = amax; v = amax * t; x = 0.5 * amax * t * t; }
else if (t < tA + tC) { a = 0; v = vp; x = 0.5 * amax * tA * tA + vp * (t - tA); }
else { const u = T - t; a = -amax; v = amax * u; x = d - 0.5 * amax * u * u; }
return { x: s * x, v: s * v, a: s * a };
} };
}
Cubic profile
64-bit
A move with zero speed at both ends.
q = q₀ + Δ (3τ² − 2τ³)
The polynomial is written out term by term, not in Horner form. At τ = 1 the bracket is 3 − 2 = 1, so the end value is q₀ + (q₁ − q₀), which can differ from q₁ in the last bit.
traj.js
lines 27 to 28
/** Cubic with zero end velocities over T: q(t) = q0 + Δ(3τ² − 2τ³). Quintic with zero end velocity and acceleration: Δ(10τ³ − 15τ⁴ + 6τ⁵). */
export function cubic(q0, q1, T) { return { T, at(t) { const u = Math.min(Math.max(t / T, 0), 1), d = q1 - q0; return { x: q0 + d * (3 * u * u - 2 * u ** 3), v: d * (6 * u - 6 * u * u) / T, a: d * (6 - 12 * u) / T / T }; } }; }
Quintic profile
64-bit
A move with zero speed and acceleration at both ends.
q = q₀ + Δ (10τ³ − 15τ⁴ + 6τ⁵)
Written out with ** powers and evaluated term by term. At τ = 1 the bracket is 10 − 15 + 6 = 1, so the end value is q₀ + (q₁ − q₀).
traj.js
line 29
export function quintic(q0, q1, T) { return { T, at(t) { const u = Math.min(Math.max(t / T, 0), 1), d = q1 - q0; return { x: q0 + d * (10 * u ** 3 - 15 * u ** 4 + 6 * u ** 5), v: d * (30 * u * u - 60 * u ** 3 + 30 * u ** 4) / T, a: d * (60 * u - 180 * u * u + 120 * u ** 3) / T / T }; } }; }
Synchronized joint move
64-bit
All joints start and stop together; the slowest joint sets the time.
qᵢ(t) = aᵢ + (bᵢ − aᵢ) × s(t) ÷ ΔS
Every joint follows the slowest joint's profile scaled by 1 ÷ ΔS. The scale is a reciprocal multiplied in, so the end value can differ from the target in the last bit.
traj.js
lines 31 to 46
/**
* jointMove(robot, qa, qb, {vmax, amax}) → {T, at(t) → {q, qd, qdd}, problems}. All joints start and stop together: the slowest joint sets T.
* vmax / amax may be numbers or arrays; the default velocity comes from each joint's limit.velocity, else 1.
*/
export function jointMove(robot, qa, qb, { vmax, amax = 2 } = {}) {
const n = robot.dof, vm = (i) => Array.isArray(vmax) ? vmax[i] : (vmax ?? robot.dofJoints[i].limit?.velocity ?? 1), am = (i) => Array.isArray(amax) ? amax[i] : amax;
const prof = qa.map((a, i) => trapezoid(qb[i] - a, vm(i), am(i))), T = Math.max(0, ...prof.map((p) => p.T));
const problems = [], lim = robot.limits();
qb.forEach((v, i) => { if (v < lim[i].lower || v > lim[i].upper) problems.push(M.problem('limit', 'warning', `Target for joint "${robot.names[i]}" (${v}) is outside its limit.`, [{ id: robot.names[i] }])); });
// every joint follows the slowest joint's profile scaled to its own distance, so all start and stop together
const si = prof.reduce((b, p, i) => (p.T > prof[b].T ? i : b), 0), dS = qb[si] - qa[si];
return { T, problems, at(t) {
const s = T > 0 && dS !== 0 ? prof[si].at(t) : { x: 0, v: 0, a: 0 }, g = dS !== 0 ? 1 / dS : 0;
return { q: qa.map((a, i) => a + (qb[i] - a) * s.x * g), qd: qa.map((a, i) => (qb[i] - a) * s.v * g), qdd: qa.map((a, i) => (qb[i] - a) * s.a * g) };
} };
}
Straight-line move
64-bit
Joint values along a straight line of the tool in space.
p_k = p_A + (p_B − p_A) k ÷ n, each solved by inverse kinematics
The line is cut into n equal steps (20 by default) and each point is solved from the previous answer. Between the points the path is not constrained, and each point is within the stop tolerance of inverse kinematics, not on the line to rounding.
traj.js
lines 48 to 58
/** Straight line in space for `link` from the current pose to point `pB`, n samples through IK (each seeded from the last). → {q[][], ok, problems} */
export function linePath(robot, link, q0, pB, { n = 20, R, local, base } = {}) {
const T0 = robot.pose(q0, link, base), pA = M.T_apply(T0, local || [0, 0, 0]), q = [q0.slice()], problems = [];
let cur = q0.slice(), ok = true;
for (let k = 1; k <= n; k++) {
const p = M.add(pA, M.scale(M.sub(pB, pA), k / n)), r = ik(robot, link, R ? { p, R } : { p }, cur, { local, base });
if (!r.ok) { ok = false; problems.push(...r.problems.map((x) => ({ ...x, id: x.id + '@' + k, text: x.text + ` (step ${k} of ${n} along the line)` }))); break; }
cur = r.q; q.push(cur);
}
return { q, ok, problems };
}
Peak joint torques of a move
64-bit
The largest torque each joint needs, against its effort limit.
peak = max |τ| over samples + 1 evenly spaced times (51 by default)
Torques come from inverse dynamics at evenly spaced times. The peak is the largest sample, so a maximum between samples is missed. The comparison with the effort limit has no margin.
traj.js
lines 60 to 66
/** Peak joint torques of a move against each joint's effort limit. → {peak[], problems} */
export function checkTorques(robot, move, { samples = 50, gravity } = {}) {
const peak = new Array(robot.dof).fill(0), problems = [];
for (let k = 0; k <= samples; k++) { const s = move.at(move.T * k / samples), tau = rnea(robot, s.q, s.qd, s.qdd, { gravity }); tau.forEach((v, i) => { peak[i] = Math.max(peak[i], Math.abs(v)); }); }
robot.dofJoints.forEach((j, i) => { const e = j.limit?.effort; if (e && peak[i] > e) problems.push(M.problem('effort', 'warning', `Joint "${j.name}" needs a peak of ${peak[i].toFixed(2)} (N or N·m) on this move; its limit is ${e}.`, [{ id: j.name }], { found: peak[i], req: e })); });
return { peak, problems };
}
In detail
Method
Damped least squares with adaptive damping (the file cites Wampler 1986 and Nakamura and Hanafusa 1986) and joint-limit clamping; trapezoidal, cubic and quintic profiles.
Number types
64-bit numbers.
Tolerances
Inverse kinematics: position 1e-6 m, rotation 1e-6 rad, 300 iterations, step limit 0.3 rad or m, up to 12 damping tries per iteration; success means within 10 times the tolerances. Planar two-link: cos q₂ may exceed ±1 by 1e-12 before the target counts as out of reach. A joint counts as at its limit within 1e-9.
Checked by
The engines test runs a planar two-link example and checks that inverse kinematics reports the target reached.
Limits
The joint angles from inverse kinematics are only as close as the stop tolerance. A straight-line move is 20 such solutions by default.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.