src/physics/formulas.js

// ————————————————————————————————————————————————
// Physics formulas used across the views.
// ————————————————————————————————————————————————

import {
  C_KMS,
  KM_PER_AU,
  KM_PER_LY,
  SECONDS_PER_YEAR,
  EARTH_SIM_PERIOD_SEC,
} from '../data/constants.js';

// ————————————————————————————————————————————————
// Orbital mechanics
// ————————————————————————————————————————————————

/**
 * Kepler's third law (normalised to Earth).
 *
 * For a planet with orbital period `periodDays`, returns its orbital angular
 * velocity in the simulation's "sim seconds" — i.e. how many radians it
 * sweeps per second of real animation time at 1× speed.
 *
 *   ω_sim = 2π / (EARTH_SIM_PERIOD_SEC × periodYears)
 *
 * This keeps the RELATIVE speed between planets exactly correct: Mercury
 * orbits 4.15× faster than Earth because its real period is 87.97 vs 365.25
 * days. Only the absolute time is rescaled to make the animation watchable.
 */
export function angularVelocityAtOneX(periodDays) {
  const periodYears = periodDays / 365.25;
  return (2 * Math.PI) / (EARTH_SIM_PERIOD_SEC * periodYears);
}

/**
 * Keplerian radius on an elliptical orbit as a function of true anomaly θ.
 *
 *   r(θ) = a(1 - e²) / (1 + e·cos θ)
 *
 * Used by the "Planet X" view (now removed) to animate an eccentric orbit.
 */
export function keplerRadius(a, e, theta) {
  return (a * (1 - e * e)) / (1 + e * Math.cos(theta));
}

// ————————————————————————————————————————————————
// Galactic rotation
// ————————————————————————————————————————————————

/**
 * Flat rotation curve approximation (valid for the Milky Way outside the bulge).
 *
 * The Milky Way's circular velocity v_c is approximately CONSTANT with radius,
 * not falling off with 1/√r as Kepler would predict (this is the key evidence
 * for dark matter). So:
 *
 *   ω(r) = v_c / r
 *
 * Inner stars whip around faster (angular), outer stars lag. At the Sun's
 * radius (~26 000 ly), v_c ≈ 220 km/s gives a full orbit in ~225 million years.
 *
 * This is what the galaxy view's vertex shader computes per-star.
 */
export function galacticAngularVelocity(vCircular, radius) {
  return vCircular / radius;
}

// ————————————————————————————————————————————————
// Special relativity
// ————————————————————————————————————————————————

/**
 * Lorentz factor γ at velocity v (in units of c).
 *
 *   γ = 1 / √(1 - β²)     where β = v/c
 *
 * Used for relativistic-ship time dilation: a journey that takes T seconds
 * in Earth's frame is experienced as T/γ seconds by the ship's crew.
 */
export function lorentzFactor(betaC) {
  if (betaC >= 1) return Infinity;
  return 1 / Math.sqrt(1 - betaC * betaC);
}

/**
 * Travel time at a given velocity (km/s) over a given distance (km).
 * Returns both Earth-frame time and ship proper time, in years.
 */
export function relativisticTravel(distanceKm, velocityKms) {
  const earthSec = distanceKm / velocityKms;
  const beta = velocityKms / C_KMS;
  const gamma = lorentzFactor(beta);
  return {
    earthYears: earthSec / SECONDS_PER_YEAR,
    shipYears: earthSec / SECONDS_PER_YEAR / gamma,
    gamma,
  };
}

// ————————————————————————————————————————————————
// Unit conversions
// ————————————————————————————————————————————————

// ————————————————————————————————————————————————
// Earth satellite orbital mechanics
// ————————————————————————————————————————————————

// Earth's standard gravitational parameter μ = GM, km³/s².
// From IAU 2015 nominal values: GM_E = 3.986 004 × 10⁵ km³/s².
const GM_EARTH_KM3_S2 = 398600.4418;

// Earth's equatorial radius, km (WGS-84).
export const EARTH_RADIUS_KM = 6378.137;

/**
 * Orbital period of a circular Earth satellite at a given altitude (km).
 *
 *   T = 2π √((R_E + h)³ / μ)
 *
 * Returns period in minutes.
 */
export function orbitalPeriodFromAltitude(altitudeKm) {
  const a = EARTH_RADIUS_KM + altitudeKm; // semi-major axis in km
  return (2 * Math.PI * Math.sqrt((a * a * a) / GM_EARTH_KM3_S2)) / 60;
}

