mechanism.js
227 lines · in natura-src-1.0.0.zip as natura/mechanism.js · a module · its entry in the module reference · its test: mechanism-test.js
// mechanism.js -- Natura MECHANISM: rigid-body planar kinematics -- linkages (joints + bars + sliders) and GEARS
// (external, internal-ring, orbiting planets, racks). Render-free + deterministic (no Date/Math.random): a piece's
// renderer draws it; this only computes how the parts MUST move. Captured from "The Shape of a Motion" so every future
// piece shares one solver AND the hard-won gear-motion truth (mesh phase, the carrier-frame contact rate, involute
// tooth geometry, contact-on-the-tooth-surface). The durable facts live in the FRAMEWORK, not one page.
// UMD (node / window.Mechanism).
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.Mechanism = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const TAU = Math.PI * 2;
// ================= GEAR KINEMATICS -- the reusable facts, as functions =================
// Two gears mesh only when they share a MODULE (radius per tooth). To make a new gear that meshes gear A with a
// chosen tooth count: rp = teeth * moduleOf(A).
function moduleOf(g) { return g.rp / g.teeth; }
function teethForRadius(g, rp) { return Math.max(4, Math.round(rp / moduleOf(g))); }
// A meshed gear turns SLOWER in exact proportion to its teeth, and the OTHER way. ratio = dphi/dtheta (absolute spin
// relative to the master clock). For B meshing driver A externally:
function meshedRatio(A, rpB) { return A.ratio * (-A.rp / rpB); }
// The MESH-PHASE formula (verified against a hand-placed gear pair): place a new gear meshing A in world-direction
// gamma (from A's centre) so a new-gear GAP meets A's TOOTH (they interleave, never tip-collide).
function meshBase(A, gamma, teethNew) {
const pA = (((gamma - A.base) * A.teeth / TAU) % 1 + 1) % 1; // A's tooth-phase at the contact line
return (gamma + Math.PI) - (0.5 - pA) * (TAU / teethNew);
}
// Full spec for a new external gear meshing A, placed toward gamma with `teeth` teeth:
function meshGear(A, gamma, teeth) {
const rp = teeth * moduleOf(A);
return { cx: A.cx + (A.rp + rp) * Math.cos(gamma), cy: A.cy + (A.rp + rp) * Math.sin(gamma),
rp: rp, teeth: teeth, ratio: meshedRatio(A, rp), base: meshBase(A, gamma, teeth), phi: 0 };
}
// EPICYCLIC (planetary): ring fixed, sun as input -> the CARRIER turns at Ns/(Ns+Nr) of the sun. Nr = Ns + 2*Np.
function epicyclicCarrierRate(Ns, Nr) { return Ns / (Ns + Nr); }
// THE key correction (the "singing a different song" bug): the tooth-CONTACT rate of a gear whose CENTRE orbits is
// NOT its absolute spin -- the line of centres itself turns at the carrier rate, so contacts happen in the CARRIER
// frame. rate = teeth * |omega_gear - omega_lineOfCentres|. omegaLoc = the orbit/carrier rate (0 for a fixed centre).
// Returns signed teeth-per-unit-theta; take Math.abs for a rate. Multiply by |thetaVel| for teeth-per-second.
function toothContactRate(g, omegaLoc) { return (g.ratio - (omegaLoc || 0)) * g.teeth; }
// Whether two gears actually mesh, geometrically. External: centre-distance ~ rpA+rpB. Internal (one is a ring):
// ~ |rpRing - rpOther|. Coaxial (a compound stack) do NOT mesh.
function gearsMesh(a, b, tol) {
const d = Math.hypot(b.cx - a.cx, b.cy - a.cy); if (d < 1e-3) return false;
const internal = a.ring || b.ring, ext = Math.abs(d - (a.rp + b.rp)),
rr = internal ? Math.abs(d - Math.abs(a.rp - b.rp)) : 1e9;
return Math.min(ext, rr) < (tol != null ? tol : Math.max(0.05, 0.12 * Math.min(a.rp, b.rp)));
}
// ================= INVOLUTE TOOTH GEOMETRY -- why the line of action is straight =================
// Real gear teeth are the INVOLUTE of a base circle Rb = Rp*cos(pressureAngle) (~0.94*Rp at 20deg). The straightness
// of the line of action is a CONSEQUENCE of the involute profile. (A trapezoid tooth is a common, honest approximation.)
const PRESSURE_ANGLE = 20 * Math.PI / 180;
function baseRadius(rp, pressureAngle) { return rp * Math.cos(pressureAngle == null ? PRESSURE_ANGLE : pressureAngle); }
// The involute of a circle radius Rb at roll angle t (t = how far the "string" has unwound):
function involutePoint(Rb, t) { return { x: Rb * (Math.cos(t) + t * Math.sin(t)), y: Rb * (Math.sin(t) - t * Math.cos(t)) }; }
function involuteRollAt(Rb, r) { return Math.sqrt(Math.max(0, (r / Rb) * (r / Rb) - 1)); } // roll angle where the involute reaches radius r
// CONTACT vs FORCE (the operator's correction). The LINE OF ACTION -- where FORCE acts -- is a straight line through
// the pitch point tilted by the pressure angle; it cuts through the body. The physical CONTACT point is ALWAYS on the
// tooth SURFACE (never through the body): it starts at the leading edge near the TIP (first contact / approach) and
// slides down the flank to the ROOT (separation / recess). The STRIKE (and the sound) is at FIRST contact.
// Returns the world contact point on gear g at engagement progress p in [0,1] (0 = first contact at the tip).
function contactPointOnFlank(g, gamma, spinSign, p, addendum, dedendum) {
const N = g.teeth, tp = TAU / N, Rt = g.rp * (addendum != null ? addendum : 1.1), Rr = g.rp * (dedendum != null ? dedendum : 0.86);
const j = Math.round((gamma - g.phi) / tp), aj = g.phi + j * tp, s = spinSign >= 0 ? 1 : -1; // the tooth actually at the contact + its leading flank
const ang = aj + s * tp * (0.16 + 0.12 * p), r = Rt + (Rr - Rt) * p; // tip corner (first contact) -> root corner (separation), along the flank
return { x: g.cx + r * Math.cos(ang), y: g.cy + r * Math.sin(ang) };
}
// ================= TOOTH SIZE + BACKLASH + THE DRIVER->FOLLOWER LEAD =================
// Involute teeth only actually MESH if sized off the tooth count. A FIXED tip factor (e.g. 1.08 for every gear)
// leaves small gears too short: the footprint sits INSIDE the pitch line and never touches. Module-true sizing:
// addendum = 1 module = 2*rp/N above the pitch circle (tip), dedendum = 1.25 module below (root), and the tooth
// is a near-full HALF-pitch thick at the pitch circle so it fills the mating gap and the flanks touch.
function addendumRadius(g, add) { return g.rp * (1 + 2 * (add == null ? 1 : add) / g.teeth); } // tip radius
function dedendumRadius(g, ded) { return g.rp * (1 - 2 * (ded == null ? 1.25 : ded) / g.teeth); } // root radius
function toothHalfAngle(teeth, thickFrac) { return Math.PI / (2 * teeth) * (thickFrac == null ? 0.97 : thickFrac); } // angular half-thickness at the pitch circle (full = a quarter pitch each side)
// The "NOT a magic motor" truth. Real gears are not both spun in perfect lockstep with teeth centred in every gap.
// There is ONE driver (the input); every gear it drives is a FOLLOWER the driver pushes FROM BEHIND -- the driver's
// leading flank bears on the follower's TRAILING flank, so the follower LAGS its own motion by the backlash and
// contact is one-sided (drive flank touching, trailing flank open by the lash). Reverse the input and the follower
// sits still until the lash is crossed, then CLUNKS onto the far flank. Model it as a per-follower phi offset.
function backlashLead(g, thickFrac) { return (1 - (thickFrac == null ? 0.97 : thickFrac)) * 0.5 * (TAU / g.teeth); } // half the circumferential backlash, as THIS gear's rotation angle
// The signed phi offset for a follower so its drive flank bears. turnSign = sign of the input's angular velocity,
// HELD through rest (the lash stays taken up) so it only flips -- clunks -- on a real reversal. Root driver: 0.
function followerLag(g, turnSign, thickFrac) { return -Math.sign(g.ratio || 1) * (turnSign >= 0 ? 1 : -1) * backlashLead(g, thickFrac); }
// ================= LINKAGE: THE INSTANTANEOUS CENTRE OF ROTATION =================
// The one point a moving rigid link is, this instant, turning about (relative to ground). For a coupler joined to
// ground through two pivots, it is where the two pivot->joint lines cross. THEN every point P on the link has
// velocity PERPENDICULAR to (P - centre), magnitude omega*|P - centre| -- which is WHY a coupler curve bends as it
// does. Parallel lines -> the centre is at infinity -> the link is in pure translation (the traced curve runs
// locally straight). A slider joint's constraint line is perpendicular to its rail; pass joint +/- that perpendicular
// as its (pivot, joint) pair. Returns {x,y} or null (parallel / translation).
function instantCentre(pivotA, jointA, pivotB, jointB) {
const ax = jointA.x - pivotA.x, ay = jointA.y - pivotA.y, bx = jointB.x - pivotB.x, by = jointB.y - pivotB.y;
const det = ax * (-by) - ay * (-bx); if (Math.abs(det) < 1e-9) return null; // lines parallel -> centre at infinity (pure translation)
const rx = jointB.x - jointA.x, ry = jointB.y - jointA.y, s = (rx * (-by) - ry * (-bx)) / det;
return { x: jointA.x + s * ax, y: jointA.y + s * ay };
}
// ================= INTERMITTENT MOTION: THE GENEVA / MALTESE STOP =================
// A driver pin advances an N-slot cross by exactly ONE slot (2*pi/N) per driver turn, then a lock holds it
// DEAD-STILL between (the "partial gear" of clocks, watches, and film projectors). Engaged only within the
// window |a| < (pi/2 - pi/N), where a is the driver angle measured within its current turn; the pin rides a
// slot and rolls the cross through 2*pi/N. External Geneva: pin radius = c*sin(pi/N), cross radius = c*cos(pi/N)
// (c = centre distance). Returns {crossAngle, engaged}.
function genevaCross(driverAngle, N) {
const lam = Math.sin(Math.PI / N), ae = Math.PI / 2 - Math.PI / N;
const step = Math.round(driverAngle / TAU), a = driverAngle - step * TAU;
if (Math.abs(a) < ae) {
let w = Math.atan2(lam * Math.sin(a), lam * Math.cos(a) - 1) + Math.PI; // the slot direction; unwrap to (-pi, pi]
w = (w + Math.PI) % TAU; if (w < 0) w += TAU; w -= Math.PI;
return { crossAngle: w - step * (TAU / N), engaged: true }; // the slot TRACKS the pin (so it reads as driven, turning opposite the driver)
}
return { crossAngle: -step * (TAU / N) - (a >= 0 ? 1 : -1) * (Math.PI / N), engaged: false }; // locked at the last step
}
// ================= BEVEL / MITRE gears: intersecting axes (rotation turns a 90deg corner) =================
// Two toothed pitch CONES, apexes touching at O, axes crossing at 90deg -- the axis of rotation LEAVES the plane. The
// tooth ratio still governs; a MITRE is the equal-cone 1:1 case, a true BEVEL an unequal-cone reduction. For 90deg
// shafts the pitch-cone half-angles satisfy tan(gA) = Na/Nb and gA + gB = 90deg. The two rims MEET (the cones roll on a
// shared slant = the mesh) only when the axial cone lengths obey La = Lb*tan(gB) (mitre: La = Lb). A differential is a
// bevel train whose carrier turns (see below).
function bevelConeAngles(Na, Nb) {
const gA = Math.atan2(Na, Nb), gB = Math.PI / 2 - gA;
return { gA: gA, gB: gB, ratio: Nb / Na, rimMeetLaOverLb: Math.tan(gB) }; // set La = Lb * rimMeetLaOverLb so the pitch rims meet
}
// ================= DIFFERENTIAL / spider gears: one input -> two outputs at DIFFERENT speeds =================
// The bevel train whose CARRIER turns. The defining law is EXACT: omega_L + omega_R = 2*omega_carrier -- the carrier
// angle is the AVERAGE of the two output angles. The SPIDER pinions spin ONLY on the DIFFERENCE of the outputs (dead
// still when they match -> going straight; spinning when they differ -> turning / a wheel held). This is how a car's
// driven wheels round a corner (outer faster than inner).
function differentialSplit(phiC, S) { return { phiL: phiC + S, phiR: phiC - S, spiderProportionalTo: -2 * S }; } // sum-law holds for ANY steering S
// Hold/turn ONE output by hand -> the OTHER obeys the law. Hold one still and the other runs at 2*carrier ("one wheel on ice").
function differentialOther(phiC, phiHeld) { return 2 * phiC - phiHeld; }
// ================= A gear-pen's TROCHOID: the curve a point on a moving gear draws =================
// A point rigidly on a gear whose centre ORBITS traces a trochoid = the SUM of two rotations (the DEFERENT = the orbit,
// the EPICYCLE = the spin). Classify it from the real rates: reduce spin:orbit to small COPRIME SIGNED integers
// (n = spin freq, m = orbit freq). Same rotation direction -> EPItrochoid, opposite -> HYPOtrochoid. Lobe/symmetry
// count = |m - n| using the SIGNED frequencies (so same-dir gives |p-q|, opposite gives p+q -- the classic first-cut
// bug is |p-q| always, which is wrong for a hypotrochoid). Closes after m (=q) orbits. Fixed centre -> a CIRCLE.
function trochoidInfo(spinRate, orbitRate) {
if (Math.abs(orbitRate) < 1e-9) return { kind: 'circle', lobes: null, red: null, closesAfterOrbits: 1 };
const k = spinRate / orbitRate; let m = null, n = null;
for (let q = 1; q <= 400; q++) { const p = Math.round(k * q); if (p === 0) continue; if (Math.abs(k - p / q) < 1e-6) { m = q; n = p; break; } }
if (m == null) return { kind: (k >= 0 ? 'epitrochoid' : 'hypotrochoid'), lobes: null, red: null, closesAfterOrbits: null };
return { kind: (k >= 0 ? 'epitrochoid' : 'hypotrochoid'), lobes: Math.abs(m - n), red: { p: Math.abs(n), q: m }, closesAfterOrbits: m };
}
// ================= CHAIN / belt over two sprockets: SAME-direction drive =================
// A chain (or belt) drives the far sprocket the SAME direction (a direct gear MESH reverses it), at the radius ratio,
// and spans a distance no gear pair can. phi_b = phi_a * (r_a / r_b) -- SAME sign (a mesh would be -r_a/r_b).
function chainCoupleAngle(phiA, rA, rB) { return phiA * (rA / rB); }
// ================= THE SOLVER -- a linkage graph (Jakobsen position relaxation) + geared wheels =================
// A joint: {x,y, kind:'pin'|'free'|'drv'}. A bar: {a,b,len}. A slider: {j, ax,ay,bx,by} (pins j onto a line).
// A driven joint rides a crank about a pivot (or a gear's face). Gears are direct-driven off theta (not relaxed).
function makeMechanism(spec) {
spec = spec || {};
const J = spec.J || [], B = spec.B || [], S = spec.S || [], gears = spec.gears || [], driven = spec.driven || [];
for (const b of B) if (b.len == null) b.len = Math.hypot(J[b.a].x - J[b.b].x, J[b.a].y - J[b.b].y);
const ITER = spec.iterations || 60;
function solve(theta) {
for (const gr of gears) { // gears turn with theta (geared, direct); orbiting planets ride the carrier
gr.phi = theta * gr.ratio + (gr.base || 0);
if (gr.orbit) { const oa = theta * gr.orbit.rate + (gr.orbit.base || 0);
gr.cx = gr.orbit.cx + gr.orbit.R * Math.cos(oa); gr.cy = gr.orbit.cy + gr.orbit.R * Math.sin(oa); }
}
for (const d of driven) { const jt = J[d.j]; // driven joints: a crank about a pivot, OR a joint pinned onto a gear's face
if (d.gear != null) { const gr = gears[d.gear], a = gr.phi + (d.base || 0); jt.x = gr.cx + d.r * Math.cos(a); jt.y = gr.cy + d.r * Math.sin(a); }
else { const p = J[d.piv], a = theta * (d.ratio != null ? d.ratio : 1) + (d.base || 0); jt.x = p.x + d.r * Math.cos(a); jt.y = p.y + d.r * Math.sin(a); }
}
for (let it = 0; it < ITER; it++) { // relax free joints toward each bar's rest length (position-based / Jakobsen), warm-started by the caller stepping theta continuously
for (const bar of B) { const a = J[bar.a], b = J[bar.b];
let dx = b.x - a.x, dy = b.y - a.y, dd = Math.hypot(dx, dy) || 1e-6; const diff = (dd - bar.len) / dd;
const ma = a.kind === 'free', mb = b.kind === 'free'; if (!ma && !mb) continue;
if (ma && mb) { a.x += dx * 0.5 * diff; a.y += dy * 0.5 * diff; b.x -= dx * 0.5 * diff; b.y -= dy * 0.5 * diff; }
else if (ma) { a.x += dx * diff; a.y += dy * diff; } else { b.x -= dx * diff; b.y -= dy * diff; }
}
for (const sl of S) { const jt = J[sl.j]; if (jt.kind !== 'free') continue; // project a slider joint onto its guide line
const ex = sl.bx - sl.ax, ey = sl.by - sl.ay, L2 = ex * ex + ey * ey || 1e-6, t = ((jt.x - sl.ax) * ex + (jt.y - sl.ay) * ey) / L2;
jt.x = sl.ax + ex * t; jt.y = sl.ay + ey * t;
}
}
}
// every place two teeth actually make contact -> {gr, other, omegaLoc, gamma} (one per meshing pair; carrier-frame aware)
function meshPoints() {
const L = [];
for (const gr of gears) if (gr.rack) L.push({ gr: gr, other: null, omegaLoc: 0, gamma: -Math.PI / 2 });
for (let i = 0; i < gears.length; i++) for (let k = i + 1; k < gears.length; k++) { const A = gears[i], Bg = gears[k];
if (A.rack || Bg.rack || !gearsMesh(A, Bg)) continue;
const gr = A.ring ? Bg : A, other = gr === A ? Bg : A, internal = A.ring || Bg.ring;
const omegaLoc = A.orbit ? A.orbit.rate : (Bg.orbit ? Bg.orbit.rate : 0);
const gamma = internal ? Math.atan2(gr.cy - other.cy, gr.cx - other.cx) : Math.atan2(other.cy - gr.cy, other.cx - gr.cx);
L.push({ gr: gr, other: other, omegaLoc: omegaLoc, gamma: gamma });
}
return L;
}
return { J: J, B: B, S: S, gears: gears, driven: driven, solve: solve, meshPoints: meshPoints };
}
return {
makeMechanism: makeMechanism,
// gear-kinematics helpers (usable standalone)
moduleOf: moduleOf, teethForRadius: teethForRadius, meshedRatio: meshedRatio, meshBase: meshBase, meshGear: meshGear,
epicyclicCarrierRate: epicyclicCarrierRate, toothContactRate: toothContactRate, gearsMesh: gearsMesh,
baseRadius: baseRadius, involutePoint: involutePoint, involuteRollAt: involuteRollAt, contactPointOnFlank: contactPointOnFlank,
addendumRadius: addendumRadius, dedendumRadius: dedendumRadius, toothHalfAngle: toothHalfAngle,
backlashLead: backlashLead, followerLag: followerLag,
instantCentre: instantCentre, genevaCross: genevaCross,
bevelConeAngles: bevelConeAngles, differentialSplit: differentialSplit, differentialOther: differentialOther,
trochoidInfo: trochoidInfo, chainCoupleAngle: chainCoupleAngle,
PRESSURE_ANGLE: PRESSURE_ANGLE, TAU: TAU,
};
});