1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum InputGroup {
12 ProjectileDrag,
13 MuzzleVelocity,
14 ZeroSightGeometry,
15 Atmosphere,
16 Wind,
17 ShotGeometry,
18 Effects,
19}
20
21impl InputGroup {
22 pub const ALL: &'static [InputGroup] = &[
23 InputGroup::ProjectileDrag,
24 InputGroup::MuzzleVelocity,
25 InputGroup::ZeroSightGeometry,
26 InputGroup::Atmosphere,
27 InputGroup::Wind,
28 InputGroup::ShotGeometry,
29 InputGroup::Effects,
30 ];
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum InputAxis {
36 Mass,
37 Diameter,
38 Length,
39 BallisticCoefficient,
40 TwistRate,
41 TwistDirection,
42 DragModel,
43 MuzzleVelocityMps,
44 SightHeight,
45 ZeroDistance,
46 ZeroPoiUp,
47 ZeroPoiRight,
48 SightOffsetLateral,
49 MuzzleHeight,
50 MuzzleAngle,
51 Altitude,
52 Temperature,
53 Pressure,
54 RelativeHumidity,
55 Latitude,
56 WindSpeed,
57 WindDirection,
58 WindVertical,
59 TargetDistance,
60 ShootingAngle,
61 Cant,
62 ShotAzimuth,
63 AimAzimuth,
64 TargetHeight,
65 MagnusEnabled,
66 CoriolisEnabled,
67 EnhancedSpinDriftEnabled,
68}
69
70impl InputAxis {
71 pub const ALL: &'static [InputAxis] = &[
72 InputAxis::Mass,
73 InputAxis::Diameter,
74 InputAxis::Length,
75 InputAxis::BallisticCoefficient,
76 InputAxis::TwistRate,
77 InputAxis::TwistDirection,
78 InputAxis::DragModel,
79 InputAxis::MuzzleVelocityMps,
80 InputAxis::SightHeight,
81 InputAxis::ZeroDistance,
82 InputAxis::ZeroPoiUp,
83 InputAxis::ZeroPoiRight,
84 InputAxis::SightOffsetLateral,
85 InputAxis::MuzzleHeight,
86 InputAxis::MuzzleAngle,
87 InputAxis::Altitude,
88 InputAxis::Temperature,
89 InputAxis::Pressure,
90 InputAxis::RelativeHumidity,
91 InputAxis::Latitude,
92 InputAxis::WindSpeed,
93 InputAxis::WindDirection,
94 InputAxis::WindVertical,
95 InputAxis::TargetDistance,
96 InputAxis::ShootingAngle,
97 InputAxis::Cant,
98 InputAxis::ShotAzimuth,
99 InputAxis::AimAzimuth,
100 InputAxis::TargetHeight,
101 InputAxis::MagnusEnabled,
102 InputAxis::CoriolisEnabled,
103 InputAxis::EnhancedSpinDriftEnabled,
104 ];
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum AxisKind {
109 Continuous {
112 unit: &'static str,
113 default_rel_step: f64,
114 min_abs_step: f64,
115 },
116 Categorical,
118}
119
120#[derive(Debug, Clone, Copy)]
121pub struct AxisMeta {
122 pub group: InputGroup,
123 pub kind: AxisKind,
124 pub requires_rezero: bool,
127}
128
129const fn cont(unit: &'static str, rel: f64, min_abs: f64) -> AxisKind {
130 AxisKind::Continuous {
131 unit,
132 default_rel_step: rel,
133 min_abs_step: min_abs,
134 }
135}
136
137pub fn axis_meta(axis: InputAxis) -> AxisMeta {
138 use InputAxis::*;
139 use InputGroup::*;
140 let (group, kind, requires_rezero) = match axis {
141 Mass => (ProjectileDrag, cont("kg", 1e-3, 1e-7), true),
142 Diameter => (ProjectileDrag, cont("m", 1e-3, 1e-7), true),
143 Length => (ProjectileDrag, cont("m", 1e-3, 1e-6), false),
144 BallisticCoefficient => (ProjectileDrag, cont("", 1e-3, 1e-4), true),
145 TwistRate => (ProjectileDrag, cont("m/turn", 1e-3, 1e-5), false),
146 TwistDirection => (ProjectileDrag, AxisKind::Categorical, false),
147 DragModel => (ProjectileDrag, AxisKind::Categorical, true),
148 MuzzleVelocityMps => (MuzzleVelocity, cont("m/s", 1e-3, 0.15), true),
149 SightHeight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
150 ZeroDistance => (ZeroSightGeometry, cont("m", 1e-3, 0.1), true),
151 ZeroPoiUp => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
152 ZeroPoiRight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
153 SightOffsetLateral => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
154 MuzzleHeight => (ZeroSightGeometry, cont("m", 1e-3, 1e-5), true),
155 MuzzleAngle => (ZeroSightGeometry, cont("rad", 1e-3, 1e-4), false),
156 Altitude => (Atmosphere, cont("m", 1e-3, 1.0), false),
157 Temperature => (Atmosphere, cont("K", 1e-3, 0.05), false),
158 Pressure => (Atmosphere, cont("Pa", 1e-3, 10.0), false),
159 RelativeHumidity => (Atmosphere, cont("", 1e-3, 1e-3), false),
160 Latitude => (Atmosphere, cont("rad", 1e-3, 1e-5), false),
161 WindSpeed => (Wind, cont("m/s", 1e-3, 0.05), false),
162 WindDirection => (Wind, cont("rad", 1e-3, 1e-4), false),
163 WindVertical => (Wind, cont("m/s", 1e-3, 0.05), false),
164 TargetDistance => (ShotGeometry, cont("m", 1e-3, 0.5), false),
165 ShootingAngle => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
166 Cant => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
167 ShotAzimuth => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
168 AimAzimuth => (ShotGeometry, cont("rad", 1e-3, 1e-4), false),
169 TargetHeight => (ShotGeometry, cont("m", 1e-3, 1e-3), false),
170 MagnusEnabled => (Effects, AxisKind::Categorical, false),
171 CoriolisEnabled => (Effects, AxisKind::Categorical, false),
172 EnhancedSpinDriftEnabled => (Effects, AxisKind::Categorical, false),
173 };
174 AxisMeta {
175 group,
176 kind,
177 requires_rezero,
178 }
179}
180
181pub fn axes_in_group(group: InputGroup) -> &'static [InputAxis] {
182 use InputAxis::*;
183 match group {
184 InputGroup::ProjectileDrag => &[
191 Mass,
192 Diameter,
193 Length,
194 BallisticCoefficient,
195 TwistRate,
196 TwistDirection,
197 DragModel,
198 ],
199 InputGroup::MuzzleVelocity => &[MuzzleVelocityMps],
200 InputGroup::ZeroSightGeometry => &[
214 MuzzleAngle,
215 SightHeight,
216 ZeroDistance,
217 ZeroPoiUp,
218 ZeroPoiRight,
219 SightOffsetLateral,
220 MuzzleHeight,
221 ],
222 InputGroup::Atmosphere => &[Altitude, Temperature, Pressure, RelativeHumidity, Latitude],
223 InputGroup::Wind => &[WindSpeed, WindDirection, WindVertical],
224 InputGroup::ShotGeometry => &[
225 TargetDistance,
226 ShootingAngle,
227 Cant,
228 ShotAzimuth,
229 AimAzimuth,
230 TargetHeight,
231 ],
232 InputGroup::Effects => &[MagnusEnabled, CoriolisEnabled, EnhancedSpinDriftEnabled],
233 }
234}
235
236#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
263 fn taxonomy_is_a_partition() {
264 let mut seen = Vec::new();
265 for g in InputGroup::ALL {
266 for a in axes_in_group(*g) {
267 assert_eq!(
268 axis_meta(*a).group,
269 *g,
270 "{a:?} listed under the wrong group"
271 );
272 assert!(!seen.contains(a), "{a:?} appears in more than one group");
273 seen.push(*a);
274 }
275 }
276 assert_eq!(
278 seen.len(),
279 InputAxis::ALL.len(),
280 "axes_in_group() covers {} axes; InputAxis::ALL has {}",
281 seen.len(),
282 InputAxis::ALL.len()
283 );
284 for axis in InputAxis::ALL {
285 assert!(
286 seen.contains(axis),
287 "{axis:?} is in InputAxis::ALL but not in any group"
288 );
289 }
290 for axis in InputAxis::ALL {
292 let _ = axis_meta(*axis);
293 }
294 }
295
296 #[test]
298 fn effects_are_categorical() {
299 for a in axes_in_group(InputGroup::Effects) {
300 assert!(
301 matches!(axis_meta(*a).kind, AxisKind::Categorical),
302 "{a:?} must be Categorical"
303 );
304 }
305 }
306
307 #[test]
309 fn only_zero_affecting_axes_require_rezero() {
310 assert!(axis_meta(InputAxis::SightHeight).requires_rezero);
311 assert!(axis_meta(InputAxis::ZeroDistance).requires_rezero);
312 assert!(!axis_meta(InputAxis::WindSpeed).requires_rezero);
313 assert!(!axis_meta(InputAxis::TargetDistance).requires_rezero);
314 }
315}