Skip to main content

oxiphysics_io/robotics_io/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use std::collections::HashMap;
6
7#[allow(unused_imports)]
8use super::functions::*;
9use super::functions::{Mat4, ROS_MAGIC, Vec3};
10
11/// A ROS-like joint state message.
12#[derive(Debug, Clone, Default)]
13pub struct JointStateMsg {
14    /// Timestamp in seconds.
15    pub timestamp: f64,
16    /// Joint names.
17    pub names: Vec<String>,
18    /// Joint positions (rad or m).
19    pub positions: Vec<f64>,
20    /// Joint velocities (rad/s or m/s).
21    pub velocities: Vec<f64>,
22    /// Joint efforts (N·m or N).
23    pub efforts: Vec<f64>,
24}
25impl JointStateMsg {
26    /// Construct a new joint state with the given timestamp and equal-length vectors.
27    ///
28    /// Panics in debug mode if the vectors have different lengths.
29    pub fn new(
30        timestamp: f64,
31        names: Vec<String>,
32        positions: Vec<f64>,
33        velocities: Vec<f64>,
34        efforts: Vec<f64>,
35    ) -> Self {
36        debug_assert_eq!(names.len(), positions.len());
37        debug_assert_eq!(names.len(), velocities.len());
38        debug_assert_eq!(names.len(), efforts.len());
39        Self {
40            timestamp,
41            names,
42            positions,
43            velocities,
44            efforts,
45        }
46    }
47    /// Return the number of joints.
48    pub fn len(&self) -> usize {
49        self.names.len()
50    }
51    /// Return `true` if there are no joints.
52    pub fn is_empty(&self) -> bool {
53        self.names.is_empty()
54    }
55    /// Look up the index of a joint by name.
56    pub fn index_of(&self, name: &str) -> Option<usize> {
57        self.names.iter().position(|n| n == name)
58    }
59    /// Get the position of a joint by name.
60    pub fn position_of(&self, name: &str) -> Option<f64> {
61        self.index_of(name).map(|i| self.positions[i])
62    }
63    /// Kinetic energy assuming unit inertia for each joint.
64    pub fn unit_kinetic_energy(&self) -> f64 {
65        self.velocities.iter().map(|v| 0.5 * v * v).sum()
66    }
67    /// Serialise to CSV line: `timestamp,name0:pos0:vel0:eff0,...`.
68    pub fn to_csv_line(&self) -> String {
69        let fields: Vec<String> = self
70            .names
71            .iter()
72            .enumerate()
73            .map(|(i, n)| {
74                format!(
75                    "{}:{:.6}:{:.6}:{:.6}",
76                    n, self.positions[i], self.velocities[i], self.efforts[i]
77                )
78            })
79            .collect();
80        format!("{:.6},{}", self.timestamp, fields.join(","))
81    }
82    /// Serialise to a binary buffer (little-endian f64 per value).
83    pub fn to_bytes(&self) -> Vec<u8> {
84        let n = self.names.len();
85        let mut buf = Vec::with_capacity(8 + n * 24);
86        buf.extend_from_slice(&(self.timestamp).to_le_bytes());
87        for i in 0..n {
88            buf.extend_from_slice(&self.positions[i].to_le_bytes());
89            buf.extend_from_slice(&self.velocities[i].to_le_bytes());
90            buf.extend_from_slice(&self.efforts[i].to_le_bytes());
91        }
92        buf
93    }
94}
95/// A 3-D reachability grid for a robot workspace.
96///
97/// Each voxel stores a reachability score in `[0, 1]`: `1` = fully reachable,
98/// `0` = unreachable or not evaluated.
99#[derive(Debug, Clone)]
100pub struct WorkspaceGrid {
101    /// Number of cells along X.
102    pub nx: usize,
103    /// Number of cells along Y.
104    pub ny: usize,
105    /// Number of cells along Z.
106    pub nz: usize,
107    /// Grid cell size in metres.
108    pub cell_size: f64,
109    /// World-space origin `[x0, y0, z0]` of the (0,0,0) voxel.
110    pub origin: Vec3,
111    /// Reachability scores, linearised as `data[ix + nx*(iy + ny*iz)]`.
112    pub data: Vec<f32>,
113}
114impl WorkspaceGrid {
115    /// Construct an all-zero workspace grid.
116    pub fn new(nx: usize, ny: usize, nz: usize, cell_size: f64, origin: Vec3) -> Self {
117        Self {
118            nx,
119            ny,
120            nz,
121            cell_size,
122            origin,
123            data: vec![0.0; nx * ny * nz],
124        }
125    }
126    /// Convert a world position to a voxel index `(ix, iy, iz)`.
127    pub fn world_to_index(&self, pos: Vec3) -> Option<(usize, usize, usize)> {
128        let ix = ((pos[0] - self.origin[0]) / self.cell_size) as isize;
129        let iy = ((pos[1] - self.origin[1]) / self.cell_size) as isize;
130        let iz = ((pos[2] - self.origin[2]) / self.cell_size) as isize;
131        if ix < 0 || iy < 0 || iz < 0 {
132            return None;
133        }
134        let (ix, iy, iz) = (ix as usize, iy as usize, iz as usize);
135        if ix >= self.nx || iy >= self.ny || iz >= self.nz {
136            return None;
137        }
138        Some((ix, iy, iz))
139    }
140    /// Get the reachability score at world position `pos`.
141    pub fn get_at(&self, pos: Vec3) -> Option<f32> {
142        let (ix, iy, iz) = self.world_to_index(pos)?;
143        Some(self.data[ix + self.nx * (iy + self.ny * iz)])
144    }
145    /// Set the reachability score at world position `pos`.
146    pub fn set_at(&mut self, pos: Vec3, value: f32) -> bool {
147        if let Some((ix, iy, iz)) = self.world_to_index(pos) {
148            self.data[ix + self.nx * (iy + self.ny * iz)] = value;
149            true
150        } else {
151            false
152        }
153    }
154    /// Fraction of voxels with reachability above `threshold`.
155    pub fn reachability_fraction(&self, threshold: f32) -> f64 {
156        let count = self.data.iter().filter(|&&v| v >= threshold).count();
157        count as f64 / self.data.len() as f64
158    }
159    /// Maximum reachability score in the grid.
160    pub fn max_reachability(&self) -> f32 {
161        self.data.iter().cloned().fold(0.0f32, f32::max)
162    }
163    /// Serialise to bytes (header + data).
164    pub fn to_bytes(&self) -> Vec<u8> {
165        let header_size = 4 * 3 + 8 + 8 * 3;
166        let mut buf = Vec::with_capacity(header_size + self.data.len() * 4);
167        buf.extend_from_slice(&(self.nx as u32).to_le_bytes());
168        buf.extend_from_slice(&(self.ny as u32).to_le_bytes());
169        buf.extend_from_slice(&(self.nz as u32).to_le_bytes());
170        buf.extend_from_slice(&self.cell_size.to_le_bytes());
171        for v in &self.origin {
172            buf.extend_from_slice(&v.to_le_bytes());
173        }
174        for &v in &self.data {
175            buf.extend_from_slice(&v.to_le_bytes());
176        }
177        buf
178    }
179}
180/// A single joint in a URDF robot description.
181#[derive(Debug, Clone)]
182pub struct UrdfJoint {
183    /// Unique joint name.
184    pub name: String,
185    /// Joint type.
186    pub joint_type: JointType,
187    /// Name of the parent link.
188    pub parent: String,
189    /// Name of the child link.
190    pub child: String,
191    /// Origin translation `[x,y,z]` relative to parent.
192    pub origin_xyz: Vec3,
193    /// Origin rotation in roll-pitch-yaw (radians).
194    pub origin_rpy: Vec3,
195    /// Joint axis (unit vector, default z-axis).
196    pub axis: Vec3,
197    /// Position/velocity/effort limits.
198    pub limits: JointLimits,
199    /// Damping coefficient.
200    pub damping: f64,
201    /// Friction coefficient.
202    pub friction: f64,
203}
204impl UrdfJoint {
205    /// Construct a revolute joint with default settings.
206    pub fn revolute(
207        name: impl Into<String>,
208        parent: impl Into<String>,
209        child: impl Into<String>,
210    ) -> Self {
211        Self {
212            name: name.into(),
213            joint_type: JointType::Revolute,
214            parent: parent.into(),
215            child: child.into(),
216            origin_xyz: [0.0; 3],
217            origin_rpy: [0.0; 3],
218            axis: [0.0, 0.0, 1.0],
219            limits: JointLimits::default(),
220            damping: 0.1,
221            friction: 0.01,
222        }
223    }
224    /// Return `true` if this is a moveable (non-fixed) joint.
225    pub fn is_moveable(&self) -> bool {
226        !matches!(self.joint_type, JointType::Fixed)
227    }
228    /// Build the local transform of this joint at zero position.
229    pub fn local_transform_zero(&self) -> Mat4 {
230        let t = mat4_translation(self.origin_xyz);
231        let r = rpy_to_mat4(self.origin_rpy);
232        mat4_mul(t, r)
233    }
234}
235/// Result of an inverse kinematics solve.
236#[derive(Debug, Clone)]
237pub struct IkSolution {
238    /// Whether a solution was found.
239    pub success: bool,
240    /// Joint configuration at the solution (radians or metres).
241    pub joint_positions: Vec<f64>,
242    /// Residual Cartesian position error (metres).
243    pub position_error: f64,
244    /// Residual orientation error (radians).
245    pub orientation_error: f64,
246    /// Number of iterations taken.
247    pub iterations: u32,
248    /// Name of the IK solver used.
249    pub solver_name: String,
250}
251impl IkSolution {
252    /// Construct a successful IK solution.
253    pub fn success(
254        joint_positions: Vec<f64>,
255        position_error: f64,
256        orientation_error: f64,
257        iterations: u32,
258    ) -> Self {
259        Self {
260            success: true,
261            joint_positions,
262            position_error,
263            orientation_error,
264            iterations,
265            solver_name: "default".to_string(),
266        }
267    }
268    /// Construct a failed IK solution.
269    pub fn failure(iterations: u32) -> Self {
270        Self {
271            success: false,
272            joint_positions: Vec::new(),
273            position_error: f64::INFINITY,
274            orientation_error: f64::INFINITY,
275            iterations,
276            solver_name: "default".to_string(),
277        }
278    }
279    /// Total error (Euclidean sum of position and orientation errors).
280    pub fn total_error(&self) -> f64 {
281        (self.position_error * self.position_error
282            + self.orientation_error * self.orientation_error)
283            .sqrt()
284    }
285    /// Serialise to a JSON-like string.
286    pub fn to_json_string(&self) -> String {
287        let joints: Vec<String> = self
288            .joint_positions
289            .iter()
290            .map(|q| format!("{q:.6}"))
291            .collect();
292        format!(
293            "{{\"success\":{},\"joints\":[{}],\"pos_err\":{:.8},\"ori_err\":{:.8},\"iter\":{}}}",
294            self.success,
295            joints.join(","),
296            self.position_error,
297            self.orientation_error,
298            self.iterations
299        )
300    }
301}
302/// A log of multiple IK solutions, e.g. for benchmarking.
303#[derive(Debug, Default, Clone)]
304pub struct IkSolutionLog {
305    /// Stored solutions.
306    pub solutions: Vec<IkSolution>,
307}
308impl IkSolutionLog {
309    /// Create an empty log.
310    pub fn new() -> Self {
311        Self::default()
312    }
313    /// Append a solution.
314    pub fn push(&mut self, sol: IkSolution) {
315        self.solutions.push(sol);
316    }
317    /// Success rate of all logged solutions.
318    pub fn success_rate(&self) -> f64 {
319        if self.solutions.is_empty() {
320            return 0.0;
321        }
322        let n = self.solutions.iter().filter(|s| s.success).count();
323        n as f64 / self.solutions.len() as f64
324    }
325    /// Mean position error of successful solutions.
326    pub fn mean_position_error(&self) -> f64 {
327        let succ: Vec<_> = self.solutions.iter().filter(|s| s.success).collect();
328        if succ.is_empty() {
329            return f64::NAN;
330        }
331        succ.iter().map(|s| s.position_error).sum::<f64>() / succ.len() as f64
332    }
333    /// Mean number of iterations.
334    pub fn mean_iterations(&self) -> f64 {
335        if self.solutions.is_empty() {
336            return 0.0;
337        }
338        self.solutions
339            .iter()
340            .map(|s| s.iterations as f64)
341            .sum::<f64>()
342            / self.solutions.len() as f64
343    }
344}
345/// Camera intrinsic parameters.
346#[derive(Debug, Clone, Copy)]
347pub struct CameraIntrinsics {
348    /// Focal length in x (pixels).
349    pub fx: f64,
350    /// Focal length in y (pixels).
351    pub fy: f64,
352    /// Principal point x (pixels).
353    pub cx: f64,
354    /// Principal point y (pixels).
355    pub cy: f64,
356    /// Image width in pixels.
357    pub width: u32,
358    /// Image height in pixels.
359    pub height: u32,
360}
361impl CameraIntrinsics {
362    /// Construct standard intrinsics.
363    pub fn new(fx: f64, fy: f64, cx: f64, cy: f64, width: u32, height: u32) -> Self {
364        Self {
365            fx,
366            fy,
367            cx,
368            cy,
369            width,
370            height,
371        }
372    }
373    /// Field of view in x (radians).
374    pub fn fov_x(&self) -> f64 {
375        2.0 * (self.width as f64 / (2.0 * self.fx)).atan()
376    }
377    /// Field of view in y (radians).
378    pub fn fov_y(&self) -> f64 {
379        2.0 * (self.height as f64 / (2.0 * self.fy)).atan()
380    }
381    /// Project a 3-D point `[x,y,z]` (camera frame) to pixel `[u,v]`.
382    ///
383    /// Returns `None` if `z <= 0`.
384    pub fn project(&self, point: Vec3) -> Option<[f64; 2]> {
385        let z = point[2];
386        if z <= 0.0 {
387            return None;
388        }
389        let u = self.fx * point[0] / z + self.cx;
390        let v = self.fy * point[1] / z + self.cy;
391        Some([u, v])
392    }
393}
394/// A single point in a robot-mounted scanner point cloud.
395#[derive(Debug, Clone, Copy)]
396pub struct ScanPoint {
397    /// 3-D position in robot base frame `[x, y, z]` (metres).
398    pub position: Vec3,
399    /// Intensity (if available, else `0`).
400    pub intensity: f32,
401    /// Scan ring index (for multi-layer lidars).
402    pub ring: u16,
403    /// Return number (1-based, for multi-return lidars).
404    pub return_num: u8,
405}
406impl ScanPoint {
407    /// Construct a point from position only.
408    pub fn from_position(pos: Vec3) -> Self {
409        Self {
410            position: pos,
411            intensity: 0.0,
412            ring: 0,
413            return_num: 1,
414        }
415    }
416}
417/// Coefficients `[a0, a1, a2, a3]` for a cubic polynomial `a0 + a1*t + a2*t² + a3*t³`.
418#[derive(Debug, Clone, Copy)]
419pub(super) struct CubicCoeffs {
420    pub(super) a0: f64,
421    pub(super) a1: f64,
422    pub(super) a2: f64,
423    pub(super) a3: f64,
424}
425impl CubicCoeffs {
426    fn eval(&self, t: f64) -> f64 {
427        self.a0 + self.a1 * t + self.a2 * t * t + self.a3 * t * t * t
428    }
429    fn deriv(&self, t: f64) -> f64 {
430        self.a1 + 2.0 * self.a2 * t + 3.0 * self.a3 * t * t
431    }
432}
433/// A visual or collision element attached to a URDF link.
434#[derive(Debug, Clone)]
435pub struct UrdfVisualElement {
436    /// Optional name of the visual element.
437    pub name: Option<String>,
438    /// Origin pose: translation `[x,y,z]` in metres.
439    pub origin_xyz: Vec3,
440    /// Origin pose: rotation in roll-pitch-yaw (radians).
441    pub origin_rpy: Vec3,
442    /// Geometry description.
443    pub geometry: UrdfGeometry,
444    /// Optional material name.
445    pub material: Option<String>,
446}
447impl UrdfVisualElement {
448    /// Create a new visual element at the identity origin.
449    pub fn new(geometry: UrdfGeometry) -> Self {
450        Self {
451            name: None,
452            origin_xyz: [0.0; 3],
453            origin_rpy: [0.0; 3],
454            geometry,
455            material: None,
456        }
457    }
458    /// Approximate bounding-sphere radius of the geometry.
459    pub fn bounding_radius(&self) -> f64 {
460        match &self.geometry {
461            UrdfGeometry::Box { half_extents } => norm3(*half_extents),
462            UrdfGeometry::Sphere { radius } => *radius,
463            UrdfGeometry::Cylinder {
464                radius,
465                half_length,
466            } => (radius * radius + half_length * half_length).sqrt(),
467            UrdfGeometry::Mesh { scale, .. } => norm3(*scale),
468        }
469    }
470}
471/// A stamped wrench measurement (time + value).
472#[derive(Debug, Clone, Copy)]
473pub struct WrenchStamped {
474    /// Timestamp in seconds.
475    pub timestamp: f64,
476    /// The wrench measurement.
477    pub wrench: Wrench,
478}
479impl WrenchStamped {
480    /// Construct a stamped wrench.
481    pub fn new(timestamp: f64, wrench: Wrench) -> Self {
482        Self { timestamp, wrench }
483    }
484}
485/// Camera-to-robot extrinsic calibration (a rigid-body transform).
486#[derive(Debug, Clone)]
487pub struct CameraRobotCalibration {
488    /// Transform from camera frame to robot base frame.
489    pub camera_to_robot: Mat4,
490    /// Transform from robot base frame to camera frame.
491    pub robot_to_camera: Mat4,
492    /// Camera intrinsics.
493    pub intrinsics: CameraIntrinsics,
494    /// Calibration residual (reprojection error in pixels).
495    pub residual_pixels: f64,
496    /// Calibration date (ISO 8601 string, informational).
497    pub calibration_date: String,
498}
499impl CameraRobotCalibration {
500    /// Construct a calibration with identity transform.
501    pub fn identity(intrinsics: CameraIntrinsics) -> Self {
502        Self {
503            camera_to_robot: mat4_identity(),
504            robot_to_camera: mat4_identity(),
505            intrinsics,
506            residual_pixels: 0.0,
507            calibration_date: "2026-01-01".to_string(),
508        }
509    }
510    /// Transform a 3-D point from camera frame to robot base frame.
511    pub fn camera_to_robot_point(&self, p: Vec3) -> Vec3 {
512        apply_transform(&self.camera_to_robot, p)
513    }
514    /// Transform a 3-D point from robot base frame to camera frame.
515    pub fn robot_to_camera_point(&self, p: Vec3) -> Vec3 {
516        apply_transform(&self.robot_to_camera, p)
517    }
518}
519/// A 6-DOF wrench: force `[fx, fy, fz]` (N) and torque `[tx, ty, tz]` (N·m).
520#[derive(Debug, Clone, Copy, Default)]
521pub struct Wrench {
522    /// Force vector `[fx, fy, fz]` in Newtons.
523    pub force: Vec3,
524    /// Torque vector `[tx, ty, tz]` in Newton-metres.
525    pub torque: Vec3,
526}
527impl Wrench {
528    /// Create a wrench from force and torque arrays.
529    pub fn new(force: Vec3, torque: Vec3) -> Self {
530        Self { force, torque }
531    }
532    /// Magnitude of the force vector.
533    pub fn force_magnitude(&self) -> f64 {
534        norm3(self.force)
535    }
536    /// Magnitude of the torque vector.
537    pub fn torque_magnitude(&self) -> f64 {
538        norm3(self.torque)
539    }
540    /// Combine two wrenches by addition.
541    pub fn add(&self, other: &Wrench) -> Wrench {
542        Wrench {
543            force: add3(self.force, other.force),
544            torque: add3(self.torque, other.torque),
545        }
546    }
547    /// Scale the wrench by a scalar.
548    pub fn scale(&self, s: f64) -> Wrench {
549        Wrench {
550            force: scale3(self.force, s),
551            torque: scale3(self.torque, s),
552        }
553    }
554    /// Serialise to bytes (little-endian f64).
555    pub fn to_bytes(&self) -> [u8; 48] {
556        let mut buf = [0u8; 48];
557        for (i, v) in self.force.iter().chain(self.torque.iter()).enumerate() {
558            buf[i * 8..(i + 1) * 8].copy_from_slice(&v.to_le_bytes());
559        }
560        buf
561    }
562}
563/// Message type identifier used in the simplified ROS-like protocol.
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565pub enum MsgType {
566    /// Joint state message.
567    JointState = 1,
568    /// Wrench (force/torque) message.
569    Wrench = 2,
570    /// Gripper state message.
571    GripperState = 3,
572    /// Point cloud message.
573    PointCloud = 4,
574    /// Generic data blob.
575    Generic = 255,
576}
577/// Inertial properties of a URDF link.
578#[derive(Debug, Clone)]
579pub struct UrdfInertial {
580    /// Mass in kilograms.
581    pub mass: f64,
582    /// Centre of mass position `[x,y,z]`.
583    pub com: Vec3,
584    /// Inertia tensor diagonal `[ixx, iyy, izz]` (kg·m²).
585    pub inertia_diag: Vec3,
586    /// Off-diagonal inertia `[ixy, ixz, iyz]` (kg·m²).
587    pub inertia_off: Vec3,
588}
589/// Geometry kind attached to a URDF link's visual or collision element.
590#[derive(Debug, Clone, PartialEq)]
591pub enum UrdfGeometry {
592    /// Box primitive with half-extents `[hx, hy, hz]` in metres.
593    Box {
594        /// Half-extents (x, y, z) in metres.
595        half_extents: Vec3,
596    },
597    /// Sphere primitive.
598    Sphere {
599        /// Radius in metres.
600        radius: f64,
601    },
602    /// Cylinder primitive.
603    Cylinder {
604        /// Radius in metres.
605        radius: f64,
606        /// Half-length in metres.
607        half_length: f64,
608    },
609    /// Mesh reference (path to file, optional scale).
610    Mesh {
611        /// Path to the mesh file.
612        filename: String,
613        /// Scale factor applied to the mesh.
614        scale: Vec3,
615    },
616}
617/// Joint type as found in URDF.
618#[derive(Debug, Clone, PartialEq)]
619pub enum JointType {
620    /// Fixed joint (no movement).
621    Fixed,
622    /// Revolute (rotational, with limits).
623    Revolute,
624    /// Prismatic (translational, with limits).
625    Prismatic,
626    /// Continuous (revolute, no limits).
627    Continuous,
628    /// Planar (movement in a plane).
629    Planar,
630    /// Floating (6-DOF).
631    Floating,
632}
633/// A single link in a URDF robot description.
634#[derive(Debug, Clone)]
635pub struct UrdfLink {
636    /// Unique link name.
637    pub name: String,
638    /// Inertial properties.
639    pub inertial: UrdfInertial,
640    /// Visual geometry elements.
641    pub visuals: Vec<UrdfVisualElement>,
642    /// Collision geometry elements.
643    pub collisions: Vec<UrdfVisualElement>,
644}
645impl UrdfLink {
646    /// Construct a new link with the given name and default inertial.
647    pub fn new(name: impl Into<String>) -> Self {
648        Self {
649            name: name.into(),
650            inertial: UrdfInertial::default(),
651            visuals: Vec::new(),
652            collisions: Vec::new(),
653        }
654    }
655    /// Total approximate volume of all visual bounding spheres.
656    pub fn approximate_visual_volume(&self) -> f64 {
657        self.visuals
658            .iter()
659            .map(|v| {
660                let r = v.bounding_radius();
661                (4.0 / 3.0) * std::f64::consts::PI * r * r * r
662            })
663            .sum()
664    }
665}
666/// A single waypoint in a robot trajectory.
667#[derive(Debug, Clone)]
668pub struct JointWaypoint {
669    /// Time from the start of the trajectory (seconds).
670    pub time: f64,
671    /// Joint positions at this waypoint.
672    pub positions: Vec<f64>,
673    /// Optional joint velocities.
674    pub velocities: Option<Vec<f64>>,
675    /// Optional joint accelerations.
676    pub accelerations: Option<Vec<f64>>,
677}
678impl JointWaypoint {
679    /// Construct a waypoint with only positions specified.
680    pub fn new(time: f64, positions: Vec<f64>) -> Self {
681        Self {
682            time,
683            positions,
684            velocities: None,
685            accelerations: None,
686        }
687    }
688}
689/// A framed message in the simplified binary protocol.
690///
691/// Wire format: `[magic:4][msg_type:1][seq:4][payload_len:4][payload:N][crc:4]`
692#[derive(Debug, Clone)]
693pub struct RosMsg {
694    /// Message type.
695    pub msg_type: MsgType,
696    /// Sequence number.
697    pub seq: u32,
698    /// Raw payload bytes.
699    pub payload: Vec<u8>,
700}
701impl RosMsg {
702    /// Construct a new message.
703    pub fn new(msg_type: MsgType, seq: u32, payload: Vec<u8>) -> Self {
704        Self {
705            msg_type,
706            seq,
707            payload,
708        }
709    }
710    /// Simple 32-bit checksum (sum of all payload bytes, modulo 2³²).
711    fn checksum(data: &[u8]) -> u32 {
712        data.iter().fold(0u32, |acc, &b| acc.wrapping_add(b as u32))
713    }
714    /// Serialise the message to wire bytes.
715    pub fn to_bytes(&self) -> Vec<u8> {
716        let len = self.payload.len() as u32;
717        let crc = Self::checksum(&self.payload);
718        let mut buf = Vec::with_capacity(17 + self.payload.len());
719        buf.extend_from_slice(&ROS_MAGIC);
720        buf.push(self.msg_type as u8);
721        buf.extend_from_slice(&self.seq.to_le_bytes());
722        buf.extend_from_slice(&len.to_le_bytes());
723        buf.extend_from_slice(&self.payload);
724        buf.extend_from_slice(&crc.to_le_bytes());
725        buf
726    }
727    /// Deserialise from wire bytes.  Returns `None` if the frame is invalid.
728    pub fn from_bytes(data: &[u8]) -> Option<Self> {
729        if data.len() < 17 {
730            return None;
731        }
732        if data[0..4] != ROS_MAGIC {
733            return None;
734        }
735        let msg_type = MsgType::try_from(data[4]).ok()?;
736        let seq = u32::from_le_bytes(data[5..9].try_into().ok()?);
737        let len = u32::from_le_bytes(data[9..13].try_into().ok()?) as usize;
738        if data.len() < 13 + len + 4 {
739            return None;
740        }
741        let payload = data[13..13 + len].to_vec();
742        let crc_stored = u32::from_le_bytes(data[13 + len..17 + len].try_into().ok()?);
743        let crc_computed = Self::checksum(&payload);
744        if crc_stored != crc_computed {
745            return None;
746        }
747        Some(Self {
748            msg_type,
749            seq,
750            payload,
751        })
752    }
753}
754/// A point cloud from a robot-mounted scanner.
755#[derive(Debug, Default, Clone)]
756pub struct RobotPointCloud {
757    /// Timestamp in seconds (scan start).
758    pub timestamp: f64,
759    /// Points in robot base frame.
760    pub points: Vec<ScanPoint>,
761    /// Frame ID (e.g. "base_link").
762    pub frame_id: String,
763    /// Scanner model name.
764    pub scanner_model: String,
765}
766impl RobotPointCloud {
767    /// Create an empty point cloud.
768    pub fn new(timestamp: f64, frame_id: impl Into<String>) -> Self {
769        Self {
770            timestamp,
771            points: Vec::new(),
772            frame_id: frame_id.into(),
773            scanner_model: "unknown".to_string(),
774        }
775    }
776    /// Number of points.
777    pub fn len(&self) -> usize {
778        self.points.len()
779    }
780    /// Return `true` if the cloud is empty.
781    pub fn is_empty(&self) -> bool {
782        self.points.is_empty()
783    }
784    /// Axis-aligned bounding box of the cloud: `(min, max)`.
785    pub fn aabb(&self) -> Option<(Vec3, Vec3)> {
786        if self.points.is_empty() {
787            return None;
788        }
789        let mut mn = [f64::INFINITY; 3];
790        let mut mx = [f64::NEG_INFINITY; 3];
791        for p in &self.points {
792            for k in 0..3 {
793                if p.position[k] < mn[k] {
794                    mn[k] = p.position[k];
795                }
796                if p.position[k] > mx[k] {
797                    mx[k] = p.position[k];
798                }
799            }
800        }
801        Some((mn, mx))
802    }
803    /// Mean point position.
804    pub fn centroid(&self) -> Option<Vec3> {
805        if self.points.is_empty() {
806            return None;
807        }
808        let n = self.points.len() as f64;
809        let mut sum = [0.0; 3];
810        for p in &self.points {
811            sum[0] += p.position[0];
812            sum[1] += p.position[1];
813            sum[2] += p.position[2];
814        }
815        Some([sum[0] / n, sum[1] / n, sum[2] / n])
816    }
817    /// Apply an in-place transform to all points.
818    pub fn transform_in_place(&mut self, m: &Mat4) {
819        for p in &mut self.points {
820            p.position = apply_transform(m, p.position);
821        }
822    }
823    /// Downsample by keeping every `stride`-th point.
824    pub fn downsample(&self, stride: usize) -> Self {
825        let stride = stride.max(1);
826        let points: Vec<ScanPoint> = self
827            .points
828            .iter()
829            .enumerate()
830            .filter(|(i, _)| i % stride == 0)
831            .map(|(_, p)| *p)
832            .collect();
833        Self {
834            timestamp: self.timestamp,
835            points,
836            frame_id: self.frame_id.clone(),
837            scanner_model: self.scanner_model.clone(),
838        }
839    }
840    /// Serialise to a minimal binary PCD-inspired format.
841    ///
842    /// Format: `[n_points:u32][point:4×f32]×n`  (x,y,z,intensity as f32).
843    pub fn to_bytes(&self) -> Vec<u8> {
844        let n = self.points.len();
845        let mut buf = Vec::with_capacity(4 + n * 16);
846        buf.extend_from_slice(&(n as u32).to_le_bytes());
847        for p in &self.points {
848            for k in 0..3 {
849                buf.extend_from_slice(&(p.position[k] as f32).to_le_bytes());
850            }
851            buf.extend_from_slice(&p.intensity.to_le_bytes());
852        }
853        buf
854    }
855    /// Deserialise from the binary format produced by `to_bytes`.
856    pub fn from_bytes(data: &[u8]) -> Option<Self> {
857        if data.len() < 4 {
858            return None;
859        }
860        let n = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;
861        if data.len() < 4 + n * 16 {
862            return None;
863        }
864        let mut points = Vec::with_capacity(n);
865        for i in 0..n {
866            let base = 4 + i * 16;
867            let read_f32 = |b: &[u8]| f32::from_le_bytes(b.try_into().unwrap_or([0u8; 4]));
868            let x = read_f32(&data[base..base + 4]) as f64;
869            let y = read_f32(&data[base + 4..base + 8]) as f64;
870            let z = read_f32(&data[base + 8..base + 12]) as f64;
871            let intensity = read_f32(&data[base + 12..base + 16]);
872            points.push(ScanPoint {
873                position: [x, y, z],
874                intensity,
875                ring: 0,
876                return_num: 1,
877            });
878        }
879        Some(Self {
880            timestamp: 0.0,
881            points,
882            frame_id: String::new(),
883            scanner_model: String::new(),
884        })
885    }
886}
887/// Limits for a URDF joint.
888#[derive(Debug, Clone)]
889pub struct JointLimits {
890    /// Lower position limit (rad or m).
891    pub lower: f64,
892    /// Upper position limit (rad or m).
893    pub upper: f64,
894    /// Maximum effort (N or N·m).
895    pub effort: f64,
896    /// Maximum velocity (rad/s or m/s).
897    pub velocity: f64,
898}
899impl JointLimits {
900    /// Return `true` if `q` lies within `[lower, upper]`.
901    pub fn in_range(&self, q: f64) -> bool {
902        q >= self.lower && q <= self.upper
903    }
904    /// Clamp `q` to `[lower, upper]`.
905    pub fn clamp(&self, q: f64) -> f64 {
906        q.clamp(self.lower, self.upper)
907    }
908    /// Range of motion.
909    pub fn range(&self) -> f64 {
910        self.upper - self.lower
911    }
912}
913/// State of a parallel-jaw gripper.
914#[derive(Debug, Clone, Copy)]
915pub struct GripperState {
916    /// Current jaw opening width in metres (`0` = fully closed, `max_width` = fully open).
917    pub position: f64,
918    /// Current gripping force in Newtons.
919    pub force: f64,
920    /// Maximum jaw opening width in metres.
921    pub max_width: f64,
922    /// Whether an object is detected in the gripper.
923    pub object_detected: bool,
924    /// Gripper temperature (°C), if available.
925    pub temperature: f64,
926}
927impl GripperState {
928    /// Normalised opening (`0` = closed, `1` = fully open).
929    pub fn openness(&self) -> f64 {
930        if self.max_width < 1e-9 {
931            0.0
932        } else {
933            (self.position / self.max_width).clamp(0.0, 1.0)
934        }
935    }
936    /// Return `true` if the gripper is effectively closed (within 1 mm).
937    pub fn is_closed(&self) -> bool {
938        self.position < 1e-3
939    }
940    /// Serialise to bytes.
941    pub fn to_bytes(&self) -> [u8; 40] {
942        let mut buf = [0u8; 40];
943        buf[0..8].copy_from_slice(&self.position.to_le_bytes());
944        buf[8..16].copy_from_slice(&self.force.to_le_bytes());
945        buf[16..24].copy_from_slice(&self.max_width.to_le_bytes());
946        buf[24..32].copy_from_slice(&self.temperature.to_le_bytes());
947        buf[32] = self.object_detected as u8;
948        buf
949    }
950    /// Deserialise from bytes.
951    pub fn from_bytes(b: &[u8; 40]) -> Self {
952        let read = |s: &[u8]| f64::from_le_bytes(s.try_into().unwrap_or([0u8; 8]));
953        Self {
954            position: read(&b[0..8]),
955            force: read(&b[8..16]),
956            max_width: read(&b[16..24]),
957            temperature: read(&b[24..32]),
958            object_detected: b[32] != 0,
959        }
960    }
961}
962/// A complete robot trajectory consisting of [`JointWaypoint`]s.
963#[derive(Debug, Default, Clone)]
964pub struct RobotTrajectory {
965    /// Ordered waypoints (should be sorted by time).
966    pub waypoints: Vec<JointWaypoint>,
967    /// Joint names (informational).
968    pub joint_names: Vec<String>,
969}
970impl RobotTrajectory {
971    /// Create an empty trajectory for `joint_names`.
972    pub fn new(joint_names: Vec<String>) -> Self {
973        Self {
974            waypoints: Vec::new(),
975            joint_names,
976        }
977    }
978    /// Append a waypoint.
979    pub fn push(&mut self, wp: JointWaypoint) {
980        self.waypoints.push(wp);
981    }
982    /// Total duration of the trajectory (last waypoint time).
983    pub fn duration(&self) -> f64 {
984        self.waypoints.last().map(|w| w.time).unwrap_or(0.0)
985    }
986    /// Number of degrees of freedom (from first waypoint).
987    pub fn dof(&self) -> usize {
988        self.waypoints
989            .first()
990            .map(|w| w.positions.len())
991            .unwrap_or(0)
992    }
993    /// Cubic spline interpolation at time `t`.
994    ///
995    /// Uses natural boundary conditions (zero velocity at endpoints if not specified).
996    /// Returns `None` if the trajectory has fewer than 2 waypoints.
997    pub fn interpolate(&self, t: f64) -> Option<Vec<f64>> {
998        let wps = &self.waypoints;
999        if wps.len() < 2 {
1000            return None;
1001        }
1002        let dof = self.dof();
1003        let t_clamped = t.clamp(wps[0].time, wps[wps.len() - 1].time);
1004        let seg = wps
1005            .windows(2)
1006            .enumerate()
1007            .find(|(_, pair)| t_clamped >= pair[0].time && t_clamped <= pair[1].time)
1008            .map(|(i, _)| i)
1009            .unwrap_or(wps.len() - 2);
1010        let t0 = wps[seg].time;
1011        let t1 = wps[seg + 1].time;
1012        let dt = (t1 - t0).max(1e-12);
1013        let u = (t_clamped - t0) / dt;
1014        let mut result = vec![0.0; dof];
1015        for j in 0..dof {
1016            let p0 = wps[seg].positions[j];
1017            let p1 = wps[seg + 1].positions[j];
1018            let v0 = wps[seg]
1019                .velocities
1020                .as_ref()
1021                .map(|v| v[j] * dt)
1022                .unwrap_or(0.0);
1023            let v1 = wps[seg + 1]
1024                .velocities
1025                .as_ref()
1026                .map(|v| v[j] * dt)
1027                .unwrap_or(0.0);
1028            let c = CubicCoeffs {
1029                a0: p0,
1030                a1: v0,
1031                a2: 3.0 * (p1 - p0) - 2.0 * v0 - v1,
1032                a3: -2.0 * (p1 - p0) + v0 + v1,
1033            };
1034            result[j] = c.eval(u);
1035        }
1036        Some(result)
1037    }
1038    /// Sample the trajectory at `n_samples` uniformly spaced times.
1039    pub fn sample_uniform(&self, n_samples: usize) -> Vec<(f64, Vec<f64>)> {
1040        if n_samples == 0 || self.waypoints.is_empty() {
1041            return Vec::new();
1042        }
1043        let dur = self.duration();
1044        (0..n_samples)
1045            .filter_map(|i| {
1046                let t = if n_samples == 1 {
1047                    0.0
1048                } else {
1049                    i as f64 * dur / (n_samples - 1) as f64
1050                };
1051                self.interpolate(t).map(|q| (t, q))
1052            })
1053            .collect()
1054    }
1055    /// Serialise to CSV with header.
1056    pub fn to_csv(&self) -> String {
1057        let header: Vec<String> = std::iter::once("time".to_string())
1058            .chain(self.joint_names.iter().cloned())
1059            .collect();
1060        let mut lines = vec![header.join(",")];
1061        for wp in &self.waypoints {
1062            let row: Vec<String> = std::iter::once(format!("{:.6}", wp.time))
1063                .chain(wp.positions.iter().map(|p| format!("{:.6}", p)))
1064                .collect();
1065            lines.push(row.join(","));
1066        }
1067        lines.join("\n")
1068    }
1069}
1070/// A simplified URDF robot model: a collection of links and joints.
1071#[derive(Debug, Default, Clone)]
1072pub struct UrdfRobot {
1073    /// Robot name.
1074    pub name: String,
1075    /// All links, keyed by name.
1076    pub links: HashMap<String, UrdfLink>,
1077    /// All joints, keyed by name.
1078    pub joints: HashMap<String, UrdfJoint>,
1079    /// Ordered list of joint names (insertion order).
1080    pub joint_order: Vec<String>,
1081}
1082impl UrdfRobot {
1083    /// Create an empty robot with the given name.
1084    pub fn new(name: impl Into<String>) -> Self {
1085        Self {
1086            name: name.into(),
1087            ..Default::default()
1088        }
1089    }
1090    /// Add a link.  Overwrites if a link with the same name exists.
1091    pub fn add_link(&mut self, link: UrdfLink) {
1092        self.links.insert(link.name.clone(), link);
1093    }
1094    /// Add a joint.  Overwrites if a joint with the same name exists.
1095    pub fn add_joint(&mut self, joint: UrdfJoint) {
1096        if !self.joint_order.contains(&joint.name) {
1097            self.joint_order.push(joint.name.clone());
1098        }
1099        self.joints.insert(joint.name.clone(), joint);
1100    }
1101    /// Return a list of moveable joint names in insertion order.
1102    pub fn moveable_joint_names(&self) -> Vec<&str> {
1103        self.joint_order
1104            .iter()
1105            .filter(|n| {
1106                self.joints
1107                    .get(n.as_str())
1108                    .map(|j| j.is_moveable())
1109                    .unwrap_or(false)
1110            })
1111            .map(|s| s.as_str())
1112            .collect()
1113    }
1114    /// Total number of degrees of freedom (moveable joints).
1115    pub fn dof(&self) -> usize {
1116        self.moveable_joint_names().len()
1117    }
1118    /// Total mass of the robot (sum of all link masses).
1119    pub fn total_mass(&self) -> f64 {
1120        self.links.values().map(|l| l.inertial.mass).sum()
1121    }
1122    /// Serialise the robot description to a human-readable text form.
1123    pub fn to_text(&self) -> String {
1124        let mut out = format!("robot name=\"{}\"\n", self.name);
1125        for (name, link) in &self.links {
1126            out += &format!("  link \"{name}\" mass={:.4} kg\n", link.inertial.mass);
1127        }
1128        for name in &self.joint_order {
1129            if let Some(j) = self.joints.get(name) {
1130                out += &format!(
1131                    "  joint \"{}\" type={} parent=\"{}\" child=\"{}\"\n",
1132                    j.name, j.joint_type, j.parent, j.child
1133                );
1134            }
1135        }
1136        out
1137    }
1138    /// Parse a minimal URDF-like text representation produced by [`UrdfRobot::to_text`].
1139    ///
1140    /// This is intentionally a simplified round-trip parser, not a full XML URDF parser.
1141    pub fn from_text(text: &str) -> Option<Self> {
1142        let mut robot = UrdfRobot::default();
1143        for line in text.lines() {
1144            let line = line.trim();
1145            if let Some(rest) = line.strip_prefix("robot name=\"") {
1146                robot.name = rest.trim_end_matches('"').to_string();
1147            } else if let Some(rest) = line.strip_prefix("link \"") {
1148                let parts: Vec<&str> = rest.splitn(2, '"').collect();
1149                if let Some(name) = parts.first() {
1150                    let mass_str = rest
1151                        .find("mass=")
1152                        .map(|i| &rest[i + 5..])
1153                        .and_then(|s| s.split_whitespace().next())
1154                        .and_then(|s| s.trim_end_matches(" kg").parse::<f64>().ok())
1155                        .unwrap_or(1.0);
1156                    let mut link = UrdfLink::new(*name);
1157                    link.inertial.mass = mass_str;
1158                    robot.add_link(link);
1159                }
1160            }
1161        }
1162        if robot.name.is_empty() {
1163            None
1164        } else {
1165            Some(robot)
1166        }
1167    }
1168}