← Natura·Docs/Modules/Source

mechanism-test.js

129 lines · in natura-src-1.0.0.zip as natura/mechanism-test.js · a test

// mechanism-test.js -- assertions for the Natura MECHANISM module (gear + linkage kinematics).
const M = require('./mechanism.js');
const TAU = Math.PI * 2;
let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log('  FAIL: ' + m); } };
const near = (a, b, e) => Math.abs(a - b) < (e || 1e-6);
console.log('mechanism assertions:');

// ---- gear-ratio facts ----
const A = { cx: 0, cy: 0, rp: 0.6, teeth: 20, ratio: 1, base: 0, phi: 0 };
ok(near(M.moduleOf(A), 0.03), 'module = rp/teeth');
ok(M.teethForRadius(A, 0.36) === 12, 'teethForRadius rounds to module');
ok(near(M.meshedRatio(A, 0.36), -0.6 / 0.36), 'meshed gear turns slower (r ratio) + reversed');

// ---- epicyclic carrier rate ----
ok(near(M.epicyclicCarrierRate(12, 24), 1 / 3), 'planetary carrier = Ns/(Ns+Nr)');

// ---- the carrier-frame contact rate (the key correction) ----
const sun = { teeth: 12, ratio: 1 }, planet = { teeth: 6, ratio: -1 };
ok(near(Math.abs(M.toothContactRate(sun, 1 / 3)), 8), 'sun-planet contact rate = 12*|1 - 1/3| = 8 (carrier frame)');
ok(near(Math.abs(M.toothContactRate(planet, 1 / 3)), 8), 'planet contact rate = 6*|-1 - 1/3| = 8 (matches: they mesh)');
ok(!near(Math.abs(M.toothContactRate(planet, 0)), 8), 'using absolute spin (omegaLoc 0) gives the WRONG rate -- the bug this guards');

// ---- mesh detection + phase ----
const B = M.meshGear(A, 0, 12);                                  // mesh a 12-tooth gear to the right of A
ok(near(B.cx, A.rp + B.rp) && near(B.cy, 0), 'meshed gear placed tangent (centre-dist = rpA+rpB)');
ok(M.gearsMesh(A, B), 'gearsMesh detects the pair');
ok(!M.gearsMesh(A, { cx: 5, cy: 0, rp: 0.3, teeth: 10 }), 'far-apart gears do not mesh');
// A (base 0) has a TOOTH at the contact (gamma=0); mesh phase should put a B GAP facing it (B's near side is world-angle pi)
const pB = (((Math.PI - B.base) * B.teeth / TAU) % 1 + 1) % 1;   // B's tooth-fraction at the contact (0 = tooth, 0.5 = gap)
ok(near(pB, 0.5, 0.02), 'mesh phase puts a B GAP (half-tooth) opposite the A tooth, so teeth interleave');

// ---- involute geometry ----
const Rb = M.baseRadius(0.6);
ok(Rb < 0.6 && Rb > 0.55, 'base radius = rp*cos(20deg) ~ 0.94 rp');
const p0 = M.involutePoint(Rb, 0);
ok(near(p0.x, Rb) && near(p0.y, 0), 'involute starts on the base circle at t=0');
ok(near(M.involuteRollAt(Rb, Rb), 0), 'roll angle at the base radius is 0');
ok(M.involuteRollAt(Rb, 0.6) > 0, 'the involute reaches the pitch radius at a positive roll angle');

// ---- mesh points of a built assembly (a compound: input->A meshes, B(coaxial)->C meshes) ----
const m = M.moduleOf(A), rI = 8 * m, rA = 18 * m, rBg = 12 * m, rC = 18 * m;
const comp = M.makeMechanism({ gears: [
  { cx: -0.6 - (rI + rA), cy: 0, rp: rI, teeth: 8, ratio: 1, base: 0, phi: 0 },     // input, placed tangent to A so they actually mesh
  { cx: -0.6, cy: 0, rp: rA, teeth: 18, ratio: -8 / 18, base: 0, phi: 0 },          // A
  { cx: -0.6, cy: 0, rp: rBg, teeth: 12, ratio: -8 / 18, base: 0, phi: 0 },         // B coaxial with A (compound; no mesh between them)
  { cx: -0.6 + (rBg + rC), cy: 0, rp: rC, teeth: 18, ratio: (-8 / 18) * (-12 / 18), base: 0, phi: 0 },  // C meshes B
] });
ok(comp.meshPoints().length === 2, 'compound train has 2 mesh points (coaxial A/B do NOT mesh)');

