Works out each bone's rest frame from its head, tail and roll, and the matrices that carry the mesh from rest to a pose.
How the precision is kept
Matrices are arrays of 16 64-bit numbers in column order. A bone's frame comes from its head and tail by cross products; a pose is composed down the bone tree; the skin matrix is the posed frame times the inverse of the rest frame. The inverse is the general 4×4 cofactor form.
Calculations under this policy
6 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.
Sixteen sums of four 64-bit products each, written to a new plain array.
anim/rig.js
lines 27 to 32
ModelCraft 0.8.1
/** Column-major 4×4 product a × b. */
export function mul(a, b) {
const o = new Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
return o;
}
4×4 matrix inverse
64-bit
The inverse of a 4×4 matrix by cofactors.
M⁻¹ = adjugate(M) ÷ det(M)
The determinant is a sum of products of 2×2 minors and can lose digits when the matrix is nearly singular. Only a determinant of exactly 0 returns the identity; there is no tolerance scaled to the matrix.
The matrix from a bone's own frame to the mesh: Y from head to tail, Z as near the mesh's +Z as it can be, turned by the roll.
y = (tail − head) made unit; z = (up − y (up · y)) made unit; x = y × z; roll turns x and z about y
The up reference switches from +Z to +Y when |y_z| is 0.999 or more. A bone whose head and tail coincide gets a zero Y axis, because the length falls back to 1. The roll angle is degrees × π ÷ 180.
anim/rig.js
lines 52 to 65
ModelCraft 0.8.1
/** A bone's rest frame → mesh (see the header). */
export function boneRest(b) {
const y = norm(sub(b.tail, b.head));
const up = Math.abs(y[2]) < 0.999 ? [0, 0, 1] : [0, 1, 0];
let z = norm(sub(up, y.map((v) => v * dot(up, y))));
let x = cross(y, z);
if (b.roll) {
// turn X and Z about Y
const r = (b.roll * Math.PI) / 180, c = Math.cos(r), s = Math.sin(r);
const x2 = [x[0] * c - z[0] * s, x[1] * c - z[1] * s, x[2] * c - z[2] * s], z2 = [x[0] * s + z[0] * c, x[1] * s + z[1] * c, x[2] * s + z[2] * c];
x = x2; z = z2;
}
return [x[0], x[1], x[2], 0, y[0], y[1], y[2], 0, z[0], z[1], z[2], 0, b.head[0], b.head[1], b.head[2], 1];
}
Posed bone and skin matrices
64-bit
Each bone's posed frame and the matrix that moves rest points to the pose.
W = W_parent × R_parent⁻¹ × R × local; skin = W × R⁻¹
Parents are done before children. Each level adds two inverses and three products, so rounding grows with the depth of the bone chain.
anim/rig.js
lines 79 to 92
ModelCraft 0.8.1
export function poseMatrices(rig, poseOf = () => REST_POSE) {
const rest = restMatrices(rig), world = new Map(), skin = new Map();
for (const b of order(rig)) {
const p = poseOf(b.id) || REST_POSE;
const local = compose({ t: p.location || [0, 0, 0], q: eulerToQuat(p.rotation || [0, 0, 0]), s: p.scale || [1, 1, 1] });
const R = rest.get(b.id);
let W;
if (b.parent && world.has(b.parent)) W = mul(mul(world.get(b.parent), mul(inv(rest.get(b.parent)), R)), local);
else W = mul(R, local);
world.set(b.id, W);
skin.set(b.id, mul(W, inv(R)));
}
return { world, skin, rest };
}
Skin matrices as dual quaternions
64-bit
Each bone's turn and move as a unit dual quaternion, for dual-quaternion skinning.
dual part = ½ × (t, 0) × q
The matrix is decomposed by render/anim.js and its scale is left out. The eight numbers per bone are kept in a Float64Array.
anim/rig.js
lines 156 to 167
ModelCraft 0.8.1
/** Each bone's skin matrix as a unit dual quaternion [qx, qy, qz, qw, dx, dy, dz, dw] (its turn and move; its size is left out). */
export function dualQuats(M, nb) {
const out = new Float64Array(nb * 8);
for (let b = 0; b < nb; b++) {
const m = Array.from(M.subarray(b * 16, b * 16 + 16)), d = decompose(m), q = d.q, t = d.t;
// dual part = ½ · (t, 0) · q
const dx = 0.5 * (t[0] * q[3] + t[1] * q[2] - t[2] * q[1]), dy = 0.5 * (-t[0] * q[2] + t[1] * q[3] + t[2] * q[0]);
const dz = 0.5 * (t[0] * q[1] - t[1] * q[0] + t[2] * q[3]), dw = -0.5 * (t[0] * q[0] + t[1] * q[1] + t[2] * q[2]);
out.set([q[0], q[1], q[2], q[3], dx, dy, dz, dw], b * 8);
}
return out;
}
The location or rotation a bone gets when it is dragged in the view.
turn: angle swept round the bone's head on screen, about the view axis carried into the bone's frame; move: pixels × pixel size, along the camera's right and up, carried into the bone's frame
The angle comes from two Math.atan2 calls on pointer positions, so it has the resolution of the pointer. The new pose is always worked out from the pose at the start of the drag, so the steps of one drag do not add up.
anim/rig-view.js
lines 111 to 146
ModelCraft 0.8.1
/** The pose the drag gives: a turn about the view's axis through the bone's head, or a move in the view's plane. */
posed(d, ev) {
const { rg, s, base, W } = d;
const { world } = poseMatrices(rg, (id) => bonePose(this.tl.values, rg.id, id));
const Wb = world.get(s.bone);
let p;
if (d.move) {
// pixels → mesh units at the bone's head, along the camera's right and up, into the bone's own frame
const head = apply(W, point(Wb, [0, 0, 0])), px = this.h.cam.pixelSize(head);
const cam = this.h.cam, fwd = norm([cam.target[0] - cam.eye[0], cam.target[1] - cam.eye[1], cam.target[2] - cam.eye[2]]);
const upW = Math.abs(fwd[2]) < 0.999 ? [0, 0, 1] : [0, 1, 0];
const right = norm([fwd[1] * upW[2] - fwd[2] * upW[1], fwd[2] * upW[0] - fwd[0] * upW[2], fwd[0] * upW[1] - fwd[1] * upW[0]]);
const up = [right[1] * fwd[2] - right[2] * fwd[1], right[2] * fwd[0] - right[0] * fwd[2], right[0] * fwd[1] - right[1] * fwd[0]];
const dw = [0, 1, 2].map((i) => ((ev.x - d.x) * right[i] - (ev.y - d.y) * up[i]) * px);
const dm = applyDir(invert(W), dw);
// a move in the mesh → the bone's local frame (its pose's location is in its rest frame, parent's pose applied)
const local = invDir(mul(Wb, inv(compose({ t: base.location, q: eulerToQuat(base.rotation), s: base.scale }))), dm);
p = { location: base.location.map((v, i) => v + local[i]), rotation: base.rotation, scale: base.scale };
} else {
// the turn: the angle swept round the bone's head on screen
const h = this.h.cam.project(apply(W, point(Wb, [0, 0, 0])));
if (!h) return null;
const a0 = Math.atan2(d.y - h[1], d.x - h[0]), a1 = Math.atan2(ev.y - h[1], ev.x - h[0]);
const ang = -(a1 - a0); // screen y runs down
const cam = this.h.cam, axisW = norm([cam.eye[0] - cam.target[0], cam.eye[1] - cam.target[1], cam.eye[2] - cam.target[2]]);
const axisM = norm(applyDir(invert(W), axisW));
// the turn in the mesh, carried into the bone's frame: X = Wb⁻¹ · R · Wb (about the bone's own origin, its head)
const R = compose({ t: [0, 0, 0], q: [axisM[0] * Math.sin(ang / 2), axisM[1] * Math.sin(ang / 2), axisM[2] * Math.sin(ang / 2), Math.cos(ang / 2)], s: [1, 1, 1] });
const Wr = Wb.slice(); Wr[12] = Wr[13] = Wr[14] = 0;
const X = mul(inv(Wr), mul(R, Wr));
const L0 = compose({ t: base.location, q: eulerToQuat(base.rotation), s: base.scale });
const L1 = mul(L0, X), dd = decompose(L1);
p = { location: base.location, rotation: quatToEuler(dd.q, base.rotation), scale: base.scale };
}
return keyOrPose(this.tl, d.start, rg.id, s.bone, p);
}
In detail
Method
4×4 products and cofactor inverse; bone frame by Gram–Schmidt against world +Z; skin matrix = world × rest⁻¹; dual quaternions from the decomposed matrix.
Number types
64-bit numbers; skin matrices in a Float64Array.
Tolerances
A bone within 0.999 of vertical uses +Y as its up reference. The inverse returns the identity only when the determinant is exactly 0.
Checked by
bin/test-animation.mjs loads the development build's copy of anim/rig.js and calls boneRest, poseMatrices, mul and inv.
Limits
A nearly singular matrix is inverted as it is; only an exactly zero determinant is caught.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.