Skip to main content

spatialrust_ros2/
sqlite.rs

1//! Read-only rosbag2 SQLite storage for bounded PointCloud2 streams.
2
3use std::path::Path;
4
5use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
6use spatialrust_core::{
7    PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
8};
9use spatialrust_records::{
10    BoundedSpatialRecordSource, CancellationToken, ChunkIdentity, MemoryReservation, MemoryTracker,
11    RecordProvenance, RecordsError, RecordsResult, SchemaDescriptor, SchemaVersion, SpatialRecord,
12    SpatialRecordChunk, StreamOptions,
13};
14use spatialrust_runtime::{
15    decode_point_cloud2_xyz, decode_tf_message, point_cloud2_has_intensity, PointCloud2Xyz,
16    RuntimeError, TfTransform, POINT_CLOUD2_TYPE, TF_MESSAGE_TYPE,
17};
18use thiserror::Error;
19
20/// Stable schema family for XYZ columns decoded from ROS 2 PointCloud2.
21pub const ROSBAG2_POINT_XYZ_SCHEMA_ID: &str = "ros2.sensor_msgs.msg.PointCloud2.xyz";
22/// Stable schema family for XYZ-I columns decoded from ROS 2 PointCloud2.
23pub const ROSBAG2_POINT_XYZI_SCHEMA_ID: &str = "ros2.sensor_msgs.msg.PointCloud2.xyzi";
24
25const CDR_SCRATCH_OVERHEAD_BYTES: u64 = 64 * 1024;
26
27/// Result type for rosbag2 SQLite operations.
28pub type Rosbag2Result<T> = Result<T, Rosbag2Error>;
29
30/// Failures while inspecting or streaming a rosbag2 SQLite bag.
31#[derive(Debug, Error)]
32pub enum Rosbag2Error {
33    /// SQLite storage failure.
34    #[error("rosbag2 SQLite error: {0}")]
35    Sqlite(#[from] rusqlite::Error),
36    /// The file is not a supported rosbag2 SQLite database.
37    #[error("invalid rosbag2 SQLite bag: {0}")]
38    InvalidBag(String),
39    /// The selected topic or serialization is outside this adapter's scope.
40    #[error("unsupported rosbag2 message: {0}")]
41    Unsupported(String),
42    /// PointCloud2 CDR decoding failure.
43    #[error("ROS 2 CDR error: {0}")]
44    Cdr(#[from] RuntimeError),
45    /// Bounded-record contract failure.
46    #[error(transparent)]
47    Records(#[from] RecordsError),
48    /// Core point-cloud construction failure.
49    #[error(transparent)]
50    Core(#[from] spatialrust_core::SpatialError),
51}
52
53/// Metadata for one rosbag2 topic.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct Rosbag2Topic {
56    /// SQLite topic id.
57    pub id: i64,
58    /// ROS topic name.
59    pub name: String,
60    /// Fully-qualified ROS message type.
61    pub type_name: String,
62    /// Storage serialization identifier, normally `cdr`.
63    pub serialization_format: String,
64    /// Number of stored messages for this topic.
65    pub message_count: u64,
66}
67
68/// One bounded rosbag2 TFMessage with its SQLite capture timestamp.
69#[derive(Clone, Debug, PartialEq)]
70pub struct Rosbag2TfMessage {
71    /// SQLite message timestamp in nanoseconds.
72    pub bag_timestamp: u64,
73    /// Ordered transforms carried by the TFMessage.
74    pub transforms: Vec<TfTransform>,
75}
76
77/// Lists topics from a rosbag2 SQLite file without modifying it.
78pub fn list_topics(path: impl AsRef<Path>) -> Rosbag2Result<Vec<Rosbag2Topic>> {
79    let connection = open_connection(path)?;
80    read_topics(&connection)
81}
82
83/// Reads at most `max_messages` TFMessage payloads from one rosbag2 topic.
84///
85/// The SQLite source remains read-only and message order is `(timestamp, id)`.
86/// This function decodes TF edges but does not compose them, select a root, or
87/// claim that the result belongs to another bag or sensor naming scheme.
88pub fn list_tf_messages(
89    path: impl AsRef<Path>,
90    topic_name: &str,
91    max_messages: usize,
92) -> Rosbag2Result<Vec<Rosbag2TfMessage>> {
93    if max_messages == 0 {
94        return Err(Rosbag2Error::InvalidBag("TF message limit must be greater than zero".into()));
95    }
96    let connection = open_connection(path)?;
97    let topics = read_topics(&connection)?;
98    let topic = topics
99        .into_iter()
100        .find(|topic| topic.name == topic_name)
101        .ok_or_else(|| Rosbag2Error::InvalidBag(format!("topic `{topic_name}` was not found")))?;
102    if topic.type_name != TF_MESSAGE_TYPE {
103        return Err(Rosbag2Error::Unsupported(format!(
104            "topic `{}` has type `{}`, expected `{TF_MESSAGE_TYPE}`",
105            topic.name, topic.type_name
106        )));
107    }
108    if !topic.serialization_format.eq_ignore_ascii_case("cdr") {
109        return Err(Rosbag2Error::Unsupported(format!(
110            "topic `{}` uses serialization `{}`, only CDR is supported",
111            topic.name, topic.serialization_format
112        )));
113    }
114    let limit = i64::try_from(max_messages)
115        .map_err(|_| Rosbag2Error::InvalidBag("TF message limit exceeds SQLite bounds".into()))?;
116    let mut statement = connection.prepare(
117        "SELECT timestamp, data FROM messages WHERE topic_id = ?1 \
118         ORDER BY timestamp ASC, id ASC LIMIT ?2",
119    )?;
120    let rows = statement.query_map(params![topic.id, limit], |row| {
121        Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
122    })?;
123    let mut messages = Vec::new();
124    for row in rows {
125        let (timestamp, data) = row?;
126        let bag_timestamp = u64::try_from(timestamp).map_err(|_| {
127            Rosbag2Error::InvalidBag(format!("TF message has negative timestamp {timestamp}"))
128        })?;
129        let transforms = decode_tf_message(&data)?;
130        messages.push(Rosbag2TfMessage { bag_timestamp, transforms });
131    }
132    Ok(messages)
133}
134
135/// Bounded, deterministic PointCloud2 XYZ/XYZI source backed by rosbag2 SQLite.
136///
137/// The SQLite connection is opened read-only. One selected topic is traversed
138/// in `(timestamp, id)` order. A PointCloud2 message may be split into several
139/// leased chunks when it exceeds `StreamOptions::chunk_points()`.
140pub struct Rosbag2PointCloudSource {
141    connection: Connection,
142    source_id: String,
143    topic: Rosbag2Topic,
144    schema: SchemaDescriptor,
145    options: StreamOptions,
146    tracker: MemoryTracker,
147    cancellation: CancellationToken,
148    max_chunk_bytes: u64,
149    max_message_bytes: u64,
150    has_intensity: bool,
151    cursor: Option<MessageCursor>,
152    pending: Option<PendingPointCloud>,
153    next_sequence: u64,
154    next_point_offset: u64,
155    finished: bool,
156}
157
158impl Rosbag2PointCloudSource {
159    /// Opens one PointCloud2 topic from a rosbag2 SQLite file.
160    pub fn open(
161        path: impl AsRef<Path>,
162        topic_name: &str,
163        options: StreamOptions,
164        cancellation: CancellationToken,
165    ) -> Rosbag2Result<Self> {
166        let path = path.as_ref();
167        let source_uri = path.display().to_string();
168        let source_id = format!("rosbag2-sqlite:{source_uri}");
169        let connection = open_connection(path)?;
170        let topics = read_topics(&connection)?;
171        let topic = topics.into_iter().find(|topic| topic.name == topic_name).ok_or_else(|| {
172            Rosbag2Error::InvalidBag(format!("topic `{topic_name}` was not found"))
173        })?;
174
175        if topic.type_name != POINT_CLOUD2_TYPE {
176            return Err(Rosbag2Error::Unsupported(format!(
177                "topic `{}` has type `{}`, expected `{POINT_CLOUD2_TYPE}`",
178                topic.name, topic.type_name
179            )));
180        }
181        if !topic.serialization_format.eq_ignore_ascii_case("cdr") {
182            return Err(Rosbag2Error::Unsupported(format!(
183                "topic `{}` uses serialization `{}`, only CDR is supported",
184                topic.name, topic.serialization_format
185            )));
186        }
187
188        let has_intensity = first_message_has_intensity(&connection, topic.id)?;
189        let (schema_id, point_schema) = if has_intensity {
190            (ROSBAG2_POINT_XYZI_SCHEMA_ID, StandardSchemas::point_xyzi())
191        } else {
192            (ROSBAG2_POINT_XYZ_SCHEMA_ID, StandardSchemas::point_xyz())
193        };
194        let max_message_bytes = max_message_bytes(&connection, topic.id)?;
195        let schema = SchemaDescriptor::try_new(schema_id, SchemaVersion::new(1, 0), point_schema)?;
196        let max_record_bytes = point_capacity_bytes(options.chunk_points(), has_intensity)?;
197        let max_message_working_bytes = message_working_bytes(max_message_bytes)?;
198        let max_chunk_bytes = max_record_bytes
199            .checked_add(max_message_working_bytes)
200            .ok_or_else(|| Rosbag2Error::InvalidBag("maximum source memory overflow".into()))?;
201        if max_chunk_bytes > options.memory_budget().limit_bytes() {
202            return Err(Rosbag2Error::Records(RecordsError::MemoryBudgetExceeded {
203                requested: max_chunk_bytes,
204                current: 0,
205                limit: options.memory_budget().limit_bytes(),
206            }));
207        }
208        let budget = options.memory_budget();
209
210        Ok(Self {
211            connection,
212            source_id,
213            topic,
214            schema,
215            options,
216            tracker: MemoryTracker::new(budget),
217            cancellation,
218            max_chunk_bytes,
219            max_message_bytes,
220            has_intensity,
221            cursor: None,
222            pending: None,
223            next_sequence: 0,
224            next_point_offset: 0,
225            finished: false,
226        })
227    }
228
229    /// Returns the selected topic metadata.
230    #[must_use]
231    pub fn topic(&self) -> &Rosbag2Topic {
232        &self.topic
233    }
234
235    /// Returns the largest raw CDR payload in the selected topic.
236    #[must_use]
237    pub const fn max_message_bytes(&self) -> u64 {
238        self.max_message_bytes
239    }
240
241    fn next_message(&mut self) -> Rosbag2Result<Option<(MessageCursor, Vec<u8>)>> {
242        let row = match self.cursor {
243            None => self.connection.query_row(
244                "SELECT id, timestamp, data \
245                 FROM messages WHERE topic_id = ?1 \
246                 ORDER BY timestamp ASC, id ASC LIMIT 1",
247                params![self.topic.id],
248                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Vec<u8>>(2)?)),
249            ),
250            Some(cursor) => self.connection.query_row(
251                "SELECT id, timestamp, data \
252                 FROM messages WHERE topic_id = ?1 \
253                   AND (timestamp > ?2 OR (timestamp = ?2 AND id > ?3)) \
254                 ORDER BY timestamp ASC, id ASC LIMIT 1",
255                params![self.topic.id, cursor.timestamp, cursor.id],
256                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Vec<u8>>(2)?)),
257            ),
258        }
259        .optional()?;
260
261        row.map(|(id, timestamp, data)| {
262            if timestamp < 0 {
263                return Err(Rosbag2Error::InvalidBag(format!(
264                    "message {id} has a negative timestamp {timestamp}"
265                )));
266            }
267            Ok((MessageCursor { id, timestamp }, data))
268        })
269        .transpose()
270    }
271
272    fn load_pending(&mut self) -> Rosbag2Result<bool> {
273        loop {
274            let Some((cursor, data)) = self.next_message()? else {
275                return Ok(false);
276            };
277            self.cursor = Some(cursor);
278
279            let working_bytes = message_working_bytes(
280                u64::try_from(data.len())
281                    .map_err(|_| Rosbag2Error::InvalidBag("CDR payload is too large".into()))?,
282            )?;
283            let mut reservation = self.tracker.try_reserve(working_bytes)?;
284            let message = decode_point_cloud2_xyz(&data)?;
285            drop(data);
286            if message.intensity.is_some() != self.has_intensity {
287                return Err(Rosbag2Error::InvalidBag(format!(
288                    "PointCloud2 fields changed in topic `{}`",
289                    self.topic.name
290                )));
291            }
292            let decoded_bytes = point_capacity_bytes(message.point_count(), self.has_intensity)?;
293            reservation.shrink_to(
294                decoded_bytes
295                    .checked_add(CDR_SCRATCH_OVERHEAD_BYTES)
296                    .ok_or_else(|| Rosbag2Error::InvalidBag("decoded memory overflow".into()))?,
297            )?;
298            if message.point_count() == 0 {
299                continue;
300            }
301
302            let timestamp = ros_timestamp_ns(&message)?;
303            self.pending = Some(PendingPointCloud {
304                message,
305                offset: 0,
306                timestamp,
307                _reservation: reservation,
308            });
309            return Ok(true);
310        }
311    }
312
313    fn build_chunk(
314        &self,
315        pending: &PendingPointCloud,
316        count: usize,
317        reservation: MemoryReservation,
318    ) -> Rosbag2Result<SpatialRecordChunk> {
319        let start = pending
320            .offset
321            .checked_mul(3)
322            .ok_or_else(|| Rosbag2Error::InvalidBag("point offset overflow".into()))?;
323        let end = start
324            .checked_add(
325                count
326                    .checked_mul(3)
327                    .ok_or_else(|| Rosbag2Error::InvalidBag("chunk point count overflow".into()))?,
328            )
329            .ok_or_else(|| Rosbag2Error::InvalidBag("chunk range overflow".into()))?;
330        let point_end = pending
331            .offset
332            .checked_add(count)
333            .ok_or_else(|| Rosbag2Error::InvalidBag("chunk point range overflow".into()))?;
334        let values =
335            pending.message.xyz.get(start..end).ok_or_else(|| {
336                Rosbag2Error::InvalidBag("decoded XYZ range is out of bounds".into())
337            })?;
338
339        let mut x = Vec::with_capacity(count);
340        let mut y = Vec::with_capacity(count);
341        let mut z = Vec::with_capacity(count);
342        for point in values.chunks_exact(3) {
343            x.push(point[0]);
344            y.push(point[1]);
345            z.push(point[2]);
346        }
347        let mut buffers = PointBufferSet::new();
348        buffers.insert("x", PointBuffer::from_f32(x));
349        buffers.insert("y", PointBuffer::from_f32(y));
350        buffers.insert("z", PointBuffer::from_f32(z));
351        if self.has_intensity {
352            let intensity = pending
353                .message
354                .intensity
355                .as_ref()
356                .and_then(|values| values.get(pending.offset..point_end))
357                .ok_or_else(|| {
358                    Rosbag2Error::InvalidBag("decoded intensity range is out of bounds".into())
359                })?;
360            buffers.insert("intensity", PointBuffer::from_f32(intensity.to_vec()));
361        }
362        let cloud = PointCloud::try_from_parts(
363            if self.has_intensity {
364                StandardSchemas::point_xyzi()
365            } else {
366                StandardSchemas::point_xyz()
367            },
368            buffers,
369            SpatialMetadata::new(
370                pending.message.frame_id.as_str(),
371                Timestamp::from_nanos(pending.timestamp),
372            ),
373        )?;
374        let provenance = RecordProvenance::try_new(self.source_id.clone())
375            .map_err(Rosbag2Error::Records)?
376            .with_source_uri(
377                self.source_id.strip_prefix("rosbag2-sqlite:").unwrap_or(&self.source_id),
378            )
379            .with_stream_id(self.topic.name.clone())
380            .with_sequence(Some(self.next_sequence));
381        let record =
382            SpatialRecord::try_new_with_provenance(self.schema.clone(), cloud, provenance)?;
383        Ok(SpatialRecordChunk::try_from_reserved(
384            ChunkIdentity { sequence: self.next_sequence, point_offset: self.next_point_offset },
385            record,
386            reservation,
387        )?)
388    }
389
390    fn fail(&mut self, error: Rosbag2Error) -> Option<RecordsResult<SpatialRecordChunk>> {
391        self.pending.take();
392        self.finished = true;
393        Some(Err(match error {
394            Rosbag2Error::Records(error) => error,
395            other => RecordsError::InvalidChunk(other.to_string()),
396        }))
397    }
398}
399
400impl BoundedSpatialRecordSource for Rosbag2PointCloudSource {
401    fn schema(&self) -> &SchemaDescriptor {
402        &self.schema
403    }
404
405    fn options(&self) -> &StreamOptions {
406        &self.options
407    }
408
409    fn memory_tracker(&self) -> &MemoryTracker {
410        &self.tracker
411    }
412
413    fn cancellation_token(&self) -> CancellationToken {
414        self.cancellation.clone()
415    }
416
417    fn max_chunk_bytes(&self) -> u64 {
418        self.max_chunk_bytes
419    }
420
421    fn next_chunk(&mut self) -> Option<RecordsResult<SpatialRecordChunk>> {
422        if self.finished {
423            return None;
424        }
425        if let Err(error) = self.cancellation.check() {
426            return self.fail(Rosbag2Error::Records(error));
427        }
428
429        if self.pending.is_none() {
430            match self.load_pending() {
431                Ok(true) => {}
432                Ok(false) => {
433                    self.finished = true;
434                    return None;
435                }
436                Err(error) => return self.fail(error),
437            }
438        }
439
440        let mut pending = self.pending.take().expect("pending message was loaded");
441        let remaining = pending.message.point_count().saturating_sub(pending.offset);
442        let count = remaining.min(self.options.chunk_points());
443        if count == 0 {
444            drop(pending);
445            return self.next_chunk();
446        }
447
448        let reservation =
449            match self.tracker.try_reserve(match point_capacity_bytes(count, self.has_intensity) {
450                Ok(bytes) => bytes,
451                Err(error) => return self.fail(error),
452            }) {
453                Ok(reservation) => reservation,
454                Err(error) => {
455                    self.pending = Some(pending);
456                    return self.fail(Rosbag2Error::Records(error));
457                }
458            };
459        let next_offset = pending.offset + count;
460        let chunk = match self.build_chunk(&pending, count, reservation) {
461            Ok(chunk) => chunk,
462            Err(error) => {
463                self.pending = Some(pending);
464                return self.fail(error);
465            }
466        };
467        pending.offset = next_offset;
468        if next_offset < pending.message.point_count() {
469            self.pending = Some(pending);
470        }
471        self.next_sequence = match self.next_sequence.checked_add(1) {
472            Some(value) => value,
473            None => {
474                return self.fail(Rosbag2Error::Records(RecordsError::ReceiptOverflow(
475                    "rosbag2 chunk sequence".into(),
476                )))
477            }
478        };
479        let count = match u64::try_from(count) {
480            Ok(count) => count,
481            Err(_) => {
482                return self.fail(Rosbag2Error::Records(RecordsError::ReceiptOverflow(
483                    "rosbag2 point count".into(),
484                )))
485            }
486        };
487        self.next_point_offset = match self.next_point_offset.checked_add(count) {
488            Some(value) => value,
489            None => {
490                return self.fail(Rosbag2Error::Records(RecordsError::ReceiptOverflow(
491                    "rosbag2 point offset".into(),
492                )))
493            }
494        };
495        Some(Ok(chunk))
496    }
497}
498
499#[derive(Clone, Copy, Debug)]
500struct MessageCursor {
501    id: i64,
502    timestamp: i64,
503}
504
505struct PendingPointCloud {
506    message: PointCloud2Xyz,
507    offset: usize,
508    timestamp: u64,
509    _reservation: MemoryReservation,
510}
511
512fn open_connection(path: impl AsRef<Path>) -> Rosbag2Result<Connection> {
513    let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
514    ensure_schema(&connection)?;
515    Ok(connection)
516}
517
518fn ensure_schema(connection: &Connection) -> Rosbag2Result<()> {
519    for table in ["topics", "messages"] {
520        let present: i64 = connection.query_row(
521            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
522            params![table],
523            |row| row.get(0),
524        )?;
525        if present != 1 {
526            return Err(Rosbag2Error::InvalidBag(format!(
527                "required rosbag2 table `{table}` is missing"
528            )));
529        }
530    }
531    Ok(())
532}
533
534fn read_topics(connection: &Connection) -> Rosbag2Result<Vec<Rosbag2Topic>> {
535    let mut statement = connection.prepare(
536        "SELECT t.id, t.name, t.\"type\", t.serialization_format, COUNT(m.id) \
537         FROM topics AS t LEFT JOIN messages AS m ON m.topic_id = t.id \
538         GROUP BY t.id, t.name, t.\"type\", t.serialization_format \
539         ORDER BY t.id",
540    )?;
541    let rows = statement.query_map([], |row| {
542        let message_count: i64 = row.get(4)?;
543        Ok(Rosbag2Topic {
544            id: row.get(0)?,
545            name: row.get(1)?,
546            type_name: row.get(2)?,
547            serialization_format: row.get(3)?,
548            message_count: u64::try_from(message_count).unwrap_or(0),
549        })
550    })?;
551    rows.collect::<Result<Vec<_>, _>>().map_err(Rosbag2Error::from)
552}
553
554fn max_message_bytes(connection: &Connection, topic_id: i64) -> Rosbag2Result<u64> {
555    let value: Option<i64> = connection.query_row(
556        "SELECT MAX(length(data)) FROM messages WHERE topic_id = ?1",
557        params![topic_id],
558        |row| row.get(0),
559    )?;
560    value
561        .unwrap_or(0)
562        .try_into()
563        .map_err(|_| Rosbag2Error::InvalidBag("negative message length".into()))
564}
565
566fn first_message_has_intensity(connection: &Connection, topic_id: i64) -> Rosbag2Result<bool> {
567    let data: Option<Vec<u8>> = connection
568        .query_row(
569            "SELECT data FROM messages WHERE topic_id = ?1 \
570             ORDER BY timestamp ASC, id ASC LIMIT 1",
571            params![topic_id],
572            |row| row.get(0),
573        )
574        .optional()?;
575    let Some(data) = data else {
576        return Ok(false);
577    };
578    Ok(point_cloud2_has_intensity(&data)?)
579}
580
581fn message_working_bytes(raw_bytes: u64) -> Rosbag2Result<u64> {
582    raw_bytes
583        .checked_mul(2)
584        .and_then(|bytes| bytes.checked_add(CDR_SCRATCH_OVERHEAD_BYTES))
585        .ok_or_else(|| Rosbag2Error::InvalidBag("message working-set overflow".into()))
586}
587
588fn point_capacity_bytes(point_count: usize, has_intensity: bool) -> Rosbag2Result<u64> {
589    u64::try_from(point_count)
590        .ok()
591        .and_then(|count| count.checked_mul(if has_intensity { 16 } else { 12 }))
592        .ok_or_else(|| Rosbag2Error::InvalidBag("point-column capacity overflow".into()))
593}
594
595fn ros_timestamp_ns(message: &PointCloud2Xyz) -> Rosbag2Result<u64> {
596    if message.stamp_sec < 0 || message.stamp_nanosec >= 1_000_000_000 {
597        return Err(Rosbag2Error::InvalidBag(format!(
598            "invalid PointCloud2 header timestamp {}.{:09}",
599            message.stamp_sec, message.stamp_nanosec
600        )));
601    }
602    u64::try_from(message.stamp_sec)
603        .ok()
604        .and_then(|seconds| seconds.checked_mul(1_000_000_000))
605        .and_then(|seconds| seconds.checked_add(u64::from(message.stamp_nanosec)))
606        .ok_or_else(|| Rosbag2Error::InvalidBag("PointCloud2 timestamp overflow".into()))
607}
608
609#[cfg(test)]
610mod tests {
611    use super::{
612        list_tf_messages, list_topics, Rosbag2PointCloudSource, ROSBAG2_POINT_XYZI_SCHEMA_ID,
613        ROSBAG2_POINT_XYZ_SCHEMA_ID,
614    };
615    use rusqlite::{params, Connection};
616    use spatialrust_core::PointBuffer;
617    use spatialrust_records::{
618        BoundedSpatialRecordSource, CancellationToken, MemoryBudget, StreamOptions,
619    };
620    use spatialrust_runtime::{
621        encode_point_cloud2_xyz, PointCloud2Xyz, TfTransform, POINT_CLOUD2_TYPE, TF_MESSAGE_TYPE,
622    };
623
624    fn align_from(bytes: &mut Vec<u8>, alignment: usize, origin: usize) {
625        let relative = bytes.len().saturating_sub(origin);
626        let remainder = relative % alignment;
627        if remainder != 0 {
628            bytes.resize(bytes.len() + alignment - remainder, 0);
629        }
630    }
631
632    fn write_string(bytes: &mut Vec<u8>, value: &str) {
633        while bytes.len() % 4 != 0 {
634            bytes.push(0);
635        }
636        bytes.extend_from_slice(&u32::try_from(value.len() + 1).unwrap().to_le_bytes());
637        bytes.extend_from_slice(value.as_bytes());
638        bytes.push(0);
639    }
640
641    fn encode_tf_message(transform: &TfTransform) -> Vec<u8> {
642        let mut bytes = vec![0x00, 0x01, 0x00, 0x00];
643        bytes.extend_from_slice(&1_u32.to_le_bytes());
644        bytes.extend_from_slice(&transform.stamp_sec.to_le_bytes());
645        bytes.extend_from_slice(&transform.stamp_nanosec.to_le_bytes());
646        write_string(&mut bytes, &transform.frame_id);
647        write_string(&mut bytes, &transform.child_frame_id);
648        align_from(&mut bytes, 8, 4);
649        for value in transform.translation.into_iter().chain(transform.rotation_xyzw) {
650            bytes.extend_from_slice(&value.to_le_bytes());
651        }
652        bytes
653    }
654
655    fn bag_file() -> (tempfile::TempDir, std::path::PathBuf) {
656        let directory = tempfile::tempdir().unwrap();
657        let path = directory.path().join("sample.db3");
658        let connection = Connection::open(&path).unwrap();
659        connection
660            .execute_batch(
661                "CREATE TABLE topics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, serialization_format TEXT NOT NULL);\
662                 CREATE TABLE messages(id INTEGER PRIMARY KEY, topic_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, data BLOB NOT NULL);",
663            )
664            .unwrap();
665        connection
666            .execute(
667                "INSERT INTO topics(id,name,type,serialization_format) VALUES(1,?1,?2,'cdr')",
668                params!["/lidar/points", POINT_CLOUD2_TYPE],
669            )
670            .unwrap();
671        for (id, stamp, offset) in [(1_i64, 20_i64, 0.0_f32), (2, 10, 10.0)] {
672            let message = PointCloud2Xyz::try_new(
673                "lidar",
674                7,
675                id as u32,
676                vec![offset, 1.0, 2.0, offset + 1.0, 3.0, 4.0, offset + 2.0, 5.0, 6.0],
677            )
678            .unwrap();
679            connection
680                .execute(
681                    "INSERT INTO messages(id,topic_id,timestamp,data) VALUES(?1,1,?2,?3)",
682                    params![id, stamp, encode_point_cloud2_xyz(&message).unwrap()],
683                )
684                .unwrap();
685        }
686        drop(connection);
687        (directory, path)
688    }
689
690    #[test]
691    fn lists_topics_and_counts_messages() {
692        let (_directory, path) = bag_file();
693        let topics = list_topics(path).unwrap();
694        assert_eq!(topics.len(), 1);
695        assert_eq!(topics[0].name, "/lidar/points");
696        assert_eq!(topics[0].message_count, 2);
697    }
698
699    #[test]
700    fn lists_and_decodes_bounded_tf_messages() {
701        let directory = tempfile::tempdir().unwrap();
702        let path = directory.path().join("tf.db3");
703        let connection = Connection::open(&path).unwrap();
704        connection
705            .execute_batch(
706                "CREATE TABLE topics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, serialization_format TEXT NOT NULL);\
707                 CREATE TABLE messages(id INTEGER PRIMARY KEY, topic_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, data BLOB NOT NULL);",
708            )
709            .unwrap();
710        connection
711            .execute(
712                "INSERT INTO topics(id,name,type,serialization_format) VALUES(1,?1,?2,'cdr')",
713                params!["/tf_static", TF_MESSAGE_TYPE],
714            )
715            .unwrap();
716        let transform = TfTransform {
717            stamp_sec: 12,
718            stamp_nanosec: 34,
719            frame_id: "base_link".into(),
720            child_frame_id: "lidar_front".into(),
721            translation: [1.0, -2.0, 3.5],
722            rotation_xyzw: [0.0, 0.0, 0.707, 0.707],
723        };
724        connection
725            .execute(
726                "INSERT INTO messages(id,topic_id,timestamp,data) VALUES(1,1,42,?1)",
727                params![encode_tf_message(&transform)],
728            )
729            .unwrap();
730        drop(connection);
731
732        let messages = list_tf_messages(&path, "/tf_static", 1).unwrap();
733        assert_eq!(messages.len(), 1);
734        assert_eq!(messages[0].bag_timestamp, 42);
735        assert_eq!(messages[0].transforms, vec![transform]);
736        assert!(list_tf_messages(&path, "/tf_static", 0).is_err());
737    }
738
739    #[test]
740    fn source_orders_messages_and_splits_chunks() {
741        let (_directory, path) = bag_file();
742        let source_uri = path.display().to_string();
743        let options = StreamOptions::new(2, MemoryBudget::new(1024 * 1024).unwrap()).unwrap();
744        let mut source = Rosbag2PointCloudSource::open(
745            path,
746            "/lidar/points",
747            options,
748            CancellationToken::default(),
749        )
750        .unwrap();
751        assert_eq!(source.schema().id.as_str(), ROSBAG2_POINT_XYZ_SCHEMA_ID);
752
753        let first = source.next_chunk().unwrap().unwrap();
754        assert_eq!(first.identity().sequence, 0);
755        assert_eq!(first.identity().point_offset, 0);
756        assert_eq!(first.record().metadata().timestamp.as_nanos(), 7_000_000_002);
757        assert_eq!(first.record().metadata().frame_id.0, "lidar");
758        assert_eq!(first.record().provenance().source_id, format!("rosbag2-sqlite:{source_uri}"));
759        assert_eq!(first.record().provenance().source_uri.as_deref(), Some(source_uri.as_str()));
760        assert_eq!(first.record().provenance().stream_id.as_deref(), Some("/lidar/points"));
761        assert_eq!(first.record().provenance().sequence, Some(0));
762        assert_eq!(first.record().cloud().len(), 2);
763        assert_eq!(
764            first.record().cloud().field("x").unwrap(),
765            &PointBuffer::from_f32(vec![10.0, 11.0])
766        );
767        drop(first);
768
769        let second = source.next_chunk().unwrap().unwrap();
770        assert_eq!(second.identity().sequence, 1);
771        assert_eq!(second.identity().point_offset, 2);
772        assert_eq!(second.record().cloud().field("x").unwrap(), &PointBuffer::from_f32(vec![12.0]));
773        drop(second);
774
775        let third = source.next_chunk().unwrap().unwrap();
776        assert_eq!(third.identity().sequence, 2);
777        assert_eq!(third.identity().point_offset, 3);
778        assert_eq!(third.record().metadata().timestamp.as_nanos(), 7_000_000_001);
779        assert_eq!(
780            third.record().cloud().field("x").unwrap(),
781            &PointBuffer::from_f32(vec![0.0, 1.0])
782        );
783        drop(third);
784
785        let fourth = source.next_chunk().unwrap().unwrap();
786        assert_eq!(fourth.identity().sequence, 3);
787        assert_eq!(fourth.identity().point_offset, 5);
788        drop(fourth);
789        assert!(source.next_chunk().is_none());
790        assert_eq!(source.memory_tracker().snapshot().current_bytes, 0);
791    }
792
793    fn bag_file_with_intensity() -> (tempfile::TempDir, std::path::PathBuf) {
794        let directory = tempfile::tempdir().unwrap();
795        let path = directory.path().join("sample-intensity.db3");
796        let connection = Connection::open(&path).unwrap();
797        connection
798            .execute_batch(
799                "CREATE TABLE topics(id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, serialization_format TEXT NOT NULL);\
800                 CREATE TABLE messages(id INTEGER PRIMARY KEY, topic_id INTEGER NOT NULL, timestamp INTEGER NOT NULL, data BLOB NOT NULL);",
801            )
802            .unwrap();
803        connection
804            .execute(
805                "INSERT INTO topics(id,name,type,serialization_format) VALUES(1,?1,?2,'cdr')",
806                params!["/lidar/points", POINT_CLOUD2_TYPE],
807            )
808            .unwrap();
809        for (id, stamp, offset) in [(1_i64, 20_i64, 0.0_f32), (2, 10, 10.0)] {
810            let message = PointCloud2Xyz::try_new_with_intensity(
811                "lidar",
812                7,
813                id as u32,
814                vec![offset, 1.0, 2.0, offset + 1.0, 3.0, 4.0, offset + 2.0, 5.0, 6.0],
815                vec![100.0 + offset, 101.0 + offset, 102.0 + offset],
816            )
817            .unwrap();
818            connection
819                .execute(
820                    "INSERT INTO messages(id,topic_id,timestamp,data) VALUES(?1,1,?2,?3)",
821                    params![id, stamp, encode_point_cloud2_xyz(&message).unwrap()],
822                )
823                .unwrap();
824        }
825        drop(connection);
826        (directory, path)
827    }
828
829    #[test]
830    fn source_preserves_intensity_and_selects_xyzi_schema() {
831        let (_directory, path) = bag_file_with_intensity();
832        let options = StreamOptions::new(2, MemoryBudget::new(1024 * 1024).unwrap()).unwrap();
833        let mut source = Rosbag2PointCloudSource::open(
834            path,
835            "/lidar/points",
836            options,
837            CancellationToken::default(),
838        )
839        .unwrap();
840        assert_eq!(source.schema().id.as_str(), ROSBAG2_POINT_XYZI_SCHEMA_ID);
841
842        let first = source.next_chunk().unwrap().unwrap();
843        assert_eq!(
844            first.record().cloud().field("intensity").unwrap(),
845            &PointBuffer::from_f32(vec![110.0, 111.0])
846        );
847        drop(first);
848        let second = source.next_chunk().unwrap().unwrap();
849        assert_eq!(
850            second.record().cloud().field("intensity").unwrap(),
851            &PointBuffer::from_f32(vec![112.0])
852        );
853        drop(second);
854        assert!(source.next_chunk().is_some());
855    }
856}