Skip to main content

copc_temporal/
temporal_index.rs

1use std::collections::HashMap;
2use std::io::Cursor;
3use std::ops::Range;
4
5use byteorder::{LittleEndian, ReadBytesExt};
6
7use copc_streaming::VoxelKey;
8
9use crate::error::TemporalError;
10use crate::gps_time::GpsTime;
11use crate::vlr::VlrData;
12
13/// Per-node temporal data: a set of sampled GPS timestamps.
14#[derive(Debug, Clone)]
15pub struct NodeTemporalEntry {
16    pub key: VoxelKey,
17    samples: Vec<GpsTime>,
18}
19
20impl NodeTemporalEntry {
21    /// Create a new entry with the given key and samples.
22    pub fn new(key: VoxelKey, samples: Vec<GpsTime>) -> Self {
23        Self { key, samples }
24    }
25
26    /// The sampled GPS time values for this node.
27    pub fn samples(&self) -> &[GpsTime] {
28        &self.samples
29    }
30
31    /// Returns (min_time, max_time) for this node.
32    pub fn time_range(&self) -> (GpsTime, GpsTime) {
33        (self.samples[0], self.samples[self.samples.len() - 1])
34    }
35
36    /// Returns true if this node may contain points in [start, end].
37    pub fn overlaps(&self, start: GpsTime, end: GpsTime) -> bool {
38        let (min, max) = self.time_range();
39        // Node overlaps if its max >= start AND its min <= end
40        max.0 >= start.0 && min.0 <= end.0
41    }
42
43    /// Estimate the point index range within the decompressed chunk for a time range.
44    ///
45    /// Implements the binary search logic from spec section 8.2:
46    /// 1. Find first sample index `i` where `samples[i] >= t_start`
47    /// 2. Find last sample index `j` where `samples[j] <= t_end`
48    /// 3. Start point = i * stride
49    /// 4. End point = min(j * stride + stride - 1, point_count - 1)
50    pub fn estimate_point_range(
51        &self,
52        start: GpsTime,
53        end: GpsTime,
54        stride: u32,
55        point_count: u32,
56    ) -> Range<u32> {
57        if point_count == 0 || self.samples.is_empty() {
58            return 0..0;
59        }
60
61        // First index i where samples[i] >= start
62        let i = self.samples.partition_point(|s| s.0 < start.0);
63
64        // Last index j where samples[j] <= end
65        // partition_point finds first index where samples[j] > end, subtract 1
66        let past_end = self.samples.partition_point(|s| s.0 <= end.0);
67
68        if i >= past_end {
69            // No samples in range
70            return 0..0;
71        }
72        let j = past_end - 1;
73
74        let start_point = (i as u64 * stride as u64).min(point_count as u64) as u32;
75        let end_point =
76            ((j as u64 * stride as u64 + stride as u64 - 1).min(point_count as u64 - 1)) as u32;
77
78        start_point..(end_point + 1)
79    }
80}
81
82/// The top-level temporal index parsed from an EVLR.
83/// Not part of the public API — used internally and in tests.
84#[derive(Debug, Clone)]
85#[cfg_attr(not(test), allow(dead_code))]
86pub(crate) struct TemporalIndex {
87    version: u32,
88    stride: u32,
89    entries: HashMap<VoxelKey, NodeTemporalEntry>,
90}
91
92#[cfg_attr(not(test), allow(dead_code))]
93impl TemporalIndex {
94    /// Parse the temporal index from a list of EVLRs.
95    ///
96    /// Returns `Ok(None)` if no temporal EVLR is present.
97    /// Returns `Err` if the EVLR is present but malformed.
98    pub fn from_evlrs(evlrs: &[VlrData]) -> Result<Option<Self>, TemporalError> {
99        let vlr = evlrs
100            .iter()
101            .find(|v| v.user_id == "copc_temporal" && v.record_id == 1000);
102
103        let vlr = match vlr {
104            Some(v) => v,
105            None => return Ok(None),
106        };
107
108        let data = &vlr.data;
109        if data.len() < 32 {
110            return Err(TemporalError::TruncatedHeader);
111        }
112
113        let mut cursor = Cursor::new(data);
114
115        let version = cursor.read_u32::<LittleEndian>()?;
116        if version != 1 {
117            return Err(TemporalError::UnsupportedVersion(version));
118        }
119
120        let stride = cursor.read_u32::<LittleEndian>()?;
121        if stride < 1 {
122            return Err(TemporalError::InvalidStride(stride));
123        }
124
125        let node_count = cursor.read_u32::<LittleEndian>()?;
126        let _page_count = cursor.read_u32::<LittleEndian>()?;
127        let _root_page_offset = cursor.read_u64::<LittleEndian>()?;
128        let _root_page_size = cursor.read_u32::<LittleEndian>()?;
129        let _reserved = cursor.read_u32::<LittleEndian>()?;
130
131        // For local file access, all pages are contiguous after the header.
132        // Scan sequentially, skipping page pointers (sample_count == 0).
133        let mut entries = HashMap::with_capacity(node_count as usize);
134
135        while entries.len() < node_count as usize {
136            let level = match cursor.read_i32::<LittleEndian>() {
137                Ok(v) => v,
138                Err(e) => return Err(TemporalError::Io(e)),
139            };
140            let x = cursor.read_i32::<LittleEndian>()?;
141            let y = cursor.read_i32::<LittleEndian>()?;
142            let z = cursor.read_i32::<LittleEndian>()?;
143            let sample_count = cursor.read_u32::<LittleEndian>()?;
144
145            if sample_count == 0 {
146                // Page pointer: skip the remaining 28 bytes
147                // (child_page_offset u64 + child_page_size u32 + subtree_time_min f64 + subtree_time_max f64)
148                cursor.set_position(cursor.position() + 28);
149                continue;
150            }
151
152            let mut samples = Vec::with_capacity(sample_count as usize);
153            for _ in 0..sample_count {
154                let t = cursor.read_f64::<LittleEndian>()?;
155                samples.push(GpsTime(t));
156            }
157
158            let key = VoxelKey { level, x, y, z };
159
160            entries.insert(key, NodeTemporalEntry { key, samples });
161        }
162
163        Ok(Some(TemporalIndex {
164            version,
165            stride,
166            entries,
167        }))
168    }
169
170    /// Look up the temporal entry for a given voxel key.
171    pub fn get(&self, key: &VoxelKey) -> Option<&NodeTemporalEntry> {
172        self.entries.get(key)
173    }
174
175    /// Return all node entries whose time range overlaps [start, end].
176    pub fn nodes_in_range(&self, start: GpsTime, end: GpsTime) -> Vec<&NodeTemporalEntry> {
177        self.entries
178            .values()
179            .filter(|e| e.overlaps(start, end))
180            .collect()
181    }
182
183    /// The sampling stride.
184    pub fn stride(&self) -> u32 {
185        self.stride
186    }
187
188    /// The format version.
189    pub fn version(&self) -> u32 {
190        self.version
191    }
192
193    /// The number of node entries.
194    pub fn len(&self) -> usize {
195        self.entries.len()
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use byteorder::WriteBytesExt;
203
204    /// Build a v2 EVLR payload: header (32 bytes) + single root page with all entries.
205    fn build_evlr_payload(
206        version: u32,
207        stride: u32,
208        nodes: &[(i32, i32, i32, i32, &[f64])],
209    ) -> Vec<u8> {
210        // Compute root page size
211        let mut page_size: u32 = 0;
212        for (_, _, _, _, samples) in nodes {
213            page_size += 20 + samples.len() as u32 * 8;
214        }
215
216        let mut buf = Vec::new();
217        // Header (32 bytes)
218        buf.write_u32::<LittleEndian>(version).unwrap();
219        buf.write_u32::<LittleEndian>(stride).unwrap();
220        buf.write_u32::<LittleEndian>(nodes.len() as u32).unwrap();
221        buf.write_u32::<LittleEndian>(1).unwrap(); // page_count
222        buf.write_u64::<LittleEndian>(32).unwrap(); // root_page_offset (relative to EVLR data start, used as absolute in tests)
223        buf.write_u32::<LittleEndian>(page_size).unwrap();
224        buf.write_u32::<LittleEndian>(0).unwrap(); // reserved
225
226        // Root page: all node entries
227        for &(level, x, y, z, samples) in nodes {
228            buf.write_i32::<LittleEndian>(level).unwrap();
229            buf.write_i32::<LittleEndian>(x).unwrap();
230            buf.write_i32::<LittleEndian>(y).unwrap();
231            buf.write_i32::<LittleEndian>(z).unwrap();
232            buf.write_u32::<LittleEndian>(samples.len() as u32).unwrap();
233            for &s in samples.iter() {
234                buf.write_f64::<LittleEndian>(s).unwrap();
235            }
236        }
237
238        buf
239    }
240
241    fn make_vlr(user_id: &str, record_id: u16, data: Vec<u8>) -> VlrData {
242        VlrData {
243            user_id: user_id.to_string(),
244            record_id,
245            data,
246        }
247    }
248
249    #[test]
250    fn test_parse_roundtrip() {
251        let samples_a: &[f64] = &[100.0, 200.0, 300.0];
252        let samples_b: &[f64] = &[400.0, 500.0];
253        let nodes = vec![(0, 0, 0, 0, samples_a), (1, 1, 0, 0, samples_b)];
254        let data = build_evlr_payload(1, 10, &nodes);
255        let vlr = make_vlr("copc_temporal", 1000, data);
256
257        let index = TemporalIndex::from_evlrs(&[vlr]).unwrap().unwrap();
258        assert_eq!(index.stride(), 10);
259        assert_eq!(index.version(), 1);
260        assert_eq!(index.len(), 2);
261
262        let entry_a = index
263            .get(&VoxelKey {
264                level: 0,
265                x: 0,
266                y: 0,
267                z: 0,
268            })
269            .unwrap();
270        assert_eq!(entry_a.samples().len(), 3);
271        assert_eq!(entry_a.samples()[0], GpsTime(100.0));
272        assert_eq!(entry_a.samples()[2], GpsTime(300.0));
273
274        let entry_b = index
275            .get(&VoxelKey {
276                level: 1,
277                x: 1,
278                y: 0,
279                z: 0,
280            })
281            .unwrap();
282        assert_eq!(entry_b.samples().len(), 2);
283        assert_eq!(entry_b.time_range(), (GpsTime(400.0), GpsTime(500.0)));
284    }
285
286    #[test]
287    fn test_no_temporal_evlr() {
288        let vlr = make_vlr("copc", 1, vec![0; 160]);
289        let result = TemporalIndex::from_evlrs(&[vlr]).unwrap();
290        assert!(result.is_none());
291    }
292
293    #[test]
294    fn test_empty_evlr_list() {
295        let result = TemporalIndex::from_evlrs(&[]).unwrap();
296        assert!(result.is_none());
297    }
298
299    #[test]
300    fn test_wrong_version() {
301        let data = build_evlr_payload(99, 10, &[]);
302        let vlr = make_vlr("copc_temporal", 1000, data);
303        let result = TemporalIndex::from_evlrs(&[vlr]);
304        assert!(matches!(result, Err(TemporalError::UnsupportedVersion(99))));
305    }
306
307    #[test]
308    fn test_truncated_header() {
309        let vlr = make_vlr("copc_temporal", 1000, vec![1, 0, 0, 0]); // only 4 bytes
310        let result = TemporalIndex::from_evlrs(&[vlr]);
311        assert!(matches!(result, Err(TemporalError::TruncatedHeader)));
312    }
313
314    #[test]
315    fn test_truncated_node_data() {
316        // Header says 1 node, but no node data follows
317        let data = build_evlr_payload(1, 10, &[]);
318        let mut modified = data.clone();
319        // Patch node_count to 1
320        modified[8] = 1;
321        let vlr = make_vlr("copc_temporal", 1000, modified);
322        let result = TemporalIndex::from_evlrs(&[vlr]);
323        assert!(result.is_err());
324    }
325
326    #[test]
327    fn test_overlaps_exact_boundaries() {
328        let entry = NodeTemporalEntry {
329            key: VoxelKey {
330                level: 0,
331                x: 0,
332                y: 0,
333                z: 0,
334            },
335            samples: vec![GpsTime(100.0), GpsTime(200.0), GpsTime(300.0)],
336        };
337
338        // Exact match on boundaries
339        assert!(entry.overlaps(GpsTime(100.0), GpsTime(300.0)));
340        // Query ends exactly at start
341        assert!(entry.overlaps(GpsTime(50.0), GpsTime(100.0)));
342        // Query starts exactly at end
343        assert!(entry.overlaps(GpsTime(300.0), GpsTime(400.0)));
344        // Query entirely before
345        assert!(!entry.overlaps(GpsTime(0.0), GpsTime(99.9)));
346        // Query entirely after
347        assert!(!entry.overlaps(GpsTime(300.1), GpsTime(400.0)));
348        // Query contains node
349        assert!(entry.overlaps(GpsTime(0.0), GpsTime(1000.0)));
350        // Query within node
351        assert!(entry.overlaps(GpsTime(150.0), GpsTime(250.0)));
352    }
353
354    #[test]
355    fn test_overlaps_single_sample() {
356        let entry = NodeTemporalEntry {
357            key: VoxelKey {
358                level: 0,
359                x: 0,
360                y: 0,
361                z: 0,
362            },
363            samples: vec![GpsTime(100.0)],
364        };
365
366        assert!(entry.overlaps(GpsTime(100.0), GpsTime(100.0)));
367        assert!(entry.overlaps(GpsTime(50.0), GpsTime(150.0)));
368        assert!(!entry.overlaps(GpsTime(50.0), GpsTime(99.9)));
369        assert!(!entry.overlaps(GpsTime(100.1), GpsTime(200.0)));
370    }
371
372    #[test]
373    fn test_nodes_in_range() {
374        let samples_a: &[f64] = &[100.0, 200.0, 300.0];
375        let samples_b: &[f64] = &[400.0, 500.0];
376        let samples_c: &[f64] = &[600.0, 700.0, 800.0];
377        let data = build_evlr_payload(
378            1,
379            10,
380            &[
381                (0, 0, 0, 0, samples_a),
382                (1, 0, 0, 0, samples_b),
383                (1, 1, 0, 0, samples_c),
384            ],
385        );
386        let vlr = make_vlr("copc_temporal", 1000, data);
387        let index = TemporalIndex::from_evlrs(&[vlr]).unwrap().unwrap();
388
389        // Should match only node A and B
390        let result = index.nodes_in_range(GpsTime(250.0), GpsTime(450.0));
391        assert_eq!(result.len(), 2);
392
393        // Should match only node C
394        let result = index.nodes_in_range(GpsTime(650.0), GpsTime(750.0));
395        assert_eq!(result.len(), 1);
396        assert_eq!(
397            result[0].key,
398            VoxelKey {
399                level: 1,
400                x: 1,
401                y: 0,
402                z: 0
403            }
404        );
405    }
406
407    #[test]
408    fn test_estimate_point_range_basic() {
409        // Samples at indices 0, 10, 20, 30, 39 (stride=10, 40 points)
410        let entry = NodeTemporalEntry {
411            key: VoxelKey {
412                level: 0,
413                x: 0,
414                y: 0,
415                z: 0,
416            },
417            samples: vec![
418                GpsTime(100.0),
419                GpsTime(200.0),
420                GpsTime(300.0),
421                GpsTime(400.0),
422                GpsTime(450.0),
423            ],
424        };
425
426        // Query covers samples[1] through samples[3] (200..400)
427        let range = entry.estimate_point_range(GpsTime(200.0), GpsTime(400.0), 10, 40);
428        // i=1 (first sample >= 200), j=3 (last sample <= 400)
429        // start = 1*10 = 10, end = min(3*10 + 9, 39) = 39
430        assert_eq!(range, 10..40);
431
432        // Query covers only sample[2] (300..300)
433        let range = entry.estimate_point_range(GpsTime(300.0), GpsTime(300.0), 10, 40);
434        // i=2, j=2
435        // start = 20, end = min(29, 39) = 29
436        assert_eq!(range, 20..30);
437    }
438
439    #[test]
440    fn test_estimate_point_range_no_overlap() {
441        let entry = NodeTemporalEntry {
442            key: VoxelKey {
443                level: 0,
444                x: 0,
445                y: 0,
446                z: 0,
447            },
448            samples: vec![GpsTime(100.0), GpsTime(200.0), GpsTime(300.0)],
449        };
450
451        // Query entirely before
452        let range = entry.estimate_point_range(GpsTime(0.0), GpsTime(50.0), 10, 30);
453        assert_eq!(range, 0..0);
454
455        // Query entirely after
456        let range = entry.estimate_point_range(GpsTime(400.0), GpsTime(500.0), 10, 30);
457        assert_eq!(range, 0..0);
458    }
459
460    #[test]
461    fn test_estimate_point_range_empty() {
462        let entry = NodeTemporalEntry {
463            key: VoxelKey {
464                level: 0,
465                x: 0,
466                y: 0,
467                z: 0,
468            },
469            samples: vec![],
470        };
471        let range = entry.estimate_point_range(GpsTime(0.0), GpsTime(100.0), 10, 0);
472        assert_eq!(range, 0..0);
473    }
474
475    #[test]
476    fn test_estimate_point_range_stride_1() {
477        let entry = NodeTemporalEntry {
478            key: VoxelKey {
479                level: 0,
480                x: 0,
481                y: 0,
482                z: 0,
483            },
484            samples: vec![
485                GpsTime(1.0),
486                GpsTime(2.0),
487                GpsTime(3.0),
488                GpsTime(4.0),
489                GpsTime(5.0),
490            ],
491        };
492
493        // With stride 1, every point is sampled
494        let range = entry.estimate_point_range(GpsTime(2.0), GpsTime(4.0), 1, 5);
495        // i=1, j=3, start=1, end=min(3+0, 4)=3
496        assert_eq!(range, 1..4);
497    }
498}