// ---- the solver: a crank-rocker four-bar closes to one shape and the coupler stays a rigid length ----
const cr = M.makeMechanism({
  J: [ { x: -1.5, y: -0.6, kind: 'pin' }, { x: 1.2, y: -0.6, kind: 'pin' }, { x: -1.5, y: 0.0, kind: 'drv' }, { x: 1.0, y: 0.5, kind: 'free' } ],
  B: [ { a: 2, b: 3 }, { a: 1, b: 3 } ],
  driven: [ { j: 2, piv: 0, r: 0.6, base: Math.PI / 2 } ], iterations: 80,
});
const couplerLen = cr.B[0].len, rockerLen = cr.B[1].len;
let okLen = true, moved = false;
for (let s = 0; s < 40; s++) { const th = s / 40 * TAU; cr.solve(th);
  if (Math.abs(Math.hypot(cr.J[2].x - cr.J[3].x, cr.J[2].y - cr.J[3].y) - couplerLen) > 0.02) okLen = false;
  if (Math.abs(Math.hypot(cr.J[1].x - cr.J[3].x, cr.J[1].y - cr.J[3].y) - rockerLen) > 0.02) okLen = false;
  if (s === 20 && Math.abs(cr.J[3].y - 0.5) > 0.05) moved = true;   // the free joint actually moves as the crank turns
}
ok(okLen, 'four-bar: both bars hold their length through a full crank revolution (solver converges)');
ok(moved, 'four-bar: the free joint is actually driven (not stuck at its seed)');

// ---- module-true tooth sizing (so meshing teeth actually reach + touch) ----
ok(near(M.addendumRadius(A), 0.6 * (1 + 2 / 20)), 'addendum tip = rp(1+2/N) -- scales with tooth count, so teeth actually reach the mesh');
ok(near(M.dedendumRadius(A), 0.6 * (1 - 2.5 / 20)), 'dedendum root = rp(1-2.5/N)');
ok(M.addendumRadius({ rp: 0.6, teeth: 12 }) > M.addendumRadius({ rp: 0.6, teeth: 40 }), 'a coarser (fewer-teeth) gear has a taller tooth -- a FIXED tip factor would be wrong for both');
ok(M.toothHalfAngle(20) < Math.PI / (2 * 20), 'tooth is a hair under a full half-pitch thick -> a little backlash');

// ---- the driver->follower lead (NOT a magic motor centring both) ----
const fol = { ratio: -20 / 12, teeth: 12 };
ok(M.backlashLead(fol) > 0, 'backlash lead is a positive angle (half the circumferential lash, as the gear turns)');
ok(Math.sign(M.followerLag(fol, 1)) === -Math.sign(fol.ratio), 'a follower LAGS its own motion (offset opposes its omega) -> its trailing flank takes the driver push');
ok(M.followerLag(fol, 1) === -M.followerLag(fol, -1), 'the lag flips sign on reversal -- the backlash clunk onto the far flank');
ok(M.followerLag({ ratio: 1, teeth: 12 }, 1) !== 0 && M.backlashLead({ teeth: 12 }, 1) === 0, 'a zero-backlash (full-thickness) gear has no lead -> teeth touch on both flanks');

// ---- the instant centre of a four-bar coupler (the WHY of the coupler curve) ----
const ic = M.instantCentre({ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 4, y: 0 }, { x: 3, y: 1 });   // lines y=x and y=4-x
ok(ic && near(ic.x, 2) && near(ic.y, 2), 'instant centre = where the two pivot->joint lines cross (here (2,2))');
ok(M.instantCentre({ x: 0, y: 0 }, { x: 0, y: 1 }, { x: 4, y: 0 }, { x: 4, y: 1 }) === null, 'parallel links -> centre at infinity (null) -> pure translation -> a locally straight curve');
const icp = M.instantCentre({ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 4, y: 0 }, { x: 3, y: 1 });   // lines y=2x and y=4-x -> cross at (4/3, 8/3)
ok(icp !== null && near(icp.x, 4 / 3) && near(icp.y, 8 / 3), 'a generic four-bar has a finite instant centre at the crossing');

