export const TAU = Math.PI * 2; export function clamp(value: number, min = 0, max = 1): number { return Math.min(max, Math.max(min, value)); } export function lerp(start: number, end: number, amount: number): number { return start + (end - start) * amount; } export function inverseLerp(start: number, end: number, value: number): number { if (Math.abs(end - start) < Number.EPSILON) return 0; return clamp((value - start) / (end - start)); } export function smoothstep(edge0: number, edge1: number, value: number): number { const x = inverseLerp(edge0, edge1, value); return x * x * (3 - 2 * x); } export function smootherstep(edge0: number, edge1: number, value: number): number { const x = inverseLerp(edge0, edge1, value); return x * x * x * (x * (x * 6 - 15) + 10); } export function positiveModulo(value: number, modulus: number): number { return ((value % modulus) + modulus) % modulus; } export function normalizedPhase(angleRadians: number): number { return positiveModulo(angleRadians / TAU, 1); } export function wrappedDistance01(a: number, b: number): number { const direct = Math.abs(positiveModulo(a, 1) - positiveModulo(b, 1)); return Math.min(direct, 1 - direct); } export function pulse01(phase: number, center: number, width: number): number { const distance = wrappedDistance01(phase, center); return 1 - smoothstep(width * 0.48, width * 0.5, distance); } export function windowPulse01(phase: number, start: number, end: number): number { const p = positiveModulo(phase, 1); if (start <= end) { return smoothstep(start, start + 0.04, p) * (1 - smoothstep(end - 0.04, end, p)); } return Math.max(windowPulse01(p, start, 1), windowPulse01(p, 0, end)); } export interface CrankSliderState { crankPinX: number; crankPinY: number; pistonY: number; connectingRodAngle: number; connectingRodLength: number; } /** * Returns a numerically stable crank-slider solution with the piston constrained * to the y axis. `angleRadians = 0` corresponds to top dead centre. */ export function crankSliderState( angleRadians: number, crankRadius: number, connectingRodLength: number, ): CrankSliderState { const sin = Math.sin(angleRadians); const cos = Math.cos(angleRadians); const crankPinX = crankRadius * sin; const crankPinY = crankRadius * cos; const underRoot = Math.max( connectingRodLength * connectingRodLength - crankPinX * crankPinX, 0.000001, ); const pistonY = crankPinY + Math.sqrt(underRoot); const dx = crankPinX; const dy = pistonY - crankPinY; return { crankPinX, crankPinY, pistonY, connectingRodAngle: -Math.atan2(dx, dy), connectingRodLength: Math.sqrt(dx * dx + dy * dy), }; } export type FourStrokePhase = 'intake' | 'compression' | 'combustion' | 'exhaust'; export function fourStrokePhase(cycleProgress: number): FourStrokePhase { const phase = positiveModulo(cycleProgress, 1); if (phase < 0.25) return 'intake'; if (phase < 0.5) return 'compression'; if (phase < 0.75) return 'combustion'; return 'exhaust'; } export function fourStrokePhaseLabel(cycleProgress: number): string { const phase = fourStrokePhase(cycleProgress); switch (phase) { case 'intake': return 'Intake stroke — charge enters as piston descends'; case 'compression': return 'Compression stroke — mixture pressure rises'; case 'combustion': return 'Power stroke — spark ignition drives the piston down'; case 'exhaust': return 'Exhaust stroke — spent gases leave through the exhaust valve'; default: return 'Four-stroke cycle'; } } export function valveLift( cycleProgress: number, centerProgress: number, openWidth: number, maxLift: number, ): number { return maxLift * pulse01(cycleProgress, centerProgress, openWidth); } export interface PlanetaryGearState { sunAngle: number; carrierAngle: number; planetOrbitAngle: number; planetSpinAngle: number; outputRatio: number; } /** * Kinematic model for a simple planetary set with a fixed ring, sun input, * and carrier output. */ export function planetaryGearState( sunAngle: number, sunTeeth: number, ringTeeth: number, planetTeeth: number, ): PlanetaryGearState { const outputRatio = sunTeeth / (sunTeeth + ringTeeth); const carrierAngle = sunAngle * outputRatio; const planetOrbitAngle = carrierAngle; const planetSpinAngle = -((sunTeeth + planetTeeth) / planetTeeth) * sunAngle + carrierAngle; return { sunAngle, carrierAngle, planetOrbitAngle, planetSpinAngle, outputRatio, }; } export interface DifferentialState { carrierAngle: number; ringAngle: number; leftAxleAngle: number; rightAxleAngle: number; spiderAngle: number; corneringBias: number; } export function differentialState( shaftAngle: number, cycleProgress: number, corneringAmplitude = 0.38, ): DifferentialState { const cornerWindow = windowPulse01(cycleProgress, 0.28, 0.74); const sideBias = Math.sin(cycleProgress * TAU) * corneringAmplitude * cornerWindow; const leftSpeedRatio = 1 - sideBias; const rightSpeedRatio = 1 + sideBias; return { carrierAngle: shaftAngle * 0.55, ringAngle: shaftAngle, leftAxleAngle: shaftAngle * leftSpeedRatio, rightAxleAngle: shaftAngle * rightSpeedRatio, spiderAngle: shaftAngle * sideBias * 3.6, corneringBias: sideBias, }; } export interface GenevaDriveState { driveAngle: number; outputAngle: number; outputStepFloat: number; activeSlot: number; drivePhase: number; engagement: number; dwell: boolean; } export function genevaDriveState( driveAngle: number, slots = 6, engageStart = 0.055, engageEnd = 0.305, ): GenevaDriveState { const driveTurns = Math.floor(driveAngle / TAU); const drivePhase = positiveModulo(driveAngle / TAU, 1); let engagement = 0; let outputStepFloat = driveTurns; if (drivePhase >= engageStart && drivePhase <= engageEnd) { engagement = smootherstep(engageStart, engageEnd, drivePhase); outputStepFloat += engagement; } else if (drivePhase > engageEnd) { outputStepFloat += 1; } const outputAngle = (outputStepFloat * TAU) / slots; return { driveAngle, outputAngle, outputStepFloat, activeSlot: positiveModulo(Math.floor(outputStepFloat), slots), drivePhase, engagement, dwell: engagement <= 0.001 || engagement >= 0.999, }; } export interface CentrifugalPumpState { impellerAngle: number; flowPhase: number; outletPressure: number; cavitationRisk: number; } export function centrifugalPumpState( shaftAngle: number, rpm: number, cycleProgress: number, ): CentrifugalPumpState { const nominalPressure = clamp((rpm - 300) / 2400, 0.1, 1); const bladePulse = 0.5 + 0.5 * Math.sin(shaftAngle * 6); const cavitationRisk = clamp((rpm - 2200) / 900, 0, 1) * (0.55 + 0.45 * bladePulse); return { impellerAngle: shaftAngle, flowPhase: positiveModulo(cycleProgress * 3, 1), outletPressure: clamp(nominalPressure + bladePulse * 0.08, 0, 1), cavitationRisk, }; } export interface DiscBrakeState { rotorAngle: number; brakePressure: number; padTravel: number; heat: number; } export function discBrakeState(shaftAngle: number, cycleProgress: number): DiscBrakeState { const apply = smootherstep(0.18, 0.42, cycleProgress); const release = 1 - smootherstep(0.72, 0.92, cycleProgress); const pressure = clamp(apply * release); const microPulse = 0.02 * Math.sin(shaftAngle * 18) * pressure; return { rotorAngle: shaftAngle * (1 - pressure * 0.58), brakePressure: pressure, padTravel: 0.18 * pressure + microPulse, heat: clamp(pressure * 0.8 + smoothstep(0.45, 1, cycleProgress) * 0.2), }; } export interface WankelState { shaftAngle: number; eccentricCenterX: number; eccentricCenterY: number; rotorAngle: number; chamberPhase: number; combustionPulse: number; } export function wankelState( shaftAngle: number, eccentricity = 0.34, combustionCenterPhase = 0.58, ): WankelState { const chamberPhase = normalizedPhase(shaftAngle / 3); return { shaftAngle, eccentricCenterX: eccentricity * Math.cos(shaftAngle), eccentricCenterY: eccentricity * Math.sin(shaftAngle), rotorAngle: -shaftAngle / 3, chamberPhase, combustionPulse: pulse01(chamberPhase, combustionCenterPhase, 0.18), }; }