Resources Numerical accuracy Calculation policy

Assembly joints and part placement

Works out where each part of an assembly sits from its joints: mate frames on faces, the motion each joint allows, and the chain of transforms from the grounded part.

How the precision is kept

Placements are 4 × 4 column-major matrices of 64-bit numbers, multiplied in order from the grounded part outward. Joint angles are in degrees and converted with Math.PI at use. Rigid inverses use the transpose of the rotation block, so no general matrix inversion is done. Each product down a chain adds rounding, and the rotation blocks are not re-orthonormalized.

Open item. Store solved joint values without the 1e-9 rounding, or round relative to the size of the value.

Calculations under this policy

7 calculations in 1 source file. 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.

assembly.js PartCraft 0.8.1 64-bit 7 calculations Reviewed

Tests bin/test-partcraft.mjs, tools/partcraft-test/test-joints.mjs, tools/partcraft-test/test-asm-loops.mjs

Inverse of a rigid transform

64-bit

Inverts a rotation-plus-translation matrix.

R⁻¹ = Rᵀ; t⁻¹ = −Rᵀ t

Uses the transpose, so the result is as orthonormal as the input and no division occurs. If the rotation block has drifted, the result is not the true inverse; the function does not check.

  • assembly.js
  • lines 111 to 117
  • PartCraft 0.8.1
/** Inverse of a rigid (rotation + translation) column-major matrix. */
export function rigidInverse(m) {
  const r = [m[0], m[4], m[8], 0, m[1], m[5], m[9], 0, m[2], m[6], m[10], 0];
  const t = [m[12], m[13], m[14]];
  return [r[0], r[1], r[2], 0, r[4], r[5], r[6], 0, r[8], r[9], r[10], 0,
    -(r[0] * t[0] + r[4] * t[1] + r[8] * t[2]), -(r[1] * t[0] + r[5] * t[1] + r[9] * t[2]), -(r[2] * t[0] + r[6] * t[1] + r[10] * t[2]), 1];
}

Mate frame on a face

64-bit

The frame a joint end uses: the face's axes, with the origin moved to where the chosen axis crosses the face.

o = a + d × ((f − a) · n) ÷ (d · n)

A line-plane intersection in 64-bit. The division is guarded: an axis with |d · n| below 1e-9 is refused with a message. The guard is absolute, on vectors that come from the kernel's frames.

  • assembly.js
  • lines 128 to 141
  • PartCraft 0.8.1
/** The mate frame (part coordinates) of a joint end, or throws Error. */
export function mateFrame(end, build, part) {
  const f = planeFrame(end.plane, build, part);
  if (!f) throw new Error(t('model.assembly.face_gone', { part: part.name }));
  let o = f.o;
  if (end.axis) {
    const ax = axesOf(build, part).find((a) => a.feature === end.axis.feature && (a.index ?? null) === (end.axis.index ?? null));
    if (!ax) throw new Error(t('model.assembly.axis_gone', { part: part.name }));
    const den = V.dot(ax.d, f.n);
    if (Math.abs(den) < 1e-9) throw new Error(t('model.assembly.axis_along_face'));
    o = V.add(ax.o, V.mul(ax.d, V.dot(V.sub(f.o, ax.o), f.n) / den));
  }
  return V.basisMatrix(f.x, f.y, f.n, o);
}

Joint value inside its limits

64-bit

A joint's angle or distance, held between its minimum and maximum.

value = min(max, max(min, v))

A clamp only; the value is not rounded. A missing value counts as 0.

  • assembly.js
  • lines 151 to 156
  • PartCraft 0.8.1
/** A joint's value, held inside its limits when it is one the joint lets move (default: the first). */
export function jointValue(j, key = JOINT_DOF[j.type]?.[0] || 'angle') {
  const v = j[key] || 0;
  const L = limitOf(j, key);
  return L ? Math.max(L.min, Math.min(L.max, v)) : v;
}

Motion of a joint

64-bit

The transform of part B relative to part A's mate frame, from the joint's angles and distances.

revolute, slider, cylindrical, rigid: T(0, 0, offset) × Rz(angle); planar: T(dx, dy, 0) × Rz; ball: Rx(tiltX) × Ry(tiltY) × Rz; pin-slot: T(s cos δ, s sin δ, 0) × Rz

Angles are stored in degrees and converted with Math.PI ÷ 180 at each use. Rotations come from the axis-angle formula in vec.js. The products are 64-bit.

  • assembly.js
  • lines 157 to 167
  • PartCraft 0.8.1
/** The motion of B's frame in A's (after the flip), from the joint's values. */
export function jointMotion(j) {
  const val = (k) => (JOINT_DOF[j.type]?.includes(k) ? jointValue(j, k) : (j[k] || 0));
  const Rz = (deg) => V.rotationAbout([0, 0, 0], [0, 0, 1], deg);
  switch (j.type) {
    case 'planar': return V.matMul(V.translation([val('dx'), val('dy'), 0]), Rz(val('angle')));
    case 'ball': return V.matMul(V.rotationAbout([0, 0, 0], [1, 0, 0], val('tiltX')), V.matMul(V.rotationAbout([0, 0, 0], [0, 1, 0], val('tiltY')), Rz(val('angle'))));
    case 'pinslot': { const d = (j.slotDir || 0) * Math.PI / 180, s = val('slide'); return V.matMul(V.translation([s * Math.cos(d), s * Math.sin(d), 0]), Rz(val('angle'))); }
    default: return V.matMul(V.translation([0, 0, val('offset')]), Rz(val('angle')));   // rigid, revolute, slider, cylindrical
  }
}

