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}