← Natura·Docs/Modules/Source

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,
  };
});