use crate::time::{Duration, Epoch, TimeWindow};
use crate::trajectory::Trajectory;
use crate::types::Geodetic4;
use geo3d::Volume;
pub trait Volume4 {
fn contains(&self, point: Geodetic4) -> bool;
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Timed<V> {
pub volume: V,
pub window: TimeWindow,
}
impl<V> Timed<V> {
#[inline]
pub const fn new(volume: V, window: TimeWindow) -> Self {
Timed { volume, window }
}
#[inline]
pub const fn always(volume: V) -> Self {
Timed::new(volume, TimeWindow::unbounded())
}
#[inline]
pub fn for_duration(volume: V, start: Epoch, duration: Duration) -> Self {
Timed::new(volume, TimeWindow::for_duration(start, duration))
}
#[inline]
pub const fn between(volume: V, start: Epoch, end: Epoch) -> Self {
Timed::new(volume, TimeWindow::between(start, end))
}
}
impl<V: Volume> Timed<V> {
#[inline]
pub fn contains4(&self, point: Geodetic4) -> bool {
self.window.contains(point.epoch) && self.volume.contains(point.position)
}
#[inline]
pub fn intersects(&self, trajectory: &Trajectory, step: Duration) -> bool {
self.first_entry(trajectory, step).is_some()
}
pub fn first_entry(&self, trajectory: &Trajectory, step: Duration) -> Option<Epoch> {
let overlap = self.window.intersect(&trajectory.window())?;
let start = overlap.start?;
let end = overlap.end?;
let step = if step.total_nanoseconds() > 0 && step <= end - start {
step
} else {
end - start
};
let inside = |t: Epoch| -> bool {
self.window.contains(t)
&& trajectory
.geodetic_at(t)
.is_some_and(|g| self.volume.contains(g.position))
};
let mut prev_out: Option<Epoch> = None;
let mut t = start;
loop {
let tc = if t > end { end } else { t };
if inside(tc) {
return Some(match prev_out {
Some(out) => bisect_entry(out, tc, inside),
None => tc, });
}
if tc == end {
return None;
}
prev_out = Some(tc);
t = t + step;
}
}
}
impl<V: Volume> Volume4 for Timed<V> {
#[inline]
fn contains(&self, point: Geodetic4) -> bool {
self.contains4(point)
}
}
fn bisect_entry(mut out: Epoch, mut inside_epoch: Epoch, inside: impl Fn(Epoch) -> bool) -> Epoch {
for _ in 0..40 {
let mid = out + (inside_epoch - out) / 2.0;
if mid == out || mid == inside_epoch {
break;
}
if inside(mid) {
inside_epoch = mid;
} else {
out = mid;
}
}
inside_epoch
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Ecef4, StateVector, Waypoint};
use geo3d::{AltitudeBand, Cylinder, Ecef, Geodetic, LocalFrame, Sphere};
fn t0() -> Epoch {
Epoch::from_gregorian_utc(2026, 7, 1, 19, 0, 0.0)
}
#[test]
fn timed_volume_is_space_and_time() {
let tfr = Timed::for_duration(
Sphere::new(Geodetic::new(0.0, 0.0, 1_000.0), 5_000.0),
t0(),
Duration::from_hours(2.0),
);
let center = Geodetic::new(0.0, 0.0, 1_000.0);
assert!(tfr.contains(Geodetic4::new(center, t0() + Duration::from_hours(1.0))));
assert!(!tfr.contains(Geodetic4::new(center, t0() - Duration::SECOND))); assert!(!tfr.contains(Geodetic4::new(center, t0() + Duration::from_hours(3.0)))); assert!(!tfr.contains(Geodetic4::new(
Geodetic::new(0.1, 0.0, 1_000.0),
t0() + Duration::from_hours(1.0)
)));
let perma = Timed::always(Sphere::new(center, 5_000.0));
assert!(perma.contains(Geodetic4::new(center, t0() + Duration::from_hours(100.0))));
}
#[test]
fn trajectory_enters_only_while_active() {
let start = Geodetic4::from_lat_lon(0.0, -0.2, 1_000.0, t0()).to_ecef4();
let vel =
LocalFrame::new(start.to_geodetic4().position).velocity_of_course(geo3d::Course::new(90.0, 200.0, 0.0));
let s0 = StateVector::new(t0(), start.position, vel);
let traj = crate::Trajectory::from_states([s0, s0.at(t0() + Duration::from_seconds(400.0))]).unwrap();
let cyl = Cylinder::new(
Geodetic::new(0.0, 0.0, 0.0),
3_000.0,
AltitudeBand::new(Some(0.0), Some(3_000.0)),
);
let active = Timed::for_duration(cyl, t0(), Duration::from_hours(1.0));
let entry = active.first_entry(&traj, Duration::from_seconds(1.0)).unwrap();
assert!(
(entry - t0()).as_seconds() > 90.0 && (entry - t0()).as_seconds() < 100.0,
"{}",
(entry - t0()).as_seconds()
);
assert!(active.intersects(&traj, Duration::from_seconds(1.0)));
let later = Timed::for_duration(cyl, t0() + Duration::from_hours(1.0), Duration::from_hours(1.0));
assert!(!later.intersects(&traj, Duration::from_seconds(1.0)));
assert!(later.first_entry(&traj, Duration::from_seconds(1.0)).is_none());
}
#[test]
fn entry_epoch_is_refined_by_bisection() {
let a = Ecef4::new(Ecef::new(6_378_137.0 - 10_000.0, 0.0, 0.0), t0());
let b = Ecef4::new(
Ecef::new(6_378_137.0 + 10_000.0, 0.0, 0.0),
t0() + Duration::from_seconds(100.0),
);
let traj = crate::Trajectory::from_waypoints([Waypoint::from(a), Waypoint::from(b)]).unwrap();
let sphere = Sphere::new(Geodetic::new(0.0, 0.0, 0.0), 1_000.0); let geo = Timed::always(sphere);
let entry = geo.first_entry(&traj, Duration::from_seconds(10.0)).unwrap();
let g = traj.geodetic_at(entry).unwrap();
assert!(sphere.contains(g.position));
assert!(!sphere.contains(traj.geodetic_at(entry - Duration::from_millis(50)).unwrap().position));
}
}