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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum OverlayKind {
13 Normals,
15 Voxels,
17 Plane,
19 Clusters,
21 Correspondences,
23 Bounds,
25 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#[derive(Clone, Debug, PartialEq)]
45pub enum OverlayGeometry {
46 Lines(Vec<f32>),
48 ScalarPoints {
50 x: Vec<f32>,
52 y: Vec<f32>,
54 z: Vec<f32>,
56 values: Vec<f32>,
58 attribute: String,
60 },
61}
62
63#[derive(Clone, Debug, PartialEq)]
65pub struct DebugOverlay {
66 pub kind: OverlayKind,
68 pub id: LayerId,
70 pub label: String,
72 pub geometry: OverlayGeometry,
74 pub style: VisualStyle,
76}
77
78impl DebugOverlay {
79 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 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 ¢er 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 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 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 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 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 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 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}