Skip to main content

copc_temporal/
temporal_cache.rs

1//! Incremental temporal index loading via async ByteSource.
2//!
3//! Loads temporal index pages on demand, pruning subtrees by time range
4//! before fetching child pages.
5
6use std::collections::HashMap;
7use std::io::Cursor;
8
9use byteorder::{LittleEndian, ReadBytesExt};
10use copc_streaming::{ByteSource, Chunk, CopcStreamingReader, Fields, VoxelKey};
11
12use crate::TemporalError;
13use crate::gps_time::GpsTime;
14use crate::temporal_index::NodeTemporalEntry;
15
16/// Header of the temporal index EVLR (32 bytes).
17#[derive(Debug, Clone)]
18pub struct TemporalHeader {
19    /// Format version (must be 1).
20    pub version: u32,
21    /// Sampling stride — every N-th point is recorded.
22    pub stride: u32,
23    /// Total number of node entries across all pages.
24    pub node_count: u32,
25    /// Total number of pages.
26    pub page_count: u32,
27    /// Absolute file offset of the root page.
28    pub root_page_offset: u64,
29    /// Size of the root page in bytes.
30    pub root_page_size: u32,
31}
32
33#[derive(Debug, Clone)]
34struct PendingPage {
35    offset: u64,
36    size: u32,
37    subtree_time_min: f64,
38    subtree_time_max: f64,
39}
40
41/// Incrementally-loaded temporal index cache.
42pub struct TemporalCache {
43    header: Option<TemporalHeader>,
44    entries: HashMap<VoxelKey, NodeTemporalEntry>,
45    pending_pages: Vec<PendingPage>,
46    stride: u32,
47}
48
49impl Default for TemporalCache {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl TemporalCache {
56    /// Create an empty temporal cache.
57    pub fn new() -> Self {
58        Self {
59            header: None,
60            entries: HashMap::new(),
61            pending_pages: Vec::new(),
62            stride: 0,
63        }
64    }
65
66    /// Open the temporal index from a COPC reader.
67    ///
68    /// Loads the temporal header and root page. Returns `Ok(None)` if
69    /// no temporal EVLR exists in the file.
70    pub async fn from_reader<S: ByteSource>(
71        reader: &CopcStreamingReader<S>,
72    ) -> Result<Option<Self>, TemporalError> {
73        let mut cache = Self::new();
74        let found = cache
75            .load_header(reader.source(), reader.evlr_offset(), reader.evlr_count())
76            .await?;
77        if !found {
78            return Ok(None);
79        }
80        cache.load_root_page(reader.source()).await?;
81        Ok(Some(cache))
82    }
83
84    /// Scan EVLRs to find the temporal EVLR and read its header.
85    /// Returns false if no temporal EVLR exists.
86    pub async fn load_header(
87        &mut self,
88        source: &impl ByteSource,
89        evlr_offset: u64,
90        evlr_count: u32,
91    ) -> Result<bool, TemporalError> {
92        let mut pos = evlr_offset;
93
94        for _ in 0..evlr_count {
95            let hdr_data = source.read_range(pos, 60).await?;
96            let mut r = Cursor::new(hdr_data.as_slice());
97
98            // reserved (2)
99            r.set_position(2);
100            let mut user_id = [0u8; 16];
101            std::io::Read::read_exact(&mut r, &mut user_id)?;
102            let record_id = r.read_u16::<LittleEndian>()?;
103            let data_length = r.read_u64::<LittleEndian>()?;
104
105            let data_start = pos + 60;
106
107            let uid_end = user_id.iter().position(|&b| b == 0).unwrap_or(16);
108            let uid_str = std::str::from_utf8(&user_id[..uid_end]).unwrap_or("");
109
110            if uid_str == "copc_temporal" && record_id == 1000 {
111                let header_data = source.read_range(data_start, 32).await?;
112                let header = parse_temporal_header(&header_data)?;
113                self.stride = header.stride;
114                self.header = Some(header);
115                return Ok(true);
116            }
117
118            pos = data_start + data_length;
119        }
120
121        Ok(false)
122    }
123
124    /// Load the root temporal page.
125    pub async fn load_root_page(&mut self, source: &impl ByteSource) -> Result<(), TemporalError> {
126        let header = self.header.as_ref().ok_or(TemporalError::TruncatedHeader)?;
127
128        let data = source
129            .read_range(header.root_page_offset, header.root_page_size as u64)
130            .await?;
131        self.parse_page(&data)?;
132        Ok(())
133    }
134
135    /// Load child pages that overlap a time range, pruning by subtree bounds.
136    pub async fn load_pages_for_time_range(
137        &mut self,
138        source: &impl ByteSource,
139        start: GpsTime,
140        end: GpsTime,
141    ) -> Result<(), TemporalError> {
142        loop {
143            let matching: Vec<PendingPage> = self
144                .pending_pages
145                .iter()
146                .filter(|p| p.subtree_time_max >= start.0 && p.subtree_time_min <= end.0)
147                .cloned()
148                .collect();
149
150            if matching.is_empty() {
151                break;
152            }
153
154            self.pending_pages
155                .retain(|p| !(p.subtree_time_max >= start.0 && p.subtree_time_min <= end.0));
156
157            let ranges: Vec<_> = matching.iter().map(|p| (p.offset, p.size as u64)).collect();
158            let results = source.read_ranges(&ranges).await?;
159
160            for data in &results {
161                self.parse_page(data)?;
162            }
163        }
164
165        Ok(())
166    }
167
168    /// Load ALL pending pages.
169    pub async fn load_all_pages(&mut self, source: &impl ByteSource) -> Result<(), TemporalError> {
170        while !self.pending_pages.is_empty() {
171            let pages: Vec<PendingPage> = self.pending_pages.drain(..).collect();
172            let ranges: Vec<_> = pages.iter().map(|p| (p.offset, p.size as u64)).collect();
173            let results = source.read_ranges(&ranges).await?;
174
175            for data in &results {
176                self.parse_page(data)?;
177            }
178        }
179        Ok(())
180    }
181
182    /// Load relevant pages and return all nodes that overlap a time range.
183    ///
184    /// This is the primary query method — it ensures the right pages are loaded
185    /// before returning results. Equivalent to calling `load_pages_for_time_range`
186    /// followed by `nodes_in_range`, but cannot return incomplete results.
187    pub async fn query(
188        &mut self,
189        source: &impl ByteSource,
190        start: GpsTime,
191        end: GpsTime,
192    ) -> Result<Vec<&NodeTemporalEntry>, TemporalError> {
193        self.load_pages_for_time_range(source, start, end).await?;
194        Ok(self.nodes_in_range(start, end))
195    }
196
197    /// Look up the temporal entry for a node.
198    pub fn get(&self, key: &VoxelKey) -> Option<&NodeTemporalEntry> {
199        self.entries.get(key)
200    }
201
202    /// Return all loaded nodes whose time range overlaps `[start, end]`.
203    pub fn nodes_in_range(&self, start: GpsTime, end: GpsTime) -> Vec<&NodeTemporalEntry> {
204        self.entries
205            .values()
206            .filter(|e| e.overlaps(start, end))
207            .collect()
208    }
209
210    /// The sampling stride (every N-th point is recorded in the index).
211    pub fn stride(&self) -> u32 {
212        self.stride
213    }
214
215    /// The parsed temporal index header, if loaded.
216    pub fn header(&self) -> Option<&TemporalHeader> {
217        self.header.as_ref()
218    }
219
220    /// Number of loaded node entries.
221    pub fn len(&self) -> usize {
222        self.entries.len()
223    }
224
225    /// Whether no node entries have been loaded.
226    pub fn is_empty(&self) -> bool {
227        self.entries.is_empty()
228    }
229
230    /// Iterate all loaded entries.
231    pub fn iter(&self) -> impl Iterator<Item = (&VoxelKey, &NodeTemporalEntry)> {
232        self.entries.iter()
233    }
234
235    // ==================== Fast API ====================
236    //
237    // Column-oriented queries that return `Chunk`s with a caller-chosen
238    // `Fields` mask, alongside an estimated candidate index range derived
239    // from the temporal index stride samples. Callers walk columns
240    // directly and decide for themselves whether to materialize
241    // `las::Point` values.
242
243    /// Query chunks by time range and spatial bounds.
244    ///
245    /// Loads the relevant hierarchy and temporal pages, fetches every
246    /// matching chunk with the caller's `fields` mask, and returns
247    /// `(chunk, candidate_range)` pairs. `candidate_range` is the
248    /// sub-range of point indices in `chunk` whose GPS times could
249    /// possibly fall within `[start, end]` based on the temporal index
250    /// stride samples — points outside this range are guaranteed not to
251    /// match and can be skipped.
252    ///
253    /// The caller is responsible for the exact per-point intersection.
254    /// Combine with [`Chunk::indices_in_bounds`] and
255    /// [`indices_in_time_range`](crate::indices_in_time_range) to compute
256    /// the precise matching set, then walk column accessors or call
257    /// [`Chunk::points_at`] to materialize owned values.
258    ///
259    /// ```rust,ignore
260    /// use copc_streaming::Fields;
261    ///
262    /// let chunks = temporal
263    ///     .query_chunks(
264    ///         &mut reader,
265    ///         &bbox,
266    ///         start,
267    ///         end,
268    ///         Fields::Z | Fields::GPS_TIME,
269    ///     )
270    ///     .await?;
271    ///
272    /// for (chunk, range) in &chunks {
273    ///     let times = chunk.gps_time().unwrap();
274    ///     for (i, t) in times.enumerate().skip(range.start as usize)
275    ///                                    .take(range.len()) {
276    ///         if t >= start.0 && t <= end.0 {
277    ///             // ...
278    ///         }
279    ///     }
280    /// }
281    /// ```
282    pub async fn query_chunks<S: ByteSource>(
283        &mut self,
284        reader: &mut CopcStreamingReader<S>,
285        bounds: &copc_streaming::Aabb,
286        start: GpsTime,
287        end: GpsTime,
288        fields: Fields,
289    ) -> Result<Vec<(Chunk, std::ops::Range<u32>)>, TemporalError> {
290        reader
291            .load_hierarchy_for_bounds(bounds)
292            .await
293            .map_err(TemporalError::Copc)?;
294
295        self.load_pages_for_time_range(reader.source(), start, end)
296            .await?;
297
298        let root_bounds = reader.copc_info().root_bounds();
299        let stride = self.stride;
300
301        let matches: Vec<_> = self
302            .nodes_in_range(start, end)
303            .into_iter()
304            .filter(|e| e.key.bounds(&root_bounds).intersects(bounds))
305            .filter_map(|e| {
306                let hier = reader.get(&e.key)?;
307                let range = e.estimate_point_range(start, end, stride, hier.point_count);
308                if range.is_empty() {
309                    return None;
310                }
311                Some((e.key, range))
312            })
313            .collect();
314
315        let keys: Vec<copc_streaming::VoxelKey> = matches.iter().map(|(k, _)| *k).collect();
316        let chunks = reader
317            .fetch_chunks(&keys, fields)
318            .await
319            .map_err(TemporalError::Copc)?;
320        let out: Vec<_> = chunks
321            .into_iter()
322            .zip(matches.into_iter().map(|(_, range)| range))
323            .collect();
324        Ok(out)
325    }
326
327    /// Query chunks by time range only (no spatial filtering).
328    ///
329    /// Like [`query_chunks`](Self::query_chunks) but loads the full
330    /// hierarchy and skips the bounding-box intersection test. Returns
331    /// every chunk whose node temporal range overlaps `[start, end]`,
332    /// each paired with its candidate index range.
333    pub async fn query_chunks_by_time<S: ByteSource>(
334        &mut self,
335        reader: &mut CopcStreamingReader<S>,
336        start: GpsTime,
337        end: GpsTime,
338        fields: Fields,
339    ) -> Result<Vec<(Chunk, std::ops::Range<u32>)>, TemporalError> {
340        reader
341            .load_all_hierarchy()
342            .await
343            .map_err(TemporalError::Copc)?;
344
345        self.load_pages_for_time_range(reader.source(), start, end)
346            .await?;
347
348        let stride = self.stride;
349
350        let matches: Vec<_> = self
351            .nodes_in_range(start, end)
352            .into_iter()
353            .filter_map(|e| {
354                let hier = reader.get(&e.key)?;
355                let range = e.estimate_point_range(start, end, stride, hier.point_count);
356                if range.is_empty() {
357                    return None;
358                }
359                Some((e.key, range))
360            })
361            .collect();
362
363        let keys: Vec<copc_streaming::VoxelKey> = matches.iter().map(|(k, _)| *k).collect();
364        let chunks = reader
365            .fetch_chunks(&keys, fields)
366            .await
367            .map_err(TemporalError::Copc)?;
368        let out: Vec<_> = chunks
369            .into_iter()
370            .zip(matches.into_iter().map(|(_, range)| range))
371            .collect();
372        Ok(out)
373    }
374
375    // ==================== Simple API ====================
376    //
377    // One-line entry points for the common case. Internally wrap the
378    // fast API with `Fields::ALL` and materialize matching points.
379
380    /// Query points by time range and spatial bounds.
381    ///
382    /// Loads the relevant hierarchy and temporal pages, fetches matching
383    /// chunks, and returns only the points that fall inside both the time
384    /// window and the bounding box.
385    ///
386    /// Thin wrapper over [`query_chunks`](Self::query_chunks) with
387    /// `Fields::ALL`. Prefer `query_chunks` when you don't need every
388    /// field materialized as `las::Point` values.
389    ///
390    /// ```rust,ignore
391    /// let points = temporal.query_points(
392    ///     &mut reader, &query_box, start, end,
393    /// ).await?;
394    /// ```
395    pub async fn query_points<S: ByteSource>(
396        &mut self,
397        reader: &mut CopcStreamingReader<S>,
398        bounds: &copc_streaming::Aabb,
399        start: GpsTime,
400        end: GpsTime,
401    ) -> Result<Vec<las::Point>, TemporalError> {
402        let chunks_with_ranges = self
403            .query_chunks(reader, bounds, start, end, Fields::ALL)
404            .await?;
405
406        let mut all_points = Vec::new();
407        for (chunk, range) in chunks_with_ranges {
408            // Column-snapshot filter walk: pull the x/y/z/gps_time columns
409            // once per chunk, then collect indices in the candidate range
410            // that match both the time window and the bounding box.
411            let start_idx = range.start as usize;
412            let end_idx = (range.end as usize).min(chunk.point_count());
413            let Some(times) = chunk.cloud().gps_time().map(|it| it.collect::<Vec<f64>>()) else {
414                // No GPS time column → no point can satisfy the time window.
415                continue;
416            };
417            let xs: Vec<f64> = chunk.cloud().x().collect();
418            let ys: Vec<f64> = chunk.cloud().y().collect();
419            let zs: Vec<f64> = chunk.cloud().z().collect();
420            let mut matching = Vec::with_capacity(end_idx - start_idx);
421            for i in start_idx..end_idx {
422                let t = times[i];
423                if t < start.0 || t > end.0 {
424                    continue;
425                }
426                let (x, y, z) = (xs[i], ys[i], zs[i]);
427                if x < bounds.min[0]
428                    || x > bounds.max[0]
429                    || y < bounds.min[1]
430                    || y > bounds.max[1]
431                    || z < bounds.min[2]
432                    || z > bounds.max[2]
433                {
434                    continue;
435                }
436                matching.push(i as u32);
437            }
438            all_points.extend(chunk.points_at(&matching)?);
439        }
440        Ok(all_points)
441    }
442
443    /// Query points by time range only (no spatial filtering).
444    ///
445    /// Loads all hierarchy pages and temporal pages that overlap the time
446    /// range, then returns points within the time window.
447    ///
448    /// Thin wrapper over [`query_chunks_by_time`](Self::query_chunks_by_time)
449    /// with `Fields::ALL`.
450    pub async fn query_points_by_time<S: ByteSource>(
451        &mut self,
452        reader: &mut CopcStreamingReader<S>,
453        start: GpsTime,
454        end: GpsTime,
455    ) -> Result<Vec<las::Point>, TemporalError> {
456        let chunks_with_ranges = self
457            .query_chunks_by_time(reader, start, end, Fields::ALL)
458            .await?;
459
460        let mut all_points = Vec::new();
461        for (chunk, range) in chunks_with_ranges {
462            let start_idx = range.start as usize;
463            let end_idx = (range.end as usize).min(chunk.point_count());
464            let Some(times) = chunk.cloud().gps_time().map(|it| it.collect::<Vec<f64>>()) else {
465                // No GPS time column → no point can satisfy the time window.
466                continue;
467            };
468            let mut matching = Vec::with_capacity(end_idx - start_idx);
469            for (offset, &t) in times[start_idx..end_idx].iter().enumerate() {
470                if t >= start.0 && t <= end.0 {
471                    matching.push((start_idx + offset) as u32);
472                }
473            }
474            all_points.extend(chunk.points_at(&matching)?);
475        }
476        Ok(all_points)
477    }
478
479    fn parse_page(&mut self, data: &[u8]) -> Result<(), TemporalError> {
480        let mut r = Cursor::new(data);
481
482        while (r.position() as usize) < data.len() {
483            if r.position() as usize + 20 > data.len() {
484                break;
485            }
486
487            let level = r.read_i32::<LittleEndian>()?;
488            let x = r.read_i32::<LittleEndian>()?;
489            let y = r.read_i32::<LittleEndian>()?;
490            let z = r.read_i32::<LittleEndian>()?;
491            let sample_count = r.read_u32::<LittleEndian>()?;
492
493            let key = VoxelKey { level, x, y, z };
494
495            if sample_count == 0 {
496                // Page pointer: 28 more bytes
497                let child_offset = r.read_u64::<LittleEndian>()?;
498                let child_size = r.read_u32::<LittleEndian>()?;
499                let time_min = r.read_f64::<LittleEndian>()?;
500                let time_max = r.read_f64::<LittleEndian>()?;
501
502                self.pending_pages.push(PendingPage {
503                    offset: child_offset,
504                    size: child_size,
505                    subtree_time_min: time_min,
506                    subtree_time_max: time_max,
507                });
508            } else {
509                let mut samples = Vec::with_capacity(sample_count as usize);
510                for _ in 0..sample_count {
511                    samples.push(GpsTime(r.read_f64::<LittleEndian>()?));
512                }
513
514                self.entries
515                    .insert(key, NodeTemporalEntry::new(key, samples));
516            }
517        }
518
519        Ok(())
520    }
521}
522
523fn parse_temporal_header(data: &[u8]) -> Result<TemporalHeader, TemporalError> {
524    if data.len() < 32 {
525        return Err(TemporalError::TruncatedHeader);
526    }
527    let mut r = Cursor::new(data);
528    let version = r.read_u32::<LittleEndian>()?;
529    if version != 1 {
530        return Err(TemporalError::UnsupportedVersion(version));
531    }
532    let stride = r.read_u32::<LittleEndian>()?;
533    if stride < 1 {
534        return Err(TemporalError::InvalidStride(stride));
535    }
536    let node_count = r.read_u32::<LittleEndian>()?;
537    let page_count = r.read_u32::<LittleEndian>()?;
538    let root_page_offset = r.read_u64::<LittleEndian>()?;
539    let root_page_size = r.read_u32::<LittleEndian>()?;
540    let _reserved = r.read_u32::<LittleEndian>()?;
541
542    Ok(TemporalHeader {
543        version,
544        stride,
545        node_count,
546        page_count,
547        root_page_offset,
548        root_page_size,
549    })
550}