Skip to main content

spatialrust_viewer/
overlay.rs

1use spatialrust_math::Vec3;
2use spatialrust_viz::{
3    LayerId, LineListView, LinearRgba, PointCloudView, PointColor, PointStyle, PositionColumns3,
4    ScalarColumn, VisualLayer, VisualPrimitive, VisualStyle,
5};
6
7use crate::{ViewerError, ViewerResult};
8
9/// Stable category for an algorithm-debug overlay.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum OverlayKind {
13    /// Per-point normal vectors.
14    Normals,
15    /// Voxel wire boxes.
16    Voxels,
17    /// Fitted plane patch.
18    Plane,
19    /// Cluster identifiers mapped as a scalar point attribute.
20    Clusters,
21    /// Registration correspondence segments.
22    Correspondences,
23    /// Axis-aligned bounds wire box.
24    Bounds,
25    /// Search-radius circle.
26    SearchRadius,
27}
28
29impl OverlayKind {
30    const fn slug(self) -> &'static str {
31        match self {
32            Self::Normals => "normals",
33            Self::Voxels => "voxels",
34            Self::Plane => "plane",
35            Self::Clusters => "clusters",
36            Self::Correspondences => "correspondences",
37            Self::Bounds => "bounds",
38            Self::SearchRadius => "search-radius",
39        }
40    }
41}
42
43/// Owned geometry retained by a debug overlay.
44#[derive(Clone, Debug, PartialEq)]
45pub enum OverlayGeometry {
46    /// Interleaved independent line segments.
47    Lines(Vec<f32>),
48    /// SoA points and a scalar debug attribute.
49    ScalarPoints {
50        /// X positions.
51        x: Vec<f32>,
52        /// Y positions.
53        y: Vec<f32>,
54        /// Z positions.
55        z: Vec<f32>,
56        /// Scalar values.
57        values: Vec<f32>,
58        /// Stable attribute name.
59        attribute: String,
60    },
61}
62
63/// Owned algorithm-debug overlay with deterministic layer identity.
64#[derive(Clone, Debug, PartialEq)]
65pub struct DebugOverlay {
66    /// Overlay category.
67    pub kind: OverlayKind,
68    /// Stable visual-layer identity.
69    pub id: LayerId,
70    /// User-facing label.
71    pub label: String,
72    /// Owned geometry.
73    pub geometry: OverlayGeometry,
74    /// Rendering style.
75    pub style: VisualStyle,
76}
77
78impl DebugOverlay {
79    /// Creates per-point normal segments.
80    pub fn normals(
81        namespace: &str,
82        positions: &[Vec3<f32>],
83        normals: &[Vec3<f32>],
84        scale: f32,
85    ) -> ViewerResult<Self> {
86        if positions.len() != normals.len() || !scale.is_finite() || scale <= 0.0 {
87            return Err(ViewerError::InvalidOverlay(
88                "normal positions/counts must match and scale must be positive".into(),
89            ));
90        }
91        let mut lines = Vec::with_capacity(positions.len() * 6);
92        for (&position, &normal) in positions.iter().zip(normals) {
93            finite_vec(position)?;
94            finite_vec(normal)?;
95            push_segment(&mut lines, position, add(position, mul(normal.normalize(), scale)));
96        }
97        Self::lines(namespace, OverlayKind::Normals, "Normals", lines, color(0.2, 1.0, 0.2))
98    }
99
100    /// Creates wire boxes around voxel centers with one shared positive half extent.
101    pub fn voxels(namespace: &str, centers: &[Vec3<f32>], half_extent: f32) -> ViewerResult<Self> {
102        if !half_extent.is_finite() || half_extent <= 0.0 {
103            return Err(ViewerError::InvalidOverlay(
104                "voxel half extent must be finite and positive".into(),
105            ));
106        }
107        let mut lines = Vec::with_capacity(centers.len() * 12 * 6);
108        for &center in centers {
109            finite_vec(center)?;
110            append_box(
111                &mut lines,
112                sub_scalar(center, half_extent),
113                add_scalar(center, half_extent),
114            );
115        }
116        Self::lines(namespace, OverlayKind::Voxels, "Voxels", lines, color(0.0, 1.0, 1.0))
117    }
118
119    /// Creates a square wire patch tangent to a fitted plane.
120    pub fn plane(
121        namespace: &str,
122        center: Vec3<f32>,
123        normal: Vec3<f32>,
124        half_extent: f32,
125    ) -> ViewerResult<Self> {
126        finite_vec(center)?;
127        finite_vec(normal)?;
128        if normal.length() <= f32::EPSILON || !half_extent.is_finite() || half_extent <= 0.0 {
129            return Err(ViewerError::InvalidOverlay(
130                "plane normal and half extent must be non-zero and finite".into(),
131            ));
132        }
133        let normal = normal.normalize();
134        let seed =
135            if normal.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) };
136        let tangent = normal.cross(seed).normalize();
137        let bitangent = normal.cross(tangent).normalize();
138        let a = add(center, add(mul(tangent, half_extent), mul(bitangent, half_extent)));
139        let b = add(center, add(mul(tangent, -half_extent), mul(bitangent, half_extent)));
140        let c = add(center, add(mul(tangent, -half_extent), mul(bitangent, -half_extent)));
141        let d = add(center, add(mul(tangent, half_extent), mul(bitangent, -half_extent)));
142        let mut lines = Vec::with_capacity(24);
143        for (from, to) in [(a, b), (b, c), (c, d), (d, a)] {
144            push_segment(&mut lines, from, to);
145        }
146        Self::lines(namespace, OverlayKind::Plane, "Plane", lines, color(1.0, 1.0, 0.0))
147    }
148
149    /// Creates scalar-colored cluster points.
150    pub fn clusters(
151        namespace: &str,
152        positions: &[Vec3<f32>],
153        cluster_ids: &[u32],
154    ) -> ViewerResult<Self> {
155        if positions.len() != cluster_ids.len() {
156            return Err(ViewerError::InvalidOverlay(
157                "cluster IDs must match the position count".into(),
158            ));
159        }
160        let mut x = Vec::with_capacity(positions.len());
161        let mut y = Vec::with_capacity(positions.len());
162        let mut z = Vec::with_capacity(positions.len());
163        let mut values = Vec::with_capacity(positions.len());
164        for (&position, &cluster_id) in positions.iter().zip(cluster_ids) {
165            finite_vec(position)?;
166            x.push(position.x);
167            y.push(position.y);
168            z.push(position.z);
169            values.push(cluster_id as f32);
170        }
171        let max = values.iter().copied().fold(0.0_f32, f32::max).max(1.0);
172        Ok(Self {
173            kind: OverlayKind::Clusters,
174            id: overlay_id(namespace, OverlayKind::Clusters)?,
175            label: "Clusters".into(),
176            geometry: OverlayGeometry::ScalarPoints {
177                x,
178                y,
179                z,
180                values,
181                attribute: "cluster_id".into(),
182            },
183            style: VisualStyle::Points(PointStyle::try_new(
184                3.0,
185                PointColor::Scalar {
186                    min: 0.0,
187                    max: max + 1.0,
188                    map: spatialrust_viz::ColorMap::Turbo,
189                },
190            )?),
191        })
192    }
193
194    /// Creates registration correspondence segments.
195    pub fn correspondences(
196        namespace: &str,
197        source: &[Vec3<f32>],
198        target: &[Vec3<f32>],
199    ) -> ViewerResult<Self> {
200        if source.len() != target.len() {
201            return Err(ViewerError::InvalidOverlay(
202                "correspondence source/target counts must match".into(),
203            ));
204        }
205        let mut lines = Vec::with_capacity(source.len() * 6);
206        for (&from, &to) in source.iter().zip(target) {
207            finite_vec(from)?;
208            finite_vec(to)?;
209            push_segment(&mut lines, from, to);
210        }
211        Self::lines(
212            namespace,
213            OverlayKind::Correspondences,
214            "Correspondences",
215            lines,
216            color(1.0, 0.0, 1.0),
217        )
218    }
219
220    /// Creates an axis-aligned bounds wire box.
221    pub fn bounds(namespace: &str, min: Vec3<f32>, max: Vec3<f32>) -> ViewerResult<Self> {
222        finite_vec(min)?;
223        finite_vec(max)?;
224        if min.x > max.x || min.y > max.y || min.z > max.z {
225            return Err(ViewerError::InvalidOverlay(
226                "bounds minimum must not exceed maximum".into(),
227            ));
228        }
229        let mut lines = Vec::with_capacity(72);
230        append_box(&mut lines, min, max);
231        Self::lines(namespace, OverlayKind::Bounds, "Bounds", lines, LinearRgba::WHITE)
232    }
233
234    /// Creates a segmented XY search-radius circle.
235    pub fn search_radius(
236        namespace: &str,
237        center: Vec3<f32>,
238        radius: f32,
239        segments: usize,
240    ) -> ViewerResult<Self> {
241        finite_vec(center)?;
242        if !radius.is_finite() || radius <= 0.0 || segments < 3 {
243            return Err(ViewerError::InvalidOverlay(
244                "search radius must be positive with at least three segments".into(),
245            ));
246        }
247        let mut lines = Vec::with_capacity(segments * 6);
248        for index in 0..segments {
249            let a = index as f32 * core::f32::consts::TAU / segments as f32;
250            let b = (index + 1) as f32 * core::f32::consts::TAU / segments as f32;
251            push_segment(
252                &mut lines,
253                add(center, Vec3::new(radius * a.cos(), radius * a.sin(), 0.0)),
254                add(center, Vec3::new(radius * b.cos(), radius * b.sin(), 0.0)),
255            );
256        }
257        Self::lines(
258            namespace,
259            OverlayKind::SearchRadius,
260            "Search radius",
261            lines,
262            color(1.0, 1.0, 0.0),
263        )
264    }
265
266    /// Borrows the owned overlay as a validated visual layer without copying.
267    pub fn as_layer(&self) -> ViewerResult<VisualLayer<'_>> {
268        let primitive = match &self.geometry {
269            OverlayGeometry::Lines(lines) => VisualPrimitive::Lines(LineListView::try_new(lines)?),
270            OverlayGeometry::ScalarPoints { x, y, z, values, attribute } => {
271                let positions = PositionColumns3::try_new(x, y, z)?;
272                let scalar = ScalarColumn::try_new(attribute, values, positions.len())?;
273                VisualPrimitive::Points(
274                    PointCloudView::positions_only(positions).with_scalar(scalar)?,
275                )
276            }
277        };
278        Ok(VisualLayer::try_new(
279            self.id.clone(),
280            self.label.clone(),
281            primitive,
282            self.style.clone(),
283        )?)
284    }
285
286    fn lines(
287        namespace: &str,
288        kind: OverlayKind,
289        label: &str,
290        lines: Vec<f32>,
291        color: LinearRgba,
292    ) -> ViewerResult<Self> {
293        LineListView::try_new(&lines)?;
294        Ok(Self {
295            kind,
296            id: overlay_id(namespace, kind)?,
297            label: label.into(),
298            geometry: OverlayGeometry::Lines(lines),
299            style: VisualStyle::Uniform(color),
300        })
301    }
302}
303
304fn overlay_id(namespace: &str, kind: OverlayKind) -> ViewerResult<LayerId> {
305    if namespace.trim().is_empty() {
306        return Err(ViewerError::InvalidOverlay("overlay namespace must not be empty".into()));
307    }
308    Ok(LayerId::try_new(format!("debug/{namespace}/{}", kind.slug()))?)
309}
310
311const fn color(red: f32, green: f32, blue: f32) -> LinearRgba {
312    LinearRgba { red, green, blue, alpha: 1.0 }
313}
314
315fn append_box(lines: &mut Vec<f32>, min: Vec3<f32>, max: Vec3<f32>) {
316    let corners = [
317        Vec3::new(min.x, min.y, min.z),
318        Vec3::new(max.x, min.y, min.z),
319        Vec3::new(max.x, max.y, min.z),
320        Vec3::new(min.x, max.y, min.z),
321        Vec3::new(min.x, min.y, max.z),
322        Vec3::new(max.x, min.y, max.z),
323        Vec3::new(max.x, max.y, max.z),
324        Vec3::new(min.x, max.y, max.z),
325    ];
326    for (a, b) in [
327        (0, 1),
328        (1, 2),
329        (2, 3),
330        (3, 0),
331        (4, 5),
332        (5, 6),
333        (6, 7),
334        (7, 4),
335        (0, 4),
336        (1, 5),
337        (2, 6),
338        (3, 7),
339    ] {
340        push_segment(lines, corners[a], corners[b]);
341    }
342}
343
344fn push_segment(lines: &mut Vec<f32>, from: Vec3<f32>, to: Vec3<f32>) {
345    lines.extend_from_slice(&[from.x, from.y, from.z, to.x, to.y, to.z]);
346}
347
348fn finite_vec(value: Vec3<f32>) -> ViewerResult<()> {
349    if !value.x.is_finite() || !value.y.is_finite() || !value.z.is_finite() {
350        return Err(ViewerError::InvalidOverlay("overlay coordinates must be finite".into()));
351    }
352    Ok(())
353}
354
355fn add(lhs: Vec3<f32>, rhs: Vec3<f32>) -> Vec3<f32> {
356    Vec3::new(lhs.x + rhs.x, lhs.y + rhs.y, lhs.z + rhs.z)
357}
358
359fn mul(value: Vec3<f32>, scalar: f32) -> Vec3<f32> {
360    Vec3::new(value.x * scalar, value.y * scalar, value.z * scalar)
361}
362
363fn sub_scalar(value: Vec3<f32>, scalar: f32) -> Vec3<f32> {
364    Vec3::new(value.x - scalar, value.y - scalar, value.z - scalar)
365}
366
367fn add_scalar(value: Vec3<f32>, scalar: f32) -> Vec3<f32> {
368    Vec3::new(value.x + scalar, value.y + scalar, value.z + scalar)
369}
370
371#[cfg(test)]
372mod tests {
373    use spatialrust_math::Vec3;
374    use spatialrust_viz::VisualPrimitive;
375
376    use super::{DebugOverlay, OverlayGeometry};
377
378    #[test]
379    fn canonical_overlays_have_stable_identity_and_geometry_counts() {
380        let points = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
381        let normals = [Vec3::new(0.0, 1.0, 0.0); 2];
382        let fixtures = [
383            DebugOverlay::normals("fixture", &points, &normals, 0.5).unwrap(),
384            DebugOverlay::voxels("fixture", &points[..1], 0.5).unwrap(),
385            DebugOverlay::plane("fixture", Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
386                .unwrap(),
387            DebugOverlay::correspondences("fixture", &points, &normals).unwrap(),
388            DebugOverlay::bounds("fixture", Vec3::new(-1.0, -1.0, -1.0), Vec3::new(1.0, 1.0, 1.0))
389                .unwrap(),
390            DebugOverlay::search_radius("fixture", points[0], 1.0, 16).unwrap(),
391        ];
392        let expected_segments = [2, 12, 4, 2, 12, 16];
393        for (overlay, expected) in fixtures.iter().zip(expected_segments) {
394            let VisualPrimitive::Lines(lines) = overlay.as_layer().unwrap().primitive else {
395                panic!("fixture must be lines");
396            };
397            assert_eq!(lines.segment_count(), expected);
398            assert!(overlay.id.as_str().starts_with("debug/fixture/"));
399        }
400        assert_eq!(
401            DebugOverlay::normals("fixture", &points, &normals, 0.5).unwrap().id,
402            fixtures[0].id
403        );
404    }
405
406    #[test]
407    fn cluster_overlay_preserves_point_and_scalar_counts() {
408        let points = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 2.0, 3.0)];
409        let overlay = DebugOverlay::clusters("segmentation", &points, &[4, 9]).unwrap();
410        let OverlayGeometry::ScalarPoints { values, .. } = &overlay.geometry else {
411            panic!("clusters must be scalar points");
412        };
413        assert_eq!(values, &[4.0, 9.0]);
414        let VisualPrimitive::Points(points) = overlay.as_layer().unwrap().primitive else {
415            panic!("cluster fixture must be points");
416        };
417        assert_eq!(points.positions.len(), 2);
418        assert_eq!(points.scalar.unwrap().name, "cluster_id");
419    }
420
421    #[test]
422    fn malformed_overlay_inputs_fail_closed() {
423        let point = [Vec3::new(0.0, 0.0, 0.0)];
424        assert!(DebugOverlay::normals("", &point, &point, 1.0).is_err());
425        assert!(DebugOverlay::normals("x", &point, &[], 1.0).is_err());
426        assert!(DebugOverlay::clusters("x", &point, &[]).is_err());
427        assert!(DebugOverlay::search_radius("x", point[0], -1.0, 2).is_err());
428        assert!(
429            DebugOverlay::bounds("x", Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 0.0)).is_err()
430        );
431    }
432}