#![allow(missing_docs)]
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn len(a: [f64; 3]) -> f64 {
dot(a, a).sqrt()
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn normalize(a: [f64; 3]) -> Option<[f64; 3]> {
let l = len(a);
if l < 1e-12 {
None
} else {
Some(scale(a, 1.0 / l))
}
}
fn local_to_world(rot: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[
rot[0][0] * v[0] + rot[1][0] * v[1] + rot[2][0] * v[2],
rot[0][1] * v[0] + rot[1][1] * v[1] + rot[2][1] * v[2],
rot[0][2] * v[0] + rot[1][2] * v[1] + rot[2][2] * v[2],
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LiftCurve {
Linear {
cl0: f64,
slope: f64,
},
Stalled {
alpha_stall_rad: f64,
cl_max: f64,
after_stall_slope: f64,
},
Lookup {
samples: Vec<(f64, f64)>,
},
}
impl LiftCurve {
pub fn evaluate(&self, alpha: f64) -> f64 {
match self {
LiftCurve::Linear { cl0, slope } => cl0 + slope * alpha,
LiftCurve::Stalled {
alpha_stall_rad,
cl_max,
after_stall_slope,
} => {
if alpha.abs() < *alpha_stall_rad {
let slope = cl_max / alpha_stall_rad;
slope * alpha
} else {
let sign = alpha.signum();
sign * cl_max + after_stall_slope * (alpha - sign * alpha_stall_rad)
}
}
LiftCurve::Lookup { samples } => interpolate_lookup(samples, alpha),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DragCurve {
Constant(f64),
Lookup {
samples: Vec<(f64, f64)>,
},
}
impl DragCurve {
pub fn evaluate(&self, alpha: f64) -> f64 {
match self {
DragCurve::Constant(c) => *c,
DragCurve::Lookup { samples } => interpolate_lookup(samples, alpha),
}
}
}
fn interpolate_lookup(samples: &[(f64, f64)], x: f64) -> f64 {
if samples.is_empty() {
return 0.0;
}
if samples.len() == 1 {
return samples[0].1;
}
if x <= samples[0].0 {
return samples[0].1;
}
if x >= samples[samples.len() - 1].0 {
return samples[samples.len() - 1].1;
}
let idx = samples.partition_point(|&(xi, _)| xi <= x);
let (x0, y0) = samples[idx - 1];
let (x1, y1) = samples[idx];
let t = (x - x0) / (x1 - x0);
y0 + t * (y1 - y0)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AeroBody {
pub drag_coeff: f64,
pub cross_section: f64,
pub air_density: f64,
}
impl Default for AeroBody {
fn default() -> Self {
Self {
drag_coeff: 0.47,
cross_section: 1.0,
air_density: 1.225,
}
}
}
impl AeroBody {
fn drag_force(&self, relative_velocity: [f64; 3]) -> [f64; 3] {
let speed = len(relative_velocity);
scale(
relative_velocity,
-0.5 * self.air_density * self.drag_coeff * self.cross_section * speed,
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WingSurface {
pub center_local: [f64; 3],
pub normal_local: [f64; 3],
pub chord: f64,
pub span: f64,
pub lift_curve: LiftCurve,
pub drag_curve: DragCurve,
pub air_density: f64,
}
impl WingSurface {
fn compute(
&self,
body_velocity: [f64; 3],
wind: [f64; 3],
rotation: &[[f64; 3]; 3],
) -> ([f64; 3], [f64; 3]) {
let relative_v = sub(body_velocity, wind);
let speed = len(relative_v);
if speed < 1e-6 {
return ([0.0; 3], [0.0; 3]);
}
let wind_dir = scale(relative_v, 1.0 / speed);
let world_normal = local_to_world(rotation, self.normal_local);
let sin_alpha = dot(scale(wind_dir, -1.0), world_normal).clamp(-1.0, 1.0);
let alpha = sin_alpha.asin();
let q = 0.5 * self.air_density * speed * speed;
let s = self.chord * self.span;
let cl = self.lift_curve.evaluate(alpha);
let cd = self.drag_curve.evaluate(alpha);
let lift_dir_raw = sub(world_normal, scale(wind_dir, dot(world_normal, wind_dir)));
let lift_force = if let Some(ld) = normalize(lift_dir_raw) {
scale(ld, cl * q * s)
} else {
[0.0; 3]
};
let drag_force = scale(wind_dir, -cd * q * s);
let wing_force = add(lift_force, drag_force);
let r = local_to_world(rotation, self.center_local);
(wing_force, r)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AeroEntry {
pub position: [f64; 3],
pub rotation: [[f64; 3]; 3],
pub velocity: [f64; 3],
pub drag_body: Option<AeroBody>,
pub wings: Vec<WingSurface>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AeroForce {
pub force: [f64; 3],
pub torque: [f64; 3],
}
impl AeroForce {
fn zero() -> Self {
Self {
force: [0.0; 3],
torque: [0.0; 3],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AeroSystem {
pub entries: Vec<AeroEntry>,
pub wind: [f64; 3],
}
impl AeroSystem {
pub fn apply(&self) -> Vec<AeroForce> {
self.entries
.iter()
.map(|entry| {
let mut total_force = [0.0; 3];
let mut total_torque = [0.0; 3];
if let Some(body) = &entry.drag_body {
let rel_v = sub(entry.velocity, self.wind);
let f = body.drag_force(rel_v);
total_force = add(total_force, f);
}
for wing in &entry.wings {
let (f, r) = wing.compute(entry.velocity, self.wind, &entry.rotation);
total_force = add(total_force, f);
total_torque = add(total_torque, cross(r, f));
}
AeroForce {
force: total_force,
torque: total_torque,
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity_rot() -> [[f64; 3]; 3] {
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
}
fn drag_only_system(velocity: [f64; 3], cd: f64, area: f64) -> AeroSystem {
AeroSystem {
entries: vec![AeroEntry {
position: [0.0; 3],
rotation: identity_rot(),
velocity,
drag_body: Some(AeroBody {
drag_coeff: cd,
cross_section: area,
air_density: 1.225,
}),
wings: vec![],
}],
wind: [0.0; 3],
}
}
#[test]
fn test_zero_velocity_zero_force() {
let sys = drag_only_system([0.0; 3], 1.0, 1.0);
let forces = sys.apply();
assert_eq!(forces.len(), 1);
let f = forces[0].force;
assert!(
f[0].abs() < 1e-12 && f[1].abs() < 1e-12 && f[2].abs() < 1e-12,
"Zero velocity should produce zero drag force, got {f:?}"
);
}
#[test]
fn test_drag_opposes_velocity() {
let sys = drag_only_system([10.0, 0.0, 0.0], 1.0, 1.0);
let forces = sys.apply();
assert!(
forces[0].force[0] < 0.0,
"Drag should oppose +X motion, got Fx={}",
forces[0].force[0]
);
}
#[test]
fn test_drag_scales_v_squared() {
let sys10 = drag_only_system([10.0, 0.0, 0.0], 1.0, 1.0);
let sys20 = drag_only_system([20.0, 0.0, 0.0], 1.0, 1.0);
let f10 = sys10.apply()[0].force[0].abs();
let f20 = sys20.apply()[0].force[0].abs();
let ratio = f20 / f10;
assert!(
(ratio - 4.0).abs() < 1e-9,
"Drag force ratio at v=20 vs v=10 should be 4.0, got {ratio}"
);
}
#[test]
fn test_wing_zero_cl_at_zero_alpha() {
let sys = AeroSystem {
entries: vec![AeroEntry {
position: [0.0; 3],
rotation: identity_rot(),
velocity: [10.0, 0.0, 0.0],
drag_body: None,
wings: vec![WingSurface {
center_local: [0.0; 3],
normal_local: [0.0, 1.0, 0.0],
chord: 1.0,
span: 1.0,
lift_curve: LiftCurve::Linear {
cl0: 0.0,
slope: 5.73,
},
drag_curve: DragCurve::Constant(0.02),
air_density: 1.225,
}],
}],
wind: [0.0; 3],
};
let forces = sys.apply();
assert!(
forces[0].force[1].abs() < 1e-6,
"Lift should be ≈0 at α=0 with cl0=0, got Fy={}",
forces[0].force[1]
);
assert!(
forces[0].force[0] < 0.0,
"Drag should be negative, got Fx={}",
forces[0].force[0]
);
}
#[test]
fn test_wing_lift_direction() {
let speed = 10.0_f64;
let angle = 0.2_f64; let sys = AeroSystem {
entries: vec![AeroEntry {
position: [0.0; 3],
rotation: identity_rot(),
velocity: [speed * angle.cos(), -speed * angle.sin(), 0.0],
drag_body: None,
wings: vec![WingSurface {
center_local: [0.0; 3],
normal_local: [0.0, 1.0, 0.0],
chord: 1.0,
span: 1.0,
lift_curve: LiftCurve::Linear {
cl0: 0.0,
slope: 5.73,
},
drag_curve: DragCurve::Constant(0.01),
air_density: 1.225,
}],
}],
wind: [0.0; 3],
};
let forces = sys.apply();
assert!(
forces[0].force[1] > 0.0,
"Lift force should have positive Y component for nose-down AoA, got Fy={}",
forces[0].force[1]
);
}
#[test]
fn test_lift_curve_stall() {
let curve = LiftCurve::Stalled {
alpha_stall_rad: 0.3,
cl_max: 1.5,
after_stall_slope: -2.0,
};
let cl_at_stall = curve.evaluate(0.3);
let cl_post_stall = curve.evaluate(0.5);
assert!(
cl_post_stall < cl_at_stall,
"Post-stall CL ({cl_post_stall}) should be less than CL at stall ({cl_at_stall})"
);
}
#[test]
fn test_lookup_interpolation() {
let curve = LiftCurve::Lookup {
samples: vec![(0.0, 0.5), (1.0, 1.5)],
};
let val = curve.evaluate(0.5);
assert!(
(val - 1.0).abs() < 1e-10,
"Linear interpolation at midpoint should give 1.0, got {val}"
);
}
#[test]
fn test_serde_round_trip() {
let original = AeroSystem {
entries: vec![AeroEntry {
position: [1.0, 2.0, 3.0],
rotation: identity_rot(),
velocity: [5.0, 0.0, 0.0],
drag_body: Some(AeroBody {
drag_coeff: 0.3,
cross_section: 0.8,
air_density: 1.225,
}),
wings: vec![WingSurface {
center_local: [0.5, 0.0, 0.0],
normal_local: [0.0, 1.0, 0.0],
chord: 0.4,
span: 2.0,
lift_curve: LiftCurve::Lookup {
samples: vec![(0.0, 0.0), (0.3, 1.2), (0.5, 0.8)],
},
drag_curve: DragCurve::Lookup {
samples: vec![(0.0, 0.01), (0.5, 0.05)],
},
air_density: 1.225,
}],
}],
wind: [2.0, 0.0, 0.0],
};
let json = serde_json::to_string(&original).expect("serialize AeroSystem");
let restored: AeroSystem = serde_json::from_str(&json).expect("deserialize AeroSystem");
assert_eq!(restored.entries.len(), original.entries.len());
assert_eq!(restored.wind, original.wind);
let e = &restored.entries[0];
assert_eq!(e.position, [1.0, 2.0, 3.0]);
assert_eq!(e.wings.len(), 1);
assert!((e.wings[0].chord - 0.4).abs() < 1e-15);
}
}