Skip to main content

use_joint/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Primitive robot joint vocabulary.
5
6use core::{fmt, str::FromStr};
7use std::error::Error;
8
9/// A non-empty joint name.
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct JointName(String);
12
13impl JointName {
14    /// Creates a joint name from non-empty text.
15    ///
16    /// # Errors
17    ///
18    /// Returns [`JointTextError::Empty`] when the trimmed name is empty.
19    pub fn new(value: impl AsRef<str>) -> Result<Self, JointTextError> {
20        non_empty_joint_text(value).map(Self)
21    }
22
23    /// Returns the joint name text.
24    #[must_use]
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28
29    /// Consumes the name and returns the owned string.
30    #[must_use]
31    pub fn into_string(self) -> String {
32        self.0
33    }
34}
35
36impl AsRef<str> for JointName {
37    fn as_ref(&self) -> &str {
38        self.as_str()
39    }
40}
41
42impl fmt::Display for JointName {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        formatter.write_str(self.as_str())
45    }
46}
47
48impl FromStr for JointName {
49    type Err = JointTextError;
50
51    fn from_str(value: &str) -> Result<Self, Self::Err> {
52        Self::new(value)
53    }
54}
55
56/// Descriptive robot joint vocabulary.
57#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub enum JointKind {
59    /// Revolute joint.
60    Revolute,
61    /// Prismatic joint.
62    Prismatic,
63    /// Fixed joint.
64    Fixed,
65    /// Continuous joint.
66    Continuous,
67    /// Spherical joint.
68    Spherical,
69    /// Planar joint.
70    Planar,
71    /// Floating joint.
72    Floating,
73    /// Unknown joint kind.
74    Unknown,
75    /// Caller-defined joint kind text.
76    Custom(String),
77}
78
79impl fmt::Display for JointKind {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        formatter.write_str(match self {
82            Self::Revolute => "revolute",
83            Self::Prismatic => "prismatic",
84            Self::Fixed => "fixed",
85            Self::Continuous => "continuous",
86            Self::Spherical => "spherical",
87            Self::Planar => "planar",
88            Self::Floating => "floating",
89            Self::Unknown => "unknown",
90            Self::Custom(value) => value.as_str(),
91        })
92    }
93}
94
95impl FromStr for JointKind {
96    type Err = JointKindParseError;
97
98    fn from_str(value: &str) -> Result<Self, Self::Err> {
99        let trimmed = value.trim();
100        if trimmed.is_empty() {
101            return Err(JointKindParseError::Empty);
102        }
103
104        match normalized_token(trimmed).as_str() {
105            "revolute" => Ok(Self::Revolute),
106            "prismatic" => Ok(Self::Prismatic),
107            "fixed" => Ok(Self::Fixed),
108            "continuous" => Ok(Self::Continuous),
109            "spherical" => Ok(Self::Spherical),
110            "planar" => Ok(Self::Planar),
111            "floating" => Ok(Self::Floating),
112            "unknown" => Ok(Self::Unknown),
113            _ => Ok(Self::Custom(trimmed.to_string())),
114        }
115    }
116}
117
118/// Optional descriptive joint limits.
119#[derive(Clone, Copy, Debug, PartialEq)]
120pub struct JointLimit {
121    minimum: Option<f64>,
122    maximum: Option<f64>,
123}
124
125impl JointLimit {
126    /// Creates optional joint limits from finite numeric bounds.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`JointLimitError::NonFinite`] when a bound is not finite, or
131    /// [`JointLimitError::Inverted`] when the minimum is greater than the maximum.
132    pub fn new(minimum: Option<f64>, maximum: Option<f64>) -> Result<Self, JointLimitError> {
133        if minimum.is_some_and(|value| !value.is_finite())
134            || maximum.is_some_and(|value| !value.is_finite())
135        {
136            return Err(JointLimitError::NonFinite);
137        }
138
139        if let (Some(minimum), Some(maximum)) = (minimum, maximum)
140            && minimum > maximum
141        {
142            return Err(JointLimitError::Inverted);
143        }
144
145        Ok(Self { minimum, maximum })
146    }
147
148    /// Creates unbounded descriptive joint limits.
149    #[must_use]
150    pub const fn unbounded() -> Self {
151        Self {
152            minimum: None,
153            maximum: None,
154        }
155    }
156
157    /// Returns the optional minimum limit.
158    #[must_use]
159    pub const fn minimum(self) -> Option<f64> {
160        self.minimum
161    }
162
163    /// Returns the optional maximum limit.
164    #[must_use]
165    pub const fn maximum(self) -> Option<f64> {
166        self.maximum
167    }
168}
169
170/// Descriptive joint axis labels.
171#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
172pub enum JointAxis {
173    /// X axis label.
174    X,
175    /// Y axis label.
176    Y,
177    /// Z axis label.
178    Z,
179    /// Unknown axis label.
180    Unknown,
181    /// Caller-defined axis label.
182    Custom(String),
183}
184
185impl fmt::Display for JointAxis {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        formatter.write_str(match self {
188            Self::X => "x",
189            Self::Y => "y",
190            Self::Z => "z",
191            Self::Unknown => "unknown",
192            Self::Custom(value) => value.as_str(),
193        })
194    }
195}
196
197impl FromStr for JointAxis {
198    type Err = JointAxisParseError;
199
200    fn from_str(value: &str) -> Result<Self, Self::Err> {
201        let trimmed = value.trim();
202        if trimmed.is_empty() {
203            return Err(JointAxisParseError::Empty);
204        }
205
206        match normalized_token(trimmed).as_str() {
207            "x" | "x-axis" => Ok(Self::X),
208            "y" | "y-axis" => Ok(Self::Y),
209            "z" | "z-axis" => Ok(Self::Z),
210            "unknown" => Ok(Self::Unknown),
211            _ => Ok(Self::Custom(trimmed.to_string())),
212        }
213    }
214}
215
216/// A zero-based joint index label.
217#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
218pub struct JointIndex(usize);
219
220impl JointIndex {
221    /// Creates a zero-based joint index.
222    #[must_use]
223    pub const fn new(index: usize) -> Self {
224        Self(index)
225    }
226
227    /// Returns the index value.
228    #[must_use]
229    pub const fn get(self) -> usize {
230        self.0
231    }
232}
233
234impl fmt::Display for JointIndex {
235    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
236        self.0.fmt(formatter)
237    }
238}
239
240/// Errors returned while constructing joint text values.
241#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242pub enum JointTextError {
243    /// The value was empty after trimming whitespace.
244    Empty,
245}
246
247impl fmt::Display for JointTextError {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        match self {
250            Self::Empty => formatter.write_str("joint text cannot be empty"),
251        }
252    }
253}
254
255impl Error for JointTextError {}
256
257/// Error returned when parsing joint kinds fails.
258#[derive(Clone, Copy, Debug, Eq, PartialEq)]
259pub enum JointKindParseError {
260    /// The joint kind was empty after trimming whitespace.
261    Empty,
262}
263
264impl fmt::Display for JointKindParseError {
265    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
266        match self {
267            Self::Empty => formatter.write_str("joint kind cannot be empty"),
268        }
269    }
270}
271
272impl Error for JointKindParseError {}
273
274/// Errors returned while constructing joint limits.
275#[derive(Clone, Copy, Debug, Eq, PartialEq)]
276pub enum JointLimitError {
277    /// Limit values must be finite.
278    NonFinite,
279    /// The minimum limit was greater than the maximum limit.
280    Inverted,
281}
282
283impl fmt::Display for JointLimitError {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        match self {
286            Self::NonFinite => formatter.write_str("joint limit values must be finite"),
287            Self::Inverted => formatter.write_str("joint minimum limit cannot exceed maximum"),
288        }
289    }
290}
291
292impl Error for JointLimitError {}
293
294/// Error returned when parsing joint axes fails.
295#[derive(Clone, Copy, Debug, Eq, PartialEq)]
296pub enum JointAxisParseError {
297    /// The axis label was empty after trimming whitespace.
298    Empty,
299}
300
301impl fmt::Display for JointAxisParseError {
302    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303        match self {
304            Self::Empty => formatter.write_str("joint axis cannot be empty"),
305        }
306    }
307}
308
309impl Error for JointAxisParseError {}
310
311fn non_empty_joint_text(value: impl AsRef<str>) -> Result<String, JointTextError> {
312    let trimmed = value.as_ref().trim();
313
314    if trimmed.is_empty() {
315        Err(JointTextError::Empty)
316    } else {
317        Ok(trimmed.to_string())
318    }
319}
320
321fn normalized_token(value: &str) -> String {
322    value
323        .trim()
324        .chars()
325        .map(|character| match character {
326            '_' | ' ' => '-',
327            other => other.to_ascii_lowercase(),
328        })
329        .collect()
330}
331
332#[cfg(test)]
333mod tests {
334    use super::{
335        JointIndex, JointKind, JointKindParseError, JointLimit, JointLimitError, JointName,
336        JointTextError,
337    };
338
339    #[test]
340    fn constructs_valid_joint_name() -> Result<(), JointTextError> {
341        let name = JointName::new("  shoulder-pan  ")?;
342
343        assert_eq!(name.as_str(), "shoulder-pan");
344        Ok(())
345    }
346
347    #[test]
348    fn rejects_empty_joint_name() {
349        assert_eq!(JointName::new(""), Err(JointTextError::Empty));
350    }
351
352    #[test]
353    fn displays_and_parses_joint_kind() -> Result<(), JointKindParseError> {
354        assert_eq!("revolute".parse::<JointKind>()?, JointKind::Revolute);
355        assert_eq!(JointKind::Prismatic.to_string(), "prismatic");
356        Ok(())
357    }
358
359    #[test]
360    fn stores_custom_joint_kind() -> Result<(), JointKindParseError> {
361        assert_eq!(
362            "parallel-elastic".parse::<JointKind>()?,
363            JointKind::Custom("parallel-elastic".to_string())
364        );
365        Ok(())
366    }
367
368    #[test]
369    fn constructs_joint_limits() -> Result<(), JointLimitError> {
370        let limit = JointLimit::new(Some(-1.0), Some(1.0))?;
371
372        assert_eq!(limit.minimum(), Some(-1.0));
373        assert_eq!(limit.maximum(), Some(1.0));
374        Ok(())
375    }
376
377    #[test]
378    fn constructs_joint_index() {
379        let index = JointIndex::new(0);
380
381        assert_eq!(index.get(), 0);
382        assert_eq!(index.to_string(), "0");
383    }
384}