Skip to main content

spatialrust_viewer/
adapters.rs

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/// Evidence describing one explicit scene-to-visual adaptation.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct AdapterReceipt {
21    /// Address identity of the first source element, or zero for empty input.
22    pub source_identity: usize,
23    /// Number of source primitives.
24    pub source_count: usize,
25    /// Number of output points, segments, or triangles.
26    pub output_count: usize,
27    /// Exact bytes allocated for generated visual geometry.
28    pub generated_bytes: usize,
29}
30
31/// Owned geometry created by an explicit scene adapter.
32#[derive(Clone, Debug, PartialEq)]
33pub enum AdaptedGeometry {
34    /// SoA point positions with optional RGB and scalar columns.
35    Points {
36        /// X coordinates.
37        x: Vec<f32>,
38        /// Y coordinates.
39        y: Vec<f32>,
40        /// Z coordinates.
41        z: Vec<f32>,
42        /// Optional SoA red, green, and blue columns.
43        rgb: Option<[Vec<u8>; 3]>,
44        /// Optional scalar attribute name and values.
45        scalar: Option<(String, Vec<f32>)>,
46    },
47    /// Interleaved independent line segments.
48    Lines(Vec<f32>),
49    /// Interleaved positions and triangle indices.
50    Triangles {
51        /// XYZ vertices.
52        positions: Vec<f32>,
53        /// Triangle indices.
54        indices: Vec<u32>,
55    },
56}
57
58/// One owned visual layer and its exact adapter receipt.
59#[derive(Clone, Debug, PartialEq)]
60pub struct AdaptedVisual {
61    /// Stable layer identity.
62    pub id: LayerId,
63    /// User-facing label.
64    pub label: String,
65    /// Generated geometry.
66    pub geometry: AdaptedGeometry,
67    /// Visual style.
68    pub style: VisualStyle,
69    /// Adaptation evidence.
70    pub receipt: AdapterReceipt,
71}
72
73impl AdaptedVisual {
74    /// Borrows generated storage as a visual layer without another copy.
75    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/// Borrows a reconstructed mesh directly, preserving source pointer identity.
116#[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/// Converts surfel centers/radii to point geometry.
142#[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/// Converts Gaussian means, colors, and opacity to point geometry.
169#[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/// Converts a stamped trajectory to line segments.
219#[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/// Converts pose-graph edges to deterministic line segments.
245#[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/// Creates camera-frustum wire geometry at the camera origin.
274#[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/// Converts semantic entity centroids and best-label confidence to points.
317#[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/// Converts lineage-preserving record entities to semantic centroid points.
345///
346/// The wrapper slice remains the source of the adapter receipt; only the
347/// renderer-facing XYZ and confidence columns are materialized.
348#[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/// Converts a validated AI semantic overlay into RGB-colored confidence points.
387///
388/// The state remains the receipt source. This adapter only materializes the
389/// renderer-facing XYZ, RGB, and confidence columns, and refuses a blocked
390/// source/frame overlay instead of rendering unbound predictions.
391#[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}