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