1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::{HashMap, HashSet};
6
7use crate::{AssetId, ModelError};
8
9const MIN_AXIS_NORM_SQUARED: f64 = 1.0e-16;
10const PSD_RELATIVE_TOLERANCE: f64 = 1.0e-12;
11
12#[derive(Clone, Debug)]
14pub struct Structure {
15 document: Value,
16 links: Vec<Link>,
17 joints: Vec<Joint>,
18 materials: Vec<Material>,
19}
20
21#[derive(Clone, Debug)]
23pub struct Link {
24 name: String,
25 inertial: Inertial,
26 visuals: Vec<Visual>,
27 collisions: Vec<Collision>,
28}
29
30#[derive(Clone, Debug)]
32pub struct Joint {
33 name: String,
34 kind: JointKind,
35 origin: Pose,
36 parent: String,
37 child: String,
38 axis: [f64; 3],
39 limit: JointLimit,
40 calibration: Option<Calibration>,
41 dynamics: Option<Dynamics>,
42 mimic: Option<Mimic>,
43 safety: Option<Safety>,
44}
45
46#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct Pose {
50 xyz: [f64; 3],
51 rpy: [f64; 3],
52}
53
54#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
56#[serde(deny_unknown_fields)]
57pub struct Inertial {
58 origin: Pose,
59 mass_kg: f64,
60 inertia: Inertia,
61}
62
63#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
65#[serde(deny_unknown_fields)]
66pub struct Inertia {
67 ixx: f64,
68 ixy: f64,
69 ixz: f64,
70 iyy: f64,
71 iyz: f64,
72 izz: f64,
73}
74
75#[derive(Clone, Debug, Deserialize, Serialize)]
77#[serde(deny_unknown_fields)]
78pub struct Visual {
79 name: Option<String>,
80 origin: Pose,
81 geometry: Geometry,
82 material: Option<Material>,
83}
84
85#[derive(Clone, Debug, Deserialize, Serialize)]
87#[serde(deny_unknown_fields)]
88pub struct Collision {
89 name: Option<String>,
90 origin: Pose,
91 geometry: Geometry,
92}
93
94#[derive(Clone, Debug, Deserialize, Serialize)]
96#[serde(deny_unknown_fields)]
97pub struct Material {
98 name: String,
99 color: Option<[f64; 4]>,
100 texture: Option<AssetId>,
101}
102
103#[derive(Clone, Debug, Deserialize, Serialize)]
105#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
106pub enum Geometry {
107 Box {
108 size: [f64; 3],
109 },
110 Cylinder {
111 radius: f64,
112 length: f64,
113 },
114 Capsule {
115 radius: f64,
116 length: f64,
117 },
118 Sphere {
119 radius: f64,
120 },
121 Mesh {
122 #[serde(rename = "filename")]
123 asset: AssetId,
124 scale: Option<[f64; 3]>,
125 },
126}
127
128#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
130#[serde(deny_unknown_fields)]
131pub struct JointLimit {
132 lower: f64,
133 upper: f64,
134 effort: f64,
135 velocity: f64,
136}
137
138#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
140#[serde(deny_unknown_fields)]
141pub struct Calibration {
142 rising: Option<f64>,
143 falling: Option<f64>,
144}
145
146#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
148#[serde(deny_unknown_fields)]
149pub struct Dynamics {
150 damping: f64,
151 friction: f64,
152}
153
154#[derive(Clone, Debug, Deserialize, Serialize)]
156#[serde(deny_unknown_fields)]
157pub struct Mimic {
158 joint: String,
159 multiplier: Option<f64>,
160 offset: Option<f64>,
161}
162
163#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
165#[serde(deny_unknown_fields)]
166pub struct Safety {
167 soft_lower_limit: f64,
168 soft_upper_limit: f64,
169 k_position: f64,
170 k_velocity: f64,
171}
172
173#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case")]
176pub enum JointKind {
177 Revolute,
178 Continuous,
179 Prismatic,
180 Fixed,
181 Floating,
182 Planar,
183 Spherical,
184}
185
186impl Structure {
187 pub fn links(&self) -> impl ExactSizeIterator<Item = &Link> {
189 self.links.iter()
190 }
191
192 pub fn joints(&self) -> impl ExactSizeIterator<Item = &Joint> {
194 self.joints.iter()
195 }
196
197 pub fn materials(&self) -> impl ExactSizeIterator<Item = &Material> {
199 self.materials.iter()
200 }
201
202 pub fn asset_ids(&self) -> impl Iterator<Item = &AssetId> {
204 let mut seen = HashSet::new();
205 self.links
206 .iter()
207 .flat_map(|link| {
208 link.visuals()
209 .filter_map(|visual| visual.geometry().asset_id())
210 .chain(
211 link.collisions()
212 .filter_map(|collision| collision.geometry().asset_id()),
213 )
214 .chain(
215 link.visuals()
216 .filter_map(|visual| visual.material()?.texture()),
217 )
218 })
219 .chain(self.materials.iter().filter_map(Material::texture))
220 .filter(move |id| seen.insert(id.as_str()))
221 }
222
223 #[must_use]
225 pub fn link(&self, id: &str) -> Option<&Link> {
226 self.links.iter().find(|link| link.name == id)
227 }
228
229 #[must_use]
231 pub fn joint(&self, id: &str) -> Option<&Joint> {
232 self.joints.iter().find(|joint| joint.name == id)
233 }
234
235 #[must_use]
237 pub fn root_link(&self) -> &Link {
238 self.links
239 .iter()
240 .find(|link| !self.joints.iter().any(|joint| joint.child() == link.name()))
241 .expect("validated structure has one root link")
242 }
243
244 #[must_use]
245 pub fn parent_joint(&self, link: &str) -> Option<&Joint> {
246 self.joints.iter().find(|joint| joint.child() == link)
247 }
248
249 pub fn child_joints<'a>(&'a self, link: &'a str) -> impl Iterator<Item = &'a Joint> {
250 self.joints
251 .iter()
252 .filter(move |joint| joint.parent() == link)
253 }
254
255 pub(crate) fn validate(&self) -> Result<(), ModelError> {
256 self.validate_leaf_values()?;
257 validate_unique(self.links.iter().map(Link::name), "link")?;
258 validate_unique(self.joints.iter().map(Joint::name), "joint")?;
259 let link_names = self.links.iter().map(Link::name).collect::<HashSet<_>>();
260 let mut children = HashSet::new();
261 let mut parent_by_child = HashMap::new();
262 for joint in &self.joints {
263 if !link_names.contains(joint.parent()) {
264 return Err(ModelError::Invalid(format!(
265 "joint '{}' references unknown parent link '{}'",
266 joint.name(),
267 joint.parent()
268 )));
269 }
270 if !link_names.contains(joint.child()) {
271 return Err(ModelError::Invalid(format!(
272 "joint '{}' references unknown child link '{}'",
273 joint.name(),
274 joint.child()
275 )));
276 }
277 if !children.insert(joint.child()) {
278 return Err(ModelError::Invalid(format!(
279 "link '{}' is the child of multiple joints",
280 joint.child()
281 )));
282 }
283 if joint.parent() == joint.child() {
284 return Err(ModelError::Invalid(format!(
285 "joint '{}' cannot use '{}' as both parent and child",
286 joint.name(),
287 joint.parent()
288 )));
289 }
290 parent_by_child.insert(joint.child(), joint.parent());
291 }
292 let roots = self
293 .links
294 .iter()
295 .map(Link::name)
296 .filter(|link| !children.contains(link))
297 .collect::<Vec<_>>();
298 if roots.len() != 1 {
299 return Err(ModelError::Invalid(format!(
300 "structure must have exactly one root link, found {}",
301 roots.len()
302 )));
303 }
304 for link in &self.links {
305 let mut seen = HashSet::new();
306 let mut current = Some(link.name());
307 while let Some(link_id) = current {
308 if !seen.insert(link_id) {
309 return Err(ModelError::Invalid(format!(
310 "structure contains a joint cycle involving '{link_id}'"
311 )));
312 }
313 current = parent_by_child.get(link_id).copied();
314 }
315 }
316 Ok(())
317 }
318
319 fn validate_leaf_values(&self) -> Result<(), ModelError> {
320 for link in &self.links {
321 let inertial = link.inertial();
322 validate_pose_values(
323 inertial.origin(),
324 &format!("link '{}' inertial", link.name()),
325 )?;
326 validate_nonnegative(inertial.mass_kg(), &format!("link '{}' mass", link.name()))?;
327 validate_inertia(inertial.inertia(), link.name())?;
328 for visual in link.visuals() {
329 validate_pose_values(visual.origin(), &format!("link '{}' visual", link.name()))?;
330 validate_geometry_values(visual.geometry(), link.name())?;
331 }
332 for collision in link.collisions() {
333 validate_pose_values(
334 collision.origin(),
335 &format!("link '{}' collision", link.name()),
336 )?;
337 validate_geometry_values(collision.geometry(), link.name())?;
338 }
339 }
340 let joint_names = self.joints.iter().map(Joint::name).collect::<HashSet<_>>();
341 for joint in &self.joints {
342 validate_pose_values(joint.origin(), &format!("joint '{}'", joint.name()))?;
343 let axis = joint.axis();
344 if !axis.iter().all(|value| value.is_finite()) {
345 return Err(ModelError::Invalid(format!(
346 "joint '{}' axis must be finite",
347 joint.name()
348 )));
349 }
350 if joint.kind() != JointKind::Fixed
351 && axis.iter().map(|value| value * value).sum::<f64>() <= MIN_AXIS_NORM_SQUARED
352 {
353 return Err(ModelError::Invalid(format!(
354 "joint '{}' axis must be non-zero",
355 joint.name()
356 )));
357 }
358 let limit = joint.limit();
359 let limits = [
360 limit.lower(),
361 limit.upper(),
362 limit.effort(),
363 limit.velocity(),
364 ];
365 if !limits.iter().all(|value| value.is_finite()) || limit.lower() > limit.upper() {
366 return Err(ModelError::Invalid(format!(
367 "joint '{}' limits must be finite with lower <= upper",
368 joint.name()
369 )));
370 }
371 if let Some(dynamics) = joint.dynamics()
372 && (!dynamics.damping().is_finite()
373 || dynamics.damping() < 0.0
374 || !dynamics.friction().is_finite()
375 || dynamics.friction() < 0.0)
376 {
377 return Err(ModelError::Invalid(format!(
378 "joint '{}' dynamics must be finite and non-negative",
379 joint.name()
380 )));
381 }
382 if let Some(mimic) = joint.mimic()
383 && !joint_names.contains(mimic.joint())
384 {
385 return Err(ModelError::Invalid(format!(
386 "joint '{}' mimics unknown joint '{}'",
387 joint.name(),
388 mimic.joint()
389 )));
390 }
391 if let Some(safety) = joint.safety()
392 && (![
393 safety.soft_lower_limit(),
394 safety.soft_upper_limit(),
395 safety.k_position(),
396 safety.k_velocity(),
397 ]
398 .iter()
399 .all(|value| value.is_finite())
400 || safety.soft_lower_limit() > safety.soft_upper_limit())
401 {
402 return Err(ModelError::Invalid(format!(
403 "joint '{}' safety limits must be finite with lower <= upper",
404 joint.name()
405 )));
406 }
407 }
408 Ok(())
409 }
410
411 pub(crate) fn validate_robot_frames(&self) -> Result<(), ModelError> {
412 if self.root_link().name() != "base_footprint" {
413 return Err(ModelError::Invalid(format!(
414 "structure root link must be 'base_footprint', found '{}'",
415 self.root_link().name()
416 )));
417 }
418 let base_joint = self
419 .joints
420 .iter()
421 .find(|joint| joint.child() == "base_link")
422 .ok_or_else(|| {
423 ModelError::Invalid(
424 "structure must attach 'base_link' under 'base_footprint' with a fixed joint"
425 .to_string(),
426 )
427 })?;
428 if base_joint.parent() != "base_footprint" || base_joint.kind() != JointKind::Fixed {
429 return Err(ModelError::Invalid(
430 "structure must attach 'base_link' directly under 'base_footprint' with a fixed joint"
431 .to_string(),
432 ));
433 }
434 Ok(())
435 }
436
437 pub(crate) fn from_compiler_value(document: Value) -> Result<Self, ModelError> {
438 let summary: Summary = serde_json::from_value(document)
439 .map_err(|error| ModelError::Invalid(error.to_string()))?;
440 Self::from_summary(summary)
441 }
442
443 fn from_summary(summary: Summary) -> Result<Self, ModelError> {
444 let document = serde_json::to_value(&summary)
445 .map_err(|error| ModelError::Invalid(error.to_string()))?;
446 let structure = Self {
447 document,
448 links: summary
449 .links
450 .into_iter()
451 .map(|link| Link {
452 name: link.name,
453 inertial: link.inertial,
454 visuals: link.visuals,
455 collisions: link.collisions,
456 })
457 .collect(),
458 joints: summary
459 .joints
460 .into_iter()
461 .map(|joint| Joint {
462 name: joint.name,
463 kind: joint.kind,
464 origin: Pose {
465 xyz: joint.origin.xyz,
466 rpy: joint.origin.rpy,
467 },
468 parent: joint.parent,
469 child: joint.child,
470 axis: joint.axis,
471 limit: joint.limit,
472 calibration: joint.calibration,
473 dynamics: joint.dynamics,
474 mimic: joint.mimic,
475 safety: joint.safety,
476 })
477 .collect(),
478 materials: summary.materials,
479 };
480 structure.validate()?;
481 Ok(structure)
482 }
483}
484
485impl Link {
486 #[must_use]
487 pub fn name(&self) -> &str {
488 &self.name
489 }
490
491 #[must_use]
492 pub const fn inertial(&self) -> Inertial {
493 self.inertial
494 }
495
496 pub fn visuals(&self) -> impl ExactSizeIterator<Item = &Visual> {
497 self.visuals.iter()
498 }
499
500 pub fn collisions(&self) -> impl ExactSizeIterator<Item = &Collision> {
501 self.collisions.iter()
502 }
503}
504
505impl Joint {
506 #[must_use]
507 pub fn name(&self) -> &str {
508 &self.name
509 }
510 #[must_use]
511 pub const fn kind(&self) -> JointKind {
512 self.kind
513 }
514 #[must_use]
515 pub const fn origin(&self) -> Pose {
516 self.origin
517 }
518 #[must_use]
519 pub fn parent(&self) -> &str {
520 &self.parent
521 }
522 #[must_use]
523 pub fn child(&self) -> &str {
524 &self.child
525 }
526 #[must_use]
527 pub const fn axis(&self) -> [f64; 3] {
528 self.axis
529 }
530
531 #[must_use]
532 pub const fn limit(&self) -> JointLimit {
533 self.limit
534 }
535
536 #[must_use]
537 pub const fn calibration(&self) -> Option<Calibration> {
538 self.calibration
539 }
540
541 #[must_use]
542 pub const fn dynamics(&self) -> Option<Dynamics> {
543 self.dynamics
544 }
545
546 #[must_use]
547 pub fn mimic(&self) -> Option<&Mimic> {
548 self.mimic.as_ref()
549 }
550
551 #[must_use]
552 pub const fn safety(&self) -> Option<Safety> {
553 self.safety
554 }
555}
556
557impl Pose {
558 #[must_use]
559 pub const fn xyz(self) -> [f64; 3] {
560 self.xyz
561 }
562 #[must_use]
563 pub const fn rpy(self) -> [f64; 3] {
564 self.rpy
565 }
566}
567
568impl Inertial {
569 #[must_use]
570 pub const fn origin(self) -> Pose {
571 self.origin
572 }
573 #[must_use]
574 pub const fn mass_kg(self) -> f64 {
575 self.mass_kg
576 }
577 #[must_use]
578 pub const fn inertia(self) -> Inertia {
579 self.inertia
580 }
581}
582
583impl Inertia {
584 #[must_use]
585 pub const fn values(self) -> [f64; 6] {
586 [self.ixx, self.ixy, self.ixz, self.iyy, self.iyz, self.izz]
587 }
588}
589
590impl Visual {
591 #[must_use]
592 pub fn name(&self) -> Option<&str> {
593 self.name.as_deref()
594 }
595 #[must_use]
596 pub const fn origin(&self) -> Pose {
597 self.origin
598 }
599 #[must_use]
600 pub fn geometry(&self) -> &Geometry {
601 &self.geometry
602 }
603 #[must_use]
604 pub fn material(&self) -> Option<&Material> {
605 self.material.as_ref()
606 }
607}
608
609impl Collision {
610 #[must_use]
611 pub fn name(&self) -> Option<&str> {
612 self.name.as_deref()
613 }
614 #[must_use]
615 pub const fn origin(&self) -> Pose {
616 self.origin
617 }
618 #[must_use]
619 pub fn geometry(&self) -> &Geometry {
620 &self.geometry
621 }
622}
623
624impl Material {
625 #[must_use]
626 pub fn name(&self) -> &str {
627 &self.name
628 }
629 #[must_use]
630 pub const fn color(&self) -> Option<[f64; 4]> {
631 self.color
632 }
633 #[must_use]
634 pub fn texture(&self) -> Option<&AssetId> {
635 self.texture.as_ref()
636 }
637}
638
639impl Geometry {
640 #[must_use]
641 pub fn asset_id(&self) -> Option<&AssetId> {
642 match self {
643 Self::Mesh { asset, .. } => Some(asset),
644 _ => None,
645 }
646 }
647}
648
649impl JointLimit {
650 #[must_use]
651 pub const fn lower(self) -> f64 {
652 self.lower
653 }
654 #[must_use]
655 pub const fn upper(self) -> f64 {
656 self.upper
657 }
658 #[must_use]
659 pub const fn effort(self) -> f64 {
660 self.effort
661 }
662 #[must_use]
663 pub const fn velocity(self) -> f64 {
664 self.velocity
665 }
666}
667
668impl Calibration {
669 #[must_use]
670 pub const fn rising(self) -> Option<f64> {
671 self.rising
672 }
673 #[must_use]
674 pub const fn falling(self) -> Option<f64> {
675 self.falling
676 }
677}
678
679impl Dynamics {
680 #[must_use]
681 pub const fn damping(self) -> f64 {
682 self.damping
683 }
684 #[must_use]
685 pub const fn friction(self) -> f64 {
686 self.friction
687 }
688}
689
690impl Mimic {
691 #[must_use]
692 pub fn joint(&self) -> &str {
693 &self.joint
694 }
695 #[must_use]
696 pub const fn multiplier(&self) -> Option<f64> {
697 self.multiplier
698 }
699 #[must_use]
700 pub const fn offset(&self) -> Option<f64> {
701 self.offset
702 }
703}
704
705impl Safety {
706 #[must_use]
707 pub const fn soft_lower_limit(self) -> f64 {
708 self.soft_lower_limit
709 }
710 #[must_use]
711 pub const fn soft_upper_limit(self) -> f64 {
712 self.soft_upper_limit
713 }
714 #[must_use]
715 pub const fn k_position(self) -> f64 {
716 self.k_position
717 }
718 #[must_use]
719 pub const fn k_velocity(self) -> f64 {
720 self.k_velocity
721 }
722}
723
724impl Serialize for Structure {
725 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
726 self.document.serialize(serializer)
727 }
728}
729
730impl<'de> Deserialize<'de> for Structure {
731 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
732 let summary = Summary::deserialize(deserializer)?;
733 Self::from_summary(summary).map_err(serde::de::Error::custom)
734 }
735}
736
737#[derive(Deserialize, Serialize)]
738#[serde(deny_unknown_fields)]
739struct Summary {
740 #[serde(rename = "name")]
741 _name: String,
742 links: Vec<LinkSummary>,
743 joints: Vec<JointSummary>,
744 materials: Vec<Material>,
745}
746
747#[derive(Deserialize, Serialize)]
748#[serde(deny_unknown_fields)]
749struct LinkSummary {
750 name: String,
751 inertial: Inertial,
752 visuals: Vec<Visual>,
753 collisions: Vec<Collision>,
754}
755
756#[derive(Deserialize, Serialize)]
757#[serde(deny_unknown_fields)]
758struct JointSummary {
759 name: String,
760 kind: JointKind,
761 origin: PoseSummary,
762 parent: String,
763 child: String,
764 axis: [f64; 3],
765 #[serde(rename = "limit")]
766 limit: JointLimit,
767 calibration: Option<Calibration>,
768 dynamics: Option<Dynamics>,
769 mimic: Option<Mimic>,
770 safety: Option<Safety>,
771}
772
773#[derive(Deserialize, Serialize)]
774#[serde(deny_unknown_fields)]
775struct PoseSummary {
776 xyz: [f64; 3],
777 rpy: [f64; 3],
778}
779
780fn validate_pose_values(pose: Pose, label: &str) -> Result<(), ModelError> {
781 if pose.xyz().into_iter().chain(pose.rpy()).all(f64::is_finite) {
782 Ok(())
783 } else {
784 Err(ModelError::Invalid(format!("{label} pose must be finite")))
785 }
786}
787
788fn validate_nonnegative(value: f64, label: &str) -> Result<(), ModelError> {
789 if value.is_finite() && value >= 0.0 {
790 Ok(())
791 } else {
792 Err(ModelError::Invalid(format!(
793 "{label} must be finite and non-negative"
794 )))
795 }
796}
797
798fn validate_inertia(inertia: Inertia, link: &str) -> Result<(), ModelError> {
799 let [ixx, ixy, ixz, iyy, iyz, izz] = inertia.values();
800 let finite = [ixx, ixy, ixz, iyy, iyz, izz]
801 .into_iter()
802 .all(f64::is_finite);
803 let scale = [ixx, ixy, ixz, iyy, iyz, izz]
804 .into_iter()
805 .map(f64::abs)
806 .fold(0.0, f64::max)
807 .max(f64::MIN_POSITIVE);
808 let diagonal_tolerance = PSD_RELATIVE_TOLERANCE * scale;
809 let minor_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(2);
810 let determinant_tolerance = PSD_RELATIVE_TOLERANCE * scale.powi(3);
811 let principal_xy = ixx * iyy - ixy * ixy;
812 let principal_xz = ixx * izz - ixz * ixz;
813 let principal_yz = iyy * izz - iyz * iyz;
814 let determinant = ixx * (iyy * izz - iyz * iyz) - ixy * (ixy * izz - iyz * ixz)
815 + ixz * (ixy * iyz - iyy * ixz);
816 if finite
817 && ixx >= -diagonal_tolerance
818 && iyy >= -diagonal_tolerance
819 && izz >= -diagonal_tolerance
820 && principal_xy >= -minor_tolerance
821 && principal_xz >= -minor_tolerance
822 && principal_yz >= -minor_tolerance
823 && determinant >= -determinant_tolerance
824 {
825 Ok(())
826 } else {
827 Err(ModelError::Invalid(format!(
828 "link '{link}' inertia must be finite and positive semidefinite"
829 )))
830 }
831}
832
833fn validate_geometry_values(geometry: &Geometry, link: &str) -> Result<(), ModelError> {
834 let values = match geometry {
835 Geometry::Box { size } => size.to_vec(),
836 Geometry::Cylinder { radius, length } | Geometry::Capsule { radius, length } => {
837 vec![*radius, *length]
838 }
839 Geometry::Sphere { radius } => vec![*radius],
840 Geometry::Mesh { scale, .. } => scale.map_or_else(Vec::new, |values| values.to_vec()),
841 };
842 if values.iter().all(|value| value.is_finite() && *value > 0.0) {
843 Ok(())
844 } else {
845 Err(ModelError::Invalid(format!(
846 "link '{link}' geometry dimensions must be finite and positive"
847 )))
848 }
849}
850
851fn validate_unique<'a>(
852 names: impl Iterator<Item = &'a str>,
853 label: &str,
854) -> Result<(), ModelError> {
855 let mut seen = HashSet::new();
856 for name in names {
857 if !seen.insert(name) {
858 return Err(ModelError::Invalid(format!(
859 "duplicate {label} identity '{name}'"
860 )));
861 }
862 }
863 Ok(())
864}