1use std::{
18 collections::BTreeMap,
19 fs::File,
20 io::{BufWriter, Cursor, Read},
21 path::Path,
22};
23
24use mcap::{records::MessageHeader, Message, Writer};
25use spatialrust_core::{
26 PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
27};
28use spatialrust_records::{SchemaVersion, SpatialRecord};
29
30use crate::{
31 ClockDomain, ClockId, MemoryEpisode, StampedRecord, StampedTime, SyncError, SyncResult, TopicId,
32};
33
34const MAGIC: u32 = 0x5352_5859;
35const ENCODING: &str = "application/x-spatialrust-xyz-v1";
36const SCHEMA_NAME: &str = "spatialrust/xyz/v1";
37
38pub fn write_memory_episode_mcap(
40 path: impl AsRef<Path>,
41 episode: &MemoryEpisode,
42) -> SyncResult<()> {
43 let file = File::create(path.as_ref()).map_err(io_err)?;
44 let mut writer = Writer::new(BufWriter::new(file)).map_err(mcap_err)?;
45 let schema_id = writer.add_schema(SCHEMA_NAME, ENCODING, &[]).map_err(mcap_err)?;
46
47 let mut channels = BTreeMap::<String, u16>::new();
48 for (sequence, stamped) in episode.records().iter().enumerate() {
49 let topic = stamped.topic.as_str().to_owned();
50 let channel_id = if let Some(id) = channels.get(&topic) {
51 *id
52 } else {
53 let id = writer
54 .add_channel(schema_id, &topic, ENCODING, &BTreeMap::new())
55 .map_err(mcap_err)?;
56 channels.insert(topic, id);
57 id
58 };
59 let payload = encode_stamped_xyz(stamped)?;
60 let stamp = stamped.stamp.as_nanos();
61 writer
62 .write_to_known_channel(
63 &MessageHeader {
64 channel_id,
65 sequence: sequence as u32,
66 log_time: stamp,
67 publish_time: stamp,
68 },
69 &payload,
70 )
71 .map_err(mcap_err)?;
72 }
73 writer.finish().map_err(mcap_err)?;
74 Ok(())
75}
76
77pub fn read_memory_episode_mcap(path: impl AsRef<Path>) -> SyncResult<MemoryEpisode> {
79 let bytes = std::fs::read(path.as_ref()).map_err(io_err)?;
80 let mut records = Vec::new();
81 for message in mcap::MessageStream::new(&bytes).map_err(mcap_err)? {
82 let message = message.map_err(mcap_err)?;
83 if message.channel.message_encoding != ENCODING {
84 return Err(SyncError::InvalidConfiguration(format!(
85 "unsupported MCAP encoding `{}`",
86 message.channel.message_encoding
87 )));
88 }
89 records.push(decode_stamped_xyz(&message)?);
90 }
91 Ok(MemoryEpisode::from_records(records))
92}
93
94fn encode_stamped_xyz(stamped: &StampedRecord) -> SyncResult<Vec<u8>> {
95 let cloud = stamped.record.cloud();
96 let xs = cloud.field("x")?.as_f32()?;
97 let ys = cloud.field("y")?.as_f32()?;
98 let zs = cloud.field("z")?.as_f32()?;
99 if xs.len() != ys.len() || ys.len() != zs.len() {
100 return Err(SyncError::InvalidConfiguration("xyz lengths disagree".into()));
101 }
102 let schema = stamped.record.schema();
103 let mut out = Vec::new();
104 out.extend_from_slice(&MAGIC.to_le_bytes());
105 out.extend_from_slice(&(xs.len() as u32).to_le_bytes());
106 let id = schema.id.as_str().as_bytes();
107 out.extend_from_slice(&(id.len() as u32).to_le_bytes());
108 out.extend_from_slice(id);
109 out.extend_from_slice(&schema.version.major.to_le_bytes());
110 out.extend_from_slice(&schema.version.minor.to_le_bytes());
111 out.extend_from_slice(&stamped.stamp.as_nanos().to_le_bytes());
112 out.push(domain_byte(stamped.stamp.domain));
113 for i in 0..xs.len() {
114 out.extend_from_slice(&xs[i].to_le_bytes());
115 out.extend_from_slice(&ys[i].to_le_bytes());
116 out.extend_from_slice(&zs[i].to_le_bytes());
117 }
118 Ok(out)
119}
120
121fn decode_stamped_xyz(message: &Message<'_>) -> SyncResult<StampedRecord> {
122 let mut cur = Cursor::new(message.data.as_ref());
123 let magic = read_u32(&mut cur)?;
124 if magic != MAGIC {
125 return Err(SyncError::InvalidConfiguration("bad spatialrust MCAP magic".into()));
126 }
127 let count = read_u32(&mut cur)? as usize;
128 let id_len = read_u32(&mut cur)? as usize;
129 let mut id_bytes = vec![0_u8; id_len];
130 cur.read_exact(&mut id_bytes).map_err(io_err)?;
131 let schema_id = String::from_utf8(id_bytes)
132 .map_err(|_| SyncError::InvalidConfiguration("schema id is not UTF-8".into()))?;
133 let major = read_u32(&mut cur)?;
134 let minor = read_u32(&mut cur)?;
135 let stamp_ns = read_u64(&mut cur)?;
136 let mut domain_byte = [0_u8; 1];
137 cur.read_exact(&mut domain_byte).map_err(io_err)?;
138 let domain = byte_domain(domain_byte[0])?;
139
140 let mut xs = Vec::with_capacity(count);
141 let mut ys = Vec::with_capacity(count);
142 let mut zs = Vec::with_capacity(count);
143 for _ in 0..count {
144 xs.push(read_f32(&mut cur)?);
145 ys.push(read_f32(&mut cur)?);
146 zs.push(read_f32(&mut cur)?);
147 }
148
149 let mut buffers = PointBufferSet::new();
150 buffers.insert("x", PointBuffer::from_f32(xs));
151 buffers.insert("y", PointBuffer::from_f32(ys));
152 buffers.insert("z", PointBuffer::from_f32(zs));
153 let cloud = PointCloud::try_from_parts(
154 StandardSchemas::point_xyz(),
155 buffers,
156 SpatialMetadata::default(),
157 )?;
158 let record = SpatialRecord::try_from_cloud(schema_id, SchemaVersion::new(major, minor), cloud)?;
159 let stamp = StampedTime {
160 clock: ClockId::new(message.channel.topic.clone()),
161 domain,
162 timestamp: Timestamp::from_nanos(stamp_ns),
163 quality: crate::SyncQuality::exact(),
164 };
165 Ok(StampedRecord::new(TopicId::new(message.channel.topic.clone()), stamp, record))
166}
167
168fn domain_byte(domain: ClockDomain) -> u8 {
169 match domain {
170 ClockDomain::HostSteady => 0,
171 ClockDomain::HostWall => 1,
172 ClockDomain::Sensor => 2,
173 ClockDomain::External => 3,
174 }
175}
176
177fn byte_domain(value: u8) -> SyncResult<ClockDomain> {
178 match value {
179 0 => Ok(ClockDomain::HostSteady),
180 1 => Ok(ClockDomain::HostWall),
181 2 => Ok(ClockDomain::Sensor),
182 3 => Ok(ClockDomain::External),
183 other => Err(SyncError::InvalidConfiguration(format!("unknown clock domain byte {other}"))),
184 }
185}
186
187fn read_u32(cur: &mut Cursor<&[u8]>) -> SyncResult<u32> {
188 let mut buf = [0_u8; 4];
189 cur.read_exact(&mut buf).map_err(io_err)?;
190 Ok(u32::from_le_bytes(buf))
191}
192
193fn read_u64(cur: &mut Cursor<&[u8]>) -> SyncResult<u64> {
194 let mut buf = [0_u8; 8];
195 cur.read_exact(&mut buf).map_err(io_err)?;
196 Ok(u64::from_le_bytes(buf))
197}
198
199fn read_f32(cur: &mut Cursor<&[u8]>) -> SyncResult<f32> {
200 let mut buf = [0_u8; 4];
201 cur.read_exact(&mut buf).map_err(io_err)?;
202 Ok(f32::from_le_bytes(buf))
203}
204
205fn io_err(error: impl std::fmt::Display) -> SyncError {
206 SyncError::Io(error.to_string())
207}
208
209fn mcap_err(error: impl std::fmt::Display) -> SyncError {
210 SyncError::Mcap(error.to_string())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::{read_memory_episode_mcap, write_memory_episode_mcap};
216 use crate::{ClockDomain, MemoryEpisode, StampedRecord, StampedTime};
217 use spatialrust_core::{
218 PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas, Timestamp,
219 };
220 use spatialrust_records::{SchemaVersion, SpatialRecord};
221
222 #[test]
223 fn roundtrips_xyz_episode() {
224 let mut buffers = PointBufferSet::new();
225 buffers.insert("x", PointBuffer::from_f32(vec![1.0, 2.0]));
226 buffers.insert("y", PointBuffer::from_f32(vec![0.0, 0.0]));
227 buffers.insert("z", PointBuffer::from_f32(vec![3.0, 4.0]));
228 let cloud = PointCloud::try_from_parts(
229 StandardSchemas::point_xyz(),
230 buffers,
231 SpatialMetadata::default(),
232 )
233 .unwrap();
234 let record =
235 SpatialRecord::try_from_cloud("point", SchemaVersion::new(1, 2), cloud).unwrap();
236 let stamped = StampedRecord::new(
237 "lidar",
238 StampedTime::exact("host", ClockDomain::HostSteady, Timestamp::from_nanos(42)),
239 record,
240 );
241 let episode = MemoryEpisode::from_records(vec![stamped]);
242 let dir = tempfile::tempdir().unwrap();
243 let path = dir.path().join("episode.mcap");
244 write_memory_episode_mcap(&path, &episode).unwrap();
245 let loaded = read_memory_episode_mcap(&path).unwrap();
246 assert_eq!(loaded.records().len(), 1);
247 assert_eq!(loaded.records()[0].topic.as_str(), "lidar");
248 assert_eq!(loaded.records()[0].stamp.as_nanos(), 42);
249 assert_eq!(
250 loaded.records()[0].record.cloud().field("x").unwrap().as_f32().unwrap(),
251 &[1.0, 2.0]
252 );
253 }
254}