Placement walk and loop mismatch

64-bit

Places each part from one already placed through their joint, and measures how far a loop-closing joint is from closing.

W_B = W_A × F_A × motion × FLIP × F_B⁻¹; off = max |want − have| (rotation entries × 100)

Each placed part is the product of all the matrices back to the grounded part, so rounding grows with chain length. The mismatch compares matrix entries directly; translation entries count in millimeters and rotation entries are multiplied by a fixed 100, not by the size of the assembly.

  • assembly.js
  • lines 210 to 238
  • PartCraft 0.8.1
/** The tree walk: every joint in order places the end not yet placed from the one that is; a joint whose ends are both placed
 *  closes a loop (measured when `measure`). → { world, closing: [{ joint, want, have, off? }] } */
function walk(asm, joints, frames, vals, measure = false) {
  const world = new Map(), up = new Map();   // up: instance → { joint, parent } (the tree the walk made)
  const ground = asm.instances[0];
  world.set(ground.id, ground.place);
  const done = new Set(), closing = [];
  let grew = true;
  while (grew) {
    grew = false;
    for (const j of joints) {
      if (done.has(j.id)) continue;
      const ha = world.has(j.a.inst), hb = world.has(j.b.inst);
      if (!ha && !hb) continue;
      done.add(j.id);
      const [FA, , K] = frames.get(j.id);
      const chain = chainOf(j, FA, K, vals?.get(j.id));
      if (ha && !hb) { world.set(j.b.inst, V.matMul(world.get(j.a.inst), chain)); up.set(j.b.inst, { joint: j.id, parent: j.a.inst }); grew = true; }
      else if (hb && !ha) { world.set(j.a.inst, V.matMul(world.get(j.b.inst), rigidInverse(chain))); up.set(j.a.inst, { joint: j.id, parent: j.b.inst }); grew = true; }
      else {
        const want = V.matMul(world.get(j.a.inst), chain), have = world.get(j.b.inst);
        let off;
        if (measure) { off = 0; for (let k = 0; k < 15; k++) if (k % 4 !== 3) off = Math.max(off, Math.abs(want[k] - have[k]) * (k >= 12 ? 1 : 100)); }
        closing.push({ joint: j, want, have, off });
      }
    }
  }
  return { world, closing, up };
}

Storing solved joint values

64-bit

Writes the values that close the loops into the joints, which is what the file saves.

stored = round(v × 1e9) ÷ 1e9

The stored value is rounded to 9 decimal places (degrees or millimeters). The rounding step is absolute (1e-9 degrees or millimeters), not relative to the size of the value, so digits beyond the ninth decimal that the solver found are dropped.

  • assembly.js
  • lines 494 to 502
  • PartCraft 0.8.1
/** Write a solve's values into the joints (the loop closed as shown). Values are rounded to 1e-9. */
export function applySolved(asm, loops) {
  if (!loops || !loops.values) return;
  for (const [id, o] of loops.values) {
    const j = asm.joints.find((x) => x.id === id);
    if (!j) continue;
    for (const [k, v] of Object.entries(o)) j[k] = Math.round(v * 1e9) / 1e9;
  }
}

Moving a free part by hand

64-bit

Applies a typed move or rotation to a part's stored placement.

M′ = T(move) × R(about, axis, angle) × M

The rotation axis is normalized and refused when shorter than 1e-12. After the products, entries smaller than 1e-15 in absolute size are set to 0; other entries are kept as computed. Repeated moves multiply onto the stored matrix, so rounding accumulates.

  • assembly.js
  • lines 546 to 557
  • PartCraft 0.8.1
export function movePlace(place, { move = null, rotate = null } = {}) {
  let m = place;
  if (rotate) {
    if (!rotate.axis.every(Number.isFinite) || V.len(rotate.axis) < 1e-12 || !Number.isFinite(rotate.angle) || !rotate.about.every(Number.isFinite)) throw new Error(t('model.assembly.rotation_needs'));
    m = V.matMul(V.rotationAbout(rotate.about, V.norm(rotate.axis), rotate.angle), m);
  }
  if (move) {
    if (!move.every(Number.isFinite)) throw new Error(t('model.assembly.move_needs'));
    m = V.matMul(V.translation(move), m);
  }
  return m.map((v) => (Math.abs(v) < 1e-15 ? 0 : v));
}

In detail

Method
Mate frame from a face's frame and an optional axis crossing; B's frame placed on A's with a half-turn flip, then the joint's own motion; a tree walk from the first (grounded) instance.
Number types
64-bit numbers in plain arrays of 16.
Tolerances
An axis counts as lying in its face when |d · n| < 1e-9 (absolute). A loop is reported as open when its mismatch exceeds 1e-6, with rotation entries weighted by 100. Solved joint values are rounded to 1e-9 before they are stored. Moved placements have entries below 1e-15 set to 0.
Limits
Rotation blocks drift from orthonormal by rounding along a long chain; rigidInverse assumes the block is a rotation.

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.

Every policy that takes this approach