// ---- the Geneva / Maltese stop: intermittent motion ----
ok(M.genevaCross(0, 6).engaged && near(M.genevaCross(0, 6).crossAngle, 0), 'Geneva: at driver 0 the cross is mid-step (engaged), angle 0');
ok(!M.genevaCross(Math.PI, 6).engaged, 'Geneva: a half-turn puts the pin out of any slot -> LOCKED');
ok(near(Math.abs(M.genevaCross(TAU, 6).crossAngle - M.genevaCross(0, 6).crossAngle), TAU / 6), 'Geneva: ONE driver turn advances the cross exactly one slot (2pi/N)');
ok(near(M.genevaCross(2.0, 6).crossAngle, M.genevaCross(2.6, 6).crossAngle), 'Geneva: the cross holds DEAD-STILL (constant) through the locked zone');

// ---- contact on the tooth SURFACE, not through the body ----
const g = { cx: 0, cy: 0, rp: 0.6, teeth: 20, phi: 0 };
const c0 = M.contactPointOnFlank(g, 0, 1, 0), c1 = M.contactPointOnFlank(g, 0, 1, 1);
ok(Math.hypot(c0.x, c0.y) > 0.6, 'first contact is out at the tooth TIP (radius > pitch)');
ok(Math.hypot(c1.x, c1.y) >= 0.6 * 0.85, 'separation stays on the tooth (never inside the body)');

// ---- bevel / mitre cone angles (rotation turns a 90deg corner) ----
const mitre = M.bevelConeAngles(13, 13);
ok(near(mitre.gA, Math.PI / 4) && near(mitre.gB, Math.PI / 4), 'mitre (equal teeth) -> both pitch-cone half-angles 45deg');
ok(near(mitre.ratio, 1), 'mitre is 1:1');
const bev = M.bevelConeAngles(12, 18);
ok(near(bev.gA, Math.atan2(12, 18)) && near(bev.gA + bev.gB, Math.PI / 2), 'bevel: tan(gA)=Na/Nb and gA+gB=90deg');
ok(near(bev.ratio, 1.5), 'a 12:18 bevel is a 1.5:1 reduction');
ok(near(bev.rimMeetLaOverLb, Math.tan(bev.gB)), 'rims meet when La = Lb*tan(gB)');

// ---- the differential law: one input, two outputs, spiders spin on the difference ----
const dOut = M.differentialSplit(3.0, 0.4);
ok(near(dOut.phiL + dOut.phiR, 2 * 3.0), 'differential: wL + wR = 2*wcarrier holds exactly');
ok(near(dOut.spiderProportionalTo, -0.8), 'the spider spins in proportion to the output DIFFERENCE (-2S)');
ok(near(M.differentialSplit(5, 0).spiderProportionalTo, 0), 'going straight (S=0): the spiders are DEAD STILL');
ok(near(M.differentialOther(5, 4), 2 * 5 - 4), 'hold one output -> the other = 2*carrier - held (the "one wheel on ice")');

// ---- a gear-pen's trochoid (the curve a point on a moving gear draws) ----
ok(M.trochoidInfo(1, 0).kind === 'circle', 'a fixed-centre gear traces a CIRCLE');
const tro = M.trochoidInfo(-1, 1 / 3);   // a planet: spin -1, orbit 1/3 -> spin:orbit 3:1, opposite direction -> hypotrochoid
ok(tro.kind === 'hypotrochoid', 'opposite spin/orbit -> hypotrochoid');
ok(tro.red.p === 3 && tro.red.q === 1, 'spin:orbit reduces to 3:1');
ok(tro.lobes === 4 && tro.closesAfterOrbits === 1, 'lobes = |m-n| with SIGNED freqs -> a 3:1 hypotrochoid has 4 lobes (NOT 2), closes in 1 orbit');
const epi = M.trochoidInfo(3, 1);   // same direction -> epitrochoid, |3-1| = 2 lobes
ok(epi.kind === 'epitrochoid' && epi.lobes === 2, 'same-direction 3:1 -> epitrochoid, 2 lobes (|p-q|)');

// ---- chain drive: SAME direction (a mesh reverses) ----
ok(near(M.chainCoupleAngle(2, 0.6, 0.3), 4), 'chain couples SAME sign: phiB = phiA*(rA/rB) = 2*(0.6/0.3) = 4');
ok(M.chainCoupleAngle(1, 0.6, 0.3) > 0 && M.meshedRatio(A, 0.36) < 0, 'a chain keeps direction (+), a direct mesh reverses it (-)');

console.log(`  ${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);