Skip to main content

fpm_rs/experiment/
spherical.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{Error, Result};
4
5use super::{Optics, ResolvedSources};
6
7/// Fixed LEDs mounted on a spherical surface on the negative-`z` source side.
8///
9/// Each angle is `(theta, phi)` in radians. `theta` is the polar angle from
10/// the positive propagation (`z`) axis and `phi` is the propagation azimuth
11/// from positive `x` toward positive `y`. Physical source positions are the
12/// opposite of those nominal propagation directions. The sphere pose is
13/// applied as `Rz * Ry * Rx`.
14#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct SphericalLedArray {
17    /// Nominal `(theta, phi)` LED positions in radians and natural source order.
18    #[serde(rename = "polar_angles_rad")]
19    pub angles: Vec<(f64, f64)>,
20    /// Positive nominal sphere radius in metres.
21    #[serde(rename = "radius_m")]
22    pub radius: f64,
23    /// Sphere-centre displacement `(x, y, z)` from the sample, in metres.
24    #[serde(rename = "center_offset_m")]
25    pub center_offset: (f64, f64, f64),
26    /// Rigid mount rotation `(rx, ry, rz)` in radians.
27    #[serde(rename = "rotation_rad")]
28    pub orientation_radians: (f64, f64, f64),
29    /// Optional per-LED `(delta_theta, delta_phi)` placement corrections.
30    #[serde(rename = "angular_corrections_rad")]
31    pub angular_corrections: Option<Vec<(f64, f64)>>,
32}
33
34impl SphericalLedArray {
35    /// Creates a sphere from natural-order `(theta, phi)` radians and a radius in metres.
36    pub fn new(angles: Vec<(f64, f64)>, radius: f64) -> Self {
37        Self {
38            angles,
39            radius,
40            center_offset: (0.0, 0.0, 0.0),
41            orientation_radians: (0.0, 0.0, 0.0),
42            angular_corrections: None,
43        }
44    }
45
46    /// Sets the sphere-centre displacement `(x, y, z)` from the sample, in metres.
47    pub fn center_offset(mut self, offset: (f64, f64, f64)) -> Self {
48        self.center_offset = offset;
49        self
50    }
51
52    /// Sets extrinsic mount rotations `(rx, ry, rz)` in degrees, applied as `Rz * Ry * Rx`.
53    pub fn orientation_deg(mut self, degrees: (f64, f64, f64)) -> Self {
54        self.orientation_radians = (
55            degrees.0.to_radians(),
56            degrees.1.to_radians(),
57            degrees.2.to_radians(),
58        );
59        self
60    }
61
62    /// Sets one `(delta_theta, delta_phi)` correction in radians per natural-order LED.
63    pub fn angular_corrections(mut self, corrections: Vec<(f64, f64)>) -> Self {
64        self.angular_corrections = Some(corrections);
65        self
66    }
67
68    /// Returns the number of fixed physical sources.
69    pub fn source_count(&self) -> usize {
70        self.angles.len()
71    }
72
73    /// Validates angles, positive radius, finite pose, and placement corrections.
74    pub fn validate(&self) -> Result<()> {
75        validate_angle_list(&self.angles, "LED sphere angles")?;
76        validate_length(self.radius, "radius")?;
77        validate_pose(self.center_offset, self.orientation_radians)?;
78        if let Some(corrections) = &self.angular_corrections
79            && (corrections.len() != self.angles.len()
80                || corrections
81                    .iter()
82                    .any(|&(theta, phi)| !theta.is_finite() || !phi.is_finite()))
83        {
84            return Err(Error::InvalidParameter {
85                name: "angular_corrections",
86                reason: format!(
87                    "must contain {} finite (delta_theta, delta_phi) pairs",
88                    self.angles.len()
89                ),
90            });
91        }
92        Ok(())
93    }
94
95    fn natural_positions(&self) -> Result<Vec<[f64; 3]>> {
96        self.angles
97            .iter()
98            .enumerate()
99            .map(|(index, &(theta, phi))| {
100                let (delta_theta, delta_phi) = self
101                    .angular_corrections
102                    .as_ref()
103                    .map_or((0.0, 0.0), |values| values[index]);
104                let direction = spherical_direction(theta + delta_theta, phi + delta_phi);
105                source_position(
106                    scale(direction, -self.radius),
107                    self.center_offset,
108                    self.orientation_radians,
109                    "LED sphere",
110                )
111            })
112            .collect()
113    }
114    /// Resolves physical positions to source-to-sample directions and transverse vectors.
115    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
116        self.validate()?;
117        ResolvedSources::from_positions(self.natural_positions()?, optics)
118    }
119}
120
121/// A single LED moved over a spherical trajectory by an azimuth/elevation arm.
122///
123/// `commanded_angles` are `(theta, phi)` encoder commands in acquisition order.
124/// The kinematic model includes encoder affine errors, direction-dependent
125/// backlash, inner-axis non-orthogonality, arm-pivot displacement, and a rigid
126/// mount rotation. Azimuth commands should be unwrapped when crossing `+-pi` so
127/// that backlash direction is unambiguous.
128#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct SphericalLedArm {
131    /// Movement-order `(theta, phi)` encoder commands in radians.
132    #[serde(rename = "commanded_angles_rad")]
133    pub commanded_angles: Vec<(f64, f64)>,
134    /// Positive distance from pivot to LED, in metres.
135    #[serde(rename = "arm_length_m")]
136    pub arm_length: f64,
137    /// Arm-pivot displacement `(x, y, z)` from the sample, in metres.
138    #[serde(rename = "pivot_offset_m")]
139    pub pivot_offset: (f64, f64, f64),
140    /// Rigid mount rotation `(rx, ry, rz)` in radians.
141    #[serde(rename = "rotation_rad")]
142    pub orientation_radians: (f64, f64, f64),
143    /// Additive elevation-encoder zero offset, in radians.
144    #[serde(rename = "theta_zero_rad")]
145    pub theta_zero_radians: f64,
146    /// Additive azimuth-encoder zero offset, in radians.
147    #[serde(rename = "phi_zero_rad")]
148    pub phi_zero_radians: f64,
149    /// Positive dimensionless elevation-encoder scale.
150    pub theta_scale: f64,
151    /// Positive dimensionless azimuth-encoder scale.
152    pub phi_scale: f64,
153    /// Tilt of the elevation axis toward the azimuth axis, in radians.
154    #[serde(rename = "elevation_axis_tilt_rad")]
155    pub elevation_axis_tilt_radians: f64,
156    /// Total separation between increasing and decreasing encoder branches.
157    #[serde(rename = "theta_backlash_rad")]
158    pub theta_backlash_radians: f64,
159    /// Total separation between increasing and decreasing encoder branches.
160    #[serde(rename = "phi_backlash_rad")]
161    pub phi_backlash_radians: f64,
162}
163
164impl SphericalLedArm {
165    /// Creates an arm from movement-order commands in radians and length in metres.
166    pub fn new(commanded_angles: Vec<(f64, f64)>, arm_length: f64) -> Self {
167        Self {
168            commanded_angles,
169            arm_length,
170            pivot_offset: (0.0, 0.0, 0.0),
171            orientation_radians: (0.0, 0.0, 0.0),
172            theta_zero_radians: 0.0,
173            phi_zero_radians: 0.0,
174            theta_scale: 1.0,
175            phi_scale: 1.0,
176            elevation_axis_tilt_radians: 0.0,
177            theta_backlash_radians: 0.0,
178            phi_backlash_radians: 0.0,
179        }
180    }
181
182    /// Sets pivot displacement `(x, y, z)` from the sample in metres.
183    pub fn pivot_offset(mut self, offset: (f64, f64, f64)) -> Self {
184        self.pivot_offset = offset;
185        self
186    }
187
188    /// Sets extrinsic mount rotations `(rx, ry, rz)` in degrees, applied as `Rz * Ry * Rx`.
189    pub fn orientation_deg(mut self, degrees: (f64, f64, f64)) -> Self {
190        self.orientation_radians = (
191            degrees.0.to_radians(),
192            degrees.1.to_radians(),
193            degrees.2.to_radians(),
194        );
195        self
196    }
197
198    /// Sets additive elevation and azimuth encoder-zero offsets in degrees.
199    pub fn encoder_zero_deg(mut self, theta: f64, phi: f64) -> Self {
200        self.theta_zero_radians = theta.to_radians();
201        self.phi_zero_radians = phi.to_radians();
202        self
203    }
204
205    /// Sets positive dimensionless elevation and azimuth encoder scales.
206    pub fn encoder_scale(mut self, theta: f64, phi: f64) -> Self {
207        self.theta_scale = theta;
208        self.phi_scale = phi;
209        self
210    }
211
212    /// Sets the elevation-axis non-orthogonality tilt in degrees.
213    pub fn elevation_axis_tilt_deg(mut self, degrees: f64) -> Self {
214        self.elevation_axis_tilt_radians = degrees.to_radians();
215        self
216    }
217
218    /// Sets total elevation and azimuth backlash branch separations in degrees.
219    pub fn backlash_deg(mut self, theta: f64, phi: f64) -> Self {
220        self.theta_backlash_radians = theta.to_radians();
221        self.phi_backlash_radians = phi.to_radians();
222        self
223    }
224
225    /// Returns the number of commanded physical source positions.
226    pub fn source_count(&self) -> usize {
227        self.commanded_angles.len()
228    }
229
230    /// Checks non-empty finite commands, positive geometry and encoder scales, and
231    /// finite pose and mechanical calibration values.
232    pub fn validate(&self) -> Result<()> {
233        validate_angle_list(&self.commanded_angles, "arm commanded_angles")?;
234        validate_length(self.arm_length, "arm_length")?;
235        validate_pose(self.pivot_offset, self.orientation_radians)?;
236        if !self.theta_zero_radians.is_finite()
237            || !self.phi_zero_radians.is_finite()
238            || !self.theta_scale.is_finite()
239            || self.theta_scale <= 0.0
240            || !self.phi_scale.is_finite()
241            || self.phi_scale <= 0.0
242        {
243            return Err(Error::InvalidParameter {
244                name: "arm encoder calibration",
245                reason: "zero offsets must be finite and scales must be finite and positive".into(),
246            });
247        }
248        if !self.elevation_axis_tilt_radians.is_finite()
249            || self.elevation_axis_tilt_radians.abs() >= std::f64::consts::FRAC_PI_2
250        {
251            return Err(Error::InvalidParameter {
252                name: "elevation_axis_tilt_radians",
253                reason: "must be finite with magnitude less than pi/2".into(),
254            });
255        }
256        if !self.theta_backlash_radians.is_finite()
257            || self.theta_backlash_radians < 0.0
258            || !self.phi_backlash_radians.is_finite()
259            || self.phi_backlash_radians < 0.0
260        {
261            return Err(Error::InvalidParameter {
262                name: "arm backlash",
263                reason: "backlash widths must be finite and non-negative".into(),
264            });
265        }
266        Ok(())
267    }
268
269    fn positions(&self) -> Result<Vec<[f64; 3]>> {
270        let theta_branches = backlash_branches(&self.commanded_angles, 0);
271        let phi_branches = backlash_branches(&self.commanded_angles, 1);
272        let (axis_sin, axis_cos) = self.elevation_axis_tilt_radians.sin_cos();
273        let elevation_axis = [0.0, axis_cos, axis_sin];
274
275        self.commanded_angles
276            .iter()
277            .enumerate()
278            .map(|(index, &(theta_command, phi_command))| {
279                let theta = self.theta_scale * theta_command
280                    + self.theta_zero_radians
281                    + 0.5 * self.theta_backlash_radians * theta_branches[index];
282                let phi = self.phi_scale * phi_command
283                    + self.phi_zero_radians
284                    + 0.5 * self.phi_backlash_radians * phi_branches[index];
285                let home = [0.0, 0.0, -self.arm_length];
286                let elevated = rotate_axis_angle(home, elevation_axis, theta);
287                let arm_position = rotate_z(elevated, phi);
288                source_position(
289                    arm_position,
290                    self.pivot_offset,
291                    self.orientation_radians,
292                    "spherical LED arm",
293                )
294            })
295            .collect()
296    }
297    /// Resolves physical positions to source-to-sample directions and transverse vectors.
298    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
299        self.validate()?;
300        ResolvedSources::from_positions(self.positions()?, optics)
301    }
302}
303
304/// A quarter-circle LED arc rotated about an axis nominally collinear with the
305/// optical axis.
306///
307/// Every LED is compiled at every commanded rotation angle. Sources are
308/// ordered rotation-major and then by `led_thetas`. The nominal arm lies in the
309/// `x-z` plane, with LED polar positions measured from positive `z`.
310#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct RotatingLedArc {
313    /// Nominal LED polar positions along the quarter-circle arm, in radians.
314    #[serde(rename = "led_polar_angles_rad")]
315    pub led_thetas: Vec<f64>,
316    /// Commanded arm azimuths in physical movement order, in radians.
317    #[serde(rename = "rotation_angles_rad")]
318    pub rotation_angles: Vec<f64>,
319    /// Positive nominal arc radius in metres.
320    #[serde(rename = "radius_m")]
321    pub radius: f64,
322    /// Point on the rotation axis relative to the sample, in metres.
323    #[serde(rename = "axis_origin_offset_m")]
324    pub axis_origin_offset: (f64, f64, f64),
325    /// Rotation-axis tilt `(rx, ry)` away from the optical axis, in radians.
326    #[serde(rename = "axis_tilt_rad")]
327    pub axis_tilt_radians: (f64, f64),
328    /// Per-LED `(delta_theta, delta_phi)` mounting corrections.
329    #[serde(rename = "led_angular_corrections_rad")]
330    pub led_angular_corrections: Option<Vec<(f64, f64)>>,
331    /// Per-LED radial corrections to the nominal arm radius, in metres.
332    #[serde(rename = "led_radial_offsets_m")]
333    pub led_radial_offsets: Option<Vec<f64>>,
334    /// Additive rotation-encoder zero offset in radians.
335    #[serde(rename = "rotation_zero_rad")]
336    pub rotation_zero_radians: f64,
337    /// Positive dimensionless rotation-encoder scale.
338    pub rotation_scale: f64,
339    /// Total separation between increasing and decreasing rotation branches.
340    #[serde(rename = "rotation_backlash_rad")]
341    pub rotation_backlash_radians: f64,
342}
343
344impl RotatingLedArc {
345    /// Creates an arc from LED polar angles and movement-order rotations in radians,
346    /// with a positive radius in metres.
347    pub fn new(led_thetas: Vec<f64>, rotation_angles: Vec<f64>, radius: f64) -> Self {
348        Self {
349            led_thetas,
350            rotation_angles,
351            radius,
352            axis_origin_offset: (0.0, 0.0, 0.0),
353            axis_tilt_radians: (0.0, 0.0),
354            led_angular_corrections: None,
355            led_radial_offsets: None,
356            rotation_zero_radians: 0.0,
357            rotation_scale: 1.0,
358            rotation_backlash_radians: 0.0,
359        }
360    }
361
362    /// Sets a point on the rotation axis `(x, y, z)` relative to the sample, in metres.
363    pub fn axis_origin_offset(mut self, offset: (f64, f64, f64)) -> Self {
364        self.axis_origin_offset = offset;
365        self
366    }
367
368    /// Sets extrinsic `x` and `y` tilts of the rotation axis in degrees.
369    pub fn axis_tilt_deg(mut self, x: f64, y: f64) -> Self {
370        self.axis_tilt_radians = (x.to_radians(), y.to_radians());
371        self
372    }
373
374    /// Sets per-LED `(delta_theta, delta_phi)` mounting corrections in radians.
375    pub fn led_angular_corrections(mut self, corrections: Vec<(f64, f64)>) -> Self {
376        self.led_angular_corrections = Some(corrections);
377        self
378    }
379
380    /// Sets per-LED radial corrections in metres.
381    pub fn led_radial_offsets(mut self, offsets: Vec<f64>) -> Self {
382        self.led_radial_offsets = Some(offsets);
383        self
384    }
385
386    /// Sets the additive rotation-encoder zero offset in degrees.
387    pub fn rotation_encoder_zero_deg(mut self, degrees: f64) -> Self {
388        self.rotation_zero_radians = degrees.to_radians();
389        self
390    }
391
392    /// Sets the positive dimensionless rotation-encoder scale.
393    pub fn rotation_encoder_scale(mut self, scale: f64) -> Self {
394        self.rotation_scale = scale;
395        self
396    }
397
398    /// Sets total separation between increasing and decreasing rotation branches in degrees.
399    pub fn rotation_backlash_deg(mut self, degrees: f64) -> Self {
400        self.rotation_backlash_radians = degrees.to_radians();
401        self
402    }
403
404    /// Returns the rotation-major physical source count.
405    pub fn source_count(&self) -> usize {
406        self.led_thetas
407            .len()
408            .checked_mul(self.rotation_angles.len())
409            .unwrap_or(0)
410    }
411
412    /// Validates angular domains, positive radius and scale, finite pose and encoder
413    /// calibration, per-LED correction lengths, optional wavelength, and gains.
414    pub fn validate(&self) -> Result<()> {
415        if self.led_thetas.is_empty()
416            || self.led_thetas.iter().any(|&theta| {
417                !theta.is_finite() || !(0.0..std::f64::consts::FRAC_PI_2).contains(&theta)
418            })
419        {
420            return Err(Error::InvalidParameter {
421                name: "led_thetas",
422                reason: "must contain at least one finite angle with 0 <= theta < pi/2".into(),
423            });
424        }
425        if self.rotation_angles.is_empty()
426            || self.rotation_angles.iter().any(|value| !value.is_finite())
427        {
428            return Err(Error::InvalidParameter {
429                name: "rotation_angles",
430                reason: "must contain at least one finite commanded azimuth".into(),
431            });
432        }
433        self.led_thetas
434            .len()
435            .checked_mul(self.rotation_angles.len())
436            .ok_or_else(|| Error::InvalidParameter {
437                name: "rotating LED arc",
438                reason: "source count overflows".into(),
439            })?;
440        validate_length(self.radius, "radius")?;
441        validate_pose(
442            self.axis_origin_offset,
443            (self.axis_tilt_radians.0, self.axis_tilt_radians.1, 0.0),
444        )?;
445        if self.axis_tilt_radians.0.abs() >= std::f64::consts::FRAC_PI_2
446            || self.axis_tilt_radians.1.abs() >= std::f64::consts::FRAC_PI_2
447        {
448            return Err(Error::InvalidParameter {
449                name: "axis_tilt_radians",
450                reason: "components must have magnitude less than pi/2".into(),
451            });
452        }
453        if let Some(corrections) = &self.led_angular_corrections
454            && (corrections.len() != self.led_thetas.len()
455                || corrections
456                    .iter()
457                    .enumerate()
458                    .any(|(index, &(theta, phi))| {
459                        !theta.is_finite()
460                            || !phi.is_finite()
461                            || !(0.0..std::f64::consts::FRAC_PI_2)
462                                .contains(&(self.led_thetas[index] + theta))
463                    }))
464        {
465            return Err(Error::InvalidParameter {
466                name: "led_angular_corrections",
467                reason: format!(
468                    "must contain {} finite corrections that keep each LED on the quarter-circle cap",
469                    self.led_thetas.len()
470                ),
471            });
472        }
473        if let Some(offsets) = &self.led_radial_offsets
474            && (offsets.len() != self.led_thetas.len()
475                || offsets
476                    .iter()
477                    .any(|offset| !offset.is_finite() || self.radius + offset <= 0.0))
478        {
479            return Err(Error::InvalidParameter {
480                name: "led_radial_offsets",
481                reason: format!(
482                    "must contain {} finite offsets with positive corrected radii",
483                    self.led_thetas.len()
484                ),
485            });
486        }
487        if !self.rotation_zero_radians.is_finite()
488            || !self.rotation_scale.is_finite()
489            || self.rotation_scale <= 0.0
490        {
491            return Err(Error::InvalidParameter {
492                name: "rotation encoder calibration",
493                reason: "zero must be finite and scale must be finite and positive".into(),
494            });
495        }
496        if !self.rotation_backlash_radians.is_finite() || self.rotation_backlash_radians < 0.0 {
497            return Err(Error::InvalidParameter {
498                name: "rotation_backlash_radians",
499                reason: "must be finite and non-negative".into(),
500            });
501        }
502        Ok(())
503    }
504
505    fn positions(&self) -> Result<Vec<[f64; 3]>> {
506        let branches = scalar_backlash_branches(&self.rotation_angles);
507        let mut positions = Vec::with_capacity(self.source_count());
508        for (rotation_index, &command) in self.rotation_angles.iter().enumerate() {
509            let rotation = self.rotation_scale * command
510                + self.rotation_zero_radians
511                + 0.5 * self.rotation_backlash_radians * branches[rotation_index];
512            for (led_index, &nominal_theta) in self.led_thetas.iter().enumerate() {
513                let (delta_theta, delta_phi) = self
514                    .led_angular_corrections
515                    .as_ref()
516                    .map_or((0.0, 0.0), |values| values[led_index]);
517                let radius = self.radius
518                    + self
519                        .led_radial_offsets
520                        .as_ref()
521                        .map_or(0.0, |values| values[led_index]);
522                let local = scale(
523                    spherical_direction(nominal_theta + delta_theta, delta_phi),
524                    -radius,
525                );
526                let rotated = rotate_z(local, rotation);
527                positions.push(source_position(
528                    rotated,
529                    self.axis_origin_offset,
530                    (self.axis_tilt_radians.0, self.axis_tilt_radians.1, 0.0),
531                    "rotating LED arc",
532                )?);
533            }
534        }
535        Ok(positions)
536    }
537    /// Resolves rotation-major physical positions and transverse vectors.
538    pub fn resolve(&self, optics: &Optics) -> Result<ResolvedSources> {
539        self.validate()?;
540        ResolvedSources::from_positions(self.positions()?, optics)
541    }
542}
543
544fn validate_angle_list(angles: &[(f64, f64)], name: &'static str) -> Result<()> {
545    if angles.is_empty()
546        || angles.iter().any(|&(theta, phi)| {
547            !theta.is_finite()
548                || !(0.0..std::f64::consts::FRAC_PI_2).contains(&theta)
549                || !phi.is_finite()
550        })
551    {
552        return Err(Error::InvalidParameter {
553            name,
554            reason: "must contain at least one finite (theta, phi) pair with 0 <= theta < pi/2"
555                .into(),
556        });
557    }
558    Ok(())
559}
560
561fn validate_length(value: f64, name: &'static str) -> Result<()> {
562    if !value.is_finite() || value <= 0.0 {
563        return Err(Error::InvalidParameter {
564            name,
565            reason: "must be finite and positive".into(),
566        });
567    }
568    Ok(())
569}
570
571fn validate_pose(offset: (f64, f64, f64), orientation: (f64, f64, f64)) -> Result<()> {
572    if [
573        offset.0,
574        offset.1,
575        offset.2,
576        orientation.0,
577        orientation.1,
578        orientation.2,
579    ]
580    .into_iter()
581    .any(|value| !value.is_finite())
582    {
583        return Err(Error::InvalidParameter {
584            name: "spherical geometry pose",
585            reason: "offset and orientation components must be finite".into(),
586        });
587    }
588    Ok(())
589}
590
591fn spherical_direction(theta: f64, phi: f64) -> [f64; 3] {
592    let (sin_theta, cos_theta) = theta.sin_cos();
593    let (sin_phi, cos_phi) = phi.sin_cos();
594    [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta]
595}
596
597fn source_position(
598    local: [f64; 3],
599    offset: (f64, f64, f64),
600    orientation: (f64, f64, f64),
601    geometry: &'static str,
602) -> Result<[f64; 3]> {
603    let rotated = rotate_pose(local, orientation);
604    let position = [
605        rotated[0] + offset.0,
606        rotated[1] + offset.1,
607        rotated[2] + offset.2,
608    ];
609    let norm =
610        (position[0] * position[0] + position[1] * position[1] + position[2] * position[2]).sqrt();
611    if !norm.is_finite() || norm <= 0.0 || position[2] >= 0.0 {
612        return Err(Error::InvalidParameter {
613            name: geometry,
614            reason: "every transformed source must be finite, distinct from the sample, and on the negative-z source hemisphere"
615                .into(),
616        });
617    }
618    Ok(position)
619}
620
621fn backlash_branches(commands: &[(f64, f64)], component: usize) -> Vec<f64> {
622    let mut branches = Vec::with_capacity(commands.len());
623    let mut branch = 0.0;
624    branches.push(branch);
625    for pair in commands.windows(2) {
626        let previous = if component == 0 { pair[0].0 } else { pair[0].1 };
627        let current = if component == 0 { pair[1].0 } else { pair[1].1 };
628        let delta = current - previous;
629        if delta != 0.0 {
630            branch = delta.signum();
631        }
632        branches.push(branch);
633    }
634    branches
635}
636
637fn scalar_backlash_branches(commands: &[f64]) -> Vec<f64> {
638    let mut branches = Vec::with_capacity(commands.len());
639    let mut branch = 0.0;
640    branches.push(branch);
641    for pair in commands.windows(2) {
642        let delta = pair[1] - pair[0];
643        if delta != 0.0 {
644            branch = delta.signum();
645        }
646        branches.push(branch);
647    }
648    branches
649}
650
651fn scale(vector: [f64; 3], scalar: f64) -> [f64; 3] {
652    [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar]
653}
654
655fn rotate_pose(vector: [f64; 3], radians: (f64, f64, f64)) -> [f64; 3] {
656    let (sin_x, cos_x) = radians.0.sin_cos();
657    let after_x = [
658        vector[0],
659        cos_x * vector[1] - sin_x * vector[2],
660        sin_x * vector[1] + cos_x * vector[2],
661    ];
662    let (sin_y, cos_y) = radians.1.sin_cos();
663    let after_y = [
664        cos_y * after_x[0] + sin_y * after_x[2],
665        after_x[1],
666        -sin_y * after_x[0] + cos_y * after_x[2],
667    ];
668    rotate_z(after_y, radians.2)
669}
670
671fn rotate_z(vector: [f64; 3], radians: f64) -> [f64; 3] {
672    let (sin, cos) = radians.sin_cos();
673    [
674        cos * vector[0] - sin * vector[1],
675        sin * vector[0] + cos * vector[1],
676        vector[2],
677    ]
678}
679
680fn rotate_axis_angle(vector: [f64; 3], axis: [f64; 3], radians: f64) -> [f64; 3] {
681    let (sin, cos) = radians.sin_cos();
682    let dot = axis[0] * vector[0] + axis[1] * vector[1] + axis[2] * vector[2];
683    let cross = [
684        axis[1] * vector[2] - axis[2] * vector[1],
685        axis[2] * vector[0] - axis[0] * vector[2],
686        axis[0] * vector[1] - axis[1] * vector[0],
687    ];
688    [
689        vector[0] * cos + cross[0] * sin + axis[0] * dot * (1.0 - cos),
690        vector[1] * cos + cross[1] * sin + axis[1] * dot * (1.0 - cos),
691        vector[2] * cos + cross[2] * sin + axis[2] * dot * (1.0 - cos),
692    ]
693}