10

Gear Ratio Simulator

Drag sideways for RPM; tap a gear to change teeth

A gear ratio simulator with three meshed gears whose teeth stay phase-correct as they interlock: each pair obeys , so every stage trades speed for torque and reverses direction. The white marker tooth on each gear lets you verify the ratio by eye, and color encodes angular speed from cool blue to hot orange. Drag horizontally to set the driver RPM (drag past zero to reverse) and tap any gear to cycle its tooth count through 8/12/16/24/32 — the train re-meshes and the ratio chain, output RPM, and torque multiplier update in the HUD.

idle
108 lines · vanilla
view source
const TOOTH_OPTS = [8, 12, 16, 24, 32];
const PAD = 12, TAU = Math.PI * 2;
let W, H, teeth, rpm, th0, lastMX, dragDist, prevDown;

function init({ ctx, width, height }) {
  W = width; H = height;
  teeth = [8, 16, 24];
  rpm = 25; th0 = 0;
  lastMX = 0; dragDist = 0; prevDown = false;
  ctx.fillStyle = "#0c0f17"; ctx.fillRect(0, 0, W, H);
}

// Phase-correct meshing: tooth of A at the contact point lines up with the
// gap of B (contact direction is +x from A, so π from B's side).
function meshAngle(thA, NA, NB) {
  return Math.PI + Math.PI / NB - (NA / NB) * thA;
}

function hueOf(r) { return 215 - 195 * Math.min(1, Math.abs(r) / 60); }

function drawGear(ctx, x, y, N, m, th, hue) {
  const r = (m * N) / 2, rT = r + m * 0.85, rR = r - m;
  const p = TAU / N;
  ctx.beginPath();
  for (let k = 0; k < N; k++) {
    const a = th + k * p;
    const a1 = a - 0.18 * p, a2 = a + 0.18 * p, a3 = a + 0.3 * p, a4 = a + 0.7 * p;
    if (k === 0) ctx.moveTo(x + Math.cos(a1) * rT, y + Math.sin(a1) * rT);
    else ctx.lineTo(x + Math.cos(a1) * rT, y + Math.sin(a1) * rT);
    ctx.lineTo(x + Math.cos(a2) * rT, y + Math.sin(a2) * rT);
    ctx.lineTo(x + Math.cos(a3) * rR, y + Math.sin(a3) * rR);
    ctx.lineTo(x + Math.cos(a4) * rR, y + Math.sin(a4) * rR);
  }
  ctx.closePath();
  ctx.fillStyle = `hsla(${hue.toFixed(0)},70%,46%,0.92)`;
  ctx.fill();
  ctx.strokeStyle = `hsla(${hue.toFixed(0)},85%,72%,0.9)`;
  ctx.lineWidth = 1.5;
  ctx.stroke();
  ctx.fillStyle = "rgba(10,12,20,0.85)";
  ctx.beginPath(); ctx.arc(x, y, Math.max(9, r * 0.35), 0, TAU); ctx.fill();
  // marked tooth 0: radial line + dot so the eye can verify the ratio
  const mx = x + Math.cos(th) * (r + m * 0.3), my = y + Math.sin(th) * (r + m * 0.3);
  ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(mx, my); ctx.stroke();
  ctx.fillStyle = "#fff";
  ctx.beginPath(); ctx.arc(mx, my, 3.5, 0, TAU); ctx.fill();
  ctx.font = "bold 13px monospace"; ctx.textAlign = "center";
  ctx.fillText(String(N), x, y + 4);
}

