1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
7use std::error::Error;
8
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct JointName(String);
12
13impl JointName {
14 pub fn new(value: impl AsRef<str>) -> Result<Self, JointTextError> {
20 non_empty_joint_text(value).map(Self)
21 }
22
23 #[must_use]
25 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28
29 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub enum JointKind {
59 Revolute,
61 Prismatic,
63 Fixed,
65 Continuous,
67 Spherical,
69 Planar,
71 Floating,
73 Unknown,
75 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#[derive(Clone, Copy, Debug, PartialEq)]
120pub struct JointLimit {
121 minimum: Option<f64>,
122 maximum: Option<f64>,
123}
124
125impl JointLimit {
126 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 #[must_use]
150 pub const fn unbounded() -> Self {
151 Self {
152 minimum: None,
153 maximum: None,
154 }
155 }
156
157 #[must_use]
159 pub const fn minimum(self) -> Option<f64> {
160 self.minimum
161 }
162
163 #[must_use]
165 pub const fn maximum(self) -> Option<f64> {
166 self.maximum
167 }
168}
169
170#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
172pub enum JointAxis {
173 X,
175 Y,
177 Z,
179 Unknown,
181 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
218pub struct JointIndex(usize);
219
220impl JointIndex {
221 #[must_use]
223 pub const fn new(index: usize) -> Self {
224 Self(index)
225 }
226
227 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
242pub enum JointTextError {
243 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
259pub enum JointKindParseError {
260 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
276pub enum JointLimitError {
277 NonFinite,
279 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
296pub enum JointAxisParseError {
297 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}