Skip to main content

phytrace_sdk/models/domains/
common.rs

1//! Common types shared across UDM domains.
2//!
3//! These types are used by multiple domains and represent shared concepts
4//! like object references, poses, dimensions, and coordinates.
5
6use serde::{Deserialize, Serialize};
7use validator::Validate;
8
9/// Reference to an object in the world or payload.
10///
11/// Used for consistent object identification across domains (perception,
12/// manipulation, payload, etc.). Supports multiple ID schemes for different
13/// use cases.
14#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
15pub struct ObjectRef {
16    /// Session-stable object identifier (assigned by perception system)
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub object_id: Option<String>,
19
20    /// External business system ID (WMS, ERP, etc.)
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub item_id: Option<String>,
23
24    /// Object type classification (package, pallet, tool, person, etc.)
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub object_type: Option<String>,
27
28    /// Specific object class (e.g., "cardboard_box", "metal_pallet")
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub object_class: Option<String>,
31
32    /// Perception system tracking ID
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub tracking_id: Option<String>,
35
36    /// Object dimensions
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub dimensions_m: Option<Dimensions>,
39
40    /// Object mass in kilograms
41    #[serde(skip_serializing_if = "Option::is_none")]
42    #[validate(range(min = 0.0))]
43    pub mass_kg: Option<f64>,
44
45    /// Object pose (position and orientation)
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub pose: Option<Pose>,
48
49    /// Reference frame for the pose
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub frame_id: Option<String>,
52}
53
54/// 3D dimensions of an object.
55#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
56pub struct Dimensions {
57    /// Length in meters (x-axis)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    #[validate(range(min = 0.0))]
60    pub length_m: Option<f64>,
61
62    /// Width in meters (y-axis)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    #[validate(range(min = 0.0))]
65    pub width_m: Option<f64>,
66
67    /// Height in meters (z-axis)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    #[validate(range(min = 0.0))]
70    pub height_m: Option<f64>,
71}
72
73impl Dimensions {
74    /// Create new dimensions with all values.
75    pub fn new(length_m: f64, width_m: f64, height_m: f64) -> Self {
76        Self {
77            length_m: Some(length_m),
78            width_m: Some(width_m),
79            height_m: Some(height_m),
80        }
81    }
82
83    /// Calculate volume in cubic meters.
84    pub fn volume_m3(&self) -> Option<f64> {
85        match (self.length_m, self.width_m, self.height_m) {
86            (Some(l), Some(w), Some(h)) => Some(l * w * h),
87            _ => None,
88        }
89    }
90}
91
92/// 6-DOF pose (position + orientation).
93#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
94pub struct Pose {
95    /// Position component
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub position: Option<Position3D>,
98
99    /// Orientation as quaternion
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub orientation: Option<Quaternion>,
102
103    /// Orientation as Euler angles (alternative to quaternion)
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub euler_deg: Option<EulerAngles>,
106}
107
108impl Pose {
109    /// Create a pose from position only (identity orientation).
110    pub fn from_position(x: f64, y: f64, z: f64) -> Self {
111        Self {
112            position: Some(Position3D::new(x, y, z)),
113            orientation: None,
114            euler_deg: None,
115        }
116    }
117
118    /// Create a pose from position and Euler angles.
119    pub fn from_position_euler(x: f64, y: f64, z: f64, roll: f64, pitch: f64, yaw: f64) -> Self {
120        Self {
121            position: Some(Position3D::new(x, y, z)),
122            orientation: None,
123            euler_deg: Some(EulerAngles::new(roll, pitch, yaw)),
124        }
125    }
126}
127
128/// 3D position in Cartesian coordinates.
129#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
130pub struct Position3D {
131    /// X coordinate in meters
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub x_m: Option<f64>,
134
135    /// Y coordinate in meters
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub y_m: Option<f64>,
138
139    /// Z coordinate in meters
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub z_m: Option<f64>,
142}
143
144impl Position3D {
145    /// Create a new 3D position.
146    pub fn new(x: f64, y: f64, z: f64) -> Self {
147        Self {
148            x_m: Some(x),
149            y_m: Some(y),
150            z_m: Some(z),
151        }
152    }
153
154    /// Calculate distance to another position.
155    pub fn distance_to(&self, other: &Position3D) -> Option<f64> {
156        match (
157            self.x_m, self.y_m, self.z_m, other.x_m, other.y_m, other.z_m,
158        ) {
159            (Some(x1), Some(y1), Some(z1), Some(x2), Some(y2), Some(z2)) => {
160                let dx = x2 - x1;
161                let dy = y2 - y1;
162                let dz = z2 - z1;
163                Some((dx * dx + dy * dy + dz * dz).sqrt())
164            }
165            _ => None,
166        }
167    }
168}
169
170/// 2D position (for floor-plane navigation).
171#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
172pub struct Position2D {
173    /// X coordinate in meters
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub x_m: Option<f64>,
176
177    /// Y coordinate in meters
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub y_m: Option<f64>,
180}
181
182impl Position2D {
183    /// Create a new 2D position.
184    pub fn new(x: f64, y: f64) -> Self {
185        Self {
186            x_m: Some(x),
187            y_m: Some(y),
188        }
189    }
190}
191
192/// Quaternion for orientation representation.
193#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
194pub struct Quaternion {
195    /// W component (scalar)
196    pub w: f64,
197    /// X component
198    pub x: f64,
199    /// Y component
200    pub y: f64,
201    /// Z component
202    pub z: f64,
203}
204
205impl Quaternion {
206    /// Create a new quaternion.
207    pub fn new(w: f64, x: f64, y: f64, z: f64) -> Self {
208        Self { w, x, y, z }
209    }
210
211    /// Identity quaternion (no rotation).
212    pub fn identity() -> Self {
213        Self {
214            w: 1.0,
215            x: 0.0,
216            y: 0.0,
217            z: 0.0,
218        }
219    }
220
221    /// Normalize the quaternion.
222    pub fn normalize(&self) -> Self {
223        let mag = (self.w * self.w + self.x * self.x + self.y * self.y + self.z * self.z).sqrt();
224        if mag > 0.0 {
225            Self {
226                w: self.w / mag,
227                x: self.x / mag,
228                y: self.y / mag,
229                z: self.z / mag,
230            }
231        } else {
232            Self::identity()
233        }
234    }
235}
236
237/// Euler angles for orientation (roll, pitch, yaw).
238#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
239pub struct EulerAngles {
240    /// Roll angle in degrees (rotation about x-axis)
241    #[serde(skip_serializing_if = "Option::is_none")]
242    #[validate(range(min = -180.0, max = 180.0))]
243    pub roll_deg: Option<f64>,
244
245    /// Pitch angle in degrees (rotation about y-axis)
246    #[serde(skip_serializing_if = "Option::is_none")]
247    #[validate(range(min = -90.0, max = 90.0))]
248    pub pitch_deg: Option<f64>,
249
250    /// Yaw angle in degrees (rotation about z-axis)
251    #[serde(skip_serializing_if = "Option::is_none")]
252    #[validate(range(min = -180.0, max = 180.0))]
253    pub yaw_deg: Option<f64>,
254}
255
256impl EulerAngles {
257    /// Create new Euler angles.
258    pub fn new(roll: f64, pitch: f64, yaw: f64) -> Self {
259        Self {
260            roll_deg: Some(roll),
261            pitch_deg: Some(pitch),
262            yaw_deg: Some(yaw),
263        }
264    }
265}
266
267/// 3D vector for velocities, accelerations, etc.
268#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
269pub struct Vector3D {
270    /// X component
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub x: Option<f64>,
273
274    /// Y component
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub y: Option<f64>,
277
278    /// Z component
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub z: Option<f64>,
281}
282
283impl Vector3D {
284    /// Create a new 3D vector.
285    pub fn new(x: f64, y: f64, z: f64) -> Self {
286        Self {
287            x: Some(x),
288            y: Some(y),
289            z: Some(z),
290        }
291    }
292
293    /// Calculate magnitude of the vector.
294    pub fn magnitude(&self) -> Option<f64> {
295        match (self.x, self.y, self.z) {
296            (Some(x), Some(y), Some(z)) => Some((x * x + y * y + z * z).sqrt()),
297            _ => None,
298        }
299    }
300}
301
302/// Covariance matrix (6x6 for pose, 3x3 for position).
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304pub struct CovarianceMatrix {
305    /// Row-major covariance values
306    pub values: Vec<f64>,
307
308    /// Matrix dimension (3 for 3x3, 6 for 6x6)
309    pub dimension: u8,
310}
311
312impl CovarianceMatrix {
313    /// Create a 3x3 covariance matrix for position uncertainty.
314    pub fn position_3x3(values: [f64; 9]) -> Self {
315        Self {
316            values: values.to_vec(),
317            dimension: 3,
318        }
319    }
320
321    /// Create a 6x6 covariance matrix for pose uncertainty.
322    pub fn pose_6x6(values: [f64; 36]) -> Self {
323        Self {
324            values: values.to_vec(),
325            dimension: 6,
326        }
327    }
328}
329
330/// Bounding box (axis-aligned or oriented).
331#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
332pub struct BoundingBox {
333    /// Center position
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub center: Option<Position3D>,
336
337    /// Dimensions
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub dimensions: Option<Dimensions>,
340
341    /// Orientation (for oriented bounding boxes)
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub orientation: Option<Quaternion>,
344
345    /// Whether this is axis-aligned
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub axis_aligned: Option<bool>,
348}
349
350/// 2D bounding box for image detections.
351#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
352pub struct BoundingBox2D {
353    /// Top-left X coordinate (pixels)
354    #[serde(skip_serializing_if = "Option::is_none")]
355    #[validate(range(min = 0.0))]
356    pub x: Option<f64>,
357
358    /// Top-left Y coordinate (pixels)
359    #[serde(skip_serializing_if = "Option::is_none")]
360    #[validate(range(min = 0.0))]
361    pub y: Option<f64>,
362
363    /// Width in pixels
364    #[serde(skip_serializing_if = "Option::is_none")]
365    #[validate(range(min = 0.0))]
366    pub width: Option<f64>,
367
368    /// Height in pixels
369    #[serde(skip_serializing_if = "Option::is_none")]
370    #[validate(range(min = 0.0))]
371    pub height: Option<f64>,
372}
373
374/// Polygon for 2D regions (zones, areas).
375#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct Polygon2D {
377    /// List of vertices
378    pub vertices: Vec<Position2D>,
379
380    /// Whether the polygon is closed
381    #[serde(default = "default_true")]
382    pub closed: bool,
383}
384
385fn default_true() -> bool {
386    true
387}
388
389impl Polygon2D {
390    /// Create a new polygon from vertices.
391    pub fn new(vertices: Vec<Position2D>) -> Self {
392        Self {
393            vertices,
394            closed: true,
395        }
396    }
397
398    /// Check if a point is inside the polygon (2D ray casting).
399    pub fn contains(&self, point: &Position2D) -> bool {
400        let (px, py) = match (point.x_m, point.y_m) {
401            (Some(x), Some(y)) => (x, y),
402            _ => return false,
403        };
404
405        let n = self.vertices.len();
406        if n < 3 {
407            return false;
408        }
409
410        let mut inside = false;
411        let mut j = n - 1;
412
413        for i in 0..n {
414            #[expect(
415                clippy::indexing_slicing,
416                reason = "i and j are bounded by n = self.vertices.len()"
417            )]
418            let (xi, yi) = match (self.vertices[i].x_m, self.vertices[i].y_m) {
419                (Some(x), Some(y)) => (x, y),
420                _ => continue,
421            };
422            #[expect(
423                clippy::indexing_slicing,
424                reason = "j is bounded by n = self.vertices.len()"
425            )]
426            let (xj, yj) = match (self.vertices[j].x_m, self.vertices[j].y_m) {
427                (Some(x), Some(y)) => (x, y),
428                _ => {
429                    j = i;
430                    continue;
431                }
432            };
433
434            if ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
435                inside = !inside;
436            }
437            j = i;
438        }
439
440        inside
441    }
442}
443
444/// Time range.
445#[derive(Debug, Clone, Default, Serialize, Deserialize)]
446pub struct TimeRange {
447    /// Start time (ISO 8601)
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub start: Option<chrono::DateTime<chrono::Utc>>,
450
451    /// End time (ISO 8601)
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub end: Option<chrono::DateTime<chrono::Utc>>,
454}
455
456/// Key-value metadata pair.
457#[derive(Debug, Clone, Default, Serialize, Deserialize)]
458pub struct Metadata {
459    /// Key
460    pub key: String,
461
462    /// Value
463    pub value: String,
464}
465
466/// Confidence score with optional source.
467#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
468pub struct Confidence {
469    /// Confidence value (0.0 to 1.0)
470    #[validate(range(min = 0.0, max = 1.0))]
471    pub score: f64,
472
473    /// Source of the confidence (e.g., "model_v2", "sensor_fusion")
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub source: Option<String>,
476}
477
478impl Confidence {
479    /// Create a new confidence score.
480    pub fn new(score: f64) -> Self {
481        Self {
482            score,
483            source: None,
484        }
485    }
486
487    /// Create a confidence score with source.
488    pub fn with_source(score: f64, source: impl Into<String>) -> Self {
489        Self {
490            score,
491            source: Some(source.into()),
492        }
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn test_dimensions_volume() {
502        let dims = Dimensions::new(2.0, 3.0, 4.0);
503        assert_eq!(dims.volume_m3(), Some(24.0));
504    }
505
506    #[test]
507    fn test_position_distance() {
508        let p1 = Position3D::new(0.0, 0.0, 0.0);
509        let p2 = Position3D::new(3.0, 4.0, 0.0);
510        assert!((p1.distance_to(&p2).unwrap() - 5.0).abs() < 1e-10);
511    }
512
513    #[test]
514    fn test_quaternion_normalize() {
515        let q = Quaternion::new(2.0, 0.0, 0.0, 0.0);
516        let normalized = q.normalize();
517        assert!((normalized.w - 1.0).abs() < 1e-10);
518    }
519
520    #[test]
521    fn test_polygon_contains() {
522        let polygon = Polygon2D::new(vec![
523            Position2D::new(0.0, 0.0),
524            Position2D::new(4.0, 0.0),
525            Position2D::new(4.0, 4.0),
526            Position2D::new(0.0, 4.0),
527        ]);
528
529        assert!(polygon.contains(&Position2D::new(2.0, 2.0)));
530        assert!(!polygon.contains(&Position2D::new(5.0, 5.0)));
531    }
532
533    // ==========================================================================
534    // ObjectRef Tests
535    // ==========================================================================
536
537    #[test]
538    fn test_object_ref_default() {
539        let obj = ObjectRef::default();
540        assert!(obj.object_id.is_none());
541        assert!(obj.item_id.is_none());
542        assert!(obj.object_type.is_none());
543        assert!(obj.object_class.is_none());
544        assert!(obj.tracking_id.is_none());
545        assert!(obj.dimensions_m.is_none());
546        assert!(obj.mass_kg.is_none());
547        assert!(obj.pose.is_none());
548        assert!(obj.frame_id.is_none());
549    }
550
551    #[test]
552    fn test_object_ref_full() {
553        let obj = ObjectRef {
554            object_id: Some("obj-001".to_string()),
555            item_id: Some("SKU12345".to_string()),
556            object_type: Some("package".to_string()),
557            object_class: Some("cardboard_box".to_string()),
558            tracking_id: Some("track-001".to_string()),
559            dimensions_m: Some(Dimensions::new(0.5, 0.3, 0.2)),
560            mass_kg: Some(2.5),
561            pose: Some(Pose::from_position(1.0, 2.0, 0.5)),
562            frame_id: Some("base_link".to_string()),
563        };
564
565        assert_eq!(obj.object_type, Some("package".to_string()));
566        assert_eq!(obj.mass_kg, Some(2.5));
567    }
568
569    // ==========================================================================
570    // Dimensions Tests
571    // ==========================================================================
572
573    #[test]
574    fn test_dimensions_new() {
575        let dims = Dimensions::new(1.0, 2.0, 3.0);
576        assert_eq!(dims.length_m, Some(1.0));
577        assert_eq!(dims.width_m, Some(2.0));
578        assert_eq!(dims.height_m, Some(3.0));
579    }
580
581    #[test]
582    fn test_dimensions_volume_partial() {
583        let dims = Dimensions {
584            length_m: Some(2.0),
585            width_m: None,
586            height_m: Some(3.0),
587        };
588        assert!(dims.volume_m3().is_none());
589    }
590
591    #[test]
592    fn test_dimensions_default() {
593        let dims = Dimensions::default();
594        assert!(dims.length_m.is_none());
595        assert!(dims.width_m.is_none());
596        assert!(dims.height_m.is_none());
597        assert!(dims.volume_m3().is_none());
598    }
599
600    // ==========================================================================
601    // Pose Tests
602    // ==========================================================================
603
604    #[test]
605    fn test_pose_from_position() {
606        let pose = Pose::from_position(1.0, 2.0, 3.0);
607        assert!(pose.position.is_some());
608        let pos = pose.position.unwrap();
609        assert_eq!(pos.x_m, Some(1.0));
610        assert_eq!(pos.y_m, Some(2.0));
611        assert_eq!(pos.z_m, Some(3.0));
612    }
613
614    #[test]
615    fn test_pose_from_position_euler() {
616        let pose = Pose::from_position_euler(1.0, 2.0, 3.0, 10.0, 20.0, 30.0);
617        assert!(pose.position.is_some());
618        assert!(pose.euler_deg.is_some());
619        let euler = pose.euler_deg.unwrap();
620        assert_eq!(euler.roll_deg, Some(10.0));
621        assert_eq!(euler.pitch_deg, Some(20.0));
622        assert_eq!(euler.yaw_deg, Some(30.0));
623    }
624
625    #[test]
626    fn test_pose_default() {
627        let pose = Pose::default();
628        assert!(pose.position.is_none());
629        assert!(pose.orientation.is_none());
630        assert!(pose.euler_deg.is_none());
631    }
632
633    // ==========================================================================
634    // Position3D Tests
635    // ==========================================================================
636
637    #[test]
638    fn test_position_3d_new() {
639        let pos = Position3D::new(1.5, 2.5, 3.5);
640        assert_eq!(pos.x_m, Some(1.5));
641        assert_eq!(pos.y_m, Some(2.5));
642        assert_eq!(pos.z_m, Some(3.5));
643    }
644
645    #[test]
646    fn test_position_3d_distance_to_partial() {
647        let p1 = Position3D::new(0.0, 0.0, 0.0);
648        let p2 = Position3D::default();
649        assert!(p1.distance_to(&p2).is_none());
650    }
651
652    #[test]
653    fn test_position_3d_default() {
654        let pos = Position3D::default();
655        assert!(pos.x_m.is_none());
656        assert!(pos.y_m.is_none());
657        assert!(pos.z_m.is_none());
658    }
659
660    // ==========================================================================
661    // Position2D Tests
662    // ==========================================================================
663
664    #[test]
665    fn test_position_2d_new() {
666        let pos = Position2D::new(10.0, 20.0);
667        assert_eq!(pos.x_m, Some(10.0));
668        assert_eq!(pos.y_m, Some(20.0));
669    }
670
671    #[test]
672    fn test_position_2d_default() {
673        let pos = Position2D::default();
674        assert!(pos.x_m.is_none());
675        assert!(pos.y_m.is_none());
676    }
677
678    // ==========================================================================
679    // Quaternion Tests
680    // ==========================================================================
681
682    #[test]
683    fn test_quaternion_new() {
684        let q = Quaternion::new(1.0, 0.0, 0.0, 0.0);
685        assert!((q.w - 1.0).abs() < f64::EPSILON);
686        assert!((q.x - 0.0).abs() < f64::EPSILON);
687        assert!((q.y - 0.0).abs() < f64::EPSILON);
688        assert!((q.z - 0.0).abs() < f64::EPSILON);
689    }
690
691    #[test]
692    fn test_quaternion_identity() {
693        let q = Quaternion::identity();
694        assert!((q.w - 1.0).abs() < f64::EPSILON);
695        assert!((q.x - 0.0).abs() < f64::EPSILON);
696        assert!((q.y - 0.0).abs() < f64::EPSILON);
697        assert!((q.z - 0.0).abs() < f64::EPSILON);
698    }
699
700    #[test]
701    fn test_quaternion_normalize_non_unit() {
702        let q = Quaternion::new(0.0, 3.0, 4.0, 0.0);
703        let normalized = q.normalize();
704        // Verify normalized quaternion has unit length (w^2 + x^2 + y^2 + z^2 = 1)
705        let mag_sq = normalized.w * normalized.w
706            + normalized.x * normalized.x
707            + normalized.y * normalized.y
708            + normalized.z * normalized.z;
709        assert!((mag_sq - 1.0).abs() < 1e-10);
710    }
711
712    #[test]
713    fn test_quaternion_default() {
714        let q = Quaternion::default();
715        assert!((q.w - 0.0).abs() < f64::EPSILON);
716        assert!((q.x - 0.0).abs() < f64::EPSILON);
717        assert!((q.y - 0.0).abs() < f64::EPSILON);
718        assert!((q.z - 0.0).abs() < f64::EPSILON);
719    }
720
721    // ==========================================================================
722    // EulerAngles Tests
723    // ==========================================================================
724
725    #[test]
726    fn test_euler_angles_new() {
727        let euler = EulerAngles::new(10.0, 20.0, 30.0);
728        assert_eq!(euler.roll_deg, Some(10.0));
729        assert_eq!(euler.pitch_deg, Some(20.0));
730        assert_eq!(euler.yaw_deg, Some(30.0));
731    }
732
733    #[test]
734    fn test_euler_angles_default() {
735        let euler = EulerAngles::default();
736        assert!(euler.roll_deg.is_none());
737        assert!(euler.pitch_deg.is_none());
738        assert!(euler.yaw_deg.is_none());
739    }
740
741    // ==========================================================================
742    // Vector3D Tests
743    // ==========================================================================
744
745    #[test]
746    fn test_vector_3d_new() {
747        let v = Vector3D::new(1.0, 2.0, 3.0);
748        assert_eq!(v.x, Some(1.0));
749        assert_eq!(v.y, Some(2.0));
750        assert_eq!(v.z, Some(3.0));
751    }
752
753    #[test]
754    fn test_vector_3d_magnitude() {
755        let v = Vector3D::new(3.0, 4.0, 0.0);
756        assert!((v.magnitude().unwrap() - 5.0).abs() < 1e-10);
757    }
758
759    #[test]
760    fn test_vector_3d_magnitude_none() {
761        let v = Vector3D::default();
762        assert!(v.magnitude().is_none());
763    }
764
765    #[test]
766    fn test_vector_3d_default() {
767        let v = Vector3D::default();
768        assert!(v.x.is_none());
769        assert!(v.y.is_none());
770        assert!(v.z.is_none());
771    }
772
773    // ==========================================================================
774    // BoundingBox Tests
775    // ==========================================================================
776
777    #[test]
778    fn test_bounding_box_default() {
779        let bb = BoundingBox::default();
780        assert!(bb.center.is_none());
781        assert!(bb.dimensions.is_none());
782        assert!(bb.orientation.is_none());
783        assert!(bb.axis_aligned.is_none());
784    }
785
786    #[test]
787    fn test_bounding_box_full() {
788        let bb = BoundingBox {
789            center: Some(Position3D::new(1.0, 2.0, 0.5)),
790            dimensions: Some(Dimensions::new(1.0, 0.5, 0.8)),
791            orientation: Some(Quaternion::identity()),
792            axis_aligned: Some(true),
793        };
794
795        assert!(bb.center.is_some());
796        assert!(bb.dimensions.is_some());
797    }
798
799    // ==========================================================================
800    // Polygon2D Tests
801    // ==========================================================================
802
803    #[test]
804    fn test_polygon_2d_new() {
805        let vertices = vec![
806            Position2D::new(0.0, 0.0),
807            Position2D::new(1.0, 0.0),
808            Position2D::new(1.0, 1.0),
809        ];
810        let poly = Polygon2D::new(vertices);
811        assert_eq!(poly.vertices.len(), 3);
812    }
813
814    #[test]
815    fn test_polygon_2d_empty() {
816        let poly = Polygon2D::new(vec![]);
817        assert!(!poly.contains(&Position2D::new(0.0, 0.0)));
818    }
819
820    #[test]
821    fn test_polygon_2d_contains_partial_vertices() {
822        let poly = Polygon2D::new(vec![
823            Position2D::default(),
824            Position2D::new(1.0, 0.0),
825            Position2D::new(1.0, 1.0),
826        ]);
827        // Should not crash with partial data
828        let _ = poly.contains(&Position2D::new(0.5, 0.5));
829    }
830
831    #[test]
832    fn test_polygon_2d_contains_point_no_coords() {
833        let poly = Polygon2D::new(vec![
834            Position2D::new(0.0, 0.0),
835            Position2D::new(1.0, 0.0),
836            Position2D::new(1.0, 1.0),
837        ]);
838        assert!(!poly.contains(&Position2D::default()));
839    }
840
841    // ==========================================================================
842    // Confidence Tests
843    // ==========================================================================
844
845    #[test]
846    fn test_confidence_new() {
847        let conf = Confidence::new(0.95);
848        assert!((conf.score - 0.95).abs() < f64::EPSILON);
849        assert!(conf.source.is_none());
850    }
851
852    #[test]
853    fn test_confidence_with_source() {
854        let conf = Confidence::with_source(0.87, "model_v2");
855        assert!((conf.score - 0.87).abs() < f64::EPSILON);
856        assert_eq!(conf.source, Some("model_v2".to_string()));
857    }
858
859    #[test]
860    fn test_confidence_default() {
861        let conf = Confidence::default();
862        assert!((conf.score - 0.0).abs() < f64::EPSILON);
863        assert!(conf.source.is_none());
864    }
865
866    // ==========================================================================
867    // TimeRange Tests
868    // ==========================================================================
869
870    #[test]
871    fn test_time_range_default() {
872        let tr = TimeRange::default();
873        assert!(tr.start.is_none());
874        assert!(tr.end.is_none());
875    }
876
877    // ==========================================================================
878    // Metadata Tests
879    // ==========================================================================
880
881    #[test]
882    fn test_metadata_default() {
883        let md = Metadata::default();
884        assert!(md.key.is_empty());
885        assert!(md.value.is_empty());
886    }
887
888    #[test]
889    fn test_metadata_full() {
890        let md = Metadata {
891            key: "version".to_string(),
892            value: "1.0.0".to_string(),
893        };
894        assert_eq!(md.key, "version");
895        assert_eq!(md.value, "1.0.0");
896    }
897
898    // ==========================================================================
899    // Serialization Roundtrip Tests
900    // ==========================================================================
901
902    #[test]
903    fn test_object_ref_serialization() {
904        let obj = ObjectRef {
905            object_id: Some("obj-001".to_string()),
906            mass_kg: Some(5.0),
907            ..Default::default()
908        };
909
910        let json = serde_json::to_string(&obj).unwrap();
911        let deserialized: ObjectRef = serde_json::from_str(&json).unwrap();
912        assert_eq!(deserialized.object_id, Some("obj-001".to_string()));
913    }
914
915    #[test]
916    fn test_pose_serialization() {
917        let pose = Pose::from_position_euler(1.0, 2.0, 3.0, 0.0, 0.0, 90.0);
918        let json = serde_json::to_string(&pose).unwrap();
919        let deserialized: Pose = serde_json::from_str(&json).unwrap();
920        assert!(deserialized.position.is_some());
921        assert!(deserialized.euler_deg.is_some());
922    }
923}