1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6
7use crate::asset::AssetId;
8use crate::component::capability::StructuralKind;
9use crate::error::{LinkRole, PoseOwner, StructureError};
10use crate::identity::{JointId, LinkId};
11
12const MIN_AXIS_NORM_SQUARED: f64 = 1.0e-16;
13const PSD_RELATIVE_TOLERANCE: f64 = 1.0e-12;
14
15pub(crate) const BASE_FOOTPRINT_LINK: &str = "base_footprint";
17pub(crate) const BASE_LINK: &str = "base_link";
19
20#[derive(Clone, Debug)]
22pub struct Structure {
23 document: Value,
24 links: Vec<Link>,
25 joints: Vec<Joint>,
26 materials: Vec<Material>,
27 root: LinkId,
32}
33
34#[derive(Clone, Debug)]
36pub struct Link {
37 name: LinkId,
38 inertial: Inertial,
39 visuals: Vec<Visual>,
40 collisions: Vec<Collision>,
41}
42
43#[derive(Clone, Debug)]
45pub struct Joint {
46 name: JointId,
47 kind: JointKind,
48 origin: Pose,
49 parent: LinkId,
50 child: LinkId,
51 axis: [f64; 3],
52 limit: JointLimit,
53 calibration: Option<Calibration>,
54 dynamics: Option<Dynamics>,
55 mimic: Option<Mimic>,
56 safety: Option<Safety>,
57}
58
59#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
61#[serde(deny_unknown_fields)]
62pub struct Pose {
63 xyz: [f64; 3],
64 rpy: [f64; 3],
65}
66
67#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
69#[serde(deny_unknown_fields)]
70pub struct Inertial {
71 origin: Pose,
72 mass_kg: f64,
73 inertia: Inertia,
74}
75
76#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct Inertia {
80 ixx: f64,
81 ixy: f64,
82 ixz: f64,
83 iyy: f64,
84 iyz: f64,
85 izz: f64,
86}
87
88#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
90#[serde(deny_unknown_fields)]
91pub struct Visual {
92 name: Option<String>,
93 origin: Pose,
94 geometry: Geometry,
95 material: Option<Material>,
96}
97
98#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct Collision {
102 name: Option<String>,
103 origin: Pose,
104 geometry: Geometry,
105}
106
107#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct Material {
111 name: String,
112 color: Option<[f64; 4]>,
113 texture: Option<AssetId>,
114}
115
116#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
118#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
119pub enum Geometry {
120 Box {
121 size: [f64; 3],
122 },
123 Cylinder {
124 radius: f64,
125 length: f64,
126 },
127 Capsule {
128 radius: f64,
129 length: f64,
130 },
131 Sphere {
132 radius: f64,
133 },
134 Mesh {
135 #[serde(rename = "filename")]
136 asset: AssetId,
137 scale: Option<[f64; 3]>,
138 },
139}
140
141#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
143#[serde(deny_unknown_fields)]
144pub struct JointLimit {
145 lower: f64,
146 upper: f64,
147 effort: f64,
148 velocity: f64,
149}
150
151#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
153#[serde(deny_unknown_fields)]
154pub struct Calibration {
155 rising: Option<f64>,
156 falling: Option<f64>,
157}
158
159#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
161#[serde(deny_unknown_fields)]
162pub struct Dynamics {
163 damping: f64,
164 friction: f64,
165}
166
167#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
169#[serde(deny_unknown_fields)]
170pub struct Mimic {
171 joint: JointId,
172 multiplier: Option<f64>,
173 offset: Option<f64>,
174}
175
176#[derive(phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize)]
178#[serde(deny_unknown_fields)]
179pub struct Safety {
180 soft_lower_limit: f64,
181 soft_upper_limit: f64,
182 k_position: f64,
183 k_velocity: f64,
184}
185
186#[derive(
188 phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq,
189)]
190#[serde(rename_all = "snake_case")]
191pub enum JointKind {
192 Revolute,
193 Continuous,
194 Prismatic,
195 Fixed,
196 Floating,
197 Planar,
198 Spherical,
199}
200
201impl Structure {
202 pub fn links(&self) -> impl ExactSizeIterator<Item = &Link> {
204 self.links.iter()
205 }
206
207 pub fn joints(&self) -> impl ExactSizeIterator<Item = &Joint> {
209 self.joints.iter()
210 }
211
212 pub fn materials(&self) -> impl ExactSizeIterator<Item = &Material> {
214 self.materials.iter()
215 }
216
217 pub fn asset_ids(&self) -> impl Iterator<Item = &AssetId> {
219 let mut seen = HashSet::new();
220 self.links
221 .iter()
222 .flat_map(|link| {
223 link.visuals()
224 .filter_map(|visual| visual.geometry().asset_id())
225 .chain(
226 link.collisions()
227 .filter_map(|collision| collision.geometry().asset_id()),
228 )
229 .chain(
230 link.visuals()
231 .filter_map(|visual| visual.material()?.texture()),
232 )
233 })
234 .chain(self.materials.iter().filter_map(Material::texture))
235 .filter(move |id| seen.insert(id.as_str()))
236 }
237
238 #[must_use]
240 pub fn link(&self, id: &str) -> Option<&Link> {
241 self.links.iter().find(|link| link.name == id)
242 }
243
244 #[must_use]
246 pub fn joint(&self, id: &str) -> Option<&Joint> {
247 self.joints.iter().find(|joint| joint.name == id)
248 }
249
250 #[must_use]
252 pub const fn root_link(&self) -> &LinkId {
253 &self.root
254 }
255
256 #[must_use]
258 pub fn parent_joint(&self, link: &str) -> Option<&Joint> {
259 self.joints.iter().find(|joint| joint.child() == link)
260 }
261
262 pub fn child_joints<'a>(&'a self, link: &'a str) -> impl Iterator<Item = &'a Joint> {
264 self.joints
265 .iter()
266 .filter(move |joint| joint.parent() == link)
267 }
268
269 fn validate(links: &[Link], joints: &[Joint]) -> Result<LinkId, StructureError> {
271 Self::validate_unique(
272 links.iter().map(|link| link.name.as_str()),
273 StructuralKind::Link,
274 )?;
275 Self::validate_unique(
276 joints.iter().map(|joint| joint.name.as_str()),
277 StructuralKind::Joint,
278 )?;
279 for link in links {
280 link.validate()?;
281 }
282 let joint_names = joints
283 .iter()
284 .map(|joint| joint.name.as_str())
285 .collect::<HashSet<_>>();
286 for joint in joints {
287 joint.validate(&joint_names)?;
288 }
289 let link_names = links.iter().map(Link::name).collect::<HashSet<_>>();
290 let mut children = HashSet::new();
291 let mut parent_by_child = HashMap::new();
292 for joint in joints {
293 for (role, link) in [
294 (LinkRole::Parent, joint.parent()),
295 (LinkRole::Child, joint.child()),
296 ] {
297 if !link_names.contains(link) {
298 return Err(StructureError::UnknownJointLink {
299 joint: joint.name().clone(),
300 role,
301 link: link.clone(),
302 });
303 }
304 }
305 if !children.insert(joint.child()) {
306 return Err(StructureError::MultipleParentJoints {
307 link: joint.child().clone(),
308 });
309 }
310 if joint.parent() == joint.child() {
311 return Err(StructureError::SelfReferentialJoint {
312 joint: joint.name().clone(),
313 link: joint.parent().clone(),
314 });
315 }
316 parent_by_child.insert(joint.child(), joint.parent());
317 }
318 let roots = links
319 .iter()
320 .map(Link::name)
321 .filter(|link| !children.contains(link))
322 .collect::<Vec<_>>();
323 let [root] = roots.as_slice() else {
324 return Err(StructureError::RootLinkCount { found: roots.len() });
325 };
326 for link in links {
327 let mut seen = HashSet::new();
328 let mut current = Some(link.name());
329 while let Some(link_id) = current {
330 if !seen.insert(link_id) {
331 return Err(StructureError::JointCycle {
332 link: link_id.clone(),
333 });
334 }
335 current = parent_by_child.get(link_id).copied();
336 }
337 }
338 Ok((*root).clone())
339 }
340
341 fn validate_unique<'a>(
342 names: impl Iterator<Item = &'a str>,
343 kind: StructuralKind,
344 ) -> Result<(), StructureError> {
345 let mut seen = HashSet::new();
346 for name in names {
347 if !seen.insert(name) {
348 return Err(StructureError::DuplicateIdentity {
349 kind,
350 name: name.to_string(),
351 });
352 }
353 }
354 Ok(())
355 }
356
357 pub(crate) fn validate_robot_frames(&self) -> Result<(), StructureError> {
360 if self.root != BASE_FOOTPRINT_LINK {
361 return Err(StructureError::RootLinkName {
362 expected: LinkId::new(BASE_FOOTPRINT_LINK),
363 found: self.root.clone(),
364 });
365 }
366 let base_joint = self
367 .joints
368 .iter()
369 .find(|joint| joint.child() == BASE_LINK)
370 .ok_or(StructureError::MissingBaseLink)?;
371 if base_joint.parent() != BASE_FOOTPRINT_LINK || base_joint.kind() != JointKind::Fixed {
372 return Err(StructureError::MisattachedBaseLink);
373 }
374 Ok(())
375 }
376
377 pub(crate) fn from_compiler_value(document: Value) -> Result<Self, StructureError> {
378 Self::from_summary(serde_json::from_value(document)?)
379 }
380
381 fn from_summary(summary: Summary) -> Result<Self, StructureError> {
382 let document = serde_json::to_value(&summary)?;
383 let links = summary
384 .links
385 .into_iter()
386 .map(|link| Link {
387 name: link.name,
388 inertial: link.inertial,
389 visuals: link.visuals,
390 collisions: link.collisions,
391 })
392 .collect::<Vec<_>>();
393 let joints = summary
394 .joints
395 .into_iter()
396 .map(|joint| Joint {
397 name: joint.name,
398 kind: joint.kind,
399 origin: Pose {
400 xyz: joint.origin.xyz,
401 rpy: joint.origin.rpy,
402 },
403 parent: joint.parent,
404 child: joint.child,
405 axis: joint.axis,
406 limit: joint.limit,
407 calibration: joint.calibration,
408 dynamics: joint.dynamics,
409 mimic: joint.mimic,
410 safety: joint.safety,
411 })
412 .collect::<Vec<_>>();
413 let root = Self::validate(&links, &joints)?;
414 Ok(Self {
415 document,
416 links,
417 joints,
418 materials: summary.materials,
419 root,
420 })
421 }
422}
423
424impl Link {
425 #[must_use]
426 pub const fn name(&self) -> &LinkId {
427 &self.name
428 }
429
430 #[must_use]
431 pub const fn inertial(&self) -> Inertial {
432 self.inertial
433 }
434
435 pub fn visuals(&self) -> impl ExactSizeIterator<Item = &Visual> {
436 self.visuals.iter()
437 }
438
439 pub fn collisions(&self) -> impl ExactSizeIterator<Item = &Collision> {
440 self.collisions.iter()
441 }
442
443 fn validate(&self) -> Result<(), StructureError> {
444 self.inertial.validate(&self.name)?;
445 for visual in &self.visuals {
446 visual
447 .origin
448 .validate(PoseOwner::LinkVisual(self.name.clone()))?;
449 visual.geometry.validate(&self.name)?;
450 }
451 for collision in &self.collisions {
452 collision
453 .origin
454 .validate(PoseOwner::LinkCollision(self.name.clone()))?;
455 collision.geometry.validate(&self.name)?;
456 }
457 Ok(())
458 }
459}
460
461impl Joint {
462 #[must_use]
463 pub const fn name(&self) -> &JointId {
464 &self.name
465 }
466 #[must_use]
467 pub const fn kind(&self) -> JointKind {
468 self.kind
469 }
470 #[must_use]
471 pub const fn origin(&self) -> Pose {
472 self.origin
473 }
474 #[must_use]
475 pub const fn parent(&self) -> &LinkId {
476 &self.parent
477 }
478 #[must_use]
479 pub const fn child(&self) -> &LinkId {
480 &self.child
481 }
482 #[must_use]
483 pub const fn axis(&self) -> [f64; 3] {
484 self.axis
485 }
486
487 #[must_use]
488 pub const fn limit(&self) -> JointLimit {
489 self.limit
490 }
491
492 #[must_use]
493 pub const fn calibration(&self) -> Option<Calibration> {
494 self.calibration
495 }
496
497 #[must_use]
498 pub const fn dynamics(&self) -> Option<Dynamics> {
499 self.dynamics
500 }
501
502 #[must_use]
503 pub fn mimic(&self) -> Option<&Mimic> {
504 self.mimic.as_ref()
505 }
506
507 #[must_use]
508 pub const fn safety(&self) -> Option<Safety> {
509 self.safety
510 }
511
512 fn validate(&self, joint_names: &HashSet<&str>) -> Result<(), StructureError> {
513 self.origin.validate(PoseOwner::Joint(self.name.clone()))?;
514 if !self.axis.iter().all(|value| value.is_finite()) {
515 return Err(StructureError::AxisNotFinite {
516 joint: self.name.clone(),
517 });
518 }
519 if self.kind != JointKind::Fixed
522 && self.axis.iter().map(|value| value * value).sum::<f64>() <= MIN_AXIS_NORM_SQUARED
523 {
524 return Err(StructureError::AxisNotOriented {
525 joint: self.name.clone(),
526 });
527 }
528 self.limit.validate(&self.name)?;
529 if let Some(dynamics) = self.dynamics {
530 dynamics.validate(&self.name)?;
531 }
532 if let Some(mimic) = &self.mimic
533 && !joint_names.contains(mimic.joint().as_str())
534 {
535 return Err(StructureError::UnknownMimicJoint {
536 joint: self.name.clone(),
537 mimicked: mimic.joint().clone(),
538 });
539 }
540 if let Some(safety) = self.safety {
541 safety.validate(&self.name)?;
542 }
543 Ok(())
544 }
545}
546
547impl Pose {
548 #[must_use]
549 pub const fn xyz(self) -> [f64; 3] {
550 self.xyz
551 }
552 #[must_use]
553 pub const fn rpy(self) -> [f64; 3] {
554 self.rpy
555 }
556
557 fn validate(self, owner: PoseOwner) -> Result<(), StructureError> {
558 if self.xyz.into_iter().chain(self.rpy).all(f64::is_finite) {
559 Ok(())
560 } else {
561 Err(StructureError::Pose { owner })
562 }
563 }
564}
565
566impl Inertial {
567 #[must_use]
568 pub const fn origin(self) -> Pose {
569 self.origin
570 }
571 #[must_use]
572 pub const fn mass_kg(self) -> f64 {
573 self.mass_kg
574 }
575 #[must_use]
576 pub const fn inertia(self) -> Inertia {
577 self.inertia
578 }
579
580 fn validate(self, link: &LinkId) -> Result<(), StructureError> {
581 self.origin
582 .validate(PoseOwner::LinkInertial(link.clone()))?;
583 if !(self.mass_kg.is_finite() && self.mass_kg >= 0.0) {
584 return Err(StructureError::Mass { link: link.clone() });
585 }
586 self.inertia.validate(link)
587 }
588}
589
590impl Inertia {
591 #[must_use]
592 pub const fn values(self) -> [f64; 6] {
593 [self.ixx, self.ixy, self.ixz, self.iyy, self.iyz, self.izz]
594 }
595
596 fn validate(self, link: &LinkId) -> Result<(), StructureError> {
603 let [ixx, ixy, ixz, iyy, iyz, izz] = self.values();
604 let finite = self.values().into_iter().all(f64::is_finite);
605 let scale = self
606 .values()
607 .into_iter()
608 .map(f64::abs)
609 .fold(0.0, f64::max)
610 .max(f64::MIN_POSITIVE);
611 let diagonal_tolerance = PSD_RELATIVE_TOLERANCE * scale;
612 let minor_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(2);
613 let determinant_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(3);
614 let principal_xy = ixx * iyy - ixy * ixy;
615 let principal_xz = ixx * izz - ixz * ixz;
616 let principal_yz = iyy * izz - iyz * iyz;
617 let determinant = ixx * (iyy * izz - iyz * iyz) - ixy * (ixy * izz - iyz * ixz)
618 + ixz * (ixy * iyz - iyy * ixz);
619 if finite
620 && ixx >= -diagonal_tolerance
621 && iyy >= -diagonal_tolerance
622 && izz >= -diagonal_tolerance
623 && principal_xy >= -minor_tolerance
624 && principal_xz >= -minor_tolerance
625 && principal_yz >= -minor_tolerance
626 && determinant >= -determinant_tolerance
627 {
628 Ok(())
629 } else {
630 Err(StructureError::Inertia { link: link.clone() })
631 }
632 }
633}
634
635impl Visual {
636 #[must_use]
637 pub fn name(&self) -> Option<&str> {
638 self.name.as_deref()
639 }
640 #[must_use]
641 pub const fn origin(&self) -> Pose {
642 self.origin
643 }
644 #[must_use]
645 pub fn geometry(&self) -> &Geometry {
646 &self.geometry
647 }
648 #[must_use]
649 pub fn material(&self) -> Option<&Material> {
650 self.material.as_ref()
651 }
652}
653
654impl Collision {
655 #[must_use]
656 pub fn name(&self) -> Option<&str> {
657 self.name.as_deref()
658 }
659 #[must_use]
660 pub const fn origin(&self) -> Pose {
661 self.origin
662 }
663 #[must_use]
664 pub fn geometry(&self) -> &Geometry {
665 &self.geometry
666 }
667}
668
669impl Material {
670 #[must_use]
671 pub fn name(&self) -> &str {
672 &self.name
673 }
674 #[must_use]
675 pub const fn color(&self) -> Option<[f64; 4]> {
676 self.color
677 }
678 #[must_use]
679 pub fn texture(&self) -> Option<&AssetId> {
680 self.texture.as_ref()
681 }
682}
683
684impl Geometry {
685 #[must_use]
686 pub fn asset_id(&self) -> Option<&AssetId> {
687 match self {
688 Self::Mesh { asset, .. } => Some(asset),
689 _ => None,
690 }
691 }
692
693 fn validate(&self, link: &LinkId) -> Result<(), StructureError> {
694 let dimensions: &[f64] = match self {
695 Self::Box { size } => size,
696 Self::Cylinder { radius, length } | Self::Capsule { radius, length } => {
697 &[*radius, *length]
698 }
699 Self::Sphere { radius } => &[*radius],
700 Self::Mesh { scale, .. } => scale.as_ref().map_or(&[], |values| values.as_slice()),
701 };
702 if dimensions
703 .iter()
704 .all(|value| value.is_finite() && *value > 0.0)
705 {
706 Ok(())
707 } else {
708 Err(StructureError::Geometry { link: link.clone() })
709 }
710 }
711}
712
713impl JointLimit {
714 #[must_use]
715 pub const fn lower(self) -> f64 {
716 self.lower
717 }
718 #[must_use]
719 pub const fn upper(self) -> f64 {
720 self.upper
721 }
722 #[must_use]
723 pub const fn effort(self) -> f64 {
724 self.effort
725 }
726 #[must_use]
727 pub const fn velocity(self) -> f64 {
728 self.velocity
729 }
730
731 fn validate(self, joint: &JointId) -> Result<(), StructureError> {
732 if [self.lower, self.upper, self.effort, self.velocity]
733 .iter()
734 .all(|value| value.is_finite())
735 && self.lower <= self.upper
736 {
737 Ok(())
738 } else {
739 Err(StructureError::JointLimits {
740 joint: joint.clone(),
741 })
742 }
743 }
744}
745
746impl Calibration {
747 #[must_use]
748 pub const fn rising(self) -> Option<f64> {
749 self.rising
750 }
751 #[must_use]
752 pub const fn falling(self) -> Option<f64> {
753 self.falling
754 }
755}
756
757impl Dynamics {
758 #[must_use]
759 pub const fn damping(self) -> f64 {
760 self.damping
761 }
762 #[must_use]
763 pub const fn friction(self) -> f64 {
764 self.friction
765 }
766
767 fn validate(self, joint: &JointId) -> Result<(), StructureError> {
768 if [self.damping, self.friction]
769 .iter()
770 .all(|value| value.is_finite() && *value >= 0.0)
771 {
772 Ok(())
773 } else {
774 Err(StructureError::JointDynamics {
775 joint: joint.clone(),
776 })
777 }
778 }
779}
780
781impl Mimic {
782 #[must_use]
783 pub const fn joint(&self) -> &JointId {
784 &self.joint
785 }
786 #[must_use]
787 pub const fn multiplier(&self) -> Option<f64> {
788 self.multiplier
789 }
790 #[must_use]
791 pub const fn offset(&self) -> Option<f64> {
792 self.offset
793 }
794}
795
796impl Safety {
797 #[must_use]
798 pub const fn soft_lower_limit(self) -> f64 {
799 self.soft_lower_limit
800 }
801 #[must_use]
802 pub const fn soft_upper_limit(self) -> f64 {
803 self.soft_upper_limit
804 }
805 #[must_use]
806 pub const fn k_position(self) -> f64 {
807 self.k_position
808 }
809 #[must_use]
810 pub const fn k_velocity(self) -> f64 {
811 self.k_velocity
812 }
813
814 fn validate(self, joint: &JointId) -> Result<(), StructureError> {
815 if [
816 self.soft_lower_limit,
817 self.soft_upper_limit,
818 self.k_position,
819 self.k_velocity,
820 ]
821 .iter()
822 .all(|value| value.is_finite())
823 && self.soft_lower_limit <= self.soft_upper_limit
824 {
825 Ok(())
826 } else {
827 Err(StructureError::JointSafety {
828 joint: joint.clone(),
829 })
830 }
831 }
832}
833
834impl Serialize for Structure {
835 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
836 self.document.serialize(serializer)
837 }
838}
839
840impl<'de> Deserialize<'de> for Structure {
841 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
842 let summary = Summary::deserialize(deserializer)?;
843 Self::from_summary(summary).map_err(serde::de::Error::custom)
844 }
845}
846
847impl phoxal_runtime_contract::wire_schema::DescribeWire for Structure {
848 fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
852 phoxal_runtime_contract::wire_schema::WireSchema::opaque(
853 "Structure",
854 <Summary as phoxal_runtime_contract::wire_schema::DescribeWire>::wire_schema(),
855 )
856 }
857}
858
859#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
865#[serde(deny_unknown_fields)]
866struct Summary {
867 name: String,
868 links: Vec<LinkSummary>,
869 joints: Vec<JointSummary>,
870 materials: Vec<Material>,
871}
872
873#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
874#[serde(deny_unknown_fields)]
875struct LinkSummary {
876 name: LinkId,
877 inertial: Inertial,
878 visuals: Vec<Visual>,
879 collisions: Vec<Collision>,
880}
881
882#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
883#[serde(deny_unknown_fields)]
884struct JointSummary {
885 name: JointId,
886 kind: JointKind,
887 origin: PoseSummary,
888 parent: LinkId,
889 child: LinkId,
890 axis: [f64; 3],
891 limit: JointLimit,
892 calibration: Option<Calibration>,
893 dynamics: Option<Dynamics>,
894 mimic: Option<Mimic>,
895 safety: Option<Safety>,
896}
897
898#[derive(phoxal_macros::DescribeWire, Deserialize, Serialize)]
899#[serde(deny_unknown_fields)]
900struct PoseSummary {
901 xyz: [f64; 3],
902 rpy: [f64; 3],
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908 use serde_json::json;
909
910 fn inertial() -> Value {
911 json!({
912 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
913 "mass_kg": 1.0,
914 "inertia": { "ixx": 1.0, "ixy": 0.0, "ixz": 0.0, "iyy": 1.0, "iyz": 0.0, "izz": 1.0 }
915 })
916 }
917
918 fn link(name: &str) -> Value {
919 json!({ "name": name, "inertial": inertial(), "visuals": [], "collisions": [] })
920 }
921
922 fn joint(name: &str, parent: &str, child: &str) -> Value {
923 json!({
924 "name": name,
925 "kind": "fixed",
926 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
927 "parent": parent,
928 "child": child,
929 "axis": [0.0, 0.0, 1.0],
930 "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 },
931 "calibration": null,
932 "dynamics": null,
933 "mimic": null,
934 "safety": null
935 })
936 }
937
938 fn document(links: Vec<Value>, joints: Vec<Value>) -> Value {
939 json!({ "name": "rover", "links": links, "joints": joints, "materials": [] })
940 }
941
942 fn tree() -> Value {
943 document(
944 vec![link("base_footprint"), link("base_link")],
945 vec![joint("base_joint", "base_footprint", "base_link")],
946 )
947 }
948
949 #[test]
950 fn the_document_survives_the_round_trip_unchanged() {
951 let source = tree();
955 let structure =
956 Structure::from_compiler_value(source.clone()).expect("a valid structure document");
957 assert_eq!(
958 serde_json::to_value(&structure).expect("the structure re-serializes"),
959 source
960 );
961 }
962
963 #[test]
964 fn the_root_is_resolved_once_and_stored() {
965 let structure = Structure::from_compiler_value(tree()).expect("a valid structure document");
966 assert_eq!(structure.root_link(), &LinkId::new("base_footprint"));
967 structure
968 .validate_robot_frames()
969 .expect("the conventional robot frames");
970 }
971
972 #[test]
973 fn a_structure_without_exactly_one_root_is_refused() {
974 let two_roots = document(vec![link("a"), link("b")], Vec::new());
975 assert!(matches!(
976 Structure::from_compiler_value(two_roots),
977 Err(StructureError::RootLinkCount { found: 2 })
978 ));
979
980 let no_root = document(
981 vec![link("a"), link("b")],
982 vec![joint("ab", "a", "b"), joint("ba", "b", "a")],
983 );
984 assert!(matches!(
986 Structure::from_compiler_value(no_root),
987 Err(StructureError::RootLinkCount { found: 0 })
988 ));
989 }
990
991 #[test]
992 fn a_dangling_joint_names_the_end_that_dangles() {
993 let dangling = document(
994 vec![link("base_footprint")],
995 vec![joint("base_joint", "base_footprint", "base_link")],
996 );
997 let error =
998 Structure::from_compiler_value(dangling).expect_err("the child link does not exist");
999 assert!(matches!(
1000 error,
1001 StructureError::UnknownJointLink {
1002 role: LinkRole::Child,
1003 ..
1004 }
1005 ));
1006 assert_eq!(
1007 error.to_string(),
1008 "joint 'base_joint' references unknown child link 'base_link'"
1009 );
1010 }
1011
1012 #[test]
1024 fn every_structural_field_is_statable_through_the_builder() {
1025 use crate::asset::AssetId;
1026 use crate::builder;
1027
1028 let mesh = AssetId::new("meshes/mast.stl").expect("a normalized asset id");
1029 let texture = AssetId::new("textures/paint.png").expect("a normalized asset id");
1030 let robot = builder::RobotBuilder::new("rover")
1031 .joint(builder::Joint {
1034 name: "mast_joint",
1035 kind: JointKind::Revolute,
1036 parent: "base_link",
1037 child: "mast",
1038 xyz: [0.1, 0.2, 0.3],
1039 rpy: [0.4, 0.5, 0.6],
1040 axis: [0.0, 1.0, 0.0],
1041 limit: builder::JointLimit {
1042 lower: -1.5,
1043 upper: 1.5,
1044 effort: 8.0,
1045 velocity: 2.0,
1046 },
1047 calibration: Some(builder::Calibration {
1048 rising: Some(0.1),
1049 falling: Some(-0.1),
1050 }),
1051 dynamics: Some(builder::Dynamics {
1052 damping: 0.7,
1053 friction: 0.2,
1054 }),
1055 mimic: Some(builder::Mimic {
1056 joint: "base_joint",
1057 multiplier: Some(2.0),
1058 offset: Some(0.25),
1059 }),
1060 safety: Some(builder::Safety {
1061 soft_lower_limit: -1.4,
1062 soft_upper_limit: 1.4,
1063 k_position: 12.0,
1064 k_velocity: 3.0,
1065 }),
1066 })
1067 .link(builder::Link {
1068 name: "mast",
1069 inertial: builder::Inertial {
1070 xyz: [0.01, 0.02, 0.03],
1071 rpy: [0.04, 0.05, 0.06],
1072 mass_kg: 2.5,
1073 inertia: builder::Inertia {
1074 ixx: 2.0,
1075 ixy: 0.1,
1076 ixz: 0.2,
1077 iyy: 3.0,
1078 iyz: 0.3,
1079 izz: 4.0,
1080 },
1081 },
1082 visuals: vec![
1085 builder::Visual {
1086 name: Some("hull"),
1087 xyz: [1.0, 2.0, 3.0],
1088 rpy: [0.7, 0.8, 0.9],
1089 geometry: Geometry::Box {
1090 size: [0.4, 0.3, 0.2],
1091 },
1092 material: Some(builder::Material {
1093 name: "grey",
1094 color: Some([0.5, 0.5, 0.5, 1.0]),
1095 texture: Some(texture.clone()),
1096 }),
1097 },
1098 builder::Visual::new(Geometry::Cylinder {
1099 radius: 0.1,
1100 length: 0.5,
1101 }),
1102 builder::Visual::new(Geometry::Capsule {
1103 radius: 0.2,
1104 length: 0.6,
1105 }),
1106 builder::Visual::new(Geometry::Sphere { radius: 0.3 }),
1107 builder::Visual::new(Geometry::Mesh {
1108 asset: mesh.clone(),
1109 scale: Some([1.0, 2.0, 3.0]),
1110 }),
1111 ],
1112 collisions: Vec::new(),
1117 })
1118 .link(builder::Link {
1122 name: "base_link",
1123 collisions: vec![builder::Collision {
1124 name: Some("hull_bounds"),
1125 xyz: [4.0, 5.0, 6.0],
1126 rpy: [0.11, 0.12, 0.13],
1127 geometry: Geometry::Sphere { radius: 0.35 },
1128 }],
1129 ..builder::Link::default()
1130 })
1131 .material(builder::Material {
1132 name: "grey",
1133 color: Some([0.5, 0.5, 0.5, 1.0]),
1134 texture: Some(texture.clone()),
1135 })
1136 .build()
1137 .expect("every structural fact composes a valid robot");
1138
1139 let structure = robot.structure();
1140 let link = structure.link("mast").expect("the stated link");
1141 let Link {
1142 name,
1143 inertial,
1144 visuals,
1145 collisions,
1146 } = link;
1147 assert_eq!(name, &LinkId::new("mast"));
1148 assert_eq!(inertial.origin().xyz(), [0.01, 0.02, 0.03]);
1149 assert_eq!(inertial.origin().rpy(), [0.04, 0.05, 0.06]);
1150 assert_eq!(inertial.mass_kg(), 2.5);
1151 assert_eq!(inertial.inertia().values(), [2.0, 0.1, 0.2, 3.0, 0.3, 4.0]);
1152 assert_eq!(visuals.len(), 5);
1153 assert_eq!(collisions.len(), 0);
1154
1155 let mut shapes = Vec::new();
1156 for visual in link.visuals() {
1157 let Visual {
1158 name,
1159 origin,
1160 geometry,
1161 material,
1162 } = visual;
1163 shapes.push(match geometry {
1164 Geometry::Box { size } => {
1165 assert_eq!(*size, [0.4, 0.3, 0.2]);
1166 assert_eq!(name.as_deref(), Some("hull"));
1169 assert_eq!(origin.xyz(), [1.0, 2.0, 3.0]);
1170 assert_eq!(origin.rpy(), [0.7, 0.8, 0.9]);
1171 let Material {
1172 name,
1173 color,
1174 texture: painted,
1175 } = material.as_ref().expect("the stated material");
1176 assert_eq!(name, "grey");
1177 assert_eq!(*color, Some([0.5, 0.5, 0.5, 1.0]));
1178 assert_eq!(painted.as_ref(), Some(&texture));
1179 "box"
1180 }
1181 Geometry::Cylinder { radius, length } => {
1182 assert_eq!((*radius, *length), (0.1, 0.5));
1183 "cylinder"
1184 }
1185 Geometry::Capsule { radius, length } => {
1186 assert_eq!((*radius, *length), (0.2, 0.6));
1187 "capsule"
1188 }
1189 Geometry::Sphere { radius } => {
1190 assert_eq!(*radius, 0.3);
1191 "sphere"
1192 }
1193 Geometry::Mesh { asset, scale } => {
1194 assert_eq!(asset, &mesh);
1195 assert_eq!(*scale, Some([1.0, 2.0, 3.0]));
1196 "mesh"
1197 }
1198 });
1199 }
1200 assert_eq!(shapes, ["box", "cylinder", "capsule", "sphere", "mesh"]);
1201
1202 let collision = structure
1203 .link("base_link")
1204 .expect("the base link")
1205 .collisions()
1206 .next()
1207 .expect("the stated collision");
1208 let Collision {
1209 name,
1210 origin,
1211 geometry,
1212 } = collision;
1213 assert_eq!(name.as_deref(), Some("hull_bounds"));
1214 assert_eq!(origin.xyz(), [4.0, 5.0, 6.0]);
1215 assert_eq!(origin.rpy(), [0.11, 0.12, 0.13]);
1216 assert!(matches!(geometry, Geometry::Sphere { radius } if *radius == 0.35));
1217
1218 let joint = structure.joint("mast_joint").expect("the stated joint");
1219 let Joint {
1220 name,
1221 kind,
1222 origin,
1223 parent,
1224 child,
1225 axis,
1226 limit,
1227 calibration,
1228 dynamics,
1229 mimic,
1230 safety,
1231 } = joint;
1232 assert_eq!(name, &JointId::new("mast_joint"));
1233 assert_eq!(*kind, JointKind::Revolute);
1234 assert_eq!(origin.xyz(), [0.1, 0.2, 0.3]);
1235 assert_eq!(origin.rpy(), [0.4, 0.5, 0.6]);
1236 assert_eq!(parent, &LinkId::new("base_link"));
1237 assert_eq!(child, &LinkId::new("mast"));
1238 assert_eq!(*axis, [0.0, 1.0, 0.0]);
1239 assert_eq!(
1240 (
1241 limit.lower(),
1242 limit.upper(),
1243 limit.effort(),
1244 limit.velocity()
1245 ),
1246 (-1.5, 1.5, 8.0, 2.0)
1247 );
1248 let calibration = calibration.expect("the stated calibration");
1249 assert_eq!(
1250 (calibration.rising(), calibration.falling()),
1251 (Some(0.1), Some(-0.1))
1252 );
1253 let dynamics = dynamics.expect("the stated dynamics");
1254 assert_eq!((dynamics.damping(), dynamics.friction()), (0.7, 0.2));
1255 let mimic = mimic.as_ref().expect("the stated mimic");
1256 assert_eq!(mimic.joint(), &JointId::new("base_joint"));
1257 assert_eq!(
1258 (mimic.multiplier(), mimic.offset()),
1259 (Some(2.0), Some(0.25))
1260 );
1261 let safety = safety.expect("the stated safety");
1262 assert_eq!(
1263 (
1264 safety.soft_lower_limit(),
1265 safety.soft_upper_limit(),
1266 safety.k_position(),
1267 safety.k_velocity()
1268 ),
1269 (-1.4, 1.4, 12.0, 3.0)
1270 );
1271
1272 let catalogue = structure.materials().collect::<Vec<_>>();
1275 assert_eq!(catalogue.len(), 1);
1276 assert_eq!(catalogue[0].name(), "grey");
1277 assert_eq!(catalogue[0].texture(), Some(&texture));
1278 let mut assets = structure
1279 .asset_ids()
1280 .map(AssetId::as_str)
1281 .collect::<Vec<_>>();
1282 assets.sort_unstable();
1283 assert_eq!(assets, ["meshes/mast.stl", "textures/paint.png"]);
1284 }
1285
1286 #[test]
1287 fn a_robot_structure_must_carry_the_conventional_base_frames() {
1288 let wrong_root = document(vec![link("chassis")], Vec::new());
1289 let structure =
1290 Structure::from_compiler_value(wrong_root).expect("a single-link structure is a tree");
1291 assert!(matches!(
1292 structure.validate_robot_frames(),
1293 Err(StructureError::RootLinkName { .. })
1294 ));
1295
1296 let no_base_link = document(
1297 vec![link("base_footprint"), link("mast")],
1298 vec![joint("mast_joint", "base_footprint", "mast")],
1299 );
1300 let structure =
1301 Structure::from_compiler_value(no_base_link).expect("a valid structure document");
1302 assert!(matches!(
1303 structure.validate_robot_frames(),
1304 Err(StructureError::MissingBaseLink)
1305 ));
1306 }
1307}