1#[cfg(any(feature = "scene", feature = "mapping", feature = "camera"))]
2use spatialrust_math::Vec3;
3#[cfg(any(feature = "scene", feature = "mapping", feature = "camera"))]
4use spatialrust_viz::LinearRgba;
5use spatialrust_viz::{
6 LayerId, LineListView, PointCloudView, PositionColumns3, Rgb8Columns, ScalarColumn,
7 TriangleMeshView, VisualLayer, VisualPrimitive, VisualStyle,
8};
9#[cfg(any(feature = "scene", feature = "semantic"))]
10use spatialrust_viz::{PointColor, PointStyle};
11
12#[cfg(any(feature = "scene", feature = "mapping", feature = "camera", feature = "semantic"))]
13use crate::ViewerError;
14use crate::ViewerResult;
15#[cfg(feature = "semantic")]
16use crate::{SemanticOverlayState, SEMANTIC_CONFIDENCE_SCALE};
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct AdapterReceipt {
21 pub source_identity: usize,
23 pub source_count: usize,
25 pub output_count: usize,
27 pub generated_bytes: usize,
29}
30
31#[derive(Clone, Debug, PartialEq)]
33pub enum AdaptedGeometry {
34 Points {
36 x: Vec<f32>,
38 y: Vec<f32>,
40 z: Vec<f32>,
42 rgb: Option<[Vec<u8>; 3]>,
44 scalar: Option<(String, Vec<f32>)>,
46 },
47 Lines(Vec<f32>),
49 Triangles {
51 positions: Vec<f32>,
53 indices: Vec<u32>,
55 },
56}
57
58#[derive(Clone, Debug, PartialEq)]
60pub struct AdaptedVisual {
61 pub id: LayerId,
63 pub label: String,
65 pub geometry: AdaptedGeometry,
67 pub style: VisualStyle,
69 pub receipt: AdapterReceipt,
71}
72
73impl AdaptedVisual {
74 pub fn as_layer(&self) -> ViewerResult<VisualLayer<'_>> {
76 let primitive = match &self.geometry {
77 AdaptedGeometry::Points { x, y, z, rgb, scalar } => {
78 let positions = PositionColumns3::try_new(x, y, z)?;
79 let mut points = PointCloudView::positions_only(positions);
80 if let Some([red, green, blue]) = rgb {
81 points = points.with_rgb(Rgb8Columns::try_new(
82 red,
83 green,
84 blue,
85 positions.len(),
86 )?)?;
87 }
88 if let Some((name, values)) = scalar {
89 points = points.with_scalar(ScalarColumn::try_new(
90 name,
91 values,
92 positions.len(),
93 )?)?;
94 }
95 VisualPrimitive::Points(points)
96 }
97 AdaptedGeometry::Lines(lines) => VisualPrimitive::Lines(LineListView::try_new(lines)?),
98 AdaptedGeometry::Triangles { positions, indices } => {
99 VisualPrimitive::Triangles(TriangleMeshView::try_new(positions, indices)?)
100 }
101 };
102 Ok(VisualLayer::try_new(
103 self.id.clone(),
104 self.label.clone(),
105 primitive,
106 normalized_style(&self.geometry, &self.style),
107 )?)
108 }
109}
110
111fn normalized_style(_geometry: &AdaptedGeometry, style: &VisualStyle) -> VisualStyle {
112 style.clone()
113}
114
115#[cfg(feature = "scene")]
117pub fn mesh_visual<'a>(
118 id: LayerId,
119 label: impl Into<String>,
120 mesh: &'a spatialrust_scene::TriangleMesh,
121 color: LinearRgba,
122) -> ViewerResult<(VisualLayer<'a>, AdapterReceipt)> {
123 let view = TriangleMeshView::try_new(&mesh.positions, &mesh.indices)?;
124 let layer = VisualLayer::try_new(
125 id,
126 label,
127 VisualPrimitive::Triangles(view),
128 VisualStyle::Uniform(color),
129 )?;
130 Ok((
131 layer,
132 AdapterReceipt {
133 source_identity: mesh.positions.as_ptr() as usize,
134 source_count: mesh.triangle_count(),
135 output_count: view.triangle_count(),
136 generated_bytes: 0,
137 },
138 ))
139}
140
141#[cfg(feature = "scene")]
143pub fn surfel_visual(
144 namespace: &str,
145 cloud: &spatialrust_scene::SurfelCloud,
146) -> ViewerResult<AdaptedVisual> {
147 let surfels = cloud.as_slice();
148 let mut x = Vec::with_capacity(surfels.len());
149 let mut y = Vec::with_capacity(surfels.len());
150 let mut z = Vec::with_capacity(surfels.len());
151 let mut radius = Vec::with_capacity(surfels.len());
152 for surfel in surfels {
153 x.push(surfel.position.x);
154 y.push(surfel.position.y);
155 z.push(surfel.position.z);
156 radius.push(surfel.radius);
157 }
158 point_scalar_visual(
159 namespace,
160 "surfels",
161 "Surfels",
162 source_identity(surfels),
163 surfels.len(),
164 PointScalarColumns { x, y, z, name: "radius", values: radius },
165 )
166}
167
168#[cfg(feature = "scene-gaussian")]
170pub fn gaussian_visual(
171 namespace: &str,
172 scene: &spatialrust_scene::GaussianScene,
173) -> ViewerResult<AdaptedVisual> {
174 let primitives = scene.primitives();
175 let mut x = Vec::with_capacity(primitives.len());
176 let mut y = Vec::with_capacity(primitives.len());
177 let mut z = Vec::with_capacity(primitives.len());
178 let mut rgb = [
179 Vec::with_capacity(primitives.len()),
180 Vec::with_capacity(primitives.len()),
181 Vec::with_capacity(primitives.len()),
182 ];
183 let mut opacity = Vec::with_capacity(primitives.len());
184 for primitive in primitives {
185 x.push(primitive.mean.x);
186 y.push(primitive.mean.y);
187 z.push(primitive.mean.z);
188 for (column, channel) in rgb.iter_mut().zip(primitive.color) {
189 column.push((channel * 255.0).round() as u8);
190 }
191 opacity.push(primitive.opacity);
192 }
193 let generated_bytes =
194 bytes_f32(x.len() * 4)?.saturating_add(rgb.iter().map(Vec::len).sum::<usize>());
195 Ok(AdaptedVisual {
196 id: adapter_id(namespace, "gaussians")?,
197 label: "Gaussians".into(),
198 geometry: AdaptedGeometry::Points {
199 x,
200 y,
201 z,
202 rgb: Some(rgb),
203 scalar: Some(("opacity".into(), opacity)),
204 },
205 style: VisualStyle::Points(PointStyle::try_new(
206 4.0,
207 PointColor::Scalar { min: 0.0, max: 1.0, map: spatialrust_viz::ColorMap::Viridis },
208 )?),
209 receipt: AdapterReceipt {
210 source_identity: source_identity(primitives),
211 source_count: primitives.len(),
212 output_count: primitives.len(),
213 generated_bytes,
214 },
215 })
216}
217
218#[cfg(feature = "mapping")]
220pub fn trajectory_visual(
221 namespace: &str,
222 trajectory: &spatialrust_mapping::Trajectory,
223) -> ViewerResult<AdaptedVisual> {
224 let samples = trajectory.samples();
225 let mut lines = Vec::with_capacity(samples.len().saturating_sub(1) * 6);
226 for window in samples.windows(2) {
227 push_segment(
228 &mut lines,
229 window[0].pose.isometry.translation(),
230 window[1].pose.isometry.translation(),
231 );
232 }
233 lines_visual(
234 namespace,
235 "trajectory",
236 "Trajectory",
237 source_identity(samples),
238 samples.len(),
239 lines,
240 LinearRgba { red: 0.2, green: 0.8, blue: 1.0, alpha: 1.0 },
241 )
242}
243
244#[cfg(feature = "mapping")]
246pub fn pose_graph_visual(
247 namespace: &str,
248 graph: &spatialrust_mapping::PoseGraph,
249) -> ViewerResult<AdaptedVisual> {
250 let mut lines = Vec::with_capacity(graph.edges().len() * 6);
251 for edge in graph.edges() {
252 let from = graph
253 .nodes()
254 .get(&edge.from.0)
255 .ok_or_else(|| ViewerError::InvalidState("pose graph source node missing".into()))?;
256 let to = graph
257 .nodes()
258 .get(&edge.to.0)
259 .ok_or_else(|| ViewerError::InvalidState("pose graph target node missing".into()))?;
260 push_segment(&mut lines, from.pose.isometry.translation(), to.pose.isometry.translation());
261 }
262 lines_visual(
263 namespace,
264 "pose-graph",
265 "Pose graph",
266 source_identity(graph.edges()),
267 graph.edges().len(),
268 lines,
269 LinearRgba { red: 1.0, green: 0.4, blue: 0.1, alpha: 1.0 },
270 )
271}
272
273#[cfg(feature = "camera")]
275pub fn camera_frustum_visual(
276 namespace: &str,
277 camera: &spatialrust_camera::PinholeCamera,
278 depth: f32,
279) -> ViewerResult<AdaptedVisual> {
280 if !depth.is_finite() || depth <= 0.0 {
281 return Err(ViewerError::InvalidState("frustum depth must be finite and positive".into()));
282 }
283 let intrinsics = camera.intrinsics;
284 let corners = [
285 (0.0, 0.0),
286 (intrinsics.width as f64, 0.0),
287 (intrinsics.width as f64, intrinsics.height as f64),
288 (0.0, intrinsics.height as f64),
289 ];
290 let mut points = Vec::with_capacity(4);
291 for (x, y) in corners {
292 let point = camera
293 .unproject(spatialrust_math::Vec2 { x, y }, depth as f64)
294 .map_err(|error| ViewerError::InvalidState(error.to_string()))?;
295 points.push(Vec3::new(point.x as f32, point.y as f32, point.z as f32));
296 }
297 let origin = Vec3::new(0.0, 0.0, 0.0);
298 let mut lines = Vec::with_capacity(8 * 6);
299 for &point in &points {
300 push_segment(&mut lines, origin, point);
301 }
302 for index in 0..4 {
303 push_segment(&mut lines, points[index], points[(index + 1) % 4]);
304 }
305 lines_visual(
306 namespace,
307 "camera-frustum",
308 "Camera frustum",
309 camera as *const _ as usize,
310 1,
311 lines,
312 LinearRgba { red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0 },
313 )
314}
315
316#[cfg(feature = "semantic")]
318pub fn semantic_visual(
319 namespace: &str,
320 entities: &[spatialrust_semantic::SemanticEntity],
321) -> ViewerResult<AdaptedVisual> {
322 let visible: Vec<_> =
323 entities.iter().filter_map(|entity| entity.centroid.map(|p| (entity, p))).collect();
324 let mut x = Vec::with_capacity(visible.len());
325 let mut y = Vec::with_capacity(visible.len());
326 let mut z = Vec::with_capacity(visible.len());
327 let mut confidence = Vec::with_capacity(visible.len());
328 for (entity, point) in &visible {
329 x.push(point.x);
330 y.push(point.y);
331 z.push(point.z);
332 confidence.push(entity.labels.iter().map(|label| label.confidence).fold(0.0_f32, f32::max));
333 }
334 point_scalar_visual(
335 namespace,
336 "semantic",
337 "Semantic entities",
338 source_identity(entities),
339 entities.len(),
340 PointScalarColumns { x, y, z, name: "confidence", values: confidence },
341 )
342}
343
344#[cfg(feature = "semantic")]
349pub fn spatial_record_entity_visual(
350 namespace: &str,
351 entities: &[spatialrust_semantic::SpatialRecordEntity],
352) -> ViewerResult<AdaptedVisual> {
353 let visible: Vec<_> = entities
354 .iter()
355 .filter_map(|record_entity| {
356 record_entity.entity().centroid.map(|point| (record_entity, point))
357 })
358 .collect();
359 let mut x = Vec::with_capacity(visible.len());
360 let mut y = Vec::with_capacity(visible.len());
361 let mut z = Vec::with_capacity(visible.len());
362 let mut confidence = Vec::with_capacity(visible.len());
363 for (record_entity, point) in &visible {
364 x.push(point.x);
365 y.push(point.y);
366 z.push(point.z);
367 confidence.push(
368 record_entity
369 .entity()
370 .labels
371 .iter()
372 .map(|label| label.confidence)
373 .fold(0.0_f32, f32::max),
374 );
375 }
376 point_scalar_visual(
377 namespace,
378 "record-semantic",
379 "Record semantic entities",
380 source_identity(entities),
381 entities.len(),
382 PointScalarColumns { x, y, z, name: "confidence", values: confidence },
383 )
384}
385
386#[cfg(feature = "semantic")]
392pub fn semantic_overlay_visual(
393 namespace: &str,
394 state: &SemanticOverlayState,
395) -> ViewerResult<AdaptedVisual> {
396 state.validate()?;
397 if !state.overlay_ready {
398 return Err(ViewerError::InvalidState("cannot render a blocked semantic overlay".into()));
399 }
400 let mut x = Vec::with_capacity(state.entities.len());
401 let mut y = Vec::with_capacity(state.entities.len());
402 let mut z = Vec::with_capacity(state.entities.len());
403 let mut red = Vec::with_capacity(state.entities.len());
404 let mut green = Vec::with_capacity(state.entities.len());
405 let mut blue = Vec::with_capacity(state.entities.len());
406 let mut confidence = Vec::with_capacity(state.entities.len());
407 for entity in &state.entities {
408 let class =
409 state.classes.iter().find(|class| class.class_id == entity.class_id).ok_or_else(
410 || ViewerError::InvalidState("semantic overlay class is missing".into()),
411 )?;
412 let coordinates = entity.centroid_um.map(|value| value as f64 / 1_000_000.0);
413 if coordinates.iter().any(|value| !value.is_finite()) {
414 return Err(ViewerError::InvalidState(
415 "semantic overlay coordinate is not finite".into(),
416 ));
417 }
418 x.push(coordinates[0] as f32);
419 y.push(coordinates[1] as f32);
420 z.push(coordinates[2] as f32);
421 red.push(class.color_rgb[0]);
422 green.push(class.color_rgb[1]);
423 blue.push(class.color_rgb[2]);
424 confidence.push(entity.confidence_million as f32 / SEMANTIC_CONFIDENCE_SCALE as f32);
425 }
426 let generated_bytes = bytes_f32(
427 x.len()
428 .checked_add(y.len())
429 .and_then(|value| value.checked_add(z.len()))
430 .and_then(|value| value.checked_add(confidence.len()))
431 .ok_or_else(|| {
432 ViewerError::InvalidState("semantic overlay point count overflow".into())
433 })?,
434 )?
435 .checked_add(red.len().checked_mul(3).ok_or_else(|| {
436 ViewerError::InvalidState("semantic overlay RGB byte count overflow".into())
437 })?)
438 .ok_or_else(|| ViewerError::InvalidState("semantic overlay byte count overflow".into()))?;
439 Ok(AdaptedVisual {
440 id: adapter_id(namespace, "semantic-overlay")?,
441 label: "AI semantic overlay".into(),
442 geometry: AdaptedGeometry::Points {
443 x,
444 y,
445 z,
446 rgb: Some([red, green, blue]),
447 scalar: Some(("confidence".into(), confidence)),
448 },
449 style: VisualStyle::Points(PointStyle::try_new(5.0, PointColor::Rgb)?),
450 receipt: AdapterReceipt {
451 source_identity: state.entities.as_ptr() as usize,
452 source_count: usize::try_from(state.summary.sampled_point_count).map_err(|_| {
453 ViewerError::InvalidState("semantic overlay source count overflows usize".into())
454 })?,
455 output_count: state.entities.len(),
456 generated_bytes,
457 },
458 })
459}
460
461#[cfg(any(feature = "scene", feature = "semantic"))]
462struct PointScalarColumns<'a> {
463 x: Vec<f32>,
464 y: Vec<f32>,
465 z: Vec<f32>,
466 name: &'a str,
467 values: Vec<f32>,
468}
469
470#[cfg(any(feature = "scene", feature = "semantic"))]
471fn point_scalar_visual(
472 namespace: &str,
473 slug: &str,
474 label: &str,
475 identity: usize,
476 source_count: usize,
477 columns: PointScalarColumns<'_>,
478) -> ViewerResult<AdaptedVisual> {
479 let PointScalarColumns { x, y, z, name: scalar_name, values: scalar } = columns;
480 let output_count = x.len();
481 let generated_bytes = bytes_f32(
482 x.len()
483 .checked_add(y.len())
484 .and_then(|value| value.checked_add(z.len()))
485 .and_then(|value| value.checked_add(scalar.len()))
486 .ok_or_else(|| ViewerError::InvalidState("adapter byte count overflow".into()))?,
487 )?;
488 let max = scalar.iter().copied().fold(0.0_f32, f32::max).max(1.0);
489 Ok(AdaptedVisual {
490 id: adapter_id(namespace, slug)?,
491 label: label.into(),
492 geometry: AdaptedGeometry::Points {
493 x,
494 y,
495 z,
496 rgb: None,
497 scalar: Some((scalar_name.into(), scalar)),
498 },
499 style: VisualStyle::Points(PointStyle::try_new(
500 3.0,
501 PointColor::Scalar { min: 0.0, max, map: spatialrust_viz::ColorMap::Viridis },
502 )?),
503 receipt: AdapterReceipt {
504 source_identity: identity,
505 source_count,
506 output_count,
507 generated_bytes,
508 },
509 })
510}
511
512#[cfg(any(feature = "mapping", feature = "camera"))]
513fn lines_visual(
514 namespace: &str,
515 slug: &str,
516 label: &str,
517 identity: usize,
518 source_count: usize,
519 lines: Vec<f32>,
520 color: LinearRgba,
521) -> ViewerResult<AdaptedVisual> {
522 let output_count = lines.len() / 6;
523 let generated_bytes = bytes_f32(lines.len())?;
524 Ok(AdaptedVisual {
525 id: adapter_id(namespace, slug)?,
526 label: label.into(),
527 geometry: AdaptedGeometry::Lines(lines),
528 style: VisualStyle::Uniform(color),
529 receipt: AdapterReceipt {
530 source_identity: identity,
531 source_count,
532 output_count,
533 generated_bytes,
534 },
535 })
536}
537
538#[cfg(any(feature = "scene", feature = "mapping", feature = "camera", feature = "semantic"))]
539fn adapter_id(namespace: &str, slug: &str) -> ViewerResult<LayerId> {
540 if namespace.trim().is_empty() {
541 return Err(ViewerError::InvalidState("adapter namespace must not be empty".into()));
542 }
543 Ok(LayerId::try_new(format!("scene/{namespace}/{slug}"))?)
544}
545
546#[cfg(any(feature = "scene", feature = "mapping", feature = "semantic"))]
547fn source_identity<T>(slice: &[T]) -> usize {
548 if slice.is_empty() {
549 0
550 } else {
551 slice.as_ptr() as usize
552 }
553}
554
555#[cfg(any(feature = "scene", feature = "mapping", feature = "camera", feature = "semantic"))]
556fn bytes_f32(count: usize) -> ViewerResult<usize> {
557 count
558 .checked_mul(core::mem::size_of::<f32>())
559 .ok_or_else(|| ViewerError::InvalidState("adapter byte count overflow".into()))
560}
561
562#[cfg(any(feature = "mapping", feature = "camera"))]
563fn push_segment(lines: &mut Vec<f32>, from: Vec3<f32>, to: Vec3<f32>) {
564 lines.extend_from_slice(&[from.x, from.y, from.z, to.x, to.y, to.z]);
565}
566
567#[cfg(test)]
568mod tests {
569 #[cfg(any(
570 feature = "scene",
571 feature = "mapping",
572 feature = "camera",
573 feature = "semantic"
574 ))]
575 use spatialrust_math::Vec3;
576 #[cfg(any(feature = "scene", feature = "semantic"))]
577 use spatialrust_viz::VisualPrimitive;
578 #[cfg(feature = "scene")]
579 use spatialrust_viz::{LayerId, LinearRgba};
580
581 #[cfg(feature = "scene")]
582 #[test]
583 fn mesh_and_surfel_adapters_preserve_source_identity_and_counts() {
584 let mesh = spatialrust_scene::TriangleMesh {
585 positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
586 indices: vec![0, 1, 2],
587 };
588 let (layer, receipt) =
589 super::mesh_visual(LayerId::try_new("mesh").unwrap(), "Mesh", &mesh, LinearRgba::WHITE)
590 .unwrap();
591 let VisualPrimitive::Triangles(view) = layer.primitive else {
592 panic!("mesh adapter must produce triangles");
593 };
594 assert_eq!(receipt.source_identity, mesh.positions.as_ptr() as usize);
595 assert_eq!(receipt.source_count, 1);
596 assert_eq!(receipt.output_count, 1);
597 assert_eq!(receipt.generated_bytes, 0);
598 assert!(core::ptr::eq(view.positions_xyz.as_ptr(), mesh.positions.as_ptr()));
599
600 let mut surfels = spatialrust_scene::SurfelCloud::new();
601 surfels
602 .push(spatialrust_scene::Surfel {
603 position: Vec3::new(1.0, 2.0, 3.0),
604 normal: Vec3::new(0.0, 0.0, 1.0),
605 radius: 0.25,
606 })
607 .unwrap();
608 let adapted = super::surfel_visual("map", &surfels).unwrap();
609 assert_eq!(adapted.receipt.source_count, 1);
610 assert_eq!(adapted.receipt.output_count, 1);
611 let VisualPrimitive::Points(points) = adapted.as_layer().unwrap().primitive else {
612 panic!("surfel adapter must produce points");
613 };
614 assert_eq!(points.scalar.unwrap().values, &[0.25]);
615 }
616
617 #[cfg(feature = "scene-gaussian")]
618 #[test]
619 fn gaussian_adapter_preserves_rgb_opacity_and_count() {
620 let mut scene = spatialrust_scene::GaussianScene::new();
621 scene
622 .push(spatialrust_scene::GaussianPrimitive {
623 mean: Vec3::new(1.0, 2.0, 3.0),
624 scale: Vec3::new(1.0, 1.0, 1.0),
625 rotation: spatialrust_math::Quat::<f32>::identity(),
626 opacity: 0.5,
627 color: [1.0, 0.5, 0.0],
628 })
629 .unwrap();
630 let adapted = super::gaussian_visual("reconstruction", &scene).unwrap();
631 assert_eq!(adapted.receipt.source_count, scene.len());
632 let VisualPrimitive::Points(points) = adapted.as_layer().unwrap().primitive else {
633 panic!("Gaussian adapter must produce points");
634 };
635 assert_eq!(points.rgb.unwrap().red, &[255]);
636 assert_eq!(points.rgb.unwrap().green, &[128]);
637 assert_eq!(points.scalar.unwrap().values, &[0.5]);
638 }
639
640 #[cfg(feature = "mapping")]
641 fn stamped(x: f32, nanos: u64) -> spatialrust_mapping::StampedPose {
642 spatialrust_mapping::StampedPose::new(
643 spatialrust_sync::StampedTime::exact(
644 "host",
645 spatialrust_sync::ClockDomain::HostSteady,
646 spatialrust_core::Timestamp::from_nanos(nanos),
647 ),
648 spatialrust_math::Pose3::new(spatialrust_math::Isometry3::new(
649 spatialrust_math::Quat::<f32>::identity(),
650 Vec3::new(x, 0.0, 0.0),
651 )),
652 )
653 }
654
655 #[cfg(feature = "mapping")]
656 #[test]
657 fn trajectory_and_pose_graph_have_exact_segment_parity() {
658 let mut trajectory = spatialrust_mapping::Trajectory::new();
659 trajectory.push(stamped(0.0, 0)).unwrap();
660 trajectory.push(stamped(1.0, 1)).unwrap();
661 trajectory.push(stamped(2.0, 2)).unwrap();
662 let adapted = super::trajectory_visual("slam", &trajectory).unwrap();
663 assert_eq!(adapted.receipt.source_count, 3);
664 assert_eq!(adapted.receipt.output_count, 2);
665
666 let mut graph = spatialrust_mapping::PoseGraph::new();
667 graph.upsert_node("a", stamped(0.0, 0));
668 graph.upsert_node("b", stamped(1.0, 1));
669 graph
670 .add_edge(spatialrust_mapping::PoseGraphEdge {
671 from: spatialrust_mapping::PoseNodeId::new("a"),
672 to: spatialrust_mapping::PoseNodeId::new("b"),
673 to_t_from: spatialrust_math::Isometry3::identity(),
674 loop_closure: false,
675 })
676 .unwrap();
677 let graph_visual = super::pose_graph_visual("slam", &graph).unwrap();
678 assert_eq!(graph_visual.receipt.source_count, 1);
679 assert_eq!(graph_visual.receipt.output_count, 1);
680 }
681
682 #[cfg(feature = "camera")]
683 #[test]
684 fn frustum_has_four_rays_and_four_image_edges() {
685 let camera = spatialrust_camera::PinholeCamera::new(
686 spatialrust_camera::CameraIntrinsics::try_new(100.0, 100.0, 50.0, 40.0, 100, 80)
687 .unwrap(),
688 );
689 let adapted = super::camera_frustum_visual("rgb", &camera, 2.0).unwrap();
690 assert_eq!(adapted.receipt.source_count, 1);
691 assert_eq!(adapted.receipt.output_count, 8);
692 assert!(super::camera_frustum_visual("rgb", &camera, 0.0).is_err());
693 }
694
695 #[cfg(feature = "semantic")]
696 #[test]
697 fn semantic_adapter_filters_missing_centroids_and_keeps_confidence() {
698 let entities = [
699 spatialrust_semantic::SemanticEntity {
700 id: spatialrust_semantic::EntityId::new("chair"),
701 centroid: Some(Vec3::new(1.0, 2.0, 3.0)),
702 labels: vec![spatialrust_semantic::OpenVocabLabel {
703 text: "chair".into(),
704 confidence: 0.8,
705 }],
706 embedding: None,
707 },
708 spatialrust_semantic::SemanticEntity {
709 id: spatialrust_semantic::EntityId::new("unknown"),
710 centroid: None,
711 labels: Vec::new(),
712 embedding: None,
713 },
714 ];
715 let adapted = super::semantic_visual("room", &entities).unwrap();
716 assert_eq!(adapted.receipt.source_count, 2);
717 assert_eq!(adapted.receipt.output_count, 1);
718 let VisualPrimitive::Points(points) = adapted.as_layer().unwrap().primitive else {
719 panic!("semantic adapter must produce points");
720 };
721 assert_eq!(points.scalar.unwrap().values, &[0.8]);
722 }
723
724 #[cfg(feature = "semantic")]
725 #[test]
726 fn record_semantic_adapter_keeps_wrapper_identity_and_confidence() {
727 let entities = [spatialrust_semantic::SpatialRecordEntity {
728 entity: spatialrust_semantic::SemanticEntity {
729 id: spatialrust_semantic::EntityId::new("record:bag:lidar:3"),
730 centroid: Some(Vec3::new(4.0, 5.0, 6.0)),
731 labels: vec![spatialrust_semantic::OpenVocabLabel {
732 text: "vehicle".into(),
733 confidence: 0.9,
734 }],
735 embedding: None,
736 },
737 provenance: spatialrust_records::RecordProvenance::unknown(),
738 frame_id: spatialrust_core::FrameId::new("map"),
739 timestamp: spatialrust_core::Timestamp::from_nanos(11),
740 }];
741 let adapted = super::spatial_record_entity_visual("room", &entities).unwrap();
742 assert_eq!(adapted.receipt.source_identity, entities.as_ptr() as usize);
743 assert_eq!(adapted.receipt.source_count, 1);
744 assert_eq!(adapted.receipt.output_count, 1);
745 let VisualPrimitive::Points(points) = adapted.as_layer().unwrap().primitive else {
746 panic!("record semantic adapter must produce points");
747 };
748 assert_eq!(points.scalar.unwrap().values, &[0.9]);
749 }
750
751 #[cfg(feature = "semantic")]
752 #[test]
753 fn semantic_overlay_adapter_preserves_class_rgb_and_receipt_counts() {
754 let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
755 let source =
756 super::super::StudioSource::try_new("canonical", "/media/input.db3", sha, sha, true)
757 .unwrap();
758 let model = super::super::SemanticOverlayModel::try_new(
759 "mock-semantic-classes",
760 "mock",
761 "deterministic test profile",
762 true,
763 4,
764 3,
765 16,
766 8,
767 0,
768 0,
769 )
770 .unwrap();
771 let class = super::super::SemanticOverlayClass::try_new(
772 2,
773 "object",
774 [255, 143, 83],
775 1,
776 950_000,
777 950_000,
778 )
779 .unwrap();
780 let entity = super::super::SemanticOverlayEntity::try_new(
781 "semantic:0",
782 0,
783 2,
784 "object",
785 950_000,
786 [1_000_000, 2_000_000, 3_000_000],
787 )
788 .unwrap();
789 let summary = super::super::SemanticOverlaySummary::try_new(
790 1, 1, 1, 1, 1, 950_000, 950_000, 1_000_000, true, true, false,
791 )
792 .unwrap();
793 let state = super::super::SemanticOverlayState::try_new(
794 "AI Semantic Overlay",
795 source,
796 "lidar_front",
797 "lidar_front",
798 "PointCloud2 header stamp",
799 model,
800 vec![class],
801 vec![entity],
802 Vec::new(),
803 summary,
804 vec!["clock calibration not applied".into()],
805 )
806 .unwrap();
807 let adapted = super::semantic_overlay_visual("overlay", &state).unwrap();
808 assert_eq!(adapted.receipt.source_count, 1);
809 assert_eq!(adapted.receipt.output_count, 1);
810 assert_eq!(adapted.receipt.generated_bytes, 19);
811 let VisualPrimitive::Points(points) = adapted.as_layer().unwrap().primitive else {
812 panic!("semantic overlay adapter must produce points");
813 };
814 assert_eq!(points.rgb.unwrap().red, &[255]);
815 assert_eq!(points.scalar.unwrap().values, &[0.95]);
816 }
817}