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.
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.
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.
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.
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.