1use clap::Parser;
2use cu_sensor_payloads::{CuImage, ImuPayload, PointCloud, PointCloudSoa};
3use cu_spatial_payloads::Transform3D as CuTransform3D;
4use cu29::clock::Tov;
5use cu29::prelude::{
6 CopperListTuple, CuError, CuResult, UnifiedLogType, UnifiedLogger, UnifiedLoggerBuilder,
7 UnifiedLoggerIOReader, UnifiedLoggerRead,
8};
9use cu29_export::copperlists_reader;
10pub use rerun::RecordingStream;
11use rerun::{Points3D, Scalars, TextDocument};
12use serde::Serialize;
13use std::any::{Any, TypeId};
14use std::path::{Path, PathBuf};
15use uom::si::thermodynamic_temperature::degree_celsius;
16
17mod fallback;
18
19pub use fallback::{extract_scalars, flatten_json};
20
21pub fn tov_to_secs(tov: &Tov) -> Option<f64> {
22 match tov {
23 Tov::Time(t) => Some(t.0 as f64 / 1_000_000_000.0),
24 Tov::Range(r) => Some(r.start.0 as f64 / 1_000_000_000.0),
25 Tov::None => None,
26 }
27}
28
29pub fn apply_tov(rec: &RecordingStream, tov: &Tov) {
30 if let Some(secs) = tov_to_secs(tov) {
31 rec.set_duration_secs("tov", secs);
32 } else {
33 rec.reset_time();
34 }
35}
36
37pub trait LogvizDataSet {
38 fn logviz_emit(&self, rec: &RecordingStream) -> CuResult<()>;
39}
40
41fn log_scalar(rec: &RecordingStream, path: &str, value: f64) -> CuResult<()> {
42 rec.log(path, &Scalars::new([value]))
43 .map_err(|e| CuError::new_with_cause("Failed to log scalar", e))
44}
45
46pub fn log_image<A>(rec: &RecordingStream, path: &str, image: &CuImage<A>) -> CuResult<()>
47where
48 A: cu29::prelude::ArrayLike<Element = u8>,
49{
50 log_as_components(rec, path, image)
51}
52
53pub fn log_pointcloud<const N: usize>(
54 rec: &RecordingStream,
55 path: &str,
56 pc: &PointCloudSoa<N>,
57) -> CuResult<()> {
58 log_as_components(rec, path, pc)
59}
60
61pub fn log_transform<T: Copy + std::fmt::Debug + 'static>(
62 rec: &RecordingStream,
63 path: &str,
64 transform: &CuTransform3D<T>,
65) -> CuResult<()>
66where
67 CuTransform3D<T>: rerun::AsComponents,
68{
69 log_as_components(rec, path, transform)
70}
71
72pub fn log_imu(rec: &RecordingStream, base: &str, imu: &ImuPayload) -> CuResult<()> {
73 log_scalar(rec, &format!("{}/accel_x", base), imu.accel_x.value as f64)?;
74 log_scalar(rec, &format!("{}/accel_y", base), imu.accel_y.value as f64)?;
75 log_scalar(rec, &format!("{}/accel_z", base), imu.accel_z.value as f64)?;
76 log_scalar(rec, &format!("{}/gyro_x", base), imu.gyro_x.value as f64)?;
77 log_scalar(rec, &format!("{}/gyro_y", base), imu.gyro_y.value as f64)?;
78 log_scalar(rec, &format!("{}/gyro_z", base), imu.gyro_z.value as f64)?;
79 log_scalar(
80 rec,
81 &format!("{}/temperature_c", base),
82 imu.temperature.get::<degree_celsius>() as f64,
83 )?;
84 Ok(())
85}
86
87pub fn log_as_components<A: rerun::AsComponents>(
88 rec: &RecordingStream,
89 path: &str,
90 value: &A,
91) -> CuResult<()> {
92 rec.log(path, value)
93 .map_err(|e| CuError::new_with_cause("Failed to log rerun components", e))
94}
95
96pub fn log_payload_auto<T>(rec: &RecordingStream, path: &str, payload: &T) -> CuResult<()>
97where
98 T: Serialize + 'static,
99{
100 let any_payload = payload as &dyn Any;
101 let payload_id = any_payload.type_id();
102
103 if payload_id == TypeId::of::<CuImage<Vec<u8>>>() {
104 let image = any_payload
105 .downcast_ref::<CuImage<Vec<u8>>>()
106 .expect("downcast must match TypeId");
107 return log_as_components(rec, path, image);
108 }
109
110 if payload_id == TypeId::of::<PointCloud>() {
111 let point = any_payload
112 .downcast_ref::<PointCloud>()
113 .expect("downcast must match TypeId");
114 return log_as_components(rec, path, point);
115 }
116
117 if payload_id == TypeId::of::<CuTransform3D<f32>>() {
118 let transform = any_payload
119 .downcast_ref::<CuTransform3D<f32>>()
120 .expect("downcast must match TypeId");
121 return log_as_components(rec, path, transform);
122 }
123
124 if payload_id == TypeId::of::<CuTransform3D<f64>>() {
125 let transform = any_payload
126 .downcast_ref::<CuTransform3D<f64>>()
127 .expect("downcast must match TypeId");
128 return log_as_components(rec, path, transform);
129 }
130
131 if payload_id == TypeId::of::<ImuPayload>() {
132 let imu = any_payload
133 .downcast_ref::<ImuPayload>()
134 .expect("downcast must match TypeId");
135 return log_imu(rec, path, imu);
136 }
137
138 if is_pointcloud_soa_type_name(core::any::type_name::<T>())
139 && let Some(points) = extract_pointcloud_soa_positions(payload)?
140 {
141 return rec
142 .log(path, &Points3D::new(points))
143 .map_err(|e| CuError::new_with_cause("Failed to log point cloud", e));
144 }
145
146 log_fallback_payload(rec, path, payload)
147}
148
149fn is_pointcloud_soa_type_name(type_name: &str) -> bool {
150 type_name.contains("PointCloudSoa<")
151}
152
153fn extract_pointcloud_soa_positions<T: Serialize>(
154 payload: &T,
155) -> CuResult<Option<Vec<rerun::Position3D>>> {
156 let value = serde_json::to_value(payload)
157 .map_err(|e| CuError::new_with_cause("Failed to serialize payload", e))?;
158 let object = match value.as_object() {
159 Some(object) => object,
160 None => return Ok(None),
161 };
162
163 let xs = match object.get("x").and_then(|v| v.as_array()) {
164 Some(values) => values,
165 None => return Ok(None),
166 };
167 let ys = match object.get("y").and_then(|v| v.as_array()) {
168 Some(values) => values,
169 None => return Ok(None),
170 };
171 let zs = match object.get("z").and_then(|v| v.as_array()) {
172 Some(values) => values,
173 None => return Ok(None),
174 };
175
176 let default_len = xs.len().min(ys.len()).min(zs.len());
177 let len = object
178 .get("len")
179 .and_then(|v| v.as_u64())
180 .map_or(default_len, |value| value as usize)
181 .min(default_len);
182
183 let mut points = Vec::with_capacity(len);
184 for i in 0..len {
185 let Some(x) = xs[i].as_f64() else {
186 return Ok(None);
187 };
188 let Some(y) = ys[i].as_f64() else {
189 return Ok(None);
190 };
191 let Some(z) = zs[i].as_f64() else {
192 return Ok(None);
193 };
194 points.push(rerun::Position3D::new(x as f32, y as f32, z as f32));
195 }
196
197 Ok(Some(points))
198}
199
200pub fn log_fallback_payload<T: Serialize>(
201 rec: &RecordingStream,
202 base: &str,
203 payload: &T,
204) -> CuResult<()> {
205 let value = serde_json::to_value(payload)
206 .map_err(|e| CuError::new_with_cause("Failed to serialize payload", e))?;
207 let flat = flatten_json(base, &value);
208 for (path, value) in flat {
209 if let Some(num) = value.as_f64() {
210 log_scalar(rec, &path, num)?;
211 } else if let Some(value_bool) = value.as_bool() {
212 log_scalar(rec, &path, if value_bool { 1.0 } else { 0.0 })?;
213 } else if let Some(text) = value.as_str() {
214 rec.log(path.as_str(), &TextDocument::new(text))
215 .map_err(|e| CuError::new_with_cause("Failed to log text", e))?;
216 } else if !value.is_null() {
217 rec.log(path.as_str(), &TextDocument::new(value.to_string()))
218 .map_err(|e| CuError::new_with_cause("Failed to log text", e))?;
219 }
220 }
221 Ok(())
222}
223
224#[derive(Debug, Parser)]
225#[command(author, version, about)]
226struct LogVizCli {
227 #[arg(value_name = "UNIFIEDLOG_BASE")]
229 unifiedlog_base: PathBuf,
230
231 #[command(flatten)]
232 rerun: rerun::clap::RerunArgs,
233}
234
235fn build_read_logger(unifiedlog_base: &Path) -> CuResult<UnifiedLoggerRead> {
236 let logger = UnifiedLoggerBuilder::new()
237 .file_base_name(unifiedlog_base)
238 .build()
239 .map_err(|e| CuError::new_with_cause("Failed to create logger", e))?;
240 match logger {
241 UnifiedLogger::Read(dl) => Ok(dl),
242 UnifiedLogger::Write(_) => Err(CuError::from(
243 "Expected read-only unified logger in logviz CLI",
244 )),
245 }
246}
247
248pub fn logviz_emit_dataset<P: LogvizDataSet>(dataset: &P, rec: &RecordingStream) -> CuResult<()> {
249 dataset.logviz_emit(rec)
250}
251
252pub fn run_cli<P>() -> CuResult<()>
253where
254 P: CopperListTuple + LogvizDataSet,
255{
256 let args = LogVizCli::parse();
257 let (rec, _guard) = args
258 .rerun
259 .init("cu29-logviz")
260 .map_err(|e| CuError::from(format!("Failed to init rerun: {e}")))?;
261 let dl = build_read_logger(&args.unifiedlog_base)?;
262 let mut reader = UnifiedLoggerIOReader::new(dl, UnifiedLogType::CopperList);
263 for culist in copperlists_reader::<P>(&mut reader) {
264 logviz_emit_dataset(&culist.msgs, &rec)?;
265 }
266 Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use cu_sensor_payloads::{PointCloud, PointCloudSoa};
273 use cu29::clock::CuTime;
274 use rerun::Position3D;
275 use serde::Serialize;
276
277 #[test]
278 fn tov_time_to_secs() {
279 let tov = Tov::Time(CuTime::from(1_500_000_000u64));
280 assert_eq!(tov_to_secs(&tov), Some(1.5));
281 }
282
283 #[test]
284 fn tov_none_returns_none() {
285 assert_eq!(tov_to_secs(&Tov::None), None);
286 }
287
288 #[derive(Serialize)]
289 struct Inner {
290 c: f64,
291 }
292
293 #[derive(Serialize)]
294 struct Outer {
295 a: i32,
296 b: Inner,
297 arr: [u8; 2],
298 }
299
300 #[test]
301 fn flatten_json_paths() {
302 let value = serde_json::to_value(Outer {
303 a: 1,
304 b: Inner { c: 2.5 },
305 arr: [3, 4],
306 })
307 .unwrap();
308 let flat = flatten_json("root", &value);
309 assert_eq!(flat["root/a"], serde_json::json!(1));
310 assert_eq!(flat["root/b/c"], serde_json::json!(2.5));
311 assert_eq!(flat["root/arr/0"], serde_json::json!(3));
312 assert_eq!(flat["root/arr/1"], serde_json::json!(4));
313 }
314
315 #[test]
316 fn pointcloud_soa_type_name_is_detected() {
317 assert!(is_pointcloud_soa_type_name(core::any::type_name::<
318 PointCloudSoa<64>,
319 >()));
320 assert!(!is_pointcloud_soa_type_name(core::any::type_name::<
321 PointCloud,
322 >()));
323 }
324
325 #[test]
326 fn extract_pointcloud_soa_positions_uses_len_and_xyz_arrays() {
327 let payload = serde_json::json!({
328 "len": 2,
329 "x": [1.0, 4.0, 9.0],
330 "y": [2.0, 5.0, 9.0],
331 "z": [3.0, 6.0, 9.0],
332 });
333 let points = extract_pointcloud_soa_positions(&payload)
334 .expect("serialize")
335 .expect("pointcloud payload");
336 assert_eq!(points.len(), 2);
337 assert_eq!(points[0], Position3D::new(1.0, 2.0, 3.0));
338 assert_eq!(points[1], Position3D::new(4.0, 5.0, 6.0));
339 }
340}