use crate::centers::ReferenceCenter;
use crate::frames::ReferenceFrame;
use qtty::{Degrees, LengthUnit, Quantity};
use std::marker::PhantomData;
#[cfg(feature = "serde")]
#[path = "direction_serde.rs"]
mod direction_serde;
#[derive(Debug, Clone, Copy)]
pub struct Direction<F: ReferenceFrame> {
pub polar: Degrees,
pub azimuth: Degrees,
_frame: PhantomData<F>,
}
impl<F: ReferenceFrame> Direction<F> {
pub const fn new_raw(polar: Degrees, azimuth: Degrees) -> Self {
Self {
polar,
azimuth,
_frame: PhantomData,
}
}
#[must_use]
pub fn position<C, U>(&self, magnitude: Quantity<U>) -> super::Position<C, F, U>
where
C: ReferenceCenter<Params = ()>,
U: LengthUnit,
{
super::Position::new_raw(self.polar, self.azimuth, magnitude)
}
#[must_use]
pub fn position_with_params<C, U>(
&self,
center_params: C::Params,
magnitude: Quantity<U>,
) -> super::Position<C, F, U>
where
C: ReferenceCenter,
U: LengthUnit,
{
super::Position::new_raw_with_params(center_params, self.polar, self.azimuth, magnitude)
}
pub fn angular_separation(&self, other: &Self) -> Degrees {
super::angular_separation_impl(self.polar, self.azimuth, other.polar, other.azimuth)
}
pub fn to_cartesian(&self) -> crate::cartesian::Direction<F>
where
F: ReferenceFrame,
{
use qtty::Radian;
let polar_rad = self.polar.to::<Radian>();
let azimuth_rad = self.azimuth.to::<Radian>();
let x = azimuth_rad.cos() * polar_rad.cos();
let y = azimuth_rad.sin() * polar_rad.cos();
let z = polar_rad.sin();
crate::cartesian::Direction::<F>::new(x, y, z)
}
pub fn from_cartesian(cart: &crate::cartesian::Direction<F>) -> Self
where
F: ReferenceFrame,
{
let (polar, azimuth) = super::xyz_to_polar_azimuth(cart.x(), cart.y(), cart.z());
Self::new_raw(polar, azimuth)
}
}
impl<F: ReferenceFrame> std::fmt::Display for Direction<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (polar_name, azimuth_name, _) =
F::spherical_names().unwrap_or(("\u{03b8}", "\u{03c6}", "r"));
write!(f, "Frame: {}, {}: ", F::frame_name(), polar_name)?;
std::fmt::Display::fmt(&self.polar, f)?;
write!(f, ", {}: ", azimuth_name)?;
std::fmt::Display::fmt(&self.azimuth, f)
}
}
impl<F: ReferenceFrame> std::fmt::LowerExp for Direction<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (polar_name, azimuth_name, _) =
F::spherical_names().unwrap_or(("\u{03b8}", "\u{03c6}", "r"));
write!(f, "Frame: {}, {}: ", F::frame_name(), polar_name)?;
std::fmt::LowerExp::fmt(&self.polar, f)?;
write!(f, ", {}: ", azimuth_name)?;
std::fmt::LowerExp::fmt(&self.azimuth, f)
}
}
impl<F: ReferenceFrame> std::fmt::UpperExp for Direction<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (polar_name, azimuth_name, _) =
F::spherical_names().unwrap_or(("\u{03b8}", "\u{03c6}", "r"));
write!(f, "Frame: {}, {}: ", F::frame_name(), polar_name)?;
std::fmt::UpperExp::fmt(&self.polar, f)?;
write!(f, ", {}: ", azimuth_name)?;
std::fmt::UpperExp::fmt(&self.azimuth, f)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DeriveReferenceCenter as ReferenceCenter, DeriveReferenceFrame as ReferenceFrame};
use qtty::*;
#[derive(Debug, Copy, Clone, ReferenceFrame)]
struct TestFrame;
#[derive(Debug, Copy, Clone, ReferenceCenter)]
struct TestCenter;
#[derive(Clone, Debug, Default, PartialEq)]
struct TestParams {
id: i32,
}
#[derive(Debug, Copy, Clone, ReferenceCenter)]
#[center(params = TestParams)]
struct ParamCenter;
#[test]
fn creates_valid_spherical_direction() {
let polar = Degrees::new(45.0);
let azimuth = Degrees::new(90.0);
let coord = Direction::<TestFrame>::new_raw(polar, azimuth);
assert_eq!(coord.azimuth.value(), 90.0);
assert_eq!(coord.polar.value(), 45.0);
}
#[test]
fn displays_coordinate_as_string_correctly() {
let coord = Direction::<TestFrame>::new_raw(Degrees::new(30.0), Degrees::new(60.0));
let output = coord.to_string();
assert!(output.contains("θ: 30"), "Missing polar angle");
assert!(output.contains("φ: 60"), "Missing azimuth");
}
#[test]
fn display_respects_format_specifiers() {
let coord = Direction::<TestFrame>::new_raw(Degrees::new(30.0), Degrees::new(60.0));
let output_prec = format!("{:.2}", coord);
let expected_polar_prec = format!("{:.2}", coord.polar);
assert!(output_prec.contains(&format!("θ: {expected_polar_prec}")));
let output_exp = format!("{:.3e}", coord);
let expected_az_exp = format!("{:.3e}", coord.azimuth);
assert!(output_exp.contains(&format!("φ: {expected_az_exp}")));
}
#[test]
fn maintains_high_precision_on_values() {
let polar = Degrees::new(45.123_456);
let azimuth = Degrees::new(90.654_321);
let coord = Direction::<TestFrame>::new_raw(polar, azimuth);
assert!((coord.polar.value() - 45.123_456).abs() < 1e-6);
assert!((coord.azimuth.value() - 90.654_321).abs() < 1e-6);
}
const EPS: f64 = 1e-6;
#[test]
fn position_method_promotes_with_given_radius() {
let dir = Direction::<TestFrame>::new_raw(Degrees::new(-30.0), Degrees::new(120.0));
let pos = dir.position::<TestCenter, Meter>(Quantity::<Meter>::new(2.0));
assert!(
(pos.azimuth.value() - 120.0).abs() < EPS,
"azimuth mismatch: got {}",
pos.azimuth.value()
);
assert!(
(pos.polar.value() - (-30.0)).abs() < EPS,
"polar mismatch: got {}",
pos.polar.value()
);
assert!((pos.distance - 2.0 * M).abs() < EPS * M);
}
#[test]
fn angular_separation_identity() {
let a = Direction::<TestFrame>::new_raw(Degrees::new(45.0), Degrees::new(30.0));
let sep = a.angular_separation(&a);
assert!(sep.abs().value() < 1e-10, "expected 0°, got {}", sep);
}
#[test]
fn canonicalizes_azimuth_to_positive_range() {
use crate::spherical::canonicalize_azimuth;
assert!((canonicalize_azimuth(Degrees::new(-90.0)).value() - 270.0).abs() < EPS);
assert!((canonicalize_azimuth(Degrees::new(450.0)).value() - 90.0).abs() < EPS);
assert!(canonicalize_azimuth(Degrees::new(-720.0)).value().abs() < EPS);
}
#[test]
fn folds_polar_to_valid_range() {
use crate::spherical::canonicalize_polar;
assert!((canonicalize_polar(Degrees::new(100.0)).value() - 80.0).abs() < EPS);
assert!((canonicalize_polar(Degrees::new(-100.0)).value() - (-80.0)).abs() < EPS);
}
#[test]
fn roundtrip_spherical_cartesian_direction() {
let original = Direction::<TestFrame>::new_raw(Degrees::new(45.0), Degrees::new(30.0));
let cartesian = original.to_cartesian();
let recovered = Direction::from_cartesian(&cartesian);
assert!(
(recovered.polar.value() - original.polar.value()).abs() < EPS,
"polar mismatch: {} vs {}",
recovered.polar.value(),
original.polar.value()
);
assert!(
(recovered.azimuth.value() - original.azimuth.value()).abs() < EPS,
"azimuth mismatch: {} vs {}",
recovered.azimuth.value(),
original.azimuth.value()
);
}
#[test]
fn roundtrip_at_poles() {
let north = Direction::<TestFrame>::new_raw(Degrees::new(90.0), Degrees::new(0.0));
let cart_n = north.to_cartesian();
let recovered_n = Direction::from_cartesian(&cart_n);
assert!((recovered_n.polar.value() - 90.0).abs() < EPS);
let south = Direction::<TestFrame>::new_raw(Degrees::new(-90.0), Degrees::new(0.0));
let cart_s = south.to_cartesian();
let recovered_s = Direction::from_cartesian(&cart_s);
assert!((recovered_s.polar.value() - (-90.0)).abs() < EPS);
}
#[test]
fn roundtrip_at_azimuth_boundaries() {
let dir0 = Direction::<TestFrame>::new_raw(Degrees::new(30.0), Degrees::new(0.0));
let cart0 = dir0.to_cartesian();
let rec0 = Direction::from_cartesian(&cart0);
assert!((rec0.azimuth.value() - 0.0).abs() < EPS);
let dir360 = Direction::<TestFrame>::new_raw(Degrees::new(30.0), Degrees::new(359.9));
let cart360 = dir360.to_cartesian();
let rec360 = Direction::from_cartesian(&cart360);
assert!((rec360.azimuth.value() - 359.9).abs() < EPS);
}
#[test]
fn uses_raw_construction() {
let dir = Direction::<TestFrame>::new_raw(Degrees::new(10.0), Degrees::new(20.0));
assert!((dir.polar.value() - 10.0).abs() < EPS);
assert!((dir.azimuth.value() - 20.0).abs() < EPS);
}
#[test]
fn position_with_params_preserves_center() {
let dir = Direction::<TestFrame>::new_raw(Degrees::new(15.0), Degrees::new(25.0));
let params = TestParams { id: 7 };
let pos = dir.position_with_params::<ParamCenter, Meter>(params.clone(), 2.5 * M);
assert_eq!(pos.center_params(), ¶ms);
assert!((pos.distance - 2.5 * M).abs() < EPS * M);
}
}