function tick({ ctx, dt, time, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  const N0 = teeth[0], N1 = teeth[1], N2 = teeth[2];
  const maxN = Math.max(N0, N1, N2);
  const hudH = 100;
  const cy = hudH + (H - 32 - hudH) / 2;
  const m = Math.min(
    (W - 28) / (N0 + N1 + N2 + 1.7),
    Math.min(cy - hudH - 4, H - 36 - cy) / (maxN / 2 + 0.85)
  );
  const r0 = (m * N0) / 2, r1 = (m * N1) / 2, r2 = (m * N2) / 2;
  const full = m * (N0 + N1 + N2 + 1.7);
  const xA = (W - full) / 2 + 0.85 * m + r0;
  const xB = xA + r0 + r1, xC = xB + r1 + r2;

  // drag horizontally anywhere to change driver RPM (negative = reverse)
  const md = input.mouseDown;
  if (md && !prevDown) { lastMX = input.mouseX; dragDist = 0; }
  if (md && prevDown) {
    const dx = input.mouseX - lastMX;
    rpm = Math.max(-60, Math.min(60, rpm + dx * 0.25));
    dragDist += Math.abs(dx);
    lastMX = input.mouseX;
  }
  prevDown = md;

  // tap a gear (without dragging) to cycle its tooth count
  for (const c of input.consumeClicks()) {
    if (dragDist > 10) continue;
    const dA = (c.x - xA) * (c.x - xA) + (c.y - cy) * (c.y - cy);
    const dB = (c.x - xB) * (c.x - xB) + (c.y - cy) * (c.y - cy);
    const dC = (c.x - xC) * (c.x - xC) + (c.y - cy) * (c.y - cy);
    let i = -1;
    if (dA < (r0 + m) * (r0 + m)) i = 0;
    else if (dB < (r1 + m) * (r1 + m)) i = 1;
    else if (dC < (r2 + m) * (r2 + m)) i = 2;
    if (i >= 0) teeth[i] = TOOTH_OPTS[(TOOTH_OPTS.indexOf(teeth[i]) + 1) % TOOTH_OPTS.length];
  }

  th0 += ((rpm * TAU) / 60) * dt;
  const th1 = meshAngle(th0, N0, N1);
  const th2 = meshAngle(th1, N1, N2);
  const rpm1 = (-rpm * N0) / N1;
  const rpm2 = (rpm * N0) / N2;

  ctx.fillStyle = "#0c0f17"; ctx.fillRect(0, 0, W, H);

  // axle line
  ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(xA - r0, cy); ctx.lineTo(xC + r2, cy); ctx.stroke();

  drawGear(ctx, xA, cy, N0, m, th0, hueOf(rpm));
  drawGear(ctx, xB, cy, N1, m, th1, hueOf(rpm1));
  drawGear(ctx, xC, cy, N2, m, th2, hueOf(rpm2));

  // per-gear rpm labels under each gear
  ctx.font = "11px monospace"; ctx.textAlign = "center";
  ctx.fillStyle = "rgba(255,255,255,0.7)";
  ctx.fillText(`${rpm.toFixed(1)}`, xA, cy + r0 + m + 16);
  ctx.fillText(`${rpm1.toFixed(1)}`, xB, cy + r1 + m + 16);
  ctx.fillText(`${rpm2.toFixed(1)} rpm`, xC, cy + r2 + m + 16);
  ctx.fillStyle = "rgba(255,255,255,0.45)";
  ctx.fillText("driver", xA, cy - r0 - m - 8);

  // HUD
  ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(PAD, PAD, 252, 86);
  ctx.font = "13px monospace"; ctx.textAlign = "left";
  ctx.fillStyle = "#fff";
  ctx.fillText(`teeth  ${N0} : ${N1} : ${N2}`, PAD + 10, PAD + 20);
  ctx.fillStyle = "#7fd4ff";
  ctx.fillText(`ω ratio  ×${(N0 / N1).toFixed(2)}  ×${(N0 / N2).toFixed(2)}`, PAD + 10, PAD + 38);
  ctx.fillStyle = "#9fe87a";
  ctx.fillText(`in ${rpm.toFixed(1)} rpm → out ${rpm2.toFixed(1)}`, PAD + 10, PAD + 56);
  ctx.fillStyle = "#ffb347";
  ctx.fillText(`torque ×${(N2 / N0).toFixed(2)}`, PAD + 10, PAD + 74);

  ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.font = "11px monospace"; ctx.textAlign = "center";
  ctx.fillText("drag ⇄ to change rpm · tap a gear to change teeth", W / 2, H - 12);
}

Comments (0)

Log in to comment.