/**
 * Angular velocity (rad / real second) for a satellite with the given
 * orbital period (minutes), normalised to the same sim-time convention as
 * angularVelocityAtOneX: at 1× speed Earth orbits the Sun in
 * EARTH_SIM_PERIOD_SEC real seconds.
 *
 * Satellites are fast — the ISS (period 92 min) laps Earth ~15.5 times per
 * day. At 1× sim speed that's extremely fast on screen; callers should
 * typically run the launch view at much higher sim speeds than the solar-
 * system views.
 *
 * At 1× speed: ω_sat = 2π / (periodMin × 60) rad/s_real
 * This is NOT normalised to EARTH_SIM_PERIOD_SEC — satellite orbits need
 * wall-clock seconds so their motion is physically correct relative to the
 * simulation's own clock (which the launch view advances in real seconds).
 */
export function satelliteAngularVelocity(periodMin) {
  return (2 * Math.PI) / (periodMin * 60); // rad / real_second at 1× speed
}

export const kmToAU = (km) => km / KM_PER_AU;
export const auToKm = (au) => au * KM_PER_AU;
export const kmToLY = (km) => km / KM_PER_LY;
export const lyToKm = (ly) => ly * KM_PER_LY;
export const lightTimeS = (km) => km / C_KMS;

// ————————————————————————————————————————————————
// Kepler orbit (planar, J2000.0 elements)
// ————————————————————————————————————————————————
//
// Phase 1a of #6: real eccentricity and orientation, but inclination is
// kept at zero so everything stays in the orbital plane (XZ in our scenes).
// Real-date anchoring and inclination are Phase 1b.

// Solve Keplers equation M = E - e·sin(E) for the eccentric anomaly E.
// Newton iteration; converges in 3–6 iterations for e < 0.5. Returns E in
// radians; M may be any real (not modulo-reduced).
export function solveKepler(meanAnomaly, eccentricity, { tol = 1e-12, maxIter = 12 } = {}) {
  let E = meanAnomaly;
  for (let i = 0; i < maxIter; i++) {
    const f = E - eccentricity * Math.sin(E) - meanAnomaly;
    const fp = 1 - eccentricity * Math.cos(E);
    const dE = f / fp;
    E -= dE;
    if (Math.abs(dE) < tol) break;
  }
  return E;
}

// Mean anomaly → true anomaly ν, via the eccentric anomaly. Returns ν in
// (-π, π]. e = 0 yields ν ≡ M (modulo 2π); identity-on-circles.
export function meanToTrueAnomaly(meanAnomaly, eccentricity) {
  const E = solveKepler(meanAnomaly, eccentricity);
  return (
    2 *
    Math.atan2(
      Math.sqrt(1 + eccentricity) * Math.sin(E / 2),
      Math.sqrt(1 - eccentricity) * Math.cos(E / 2)
    )
  );
}

// Heliocentric XZ position on a Kepler ellipse (i = 0). a in any length
// unit, M in radians. ascNode (Ω) and argPerihelion (ω) orient the orbit
// in the plane. Returns { x, z } in the same length unit as a.
export function keplerXZ(a, e, M, argPerihelion, ascNode) {
  const E = solveKepler(M, e);
  const r = a * (1 - e * Math.cos(E));
  const nu = 2 * Math.atan2(Math.sqrt(1 + e) * Math.sin(E / 2), Math.sqrt(1 - e) * Math.cos(E / 2));
  const ang = nu + argPerihelion + ascNode;
  return { x: r * Math.cos(ang), z: r * Math.sin(ang) };
}

// Sample N+1 points along the ellipse for orbit-ring rendering. Uniform in
// eccentric anomaly E gives smoother visual sampling than uniform M (which
// bunches near aphelion). Returns an array of { x, z } pairs, closing the
// loop (last point == first).
export function ellipsePoints(a, e, argPerihelion, ascNode, segments = 192) {
  const b = a * Math.sqrt(1 - e * e);
  const ang = argPerihelion + ascNode;
  const ca = Math.cos(ang),
    sa = Math.sin(ang);
  const pts = new Array(segments + 1);
  for (let i = 0; i <= segments; i++) {
    const E = (i / segments) * 2 * Math.PI;
    const xo = a * (Math.cos(E) - e); // ellipse focus at origin
    const yo = b * Math.sin(E);
    pts[i] = { x: xo * ca - yo * sa, z: xo * sa + yo * ca };
  }
  return pts;
}

// ————————————————————————————————————————————————
// Real-date anchor: Julian Date helpers and full 3D Kepler elements.
// Phase 1b of #6.
// ————————————————————————————————————————————————

