OMROpticMath.cs
using Sandbox;
using System;

/// <summary>
/// Shared optic projection math. Keeping PiP rendering and look-sensitivity
/// matching on the same FOV model prevents the scope from visually showing one
/// magnification while input scaling assumes another.
/// </summary>
public static class OMROpticMath
{
	public static float ResolveFullScopeFieldOfView(
		float sourceFieldOfView,
		OMROpticProfile profile
	)
	{
		float source = MathX.Clamp( sourceFieldOfView, 1f, 179f );

		if ( profile is null || !profile.IsValid )
			return source;

		if ( profile.UseNominalMagnificationForPiP )
		{
			float magnification = MathF.Max( profile.NominalMagnification, 1f );
			float sourceRadians = source * (MathF.PI / 180f);
			float scopedRadians = 2f * MathF.Atan(
				MathF.Tan( sourceRadians * 0.5f ) / magnification
			);

			return MathX.Clamp(
				scopedRadians * (180f / MathF.PI),
				1f,
				179f
			);
		}

		float scale = MathX.Clamp( profile.AimFovScale, 0.05f, 1f );
		return MathX.Clamp( source * scale, 1f, 179f );
	}

	/// <summary>
	/// FOV physically rendered by the optic. A real fixed-power optic remains
	/// magnified even when the shooter's eye is not yet centered behind it; what
	/// changes during ADS is primarily eye-box/clarity, not the glass suddenly
	/// gaining magnification.
	/// </summary>
	public static float ResolveRenderedScopeFieldOfView(
		float sourceFieldOfView,
		OMROpticProfile profile,
		float aimFraction
	)
	{
		float source = MathX.Clamp( sourceFieldOfView, 1f, 179f );
		float full = ResolveFullScopeFieldOfView( source, profile );

		if ( profile is null || !profile.IsValid )
			return source;

		if ( profile.PiPMagnificationMode == OMRPiPMagnificationMode.ConstantOptical )
			return full;

		return MathX.Lerp(
			source,
			full,
			MathX.Clamp( aimFraction, 0f, 1f )
		);
	}

	/// <summary>
	/// Input sensitivity should still blend into the scoped value as the player
	/// aims, even when a tiny hip-fire lens preview is already optically 4x.
	/// </summary>
	public static float ResolveSensitivityFieldOfView(
		float sourceFieldOfView,
		OMROpticProfile profile,
		float aimFraction
	)
	{
		float source = MathX.Clamp( sourceFieldOfView, 1f, 179f );
		float full = ResolveFullScopeFieldOfView( source, profile );

		return MathX.Lerp(
			source,
			full,
			MathX.Clamp( aimFraction, 0f, 1f )
		);
	}

	public static float CalculateAngularMagnification(
		float sourceFieldOfView,
		float scopedFieldOfView
	)
	{
		float source = MathX.Clamp( sourceFieldOfView, 1f, 179f ) * (MathF.PI / 180f);
		float scoped = MathX.Clamp( scopedFieldOfView, 1f, 179f ) * (MathF.PI / 180f);
		float scopedTan = MathF.Tan( scoped * 0.5f );

		if ( MathF.Abs( scopedTan ) <= 0.00001f )
			return 1f;

		return MathF.Max(
			MathF.Tan( source * 0.5f ) / scopedTan,
			1f
		);
	}
}