Skip to main content

copc_temporal/
lib.rs

1//! Reader for the [COPC Temporal Index Extension](https://github.com/360-geo/copc/blob/master/copc-temporal/docs/temporal-index-spec.md).
2//!
3//! When a COPC file contains data from multiple survey passes over the same area,
4//! a spatial query alone returns points from *every* pass that touched that region.
5//! The temporal index extension adds per-node GPS time metadata so that clients can
6//! filter by time **before** decompressing any point data.
7//!
8//! This crate reads the temporal index incrementally via [`ByteSource`], matching
9//! the streaming design of [`copc_streaming`].
10//!
11//! # Quick start
12//!
13//! ```rust,ignore
14//! use copc_streaming::{Aabb, CopcStreamingReader, FileSource};
15//! use copc_temporal::{GpsTime, TemporalCache};
16//!
17//! let mut reader = CopcStreamingReader::open(
18//!     FileSource::open("survey.copc.laz")?,
19//! ).await?;
20//!
21//! let mut temporal = match TemporalCache::from_reader(&reader).await? {
22//!     Some(t) => t,
23//!     None => return Ok(()), // no temporal index in this file
24//! };
25//!
26//! let start = GpsTime(1_000_000.0);
27//! let end   = GpsTime(1_000_010.0);
28//!
29//! // One call: loads hierarchy + temporal pages, fetches chunks,
30//! // filters by both bounds and time.
31//! let points = temporal.query_points(
32//!     &mut reader, &my_query_box, start, end,
33//! ).await?;
34//! ```
35//!
36//! # Fast path: select only the fields you need
37//!
38//! [`TemporalCache::query_points`] decodes every field and materializes
39//! `las::Point` values. For hot paths,
40//! [`TemporalCache::query_chunks`] returns `(Chunk, candidate_range)`
41//! pairs with a caller-chosen [`Fields`](copc_streaming::Fields) mask and
42//! lets you walk columns directly. `candidate_range` is the sub-range of
43//! point indices within each chunk whose GPS times could possibly match
44//! `[start, end]` based on the temporal index stride samples — use it to
45//! skip points that are guaranteed not to match.
46//!
47//! ```rust,ignore
48//! use copc_streaming::Fields;
49//! use copc_temporal::indices_in_time_range;
50//!
51//! let chunks = temporal
52//!     .query_chunks(
53//!         &mut reader,
54//!         &my_query_box,
55//!         start,
56//!         end,
57//!         Fields::Z | Fields::GPS_TIME,
58//!     )
59//!     .await?;
60//!
61//! for (chunk, range) in &chunks {
62//!     // Narrow to the candidate range first, then filter exactly.
63//!     let precise: Vec<u32> = indices_in_time_range(chunk, start, end)
64//!         .unwrap()
65//!         .into_iter()
66//!         .filter(|&i| (range.start..range.end).contains(&i))
67//!         .collect();
68//!     // ... walk chunk.positions() / chunk.gps_time() at these indices
69//! }
70//! ```
71//!
72//! # Low-level access
73//!
74//! For full control over page loading, use the building blocks directly:
75//!
76//! ```rust,ignore
77//! // Load only temporal pages that overlap the time range.
78//! temporal.load_pages_for_time_range(reader.source(), start, end).await?;
79//!
80//! // Find matching nodes, estimate point ranges, fetch chunks yourself.
81//! for entry in temporal.nodes_in_range(start, end) {
82//!     let range = entry.estimate_point_range(
83//!         start, end, temporal.stride(), hier.point_count,
84//!     );
85//!     // ...
86//! }
87//! ```
88//!
89//! # How it works
90//!
91//! [`TemporalCache::from_reader`] loads the header and root page.
92//! [`TemporalCache::query_chunks`] / [`TemporalCache::query_points`] then
93//! load the relevant hierarchy and temporal pages, fetch matching chunks,
94//! and return them (with candidate ranges) or the points inside both the
95//! bounding box and time window.
96//!
97//! For time-only queries (no spatial filter), use
98//! [`TemporalCache::query_chunks_by_time`] /
99//! [`TemporalCache::query_points_by_time`].
100//!
101//! For advanced use cases you can call [`TemporalCache::load_pages_for_time_range`]
102//! and [`TemporalCache::nodes_in_range`] separately, or
103//! [`TemporalCache::load_all_pages`] to fetch the entire index at once.
104
105mod error;
106mod gps_time;
107mod temporal_cache;
108mod temporal_index;
109mod vlr;
110
111pub use error::TemporalError;
112pub use gps_time::GpsTime;
113pub use temporal_cache::{TemporalCache, TemporalHeader};
114pub use temporal_index::NodeTemporalEntry;
115
116// Re-export copc-streaming types that temporal consumers will need.
117pub use copc_streaming::{Aabb, ByteSource, VoxelKey};
118
119/// Indices of points whose GPS time falls within `[start, end]`.
120///
121/// Returns `None` if the chunk was not decoded with
122/// [`copc_streaming::Fields::GPS_TIME`] or the underlying format does not
123/// include GPS time. Pair the returned indices with any other column
124/// iterator on the chunk to build a filtered view without materializing
125/// `las::Point` values.
126///
127/// ```rust,ignore
128/// use copc_streaming::Fields;
129///
130/// let chunk = reader.fetch_chunk(&key, Fields::Z | Fields::GPS_TIME).await?;
131/// let indices = copc_temporal::indices_in_time_range(&chunk, start, end)
132///     .expect("we asked for GPS_TIME");
133/// for &i in &indices {
134///     let p = chunk.point(i as usize);
135///     // ...
136/// }
137/// ```
138pub fn indices_in_time_range(
139    chunk: &copc_streaming::Chunk,
140    start: GpsTime,
141    end: GpsTime,
142) -> Option<Vec<u32>> {
143    let times = chunk.gps_time()?;
144    Some(
145        times
146            .enumerate()
147            .filter_map(|(i, t)| (t >= start.0 && t <= end.0).then_some(i as u32))
148            .collect(),
149    )
150}
151
152/// Filter points to only those whose GPS time falls within `[start, end]`.
153///
154/// Points without a GPS time are excluded. Convenience for the simple
155/// `Vec<las::Point>` API; prefer [`indices_in_time_range`] on a
156/// [`copc_streaming::Chunk`] when you're already on the column-oriented path.
157pub fn filter_points_by_time(
158    points: Vec<las::Point>,
159    start: GpsTime,
160    end: GpsTime,
161) -> Vec<las::Point> {
162    points
163        .into_iter()
164        .filter(|p| p.gps_time.is_some_and(|t| t >= start.0 && t <= end.0))
165        .collect()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use copc_streaming::{Chunk, Fields, VoxelKey};
172    use las::point::Format;
173    use las::raw::point::{Flags, ScanAngle};
174
175    fn build_test_cloud(n: i32) -> las::PointData {
176        let format = Format::new(7).unwrap();
177        let unit = las::Transform {
178            scale: 1.0,
179            offset: 0.0,
180        };
181        let transforms = las::Vector {
182            x: unit,
183            y: unit,
184            z: unit,
185        };
186        let mut buf = Vec::new();
187        for i in 0..n {
188            let rp = las::raw::Point {
189                x: i,
190                y: i,
191                z: i,
192                intensity: 0,
193                flags: Flags::ThreeByte(0, 0, 0),
194                scan_angle: ScanAngle::Scaled(0),
195                user_data: 0,
196                point_source_id: 0,
197                gps_time: Some(1000.0 + f64::from(i)),
198                color: Some(las::Color {
199                    red: 0,
200                    green: 0,
201                    blue: 0,
202                }),
203                waveform: None,
204                nir: None,
205                extra_bytes: Vec::new(),
206            };
207            rp.write_to(&mut buf, &format).unwrap();
208        }
209        las::PointDataBuilder::new()
210            .with_format(format)
211            .with_transforms(transforms)
212            .build_from_bytes(buf)
213            .unwrap()
214    }
215
216    fn make_chunk(cloud: las::PointData, fields: Fields) -> Chunk {
217        Chunk::new(VoxelKey::ROOT, fields, cloud)
218    }
219
220    #[test]
221    fn indices_in_time_range_returns_none_without_gps_time_field() {
222        let chunk = make_chunk(build_test_cloud(5), Fields::Z);
223        let r = indices_in_time_range(&chunk, GpsTime(1000.0), GpsTime(1003.0));
224        assert!(r.is_none());
225    }
226
227    #[test]
228    fn indices_in_time_range_filters_window() {
229        let chunk = make_chunk(build_test_cloud(5), Fields::ALL);
230        // gps times: 1000, 1001, 1002, 1003, 1004
231        let inside = indices_in_time_range(&chunk, GpsTime(1001.0), GpsTime(1003.0)).unwrap();
232        assert_eq!(inside, vec![1, 2, 3]);
233    }
234
235    #[test]
236    fn indices_in_time_range_empty_when_outside() {
237        let chunk = make_chunk(build_test_cloud(5), Fields::ALL);
238        let inside = indices_in_time_range(&chunk, GpsTime(9000.0), GpsTime(10000.0)).unwrap();
239        assert!(inside.is_empty());
240    }
241
242    #[test]
243    fn filter_points_by_time_excludes_none_gps_time() {
244        let p = las::Point {
245            gps_time: None,
246            ..Default::default()
247        };
248        let filtered = filter_points_by_time(vec![p], GpsTime(0.0), GpsTime(10.0));
249        assert!(filtered.is_empty());
250    }
251}