// J2000.0 epoch in Julian Date.
export const J2000_JD = 2451545.0;

// Julian Date for "right now". 1970-01-01T00:00:00Z = JD 2440587.5.
export function jdNow() {
  return jdFromMs(Date.now());
}

// Julian Date for a Date / epoch milliseconds value.
export function jdFromMs(ms) {
  return ms / 86400000 + 2440587.5;
}

// Inverse of jdFromMs.
export function msFromJD(jd) {
  return (jd - 2440587.5) * 86400000;
}

// Linearly extrapolate the six Kepler elements from their J2000.0 values
// + per-Julian-century rates to the given JD. Returns a fresh object
// holding the six numbers; the input planet entry isn't mutated.
//
// The JPL "approximate-elements" fit is valid AD 1800–2050 to about an
// arcminute, multi-century to a few; rates are linear in time.
export function elementsAtJD(planet, jd) {
  const T = (jd - J2000_JD) / 36525; // Julian centuries since J2000
  return {
    a: planet.orbitKm, // a not stored as a rate
    e: planet.e + planet.eRate * T,
    iRad: planet.iRad + planet.iRadRate * T,
    ascNodeRad: planet.ascNodeRad + planet.ascNodeRadRate * T,
    argPerihelionRad: planet.argPerihelionRad + planet.argPerihelionRadRate * T,
    meanLongitudeRad: planet.meanLongitudeJ2000Rad + planet.meanLongitudeRadRate * T,
  };
}

// Mean anomaly at JD = L − ϖ where ϖ = Ω + ω. Wrapped to (-π, π] so
// solveKepler stays in its well-conditioned range.
export function meanAnomalyAtJD(planet, jd) {
  const el = elementsAtJD(planet, jd);
  let M = el.meanLongitudeRad - (el.ascNodeRad + el.argPerihelionRad);
  M = ((((M + Math.PI) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) - Math.PI;
  return M;
}

// Heliocentric ecliptic position via the standard 3-2-3 rotation
// (perifocal → heliocentric ecliptic): rotate by ω in-plane, tilt by i,
// rotate by Ω. Returns { x, y, z } in the same length unit as `a`.
//
// Reduces to keplerXZ when iRad = 0, with y = 0.
export function keplerHeliocentric(a, e, M, argPerihelion, ascNode, iRad) {
  const E = solveKepler(M, e);
  const cE = Math.cos(E),
    sE = Math.sin(E);
  // Perifocal frame (focus at origin, +x toward perihelion).
  const xp = a * (cE - e);
  const yp = a * Math.sqrt(1 - e * e) * sE;
  // Rotate by ω about z (perifocal z = orbit normal).
  const co = Math.cos(argPerihelion),
    so = Math.sin(argPerihelion);
  const x1 = co * xp - so * yp;
  const y1 = so * xp + co * yp;
  // Tilt by i about x.
  const ci = Math.cos(iRad),
    si = Math.sin(iRad);
  const x2 = x1;
  const y2 = ci * y1;
  const z2 = si * y1;
  // Rotate by Ω about z (ecliptic normal).
  const cO = Math.cos(ascNode),
    sO = Math.sin(ascNode);
  return {
    x: cO * x2 - sO * y2,
    y: z2, // we use Y-up; orbital z → world y
    z: sO * x2 + cO * y2,
  };
}

// Sample N+1 points along the inclined ellipse for orbit-ring rendering.
// Uniform in eccentric anomaly E. Returns { x, y, z } in the same length
// unit as a, with the orbit's plane tilted by iRad about the line of
// nodes (ascNode).
export function inclinedEllipsePoints(a, e, argPerihelion, ascNode, iRad, segments = 192) {
  const b = a * Math.sqrt(1 - e * e);
  const co = Math.cos(argPerihelion),
    so = Math.sin(argPerihelion);
  const ci = Math.cos(iRad),
    si = Math.sin(iRad);
  const cO = Math.cos(ascNode),
    sO = Math.sin(ascNode);
  const pts = new Array(segments + 1);
  for (let i = 0; i <= segments; i++) {
    const E = (i / segments) * 2 * Math.PI;
    const xp = a * (Math.cos(E) - e);
    const yp = b * Math.sin(E);
    const x1 = co * xp - so * yp;
    const y1 = so * xp + co * yp;
    const y2 = ci * y1;
    const z2 = si * y1;
    pts[i] = {
      x: cO * x1 - sO * y2,
      y: z2,
      z: sO * x1 + cO * y2,
    };
  }
  return pts;
}
← Astrarium