Skip to main content

phoxal_model/
builder.rs

1//! Compose a canonical [`Robot`] programmatically.
2//!
3//! This is the in-memory counterpart to the document compiler: a tool, a test,
4//! or a robot project states the robot it wants and gets back the same
5//! validated [`Robot`] a compiled bundle yields. Nothing here reads or parses a
6//! document, and nothing here touches the filesystem.
7//!
8//! It is deliberately **not** a second authoring surface. A real robot is
9//! described by authored YAML and URDF, compiled by `phoxal-manifest`; that
10//! remains the only way a robot is shipped. What this offers is the ability to
11//! build a model without documents at all - for a test that wants to assert on
12//! exactly the robot it just stated, or for a tool that composes a model from
13//! somewhere other than a bundle.
14//!
15//! Every value is normalized and validated when [`RobotBuilder::build`] runs,
16//! through the same entry points the document compiler uses, so a built robot
17//! is a robot the runtime accepts or an explicit [`ModelError`].
18//!
19//! # What gets generated
20//!
21//! A canonical robot is only valid with a consistent link tree, and most
22//! callers do not care what that tree looks like. Anything not stated is
23//! generated:
24//!
25//! - The robot is rooted at `base_footprint` with `base_link` fixed beneath it,
26//!   unless a stated joint already attaches `base_link`.
27//! - A mount link that no stated joint attaches is added beneath `base_link` by
28//!   a fixed joint named `<link>_joint`.
29//! - A component is rooted at `mount`, which is the link the frame graph
30//!   attaches to the robot link its instance is mounted on.
31//! - A capability whose target no stated joint provides gets one: a joint
32//!   target `j` becomes a continuous joint `j` from `mount` to a new link
33//!   `j_link`, and a link target `l` becomes that link, fixed to `mount` by a
34//!   joint `l_joint`. Two capabilities naming one target share it, which is how
35//!   a motor and the encoder measuring it end up on a single joint.
36//! - A stated [`Link`] that no stated joint attaches is added the same way a
37//!   mount link is, so giving a link a body is enough to put it on the robot.
38//!
39//! Generated links carry a unit inertial and no geometry, and generated joints
40//! sit at their parent's origin turning about Z. State a [`Joint`] or a
41//! [`Link`] to say otherwise: between them they reach every field the canonical
42//! [`structure`](crate::structure) carries.
43//!
44//! # Why the structural values are stated twice
45//!
46//! [`Joint`], [`Link`], [`Inertial`], [`Inertia`], [`Visual`], [`Collision`],
47//! [`Material`], [`JointLimit`], [`Calibration`], [`Dynamics`], [`Mimic`] and
48//! [`Safety`] name the same facts as their counterparts in
49//! [`structure`](crate::structure), and exist only because the canonical types
50//! deliberately cannot be built from raw values. A canonical structural value
51//! exists only as part of a validated [`Structure`], so it has no public
52//! constructor and none is added here; what the builder holds is a plain
53//! statement of intent, borrowing its names, which it normalizes into the
54//! canonical structure document and hands to the one construction seam. The
55//! canonical values therefore stay unreachable in an unvalidated state, and
56//! nothing about the serialized form depends on this module.
57//!
58//! [`Geometry`] is the exception, and is used directly: it is already a plain
59//! public vocabulary with no invariant of its own beyond the dimension check
60//! [`RobotBuilder::build`] runs, so mirroring it would only risk the two
61//! drifting apart.
62//!
63//! ```
64//! use phoxal_model::builder::{Kinematics, RobotBuilder};
65//!
66//! let robot = RobotBuilder::new("rover")
67//!     .component_type("drive_motor", |motor| {
68//!         motor.motor("spin", "axle").encoder("count", "axle")
69//!     })
70//!     .component("left_drive", "drive_motor")
71//!     .component("right_drive", "drive_motor")
72//!     .kinematics(Kinematics::Differential {
73//!         left_actuators: &["left_drive.spin"],
74//!         right_actuators: &["right_drive.spin"],
75//!         left_encoders: &["left_drive.count"],
76//!         right_encoders: &["right_drive.count"],
77//!         wheel_radius_m: 0.1,
78//!         wheel_base_m: 0.4,
79//!     })
80//!     .build()?;
81//!
82//! assert_eq!(robot.component_ids().len(), 2);
83//! # Ok::<(), phoxal_model::ModelError>(())
84//! ```
85
86use std::collections::{BTreeMap, BTreeSet};
87
88use serde_json::{Value, json};
89
90use crate::asset::AssetId;
91use crate::compiler::{self, RobotParts};
92use crate::component::Component;
93use crate::component::capability::{
94    Accelerometer, Battery, Camera, CameraMode, Capability, Depth, EmergencyStop, Encoder,
95    EncoderType, Gnss, GnssCoordinateSystem, Gyroscope, Imu, Led, Lidar, LidarOutput, Magnetometer,
96    Microphone, Mmwave, Motor, MotorCommand, Range, Speaker, StructuralTarget,
97};
98use crate::error::ModelError;
99use crate::identity::{
100    CapabilityId, CapabilityRef, ComponentInstanceId, ComponentTypeId, JointId, LinkId, RobotId,
101};
102use crate::robot::{Clock, KinematicConfig, MotionLimits, Robot};
103use crate::simulation;
104use crate::structure::{BASE_FOOTPRINT_LINK, BASE_LINK, Geometry, JointKind, Structure};
105
106/// The root link of every component structure this module generates.
107pub const COMPONENT_ROOT_LINK: &str = "mount";
108
109/// The suffix naming the link a generated joint moves.
110const JOINT_CHILD_SUFFIX: &str = "_link";
111/// The suffix naming the fixed joint that holds a generated link in place.
112const LINK_JOINT_SUFFIX: &str = "_joint";
113/// The suffix of the mount link generated for an instance that states none.
114const MOUNT_LINK_SUFFIX: &str = "_mount";
115/// The joint attaching `base_link` beneath the root, when none is stated.
116const BASE_JOINT: &str = "base_joint";
117
118/// The envelope a built robot clamps motion to unless limits are stated.
119const DEFAULT_MOTION_LIMITS: MotionLimits = MotionLimits {
120    max_linear_speed_mps: 1.0,
121    max_angular_speed_radps: 1.0,
122};
123
124/// The rate a generated sensor capability publishes at.
125const DEFAULT_PUBLISH_RATE_HZ: f64 = 50.0;
126
127/// The drive geometry of a built robot, and the capabilities realizing it.
128///
129/// This mirrors [`KinematicConfig`] one variant at a time, taking each
130/// capability as the `component.capability` string an authored document writes
131/// rather than an already-parsed reference. The references must resolve to
132/// motors and encoders the robot declares, which [`RobotBuilder::build`]
133/// checks.
134///
135/// ```
136/// use phoxal_model::builder::{Kinematics, RobotBuilder};
137///
138/// let robot = RobotBuilder::new("car")
139///     .component_type("steer", |steer| steer.motor("turn", "kingpin"))
140///     .component_type("drive", |drive| drive.motor("spin", "axle"))
141///     .component("front", "steer")
142///     .component("rear", "drive")
143///     .kinematics(Kinematics::Ackermann {
144///         steering_actuator: "front.turn",
145///         drive_actuator: "rear.spin",
146///         steering_encoder: None,
147///         drive_encoder: None,
148///         wheel_base_m: 2.5,
149///         track_m: 1.5,
150///         max_steering_angle_rad: 0.6,
151///     })
152///     .build()?;
153///
154/// assert!(robot.motion().kinematic().drive_kinematics().is_ok());
155/// # Ok::<(), phoxal_model::ModelError>(())
156/// ```
157#[derive(Clone, Copy, Debug)]
158pub enum Kinematics<'a> {
159    /// Two independently driven sides.
160    Differential {
161        left_actuators: &'a [&'a str],
162        right_actuators: &'a [&'a str],
163        left_encoders: &'a [&'a str],
164        right_encoders: &'a [&'a str],
165        wheel_radius_m: f64,
166        wheel_base_m: f64,
167    },
168    /// Four independently driven wheels with 45-degree rollers.
169    Mecanum {
170        front_left_actuator: &'a str,
171        front_right_actuator: &'a str,
172        rear_left_actuator: &'a str,
173        rear_right_actuator: &'a str,
174        wheel_radius_m: f64,
175        wheel_base_m: f64,
176        track_m: f64,
177    },
178    /// One steered axle and one driven axle.
179    Ackermann {
180        steering_actuator: &'a str,
181        drive_actuator: &'a str,
182        steering_encoder: Option<&'a str>,
183        drive_encoder: Option<&'a str>,
184        wheel_base_m: f64,
185        track_m: f64,
186        max_steering_angle_rad: f64,
187    },
188    /// Actuators and encoders whose geometry the model does not describe.
189    ///
190    /// This is what a robot with no drive at all declares, and it is what a
191    /// builder starts with.
192    Omnidirectional {
193        actuators: &'a [&'a str],
194        encoders: &'a [&'a str],
195    },
196}
197
198/// One joint of a built structure, and the link it moves.
199///
200/// The child link is created if no other joint already provides it, so stating
201/// a joint is also how a structure grows a link.
202///
203/// Every field but the three names has a default: the joint sits at its
204/// parent's origin, turns about Z, is [`JointKind::Fixed`], carries the all-zero
205/// limits a URDF joint without a `<limit>` compiles to, and states no
206/// calibration, dynamics, mimic or safety.
207///
208/// ```
209/// use phoxal_model::builder::{Joint, JointLimit, RobotBuilder};
210/// use phoxal_model::structure::JointKind;
211///
212/// let robot = RobotBuilder::new("rover")
213///     .joint(Joint {
214///         name: "mast_joint",
215///         kind: JointKind::Revolute,
216///         parent: "base_link",
217///         child: "mast",
218///         xyz: [0.0, 0.0, 0.4],
219///         limit: JointLimit {
220///             lower: -1.5,
221///             upper: 1.5,
222///             effort: 8.0,
223///             velocity: 2.0,
224///         },
225///         ..Joint::default()
226///     })
227///     .build()?;
228///
229/// let mast = robot.structure().joint("mast_joint").expect("the stated joint");
230/// assert_eq!(mast.limit().upper(), 1.5);
231/// assert!(robot.structure().link("mast").is_some());
232/// # Ok::<(), phoxal_model::ModelError>(())
233/// ```
234#[derive(Clone, Copy, Debug)]
235pub struct Joint<'a> {
236    /// The joint's own identity, unique within its structure.
237    pub name: &'a str,
238    /// Which degree of freedom the joint has.
239    pub kind: JointKind,
240    /// The link this joint hangs from, which must already exist.
241    pub parent: &'a str,
242    /// The link this joint moves, created here if nothing else provides it.
243    pub child: &'a str,
244    /// The child's offset from the parent, in metres.
245    pub xyz: [f64; 3],
246    /// The child's roll, pitch and yaw relative to the parent, in radians.
247    pub rpy: [f64; 3],
248    /// The axis a movable joint turns or slides along, in the parent's frame.
249    pub axis: [f64; 3],
250    /// How far, how hard and how fast the joint may be driven.
251    pub limit: JointLimit,
252    /// Where the joint's reference switch trips, when it has one.
253    pub calibration: Option<Calibration>,
254    /// The joint's passive damping and friction, when they are modelled.
255    pub dynamics: Option<Dynamics>,
256    /// The joint this one follows instead of being driven independently.
257    pub mimic: Option<Mimic<'a>>,
258    /// The soft envelope a safety controller holds the joint inside.
259    pub safety: Option<Safety>,
260}
261
262impl Default for Joint<'_> {
263    fn default() -> Self {
264        Self {
265            name: "",
266            kind: JointKind::Fixed,
267            parent: "",
268            child: "",
269            xyz: [0.0; 3],
270            rpy: [0.0; 3],
271            axis: [0.0, 0.0, 1.0],
272            limit: JointLimit::default(),
273            calibration: None,
274            dynamics: None,
275            mimic: None,
276            safety: None,
277        }
278    }
279}
280
281/// How far, how hard and how fast a joint may be driven.
282///
283/// The default is all zeroes, which is what the document compiler emits for a
284/// URDF joint that authors no `<limit>`. The range must be finite and
285/// non-inverted, which [`RobotBuilder::build`] checks.
286#[derive(Clone, Copy, Debug, Default)]
287pub struct JointLimit {
288    /// The lowest position the joint may reach, in metres or radians.
289    pub lower: f64,
290    /// The highest position the joint may reach, in metres or radians.
291    pub upper: f64,
292    /// The largest force or torque the joint may apply, in N or Nm.
293    pub effort: f64,
294    /// The largest speed the joint may move at, in m/s or rad/s.
295    pub velocity: f64,
296}
297
298/// Where a joint's reference switch trips.
299///
300/// Either end may be left unstated, which is what a switch that only reports
301/// one edge means.
302#[derive(Clone, Copy, Debug, Default)]
303pub struct Calibration {
304    /// The position the switch rises at, in metres or radians.
305    pub rising: Option<f64>,
306    /// The position the switch falls at, in metres or radians.
307    pub falling: Option<f64>,
308}
309
310/// A joint's passive damping and friction.
311///
312/// Both must be finite and non-negative, which [`RobotBuilder::build`] checks.
313#[derive(Clone, Copy, Debug, Default)]
314pub struct Dynamics {
315    /// Resistance proportional to speed, in Ns/m or Nms/rad.
316    pub damping: f64,
317    /// Resistance opposing motion at any speed, in N or Nm.
318    pub friction: f64,
319}
320
321/// The joint another joint follows, and the affine relation it follows it by.
322///
323/// The named joint must exist in the same structure, which
324/// [`RobotBuilder::build`] checks.
325#[derive(Clone, Copy, Debug)]
326pub struct Mimic<'a> {
327    /// The joint whose position drives this one.
328    pub joint: &'a str,
329    /// The factor the driving position is scaled by; unstated means one.
330    pub multiplier: Option<f64>,
331    /// The constant added after scaling; unstated means zero.
332    pub offset: Option<f64>,
333}
334
335impl<'a> Mimic<'a> {
336    /// Follow `joint` one for one, with no offset.
337    #[must_use]
338    pub const fn new(joint: &'a str) -> Self {
339        Self {
340            joint,
341            multiplier: None,
342            offset: None,
343        }
344    }
345}
346
347/// The soft envelope a safety controller holds a joint inside.
348///
349/// The range must be finite and non-inverted, which [`RobotBuilder::build`]
350/// checks.
351#[derive(Clone, Copy, Debug, Default)]
352pub struct Safety {
353    /// Where the controller starts pushing back, at the low end.
354    pub soft_lower_limit: f64,
355    /// Where the controller starts pushing back, at the high end.
356    pub soft_upper_limit: f64,
357    /// The position gain the controller pushes back with.
358    pub k_position: f64,
359    /// The velocity gain the controller pushes back with.
360    pub k_velocity: f64,
361}
362
363/// One link of a built structure: the mass it has, and the shapes it is drawn
364/// and collided with.
365///
366/// A link that no stated joint attaches is added beneath the structure's body
367/// frame by a fixed joint named `<link>_joint`, exactly as a mount link is, so
368/// stating a link is also how a structure grows one. Naming a link some joint
369/// already provides gives that link its body instead.
370///
371/// Every field but the name has a default: a link carries the same unit
372/// inertial a generated link does, and no geometry at all.
373///
374/// ```
375/// use phoxal_model::builder::{Inertia, Inertial, Link, RobotBuilder};
376///
377/// let robot = RobotBuilder::new("rover")
378///     .link(Link {
379///         name: "base_link",
380///         inertial: Inertial {
381///             mass_kg: 12.0,
382///             inertia: Inertia {
383///                 ixx: 0.8,
384///                 iyy: 1.2,
385///                 izz: 1.6,
386///                 ..Inertia::default()
387///             },
388///             ..Inertial::default()
389///         },
390///         ..Link::default()
391///     })
392///     .build()?;
393///
394/// let base = robot.structure().link("base_link").expect("the stated link");
395/// assert_eq!(base.inertial().mass_kg(), 12.0);
396/// # Ok::<(), phoxal_model::ModelError>(())
397/// ```
398#[derive(Clone, Debug, Default)]
399pub struct Link<'a> {
400    /// The link's own identity, unique within its structure.
401    pub name: &'a str,
402    /// The link's mass properties.
403    pub inertial: Inertial,
404    /// The shapes the link is drawn with.
405    pub visuals: Vec<Visual<'a>>,
406    /// The shapes the link collides with.
407    pub collisions: Vec<Collision<'a>>,
408}
409
410/// The mass properties of one link.
411///
412/// The default is the unit inertial a generated link carries: one kilogram at
413/// the link's own origin, with a unit tensor.
414#[derive(Clone, Copy, Debug)]
415pub struct Inertial {
416    /// The centre of mass, offset from the link's origin, in metres.
417    pub xyz: [f64; 3],
418    /// The inertia frame's roll, pitch and yaw relative to the link, in radians.
419    pub rpy: [f64; 3],
420    /// The link's mass, in kilograms.
421    pub mass_kg: f64,
422    /// The inertia tensor about the centre of mass.
423    pub inertia: Inertia,
424}
425
426impl Default for Inertial {
427    fn default() -> Self {
428        Self {
429            xyz: [0.0; 3],
430            rpy: [0.0; 3],
431            mass_kg: 1.0,
432            inertia: Inertia::default(),
433        }
434    }
435}
436
437/// The symmetric inertia tensor of one link, in kg*m^2.
438///
439/// The default is the unit tensor. The tensor must describe a physically
440/// realizable body, which [`RobotBuilder::build`] checks.
441#[derive(Clone, Copy, Debug)]
442pub struct Inertia {
443    /// The moment about X.
444    pub ixx: f64,
445    /// The product of inertia between X and Y.
446    pub ixy: f64,
447    /// The product of inertia between X and Z.
448    pub ixz: f64,
449    /// The moment about Y.
450    pub iyy: f64,
451    /// The product of inertia between Y and Z.
452    pub iyz: f64,
453    /// The moment about Z.
454    pub izz: f64,
455}
456
457impl Default for Inertia {
458    fn default() -> Self {
459        Self {
460            ixx: 1.0,
461            ixy: 0.0,
462            ixz: 0.0,
463            iyy: 1.0,
464            iyz: 0.0,
465            izz: 1.0,
466        }
467    }
468}
469
470/// One shape a link is drawn with.
471///
472/// The shape itself is the one thing a visual cannot default, so
473/// [`Visual::new`] takes it and leaves the rest to `..`.
474///
475/// ```
476/// use phoxal_model::AssetId;
477/// use phoxal_model::builder::{Link, Material, RobotBuilder, Visual};
478/// use phoxal_model::structure::Geometry;
479///
480/// let robot = RobotBuilder::new("rover")
481///     .link(Link {
482///         name: "base_link",
483///         visuals: vec![Visual {
484///             material: Some(Material {
485///                 color: Some([0.2, 0.2, 0.2, 1.0]),
486///                 ..Material::new("carbon")
487///             }),
488///             ..Visual::new(Geometry::Mesh {
489///                 asset: AssetId::new("meshes/chassis.stl")?,
490///                 scale: None,
491///             })
492///         }],
493///         ..Link::default()
494///     })
495///     .build()?;
496///
497/// let base = robot.structure().link("base_link").expect("the stated link");
498/// assert_eq!(base.visuals().len(), 1);
499/// # Ok::<(), phoxal_model::ModelError>(())
500/// ```
501#[derive(Clone, Debug)]
502pub struct Visual<'a> {
503    /// The visual's own name, when the structure gives it one.
504    pub name: Option<&'a str>,
505    /// The shape's offset from the link's origin, in metres.
506    pub xyz: [f64; 3],
507    /// The shape's roll, pitch and yaw relative to the link, in radians.
508    pub rpy: [f64; 3],
509    /// The shape itself.
510    pub geometry: Geometry,
511    /// How the shape is rendered, when the structure says.
512    pub material: Option<Material<'a>>,
513}
514
515impl Visual<'_> {
516    /// An unnamed, unpainted visual of `geometry` at the link's own origin.
517    #[must_use]
518    pub fn new(geometry: Geometry) -> Self {
519        Self {
520            name: None,
521            xyz: [0.0; 3],
522            rpy: [0.0; 3],
523            geometry,
524            material: None,
525        }
526    }
527}
528
529/// One shape a link collides with.
530///
531/// The shape itself is the one thing a collision cannot default, so
532/// [`Collision::new`] takes it and leaves the rest to `..`.
533#[derive(Clone, Debug)]
534pub struct Collision<'a> {
535    /// The collision's own name, when the structure gives it one.
536    pub name: Option<&'a str>,
537    /// The shape's offset from the link's origin, in metres.
538    pub xyz: [f64; 3],
539    /// The shape's roll, pitch and yaw relative to the link, in radians.
540    pub rpy: [f64; 3],
541    /// The shape itself.
542    pub geometry: Geometry,
543}
544
545impl Collision<'_> {
546    /// An unnamed collision of `geometry` at the link's own origin.
547    #[must_use]
548    pub fn new(geometry: Geometry) -> Self {
549        Self {
550            name: None,
551            xyz: [0.0; 3],
552            rpy: [0.0; 3],
553            geometry,
554        }
555    }
556}
557
558/// How a visual is rendered.
559///
560/// A material is stated where it is used, and may also be added to the
561/// structure's own catalogue with [`RobotBuilder::material`].
562#[derive(Clone, Debug)]
563pub struct Material<'a> {
564    /// The material's name, which is how a structure refers to it.
565    pub name: &'a str,
566    /// Linear RGBA in `0.0..=1.0`, when the material states a colour.
567    pub color: Option<[f64; 4]>,
568    /// The texture image, when the material states one.
569    pub texture: Option<AssetId>,
570}
571
572impl<'a> Material<'a> {
573    /// A material named `name`, with neither colour nor texture.
574    #[must_use]
575    pub const fn new(name: &'a str) -> Self {
576        Self {
577            name,
578            color: None,
579            texture: None,
580        }
581    }
582}
583
584/// One joint as the builder holds it, with its names owned.
585#[derive(Debug)]
586struct JointSpec {
587    name: String,
588    kind: JointKind,
589    parent: String,
590    child: String,
591    xyz: [f64; 3],
592    rpy: [f64; 3],
593    axis: [f64; 3],
594    limit: JointLimit,
595    calibration: Option<Calibration>,
596    dynamics: Option<Dynamics>,
597    mimic: Option<MimicSpec>,
598    safety: Option<Safety>,
599}
600
601/// One mimic relationship as the builder holds it, with its joint owned.
602#[derive(Debug)]
603struct MimicSpec {
604    joint: String,
605    multiplier: Option<f64>,
606    offset: Option<f64>,
607}
608
609impl From<Joint<'_>> for JointSpec {
610    fn from(joint: Joint<'_>) -> Self {
611        Self {
612            name: joint.name.to_owned(),
613            kind: joint.kind,
614            parent: joint.parent.to_owned(),
615            child: joint.child.to_owned(),
616            xyz: joint.xyz,
617            rpy: joint.rpy,
618            axis: joint.axis,
619            limit: joint.limit,
620            calibration: joint.calibration,
621            dynamics: joint.dynamics,
622            mimic: joint.mimic.map(MimicSpec::from),
623            safety: joint.safety,
624        }
625    }
626}
627
628impl From<Mimic<'_>> for MimicSpec {
629    fn from(mimic: Mimic<'_>) -> Self {
630        Self {
631            joint: mimic.joint.to_owned(),
632            multiplier: mimic.multiplier,
633            offset: mimic.offset,
634        }
635    }
636}
637
638/// The links and materials of one structure, each keyed by its own name and
639/// already normalized into the canonical document the compiler reads.
640///
641/// The document is the only route into a [`Structure`], so the builder keeps
642/// what it was told in that form rather than in a third copy of the shape.
643#[derive(Debug, Default)]
644struct Bodies {
645    links: BTreeMap<String, Value>,
646    materials: BTreeMap<String, Value>,
647}
648
649impl Bodies {
650    /// State one link's body, replacing any earlier statement of it.
651    fn link(&mut self, link: &Link<'_>) {
652        self.links.insert(link.name.to_owned(), link_value(link));
653    }
654
655    /// Add one material to the catalogue, replacing any earlier one of its name.
656    fn material(&mut self, material: &Material<'_>) {
657        self.materials
658            .insert(material.name.to_owned(), material_value(material));
659    }
660}
661
662/// One component type as the builder holds it.
663#[derive(Debug, Default)]
664struct TypeSpec {
665    capabilities: BTreeMap<String, Capability>,
666    joints: Vec<JointSpec>,
667    bodies: Bodies,
668    simulated: BTreeMap<String, simulation::Capability>,
669    contact_materials: BTreeMap<String, String>,
670}
671
672/// One mounted instance as the builder holds it.
673#[derive(Debug)]
674struct InstanceSpec {
675    component_type: String,
676    mount_link: Option<String>,
677    direction_signs: BTreeMap<String, i8>,
678}
679
680/// Composes a canonical [`Robot`] from stated facts.
681///
682/// No method here fails. A rejected value is held until [`Self::build`], which
683/// reports the first one as a typed [`ModelError`], so a chain reads as one
684/// statement rather than a sequence of fallible steps.
685///
686/// ```
687/// use phoxal_model::builder::RobotBuilder;
688///
689/// let robot = RobotBuilder::new("rover")
690///     .component_type("rgbd", |camera| camera.camera("rgb", "lens"))
691///     .component("front_camera", "rgbd")
692///     .build()?;
693///
694/// assert_eq!(robot.id().as_str(), "rover");
695/// # Ok::<(), phoxal_model::ModelError>(())
696/// ```
697#[derive(Debug)]
698pub struct RobotBuilder {
699    id: String,
700    clock: Clock,
701    motion_limits: MotionLimits,
702    /// The drive, already normalized. Held as a `Result` so that a malformed
703    /// capability reference is reported by [`RobotBuilder::build`] rather than
704    /// forcing every caller to handle one mid-chain.
705    kinematic: Result<KinematicConfig, ModelError>,
706    joints: Vec<JointSpec>,
707    bodies: Bodies,
708    types: BTreeMap<String, TypeSpec>,
709    instances: BTreeMap<String, InstanceSpec>,
710}
711
712/// Declares one component type: the capabilities and structure every instance
713/// of it has, and how a simulated world models it.
714///
715/// Reached through [`RobotBuilder::component_type`].
716#[derive(Debug)]
717pub struct ComponentTypeBuilder {
718    spec: TypeSpec,
719}
720
721/// Configures one mounted component instance.
722///
723/// Reached through [`RobotBuilder::component_with`].
724#[derive(Debug)]
725pub struct ComponentBuilder {
726    spec: InstanceSpec,
727}
728
729impl RobotBuilder {
730    /// A robot with the given id, no components and no drive.
731    ///
732    /// It starts on the real clock, with an omnidirectional kinematic config
733    /// declaring no actuators - the one
734    /// geometry that describes nothing a robot without a drive would have to
735    /// invent.
736    ///
737    /// ```
738    /// use phoxal_model::builder::RobotBuilder;
739    ///
740    /// let robot = RobotBuilder::new("rover").build()?;
741    ///
742    /// assert_eq!(robot.id().as_str(), "rover");
743    /// assert_eq!(robot.clock(), phoxal_model::Clock::Real);
744    /// # Ok::<(), phoxal_model::ModelError>(())
745    /// ```
746    #[must_use]
747    pub fn new(id: &str) -> Self {
748        Self {
749            id: id.to_owned(),
750            clock: Clock::Real,
751            motion_limits: DEFAULT_MOTION_LIMITS,
752            kinematic: Ok(KinematicConfig::Omnidirectional {
753                actuators: Vec::new(),
754                encoders: Vec::new(),
755            }),
756            joints: Vec::new(),
757            bodies: Bodies::default(),
758            types: BTreeMap::new(),
759            instances: BTreeMap::new(),
760        }
761    }
762
763    /// Put this robot on the given time domain.
764    ///
765    /// ```
766    /// use phoxal_model::Clock;
767    /// use phoxal_model::builder::RobotBuilder;
768    ///
769    /// let robot = RobotBuilder::new("rover").clock(Clock::Simulated).build()?;
770    ///
771    /// assert_eq!(robot.clock(), Clock::Simulated);
772    /// # Ok::<(), phoxal_model::ModelError>(())
773    /// ```
774    #[must_use]
775    pub const fn clock(mut self, clock: Clock) -> Self {
776        self.clock = clock;
777        self
778    }
779
780    /// Clamp this robot's motion to the given envelope.
781    ///
782    /// The limits must be finite, positive and representable as `f32`, which
783    /// [`Self::build`] checks.
784    #[must_use]
785    pub const fn motion_limits(mut self, limits: MotionLimits) -> Self {
786        self.motion_limits = limits;
787        self
788    }
789
790    /// Drive this robot with the given geometry.
791    #[must_use]
792    pub fn kinematics(mut self, kinematics: Kinematics<'_>) -> Self {
793        self.kinematic = kinematics.into_config();
794        self
795    }
796
797    /// Add one joint, and its child link, to the robot's own structure.
798    ///
799    /// Use this when the robot's link tree is part of what is being stated;
800    /// a robot that says nothing still gets the conventional base frames and a
801    /// mount link per instance.
802    #[must_use]
803    pub fn joint(mut self, joint: Joint<'_>) -> Self {
804        self.joints.push(joint.into());
805        self
806    }
807
808    /// Give one link of the robot's own structure a body.
809    ///
810    /// A link no stated joint attaches is added beneath `base_link` by a fixed
811    /// joint named `<link>_joint`, so this is enough on its own to put a link
812    /// on the robot. Stating the same link twice replaces the earlier body.
813    ///
814    /// ```
815    /// use phoxal_model::AssetId;
816    /// use phoxal_model::builder::{
817    ///     Collision, Inertia, Inertial, Link, Material, RobotBuilder, Visual,
818    /// };
819    /// use phoxal_model::structure::Geometry;
820    ///
821    /// let robot = RobotBuilder::new("rover")
822    ///     .link(Link {
823    ///         name: "chassis",
824    ///         inertial: Inertial {
825    ///             xyz: [0.0, 0.0, 0.05],
826    ///             mass_kg: 12.0,
827    ///             inertia: Inertia {
828    ///                 ixx: 0.8,
829    ///                 iyy: 1.2,
830    ///                 izz: 1.6,
831    ///                 ..Inertia::default()
832    ///             },
833    ///             ..Inertial::default()
834    ///         },
835    ///         visuals: vec![Visual {
836    ///             name: Some("shell"),
837    ///             material: Some(Material {
838    ///                 color: Some([0.2, 0.2, 0.2, 1.0]),
839    ///                 texture: Some(AssetId::new("textures/carbon.png")?),
840    ///                 ..Material::new("carbon")
841    ///             }),
842    ///             ..Visual::new(Geometry::Mesh {
843    ///                 asset: AssetId::new("meshes/chassis.stl")?,
844    ///                 scale: None,
845    ///             })
846    ///         }],
847    ///         collisions: vec![Collision::new(Geometry::Box {
848    ///             size: [0.6, 0.4, 0.2],
849    ///         })],
850    ///         ..Link::default()
851    ///     })
852    ///     .build()?;
853    ///
854    /// let chassis = robot.structure().link("chassis").expect("the stated link");
855    /// assert_eq!(chassis.inertial().mass_kg(), 12.0);
856    /// assert_eq!(chassis.collisions().len(), 1);
857    /// # Ok::<(), phoxal_model::ModelError>(())
858    /// ```
859    #[must_use]
860    pub fn link(mut self, link: Link<'_>) -> Self {
861        self.bodies.link(&link);
862        self
863    }
864
865    /// Add one material to the robot structure's own catalogue.
866    ///
867    /// This is the structure-level material table, which is one of the places a
868    /// bundle's declared assets are read from; a visual states the material it
869    /// is drawn with itself. Restating a name replaces the earlier material.
870    #[must_use]
871    pub fn material(mut self, material: Material<'_>) -> Self {
872        self.bodies.material(&material);
873        self
874    }
875
876    /// Declare one component type.
877    ///
878    /// Declaring the same type twice replaces the earlier declaration, so a
879    /// type is stated once and mounted as many times as needed.
880    ///
881    /// ```
882    /// use phoxal_model::builder::RobotBuilder;
883    ///
884    /// let robot = RobotBuilder::new("rover")
885    ///     .component_type("drive_motor", |motor| {
886    ///         motor.motor("spin", "axle").encoder("count", "axle")
887    ///     })
888    ///     .component("left_drive", "drive_motor")
889    ///     .component("right_drive", "drive_motor")
890    ///     .build()?;
891    ///
892    /// assert_eq!(robot.capability_refs(|_| true).len(), 4);
893    /// # Ok::<(), phoxal_model::ModelError>(())
894    /// ```
895    #[must_use]
896    pub fn component_type(
897        mut self,
898        component_type: &str,
899        declare: impl FnOnce(ComponentTypeBuilder) -> ComponentTypeBuilder,
900    ) -> Self {
901        self.types.insert(
902            component_type.to_owned(),
903            declare(ComponentTypeBuilder {
904                spec: TypeSpec::default(),
905            })
906            .spec,
907        );
908        self
909    }
910
911    /// Mount one instance of `component_type` on a generated mount link named
912    /// `<instance>_mount`.
913    ///
914    /// The type must be declared by [`Self::component_type`], which
915    /// [`Self::build`] checks.
916    #[must_use]
917    pub fn component(self, instance: &str, component_type: &str) -> Self {
918        self.component_with(instance, component_type, |mounted| mounted)
919    }
920
921    /// Mount one instance of `component_type`, stating where it sits and how
922    /// its actuators are turned.
923    ///
924    /// Mounting the same instance twice replaces the earlier mount.
925    ///
926    /// ```
927    /// use phoxal_model::builder::RobotBuilder;
928    ///
929    /// let robot = RobotBuilder::new("rover")
930    ///     .component_type("drive_motor", |motor| motor.motor("spin", "axle"))
931    ///     .component_with("right_drive", "drive_motor", |mounted| {
932    ///         mounted
933    ///             .mounted_on("right_wheel_mount")
934    ///             .direction_sign("spin", -1)
935    ///     })
936    ///     .build()?;
937    ///
938    /// let (_motor, sign) = robot.require_motor(&"right_drive.spin".parse()?)?;
939    /// assert_eq!(sign, -1);
940    /// # Ok::<(), phoxal_model::ModelError>(())
941    /// ```
942    #[must_use]
943    pub fn component_with(
944        mut self,
945        instance: &str,
946        component_type: &str,
947        mount: impl FnOnce(ComponentBuilder) -> ComponentBuilder,
948    ) -> Self {
949        self.instances.insert(
950            instance.to_owned(),
951            mount(ComponentBuilder {
952                spec: InstanceSpec {
953                    component_type: component_type.to_owned(),
954                    mount_link: None,
955                    direction_signs: BTreeMap::new(),
956                },
957            })
958            .spec,
959        );
960        self
961    }
962
963    /// Normalize, assemble and validate the robot.
964    ///
965    /// # Errors
966    ///
967    /// Returns the first [`ModelError`] the stated robot violates: an
968    /// identifier that is not a normalized token, a capability reference that
969    /// does not resolve to the kind its kinematic role needs, a structure that
970    /// is not a single link tree, or any other invariant the canonical model
971    /// enforces on a compiled bundle.
972    ///
973    /// ```
974    /// use phoxal_model::{IdentifierKind, ModelError};
975    /// use phoxal_model::builder::RobotBuilder;
976    ///
977    /// let rejected = RobotBuilder::new("Rover").build();
978    ///
979    /// assert!(matches!(
980    ///     rejected,
981    ///     Err(ModelError::NotNormalized { kind: IdentifierKind::RobotId, .. })
982    /// ));
983    /// ```
984    pub fn build(self) -> Result<Robot, ModelError> {
985        let id = RobotId::new(self.id)?;
986        let kinematic = self.kinematic?;
987        let types = build_types(self.types)?;
988        let mut component_instances = BTreeMap::new();
989        let mut mounts = BTreeSet::new();
990        for (instance, spec) in self.instances {
991            let instance = ComponentInstanceId::new(instance)?;
992            let mount_link = LinkId::new(
993                spec.mount_link
994                    .unwrap_or_else(|| format!("{instance}{MOUNT_LINK_SUFFIX}")),
995            );
996            mounts.insert(mount_link.clone());
997            let mut direction_signs = BTreeMap::new();
998            for (capability, sign) in spec.direction_signs {
999                direction_signs.insert(CapabilityId::new(capability)?, sign);
1000            }
1001            component_instances.insert(
1002                instance.clone(),
1003                compiler::component_instance(
1004                    instance,
1005                    ComponentTypeId::new(spec.component_type)?,
1006                    mount_link,
1007                    direction_signs,
1008                ),
1009            );
1010        }
1011        let structure = robot_structure(&id, self.joints, &mounts, &self.bodies)?;
1012        compiler::robot(RobotParts {
1013            id,
1014            clock: self.clock,
1015            kinematic,
1016            motion_limits: self.motion_limits,
1017            component_instances,
1018            component_types: types.components,
1019            simulation_types: types.simulations,
1020            structure,
1021        })
1022    }
1023}
1024
1025impl ComponentTypeBuilder {
1026    /// Declare one capability, exactly as the canonical model carries it.
1027    ///
1028    /// Every shorthand below is this method with one kind's defaults filled in;
1029    /// reach for this one when a capability needs parameters, or a structural
1030    /// target, that its shorthand does not offer.
1031    ///
1032    /// ```
1033    /// use phoxal_model::builder::RobotBuilder;
1034    /// use phoxal_model::component::capability::{
1035    ///     Capability, Motor, MotorCommand, StructuralTarget,
1036    /// };
1037    /// use phoxal_model::identity::JointId;
1038    ///
1039    /// let robot = RobotBuilder::new("arm-bot")
1040    ///     .component_type("joint_motor", |joint_motor| {
1041    ///         joint_motor.capability(
1042    ///             "lift",
1043    ///             Capability::Motor(Motor {
1044    ///                 target: StructuralTarget::Joint { id: JointId::new("elbow") },
1045    ///                 command: MotorCommand::Position,
1046    ///                 gear_ratio: 50.0,
1047    ///                 max_torque_nm: Some(12.0),
1048    ///                 max_velocity_radps: None,
1049    ///             }),
1050    ///         )
1051    ///     })
1052    ///     .component("arm", "joint_motor")
1053    ///     .build()?;
1054    ///
1055    /// let (motor, _sign) = robot.require_motor(&"arm.lift".parse()?)?;
1056    /// assert_eq!(motor.gear_ratio, 50.0);
1057    /// # Ok::<(), phoxal_model::ModelError>(())
1058    /// ```
1059    #[must_use]
1060    pub fn capability(mut self, capability: &str, declared: Capability) -> Self {
1061        self.spec
1062            .capabilities
1063            .insert(capability.to_owned(), declared);
1064        self
1065    }
1066
1067    /// Add one joint, and its child link, to this component's structure.
1068    ///
1069    /// A component that states nothing still gets a joint or link for every
1070    /// capability target it declares.
1071    #[must_use]
1072    pub fn joint(mut self, joint: Joint<'_>) -> Self {
1073        self.spec.joints.push(joint.into());
1074        self
1075    }
1076
1077    /// Give one link of this component's structure a body.
1078    ///
1079    /// A link no stated joint attaches is added beneath `mount` by a fixed
1080    /// joint named `<link>_joint`, so this is enough on its own to put a link
1081    /// on the component. Stating the same link twice replaces the earlier body.
1082    ///
1083    /// ```
1084    /// use phoxal_model::builder::{Link, RobotBuilder, Visual};
1085    /// use phoxal_model::structure::Geometry;
1086    ///
1087    /// let robot = RobotBuilder::new("rover")
1088    ///     .component_type("rgbd", |camera| {
1089    ///         camera.camera("rgb", "lens").link(Link {
1090    ///             name: "lens",
1091    ///             visuals: vec![Visual::new(Geometry::Cylinder {
1092    ///                 radius: 0.02,
1093    ///                 length: 0.01,
1094    ///             })],
1095    ///             ..Link::default()
1096    ///         })
1097    ///     })
1098    ///     .component("front_camera", "rgbd")
1099    ///     .build()?;
1100    ///
1101    /// let camera = robot
1102    ///     .component_for_instance("front_camera")
1103    ///     .expect("the mounted type is loaded");
1104    /// let lens = camera.structure().link("lens").expect("the stated link");
1105    /// assert_eq!(lens.visuals().len(), 1);
1106    /// # Ok::<(), phoxal_model::ModelError>(())
1107    /// ```
1108    #[must_use]
1109    pub fn link(mut self, link: Link<'_>) -> Self {
1110        self.spec.bodies.link(&link);
1111        self
1112    }
1113
1114    /// Add one material to this component structure's own catalogue.
1115    ///
1116    /// The component counterpart of [`RobotBuilder::material`]. Restating a
1117    /// name replaces the earlier material.
1118    #[must_use]
1119    pub fn material(mut self, material: Material<'_>) -> Self {
1120        self.spec.bodies.material(&material);
1121        self
1122    }
1123
1124    /// Model one of this type's capabilities in a simulated world.
1125    ///
1126    /// The named capability must be one this type declares, of the same kind,
1127    /// which [`RobotBuilder::build`] checks.
1128    ///
1129    /// ```
1130    /// use phoxal_model::builder::RobotBuilder;
1131    /// use phoxal_model::simulation;
1132    ///
1133    /// let robot = RobotBuilder::new("rover")
1134    ///     .component_type("drive_motor", |motor| {
1135    ///         motor.motor("spin", "axle").simulated(
1136    ///             "spin",
1137    ///             simulation::Capability::Motor(simulation::Motor::default()),
1138    ///         )
1139    ///     })
1140    ///     .component("left_drive", "drive_motor")
1141    ///     .build()?;
1142    ///
1143    /// assert!(robot.simulation_for_instance("left_drive").is_some());
1144    /// # Ok::<(), phoxal_model::ModelError>(())
1145    /// ```
1146    #[must_use]
1147    pub fn simulated(mut self, capability: &str, simulated: simulation::Capability) -> Self {
1148        self.spec.simulated.insert(capability.to_owned(), simulated);
1149        self
1150    }
1151
1152    /// Give one component-local link a simulated contact material.
1153    #[must_use]
1154    pub fn contact_material(mut self, link: &str, material: &str) -> Self {
1155        self.spec
1156            .contact_materials
1157            .insert(link.to_owned(), material.to_owned());
1158        self
1159    }
1160
1161    /// A velocity motor driving `joint`, geared one to one.
1162    #[must_use]
1163    pub fn motor(self, capability: &str, joint: &str) -> Self {
1164        self.capability(
1165            capability,
1166            Capability::Motor(Motor {
1167                target: joint_target(joint),
1168                command: MotorCommand::Velocity,
1169                gear_ratio: 1.0,
1170                max_torque_nm: None,
1171                max_velocity_radps: None,
1172            }),
1173        )
1174    }
1175
1176    /// An incremental encoder measuring `joint`, geared one to one.
1177    #[must_use]
1178    pub fn encoder(self, capability: &str, joint: &str) -> Self {
1179        self.capability(
1180            capability,
1181            Capability::Encoder(Encoder {
1182                target: joint_target(joint),
1183                publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
1184                gear_ratio: 1.0,
1185                encoder_type: EncoderType::Incremental,
1186                counts_per_revolution: 4096,
1187            }),
1188        )
1189    }
1190
1191    /// A three-axis accelerometer on `link`.
1192    #[must_use]
1193    pub fn accelerometer(self, capability: &str, link: &str) -> Self {
1194        self.capability(
1195            capability,
1196            Capability::Accelerometer(Accelerometer {
1197                target: link_target(link),
1198                publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
1199                axes: None,
1200            }),
1201        )
1202    }
1203
1204    /// A three-axis gyroscope on `link`.
1205    #[must_use]
1206    pub fn gyroscope(self, capability: &str, link: &str) -> Self {
1207        self.capability(
1208            capability,
1209            Capability::Gyroscope(Gyroscope {
1210                target: link_target(link),
1211                publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
1212                axes: None,
1213            }),
1214        )
1215    }
1216
1217    /// A three-axis magnetometer on `link`.
1218    #[must_use]
1219    pub fn magnetometer(self, capability: &str, link: &str) -> Self {
1220        self.capability(
1221            capability,
1222            Capability::Magnetometer(Magnetometer {
1223                target: link_target(link),
1224                publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
1225                axes: None,
1226            }),
1227        )
1228    }
1229
1230    /// A fused inertial measurement unit on `link`.
1231    #[must_use]
1232    pub fn imu(self, capability: &str, link: &str) -> Self {
1233        self.capability(
1234            capability,
1235            Capability::Imu(Imu {
1236                target: link_target(link),
1237                publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
1238                axes: None,
1239            }),
1240        )
1241    }
1242
1243    /// A satellite receiver on `link`, reporting in the robot's local frame.
1244    #[must_use]
1245    pub fn gnss(self, capability: &str, link: &str) -> Self {
1246        self.capability(
1247            capability,
1248            Capability::Gnss(Gnss {
1249                target: link_target(link),
1250                publish_rate_hz: 10.0,
1251                coordinate_system: GnssCoordinateSystem::Local,
1252            }),
1253        )
1254    }
1255
1256    /// A 640x480 colour camera looking out of `link`.
1257    #[must_use]
1258    pub fn camera(self, capability: &str, link: &str) -> Self {
1259        self.capability(
1260            capability,
1261            Capability::Camera(Camera {
1262                target: link_target(link),
1263                mode: CameraMode::Rgb,
1264                publish_rate_hz: 30.0,
1265                width_px: 640,
1266                height_px: 480,
1267                field_of_view_rad: None,
1268            }),
1269        )
1270    }
1271
1272    /// A 640x480 depth sensor looking out of `link`.
1273    #[must_use]
1274    pub fn depth(self, capability: &str, link: &str) -> Self {
1275        self.capability(
1276            capability,
1277            Capability::Depth(Depth {
1278                target: link_target(link),
1279                publish_rate_hz: 30.0,
1280                width_px: 640,
1281                height_px: 480,
1282                field_of_view_rad: None,
1283                min_range_m: None,
1284                max_range_m: None,
1285            }),
1286        )
1287    }
1288
1289    /// An emergency stop input on `link`.
1290    #[must_use]
1291    pub fn emergency_stop(self, capability: &str, link: &str) -> Self {
1292        self.capability(
1293            capability,
1294            Capability::EmergencyStop(EmergencyStop {
1295                target: link_target(link),
1296            }),
1297        )
1298    }
1299
1300    /// A narrow single-beam range finder on `link`.
1301    #[must_use]
1302    pub fn range(self, capability: &str, link: &str) -> Self {
1303        self.capability(
1304            capability,
1305            Capability::Range(Range {
1306                target: link_target(link),
1307                publish_rate_hz: 20.0,
1308                min_range_m: 0.05,
1309                max_range_m: 4.0,
1310                field_of_view_rad: 0.4,
1311            }),
1312        )
1313    }
1314
1315    /// A planar lidar on `link`, publishing ranges.
1316    #[must_use]
1317    pub fn lidar(self, capability: &str, link: &str) -> Self {
1318        self.capability(
1319            capability,
1320            Capability::Lidar(Lidar {
1321                target: link_target(link),
1322                publish_rate_hz: 10.0,
1323                output: LidarOutput::Ranges,
1324                min_range_m: None,
1325                max_range_m: None,
1326                horizontal_fov_rad: None,
1327                horizontal_resolution_rad: None,
1328                vertical_fov_rad: None,
1329                vertical_resolution_rad: None,
1330            }),
1331        )
1332    }
1333
1334    /// A millimetre-wave radar on `link`.
1335    #[must_use]
1336    pub fn mmwave(self, capability: &str, link: &str) -> Self {
1337        self.capability(
1338            capability,
1339            Capability::Mmwave(Mmwave {
1340                target: link_target(link),
1341                publish_rate_hz: 20.0,
1342            }),
1343        )
1344    }
1345
1346    /// A microphone on `link`.
1347    #[must_use]
1348    pub fn microphone(self, capability: &str, link: &str) -> Self {
1349        self.capability(
1350            capability,
1351            Capability::Microphone(Microphone {
1352                target: link_target(link),
1353                publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
1354            }),
1355        )
1356    }
1357
1358    /// A speaker on `link`.
1359    #[must_use]
1360    pub fn speaker(self, capability: &str, link: &str) -> Self {
1361        self.capability(
1362            capability,
1363            Capability::Speaker(Speaker {
1364                target: link_target(link),
1365            }),
1366        )
1367    }
1368
1369    /// A 12 V battery on `link`.
1370    #[must_use]
1371    pub fn battery(self, capability: &str, link: &str) -> Self {
1372        self.capability(
1373            capability,
1374            Capability::Battery(Battery {
1375                target: link_target(link),
1376                publish_rate_hz: 1.0,
1377                voltage_v: 12.0,
1378                capacity_ah: 5.0,
1379            }),
1380        )
1381    }
1382
1383    /// An indicator light on `link`.
1384    #[must_use]
1385    pub fn led(self, capability: &str, link: &str) -> Self {
1386        self.capability(
1387            capability,
1388            Capability::Led(Led {
1389                target: link_target(link),
1390            }),
1391        )
1392    }
1393}
1394
1395impl ComponentBuilder {
1396    /// Mount this instance on the named robot link rather than on the one
1397    /// generated from its instance id.
1398    ///
1399    /// The link is added beneath `base_link` unless a stated joint already
1400    /// attaches it.
1401    #[must_use]
1402    pub fn mounted_on(mut self, link: &str) -> Self {
1403        self.spec.mount_link = Some(link.to_owned());
1404        self
1405    }
1406
1407    /// State which way this instance's capability is turned, as `1` or `-1`.
1408    ///
1409    /// This is what [`Robot::require_motor`] and [`Robot::require_encoder`]
1410    /// return beside the capability, so that a mirrored actuator is described
1411    /// once on the model rather than by every consumer that drives it.
1412    #[must_use]
1413    pub fn direction_sign(mut self, capability: &str, sign: i8) -> Self {
1414        self.spec
1415            .direction_signs
1416            .insert(capability.to_owned(), sign);
1417        self
1418    }
1419}
1420
1421impl Kinematics<'_> {
1422    /// The canonical config this states.
1423    fn into_config(self) -> Result<KinematicConfig, ModelError> {
1424        Ok(match self {
1425            Self::Differential {
1426                left_actuators,
1427                right_actuators,
1428                left_encoders,
1429                right_encoders,
1430                wheel_radius_m,
1431                wheel_base_m,
1432            } => KinematicConfig::Differential {
1433                left_actuators: references(left_actuators)?,
1434                right_actuators: references(right_actuators)?,
1435                left_encoders: references(left_encoders)?,
1436                right_encoders: references(right_encoders)?,
1437                wheel_radius_m,
1438                wheel_base_m,
1439            },
1440            Self::Mecanum {
1441                front_left_actuator,
1442                front_right_actuator,
1443                rear_left_actuator,
1444                rear_right_actuator,
1445                wheel_radius_m,
1446                wheel_base_m,
1447                track_m,
1448            } => KinematicConfig::Mecanum {
1449                front_left_actuator: front_left_actuator.parse()?,
1450                front_right_actuator: front_right_actuator.parse()?,
1451                rear_left_actuator: rear_left_actuator.parse()?,
1452                rear_right_actuator: rear_right_actuator.parse()?,
1453                wheel_radius_m,
1454                wheel_base_m,
1455                track_m,
1456            },
1457            Self::Ackermann {
1458                steering_actuator,
1459                drive_actuator,
1460                steering_encoder,
1461                drive_encoder,
1462                wheel_base_m,
1463                track_m,
1464                max_steering_angle_rad,
1465            } => KinematicConfig::Ackermann {
1466                steering_actuator: steering_actuator.parse()?,
1467                drive_actuator: drive_actuator.parse()?,
1468                steering_encoder: optional_reference(steering_encoder)?,
1469                drive_encoder: optional_reference(drive_encoder)?,
1470                wheel_base_m,
1471                track_m,
1472                max_steering_angle_rad,
1473            },
1474            Self::Omnidirectional {
1475                actuators,
1476                encoders,
1477            } => KinematicConfig::Omnidirectional {
1478                actuators: references(actuators)?,
1479                encoders: references(encoders)?,
1480            },
1481        })
1482    }
1483}
1484
1485/// The normalized component types, and the simulations modelling them.
1486///
1487/// The two are produced together because a simulation is keyed by the type it
1488/// models, and returned together so neither can be assembled without the other.
1489#[derive(Debug)]
1490struct NormalizedTypes {
1491    components: BTreeMap<ComponentTypeId, Component>,
1492    simulations: BTreeMap<ComponentTypeId, simulation::Simulation>,
1493}
1494
1495/// Normalize every component type, and the simulations that model them.
1496fn build_types(types: BTreeMap<String, TypeSpec>) -> Result<NormalizedTypes, ModelError> {
1497    let mut component_types = BTreeMap::new();
1498    let mut simulation_types = BTreeMap::new();
1499    for (component_type, spec) in types {
1500        let component_type = ComponentTypeId::new(component_type)?;
1501        let mut capabilities = BTreeMap::new();
1502        for (capability, declared) in spec.capabilities {
1503            capabilities.insert(CapabilityId::new(capability)?, declared);
1504        }
1505        let structure =
1506            component_structure(&component_type, &capabilities, spec.joints, &spec.bodies)?;
1507        // A simulation is only carried for a type that states one: an empty
1508        // simulation and no simulation are different facts.
1509        if !spec.simulated.is_empty() || !spec.contact_materials.is_empty() {
1510            let mut simulated = BTreeMap::new();
1511            for (capability, modelled) in spec.simulated {
1512                simulated.insert(CapabilityId::new(capability)?, modelled);
1513            }
1514            simulation_types.insert(
1515                component_type.clone(),
1516                compiler::simulation(
1517                    simulated,
1518                    spec.contact_materials
1519                        .into_iter()
1520                        .map(|(link, material)| (LinkId::new(link), Some(material)))
1521                        .collect(),
1522                ),
1523            );
1524        }
1525        component_types.insert(component_type, compiler::component(capabilities, structure));
1526    }
1527    Ok(NormalizedTypes {
1528        components: component_types,
1529        simulations: simulation_types,
1530    })
1531}
1532
1533/// The component structure its stated joints, links and capability targets
1534/// imply.
1535fn component_structure(
1536    component_type: &ComponentTypeId,
1537    capabilities: &BTreeMap<CapabilityId, Capability>,
1538    mut joints: Vec<JointSpec>,
1539    bodies: &Bodies,
1540) -> Result<Structure, ModelError> {
1541    for capability in capabilities.values() {
1542        match capability.target() {
1543            StructuralTarget::Joint { id } => {
1544                if !joints.iter().any(|joint| joint.name == id.as_str()) {
1545                    joints.push(generated_joint(
1546                        id.as_str(),
1547                        JointKind::Continuous,
1548                        COMPONENT_ROOT_LINK,
1549                        &format!("{id}{JOINT_CHILD_SUFFIX}"),
1550                    ));
1551                }
1552            }
1553            StructuralTarget::Link { id } => attach(
1554                &mut joints,
1555                COMPONENT_ROOT_LINK,
1556                COMPONENT_ROOT_LINK,
1557                id.as_str(),
1558            ),
1559        }
1560    }
1561    for link in bodies.links.keys() {
1562        attach(&mut joints, COMPONENT_ROOT_LINK, COMPONENT_ROOT_LINK, link);
1563    }
1564    structure(
1565        component_type.as_str(),
1566        COMPONENT_ROOT_LINK,
1567        &joints,
1568        bodies,
1569    )
1570}
1571
1572/// The robot structure its stated joints, stated links and every mount link
1573/// imply.
1574fn robot_structure(
1575    id: &RobotId,
1576    mut joints: Vec<JointSpec>,
1577    mounts: &BTreeSet<LinkId>,
1578    bodies: &Bodies,
1579) -> Result<Structure, ModelError> {
1580    if !joints.iter().any(|joint| joint.child == BASE_LINK) {
1581        joints.push(generated_joint(
1582            BASE_JOINT,
1583            JointKind::Fixed,
1584            BASE_FOOTPRINT_LINK,
1585            BASE_LINK,
1586        ));
1587    }
1588    for link in mounts
1589        .iter()
1590        .map(LinkId::as_str)
1591        .chain(bodies.links.keys().map(String::as_str))
1592    {
1593        attach(&mut joints, BASE_FOOTPRINT_LINK, BASE_LINK, link);
1594    }
1595    structure(id.as_str(), BASE_FOOTPRINT_LINK, &joints, bodies)
1596}
1597
1598/// Hang `link` beneath `parent` unless the structure already provides it.
1599///
1600/// The root is provided by being the root and any link a joint already moves is
1601/// provided by that joint; a link nothing provides would leave the structure in
1602/// pieces rather than as a single tree.
1603fn attach(joints: &mut Vec<JointSpec>, root: &str, parent: &str, link: &str) {
1604    if link == root || joints.iter().any(|joint| joint.child == link) {
1605        return;
1606    }
1607    joints.push(generated_joint(
1608        &format!("{link}{LINK_JOINT_SUFFIX}"),
1609        JointKind::Fixed,
1610        parent,
1611        link,
1612    ));
1613}
1614
1615/// A joint the builder adds because nothing stated one.
1616fn generated_joint(name: &str, kind: JointKind, parent: &str, child: &str) -> JointSpec {
1617    JointSpec::from(Joint {
1618        name,
1619        kind,
1620        parent,
1621        child,
1622        ..Joint::default()
1623    })
1624}
1625
1626/// The canonical structure rooted at `root`, with a link for the root and one
1627/// for every joint's child.
1628///
1629/// A link the caller gave a body carries it; every other link gets the unit
1630/// inertial and no geometry that being a frame requires and nothing more.
1631fn structure(
1632    name: &str,
1633    root: &str,
1634    joints: &[JointSpec],
1635    bodies: &Bodies,
1636) -> Result<Structure, ModelError> {
1637    let body_of = |link: &str| {
1638        bodies.links.get(link).cloned().unwrap_or_else(|| {
1639            link_value(&Link {
1640                name: link,
1641                ..Link::default()
1642            })
1643        })
1644    };
1645    let mut links = vec![body_of(root)];
1646    links.extend(joints.iter().map(|joint| body_of(&joint.child)));
1647    compiler::structure(json!({
1648        "name": name,
1649        "links": links,
1650        "joints": joints.iter().map(joint_value).collect::<Vec<_>>(),
1651        "materials": bodies.materials.values().collect::<Vec<_>>()
1652    }))
1653}
1654
1655/// One link as the canonical structure document carries it.
1656fn link_value(link: &Link<'_>) -> Value {
1657    json!({
1658        "name": link.name,
1659        "inertial": inertial_value(link.inertial),
1660        "visuals": link.visuals.iter().map(visual_value).collect::<Vec<_>>(),
1661        "collisions": link.collisions.iter().map(collision_value).collect::<Vec<_>>()
1662    })
1663}
1664
1665fn inertial_value(inertial: Inertial) -> Value {
1666    let Inertia {
1667        ixx,
1668        ixy,
1669        ixz,
1670        iyy,
1671        iyz,
1672        izz,
1673    } = inertial.inertia;
1674    json!({
1675        "origin": pose_value(inertial.xyz, inertial.rpy),
1676        "mass_kg": inertial.mass_kg,
1677        "inertia": { "ixx": ixx, "ixy": ixy, "ixz": ixz, "iyy": iyy, "iyz": iyz, "izz": izz }
1678    })
1679}
1680
1681fn visual_value(visual: &Visual<'_>) -> Value {
1682    json!({
1683        "name": visual.name,
1684        "origin": pose_value(visual.xyz, visual.rpy),
1685        "geometry": visual.geometry,
1686        "material": visual.material.as_ref().map(material_value)
1687    })
1688}
1689
1690fn collision_value(collision: &Collision<'_>) -> Value {
1691    json!({
1692        "name": collision.name,
1693        "origin": pose_value(collision.xyz, collision.rpy),
1694        "geometry": collision.geometry
1695    })
1696}
1697
1698fn material_value(material: &Material<'_>) -> Value {
1699    json!({
1700        "name": material.name,
1701        "color": material.color,
1702        "texture": material.texture
1703    })
1704}
1705
1706/// One joint as the canonical structure document carries it.
1707fn joint_value(joint: &JointSpec) -> Value {
1708    let JointLimit {
1709        lower,
1710        upper,
1711        effort,
1712        velocity,
1713    } = joint.limit;
1714    json!({
1715        "name": joint.name,
1716        "kind": joint.kind,
1717        "origin": pose_value(joint.xyz, joint.rpy),
1718        "parent": joint.parent,
1719        "child": joint.child,
1720        "axis": joint.axis,
1721        "limit": { "lower": lower, "upper": upper, "effort": effort, "velocity": velocity },
1722        "calibration": joint.calibration.map(|calibration| json!({
1723            "rising": calibration.rising,
1724            "falling": calibration.falling
1725        })),
1726        "dynamics": joint.dynamics.map(|dynamics| json!({
1727            "damping": dynamics.damping,
1728            "friction": dynamics.friction
1729        })),
1730        "mimic": joint.mimic.as_ref().map(|mimic| json!({
1731            "joint": mimic.joint,
1732            "multiplier": mimic.multiplier,
1733            "offset": mimic.offset
1734        })),
1735        "safety": joint.safety.map(|safety| json!({
1736            "soft_lower_limit": safety.soft_lower_limit,
1737            "soft_upper_limit": safety.soft_upper_limit,
1738            "k_position": safety.k_position,
1739            "k_velocity": safety.k_velocity
1740        }))
1741    })
1742}
1743
1744fn pose_value(xyz: [f64; 3], rpy: [f64; 3]) -> Value {
1745    json!({ "xyz": xyz, "rpy": rpy })
1746}
1747
1748fn joint_target(id: &str) -> StructuralTarget {
1749    StructuralTarget::Joint {
1750        id: JointId::new(id),
1751    }
1752}
1753
1754fn link_target(id: &str) -> StructuralTarget {
1755    StructuralTarget::Link {
1756        id: LinkId::new(id),
1757    }
1758}
1759
1760fn references(values: &[&str]) -> Result<Vec<CapabilityRef>, ModelError> {
1761    values.iter().map(|value| value.parse()).collect()
1762}
1763
1764fn optional_reference(value: Option<&str>) -> Result<Option<CapabilityRef>, ModelError> {
1765    value.map(str::parse).transpose()
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770    use super::{
1771        Collision, Dynamics, Inertial, Joint, JointLimit, Kinematics, Link, Material, Mimic,
1772        RobotBuilder, Visual,
1773    };
1774    use crate::asset::AssetId;
1775    use crate::component::capability::{
1776        Capability, CapabilityKind, Motor, MotorCommand, StructuralTarget,
1777    };
1778    use crate::error::{IdentifierKind, ModelError, StructureError};
1779    use crate::identity::{CapabilityRef, JointId, LinkId};
1780    use crate::robot::{Clock, DriveKinematics, KinematicConfig, MotionLimits};
1781    use crate::simulation;
1782    use crate::structure::{Geometry, JointKind};
1783
1784    fn reference(value: &str) -> CapabilityRef {
1785        value.parse().expect("a well formed capability reference")
1786    }
1787
1788    /// Every kind the canonical model can declare has to survive the trip
1789    /// through the builder, because a kind that cannot be stated is a robot
1790    /// that cannot be composed without documents.
1791    #[test]
1792    fn every_capability_kind_reaches_a_validated_robot() {
1793        let robot = RobotBuilder::new("rover")
1794            .component_type("everything", |all| {
1795                all.motor("spin", "axle")
1796                    .encoder("count", "axle")
1797                    .accelerometer("accel", "imu_link")
1798                    .gyroscope("gyro", "imu_link")
1799                    .magnetometer("mag", "imu_link")
1800                    .imu("imu", "imu_link")
1801                    .gnss("fix", "antenna")
1802                    .camera("rgb", "lens")
1803                    .depth("depth", "lens")
1804                    .emergency_stop("estop", "panel")
1805                    .range("tof", "nose")
1806                    .lidar("scan", "dome")
1807                    .mmwave("radar", "nose")
1808                    .microphone("mic", "panel")
1809                    .speaker("horn", "panel")
1810                    .battery("pack", "chassis")
1811                    .led("beacon", "dome")
1812            })
1813            .component("kitchen_sink", "everything")
1814            .build()
1815            .expect("every capability kind composes a valid robot");
1816
1817        let component = robot
1818            .component_for_instance("kitchen_sink")
1819            .expect("the mounted type is loaded");
1820        let mut kinds = component
1821            .capabilities()
1822            .map(|(_, capability)| capability.kind())
1823            .collect::<Vec<_>>();
1824        kinds.sort_unstable();
1825        kinds.dedup();
1826        assert_eq!(
1827            kinds.len(),
1828            17,
1829            "every canonical capability kind must be reachable"
1830        );
1831        assert_eq!(robot.capability_refs(|_| true).len(), 17);
1832    }
1833
1834    /// A capability is only usable if the structural item it names really
1835    /// exists, so both target kinds must resolve on the generated structure.
1836    #[test]
1837    fn both_structural_target_kinds_resolve() {
1838        let robot = RobotBuilder::new("rover")
1839            .component_type("drive_motor", |motor| {
1840                motor.motor("spin", "axle").encoder("count", "axle")
1841            })
1842            .component_type("rgbd", |camera| camera.camera("rgb", "lens"))
1843            .component("left_drive", "drive_motor")
1844            .component("front_camera", "rgbd")
1845            .build()
1846            .expect("a valid robot");
1847
1848        // A link target resolves to the runtime frame it names.
1849        assert_eq!(
1850            robot
1851                .link_target_frame(&reference("front_camera.rgb"))
1852                .expect("the camera targets a link"),
1853            LinkId::new("front_camera__lens")
1854        );
1855        // A joint target names a joint the component structure carries, and
1856        // the motor and the encoder measuring it share one.
1857        let component = robot
1858            .component_for_instance("left_drive")
1859            .expect("the mounted type is loaded");
1860        assert!(component.structure().joint("axle").is_some());
1861        assert!(component.structure().link("axle_link").is_some());
1862        for capability in ["left_drive.spin", "left_drive.count"] {
1863            let target = robot
1864                .capability(&reference(capability))
1865                .expect("the capability is declared")
1866                .target();
1867            assert_eq!(
1868                target,
1869                &StructuralTarget::Joint {
1870                    id: JointId::new("axle")
1871                },
1872                "{capability}"
1873            );
1874        }
1875    }
1876
1877    #[test]
1878    fn every_kinematic_config_validates_and_resolves() {
1879        let wheeled = |builder: RobotBuilder| {
1880            builder
1881                .component_type("drive_motor", |motor| {
1882                    motor.motor("spin", "axle").encoder("count", "axle")
1883                })
1884                .component("front_left", "drive_motor")
1885                .component("front_right", "drive_motor")
1886                .component("rear_left", "drive_motor")
1887                .component("rear_right", "drive_motor")
1888        };
1889        let differential = wheeled(RobotBuilder::new("rover"))
1890            .kinematics(Kinematics::Differential {
1891                left_actuators: &["front_left.spin", "rear_left.spin"],
1892                right_actuators: &["front_right.spin", "rear_right.spin"],
1893                left_encoders: &["front_left.count", "rear_left.count"],
1894                right_encoders: &["front_right.count", "rear_right.count"],
1895                wheel_radius_m: 0.1,
1896                wheel_base_m: 0.5,
1897            })
1898            .build()
1899            .expect("a valid differential robot");
1900        assert!(matches!(
1901            differential
1902                .motion()
1903                .kinematic()
1904                .drive_kinematics()
1905                .expect("the geometry is usable"),
1906            DriveKinematics::Differential(geometry) if geometry.wheel_radius_m == 0.1
1907        ));
1908
1909        let mecanum = wheeled(RobotBuilder::new("rover"))
1910            .kinematics(Kinematics::Mecanum {
1911                front_left_actuator: "front_left.spin",
1912                front_right_actuator: "front_right.spin",
1913                rear_left_actuator: "rear_left.spin",
1914                rear_right_actuator: "rear_right.spin",
1915                wheel_radius_m: 0.1,
1916                wheel_base_m: 0.4,
1917                track_m: 0.6,
1918            })
1919            .build()
1920            .expect("a valid mecanum robot");
1921        assert!(matches!(
1922            mecanum
1923                .motion()
1924                .kinematic()
1925                .drive_kinematics()
1926                .expect("the geometry is usable"),
1927            DriveKinematics::Mecanum(geometry) if geometry.track_m == 0.6
1928        ));
1929
1930        let ackermann = wheeled(RobotBuilder::new("rover"))
1931            .kinematics(Kinematics::Ackermann {
1932                steering_actuator: "front_left.spin",
1933                drive_actuator: "rear_left.spin",
1934                steering_encoder: Some("front_left.count"),
1935                drive_encoder: Some("rear_left.count"),
1936                wheel_base_m: 2.5,
1937                track_m: 1.5,
1938                max_steering_angle_rad: 0.6,
1939            })
1940            .build()
1941            .expect("a valid ackermann robot");
1942        assert!(matches!(
1943            ackermann
1944                .motion()
1945                .kinematic()
1946                .drive_kinematics()
1947                .expect("the geometry is usable"),
1948            DriveKinematics::Ackermann(geometry) if geometry.max_steering_angle_rad == 0.6
1949        ));
1950
1951        let omnidirectional = wheeled(RobotBuilder::new("rover"))
1952            .kinematics(Kinematics::Omnidirectional {
1953                actuators: &["front_left.spin"],
1954                encoders: &["front_left.count"],
1955            })
1956            .build()
1957            .expect("a valid omnidirectional robot");
1958        assert_eq!(
1959            omnidirectional
1960                .motion()
1961                .kinematic()
1962                .drive_kinematics()
1963                .expect("an omnidirectional drive carries no scalars to reject"),
1964            DriveKinematics::Omnidirectional
1965        );
1966    }
1967
1968    /// A drive resolves each side through `require_motor`/`require_encoder`,
1969    /// so the references a kinematic config carries have to name capabilities
1970    /// of the right kind on components the robot really mounts.
1971    #[test]
1972    fn a_kinematic_reference_must_name_a_capability_of_the_right_kind() {
1973        let miswired = RobotBuilder::new("rover")
1974            .component_type("drive_motor", |motor| {
1975                motor.motor("spin", "axle").encoder("count", "axle")
1976            })
1977            .component("left_drive", "drive_motor")
1978            .kinematics(Kinematics::Omnidirectional {
1979                actuators: &["left_drive.count"],
1980                encoders: &[],
1981            })
1982            .build();
1983
1984        assert!(matches!(
1985            miswired,
1986            Err(ModelError::CapabilityKindMismatch {
1987                expected: CapabilityKind::Motor,
1988                actual: CapabilityKind::Encoder,
1989                ..
1990            })
1991        ));
1992    }
1993
1994    #[test]
1995    fn direction_signs_come_back_beside_the_capability() {
1996        let robot = RobotBuilder::new("rover")
1997            .component_type("drive_motor", |motor| {
1998                motor.motor("spin", "axle").encoder("count", "axle")
1999            })
2000            .component("left_drive", "drive_motor")
2001            .component_with("right_drive", "drive_motor", |mounted| {
2002                mounted
2003                    .direction_sign("spin", -1)
2004                    .direction_sign("count", -1)
2005            })
2006            .build()
2007            .expect("a valid robot");
2008
2009        for (capability, expected) in [("left_drive.spin", 1), ("right_drive.spin", -1)] {
2010            let (_motor, sign) = robot
2011                .require_motor(&reference(capability))
2012                .expect("the motor resolves");
2013            assert_eq!(sign, expected, "{capability}");
2014        }
2015        for (capability, expected) in [("left_drive.count", 1), ("right_drive.count", -1)] {
2016            let (_encoder, sign) = robot
2017                .require_encoder(&reference(capability))
2018                .expect("the encoder resolves");
2019            assert_eq!(sign, expected, "{capability}");
2020        }
2021    }
2022
2023    #[test]
2024    fn a_direction_sign_that_is_not_a_direction_is_refused() {
2025        let rejected = RobotBuilder::new("rover")
2026            .component_type("drive_motor", |motor| motor.motor("spin", "axle"))
2027            .component_with("left_drive", "drive_motor", |mounted| {
2028                mounted.direction_sign("spin", 0)
2029            })
2030            .build();
2031
2032        assert!(matches!(
2033            rejected,
2034            Err(ModelError::DirectionSign { value: 0, .. })
2035        ));
2036    }
2037
2038    #[test]
2039    fn identity_clock_and_limits_are_carried_as_stated() {
2040        let robot = RobotBuilder::new("rover")
2041            .clock(Clock::Simulated)
2042            .motion_limits(MotionLimits {
2043                max_linear_speed_mps: 0.6,
2044                max_angular_speed_radps: 2.0,
2045            })
2046            .build()
2047            .expect("a valid robot");
2048
2049        assert_eq!(robot.id().as_str(), "rover");
2050        assert_eq!(robot.clock(), Clock::Simulated);
2051        assert_eq!(robot.motion().limits().max_linear_speed_mps, 0.6);
2052    }
2053
2054    /// The structure a caller states is theirs; only what they leave out is
2055    /// generated, and the conventional base frames are always there.
2056    #[test]
2057    fn stated_structure_is_kept_and_the_rest_is_generated() {
2058        let robot = RobotBuilder::new("rover")
2059            .joint(Joint {
2060                name: "mast_joint",
2061                kind: JointKind::Revolute,
2062                parent: "base_link",
2063                child: "mast",
2064                xyz: [0.1, 0.0, 0.4],
2065                ..Joint::default()
2066            })
2067            .component_type("rgbd", |camera| camera.camera("rgb", "lens"))
2068            .component_with("front_camera", "rgbd", |mounted| mounted.mounted_on("mast"))
2069            .component("rear_camera", "rgbd")
2070            .build()
2071            .expect("a valid robot");
2072
2073        let structure = robot.structure();
2074        assert_eq!(structure.root_link(), &LinkId::new("base_footprint"));
2075        let mast = structure.joint("mast_joint").expect("the stated joint");
2076        assert_eq!(mast.kind(), JointKind::Revolute);
2077        assert_eq!(mast.origin().xyz(), [0.1, 0.0, 0.4]);
2078        // The stated mount link is the one stated; the unstated one is
2079        // generated from the instance id.
2080        assert!(structure.link("mast").is_some());
2081        assert!(structure.link("rear_camera_mount").is_some());
2082        assert!(
2083            structure.joint("rear_camera_mount_joint").is_some(),
2084            "an unstated mount link is attached beneath base_link"
2085        );
2086    }
2087
2088    /// A stated link is a body for a link the tree already has, or a new link
2089    /// hung where a mount link would be. Either way nothing else changes: a
2090    /// link nobody described keeps the unit inertial and no geometry.
2091    #[test]
2092    fn a_stated_link_carries_its_body_and_leaves_the_rest_generated() {
2093        let robot = RobotBuilder::new("rover")
2094            .link(Link {
2095                name: "base_link",
2096                inertial: Inertial {
2097                    mass_kg: 12.0,
2098                    ..Inertial::default()
2099                },
2100                ..Link::default()
2101            })
2102            .link(Link {
2103                name: "mast",
2104                collisions: vec![Collision::new(Geometry::Sphere { radius: 0.2 })],
2105                ..Link::default()
2106            })
2107            .build()
2108            .expect("a valid robot");
2109
2110        let structure = robot.structure();
2111        // A body given to a link the base frames already provide does not
2112        // attach it a second time.
2113        assert_eq!(
2114            structure
2115                .link("base_link")
2116                .expect("the body frame")
2117                .inertial()
2118                .mass_kg(),
2119            12.0
2120        );
2121        assert!(structure.joint("base_link_joint").is_none());
2122        // A link nothing else provides is hung beneath the body frame.
2123        let mast_joint = structure.joint("mast_joint").expect("the generated joint");
2124        assert_eq!(mast_joint.parent(), &LinkId::new("base_link"));
2125        assert_eq!(
2126            structure
2127                .link("mast")
2128                .expect("the stated link")
2129                .collisions()
2130                .len(),
2131            1
2132        );
2133        // The root was never described, so it is still a bare frame.
2134        let root = structure.link("base_footprint").expect("the root link");
2135        assert_eq!(root.inertial().mass_kg(), 1.0);
2136        assert_eq!(root.visuals().len(), 0);
2137    }
2138
2139    /// A component type states its structure exactly the way the robot does,
2140    /// rooted at `mount` instead of the base frames.
2141    #[test]
2142    fn a_component_type_states_its_own_links_joints_and_materials() {
2143        let robot = RobotBuilder::new("rover")
2144            .component_type("pan_tilt", |head| {
2145                head.motor("pan", "pan_joint")
2146                    .joint(Joint {
2147                        name: "pan_joint",
2148                        kind: JointKind::Revolute,
2149                        parent: "mount",
2150                        child: "lens",
2151                        limit: JointLimit {
2152                            lower: -3.0,
2153                            upper: 3.0,
2154                            effort: 1.0,
2155                            velocity: 4.0,
2156                        },
2157                        dynamics: Some(Dynamics {
2158                            damping: 0.05,
2159                            friction: 0.01,
2160                        }),
2161                        ..Joint::default()
2162                    })
2163                    .link(Link {
2164                        name: "lens",
2165                        visuals: vec![Visual::new(Geometry::Cylinder {
2166                            radius: 0.02,
2167                            length: 0.01,
2168                        })],
2169                        ..Link::default()
2170                    })
2171                    .link(Link {
2172                        name: "shade",
2173                        inertial: Inertial {
2174                            mass_kg: 0.05,
2175                            ..Inertial::default()
2176                        },
2177                        ..Link::default()
2178                    })
2179                    .material(Material {
2180                        color: Some([0.0, 0.0, 0.0, 1.0]),
2181                        ..Material::new("matte")
2182                    })
2183            })
2184            .component("head", "pan_tilt")
2185            .build()
2186            .expect("a valid robot");
2187
2188        let structure = robot
2189            .component_for_instance("head")
2190            .expect("the mounted type is loaded")
2191            .structure();
2192        assert_eq!(structure.root_link(), &LinkId::new("mount"));
2193        let pan = structure.joint("pan_joint").expect("the stated joint");
2194        assert_eq!(pan.limit().velocity(), 4.0);
2195        assert_eq!(
2196            pan.dynamics().map(|dynamics| dynamics.damping()),
2197            Some(0.05)
2198        );
2199        assert_eq!(
2200            structure
2201                .link("lens")
2202                .expect("the stated link")
2203                .visuals()
2204                .len(),
2205            1
2206        );
2207        // A stated link no joint attaches is hung beneath the component root.
2208        assert_eq!(
2209            structure
2210                .joint("shade_joint")
2211                .expect("the generated joint")
2212                .parent(),
2213            &LinkId::new("mount")
2214        );
2215        let catalogue = structure.materials().collect::<Vec<_>>();
2216        assert_eq!(catalogue.len(), 1);
2217        assert_eq!(catalogue[0].name(), "matte");
2218    }
2219
2220    /// A joint's own fields are checked by the same canonical rules a compiled
2221    /// document is, and the builder is not a way around any of them.
2222    #[test]
2223    fn a_structural_value_the_model_refuses_is_refused_here_too() {
2224        let inverted = |limit| {
2225            RobotBuilder::new("rover")
2226                .joint(Joint {
2227                    name: "mast_joint",
2228                    kind: JointKind::Revolute,
2229                    parent: "base_link",
2230                    child: "mast",
2231                    limit,
2232                    ..Joint::default()
2233                })
2234                .build()
2235        };
2236        assert!(matches!(
2237            inverted(JointLimit {
2238                lower: 1.0,
2239                upper: -1.0,
2240                effort: 0.0,
2241                velocity: 0.0,
2242            }),
2243            Err(ModelError::Structure(StructureError::JointLimits { .. }))
2244        ));
2245        assert!(matches!(
2246            RobotBuilder::new("rover")
2247                .joint(Joint {
2248                    name: "mast_joint",
2249                    kind: JointKind::Revolute,
2250                    parent: "base_link",
2251                    child: "mast",
2252                    mimic: Some(Mimic::new("no_such_joint")),
2253                    ..Joint::default()
2254                })
2255                .build(),
2256            Err(ModelError::Structure(
2257                StructureError::UnknownMimicJoint { .. }
2258            ))
2259        ));
2260        assert!(matches!(
2261            RobotBuilder::new("rover")
2262                .link(Link {
2263                    name: "mast",
2264                    inertial: Inertial {
2265                        mass_kg: -1.0,
2266                        ..Inertial::default()
2267                    },
2268                    ..Link::default()
2269                })
2270                .build(),
2271            Err(ModelError::Structure(StructureError::Mass { .. }))
2272        ));
2273        assert!(matches!(
2274            RobotBuilder::new("rover")
2275                .link(Link {
2276                    name: "mast",
2277                    visuals: vec![Visual::new(Geometry::Sphere { radius: 0.0 })],
2278                    ..Link::default()
2279                })
2280                .build(),
2281            Err(ModelError::Structure(StructureError::Geometry { .. }))
2282        ));
2283    }
2284
2285    #[test]
2286    fn a_simulation_is_carried_only_for_the_types_that_state_one() {
2287        let robot = RobotBuilder::new("rover")
2288            .component_type("drive_motor", |motor| {
2289                motor
2290                    .motor("spin", "axle")
2291                    .simulated(
2292                        "spin",
2293                        simulation::Capability::Motor(simulation::Motor::default()),
2294                    )
2295                    .contact_material("axle_link", "rubber")
2296            })
2297            .component_type("rgbd", |camera| camera.camera("rgb", "lens"))
2298            .component("left_drive", "drive_motor")
2299            .component("front_camera", "rgbd")
2300            .build()
2301            .expect("a valid robot");
2302
2303        let simulation = robot
2304            .simulation_for_instance("left_drive")
2305            .expect("the drive states a simulation");
2306        assert_eq!(
2307            simulation
2308                .capability("spin")
2309                .expect("the simulated motor")
2310                .kind(),
2311            CapabilityKind::Motor
2312        );
2313        assert_eq!(
2314            simulation
2315                .links()
2316                .next()
2317                .and_then(|(_, link)| link.contact_material()),
2318            Some("rubber")
2319        );
2320        assert!(robot.simulation_for_instance("front_camera").is_none());
2321    }
2322
2323    /// A simulation may only model a capability its component declares, of the
2324    /// same kind, and the builder must not be a way around that.
2325    #[test]
2326    fn a_simulation_cannot_model_a_capability_the_component_does_not_declare() {
2327        let rejected = RobotBuilder::new("rover")
2328            .component_type("drive_motor", |motor| {
2329                motor.motor("spin", "axle").simulated(
2330                    "nonexistent",
2331                    simulation::Capability::Motor(simulation::Motor::default()),
2332                )
2333            })
2334            .component("left_drive", "drive_motor")
2335            .build();
2336
2337        assert!(matches!(
2338            rejected,
2339            Err(ModelError::SimulationWithoutCapability { .. })
2340        ));
2341    }
2342
2343    /// Every rejection is a typed value the caller can match on, not a panic.
2344    #[test]
2345    fn a_rejected_robot_returns_the_condition_it_violated() {
2346        assert!(matches!(
2347            RobotBuilder::new("Rover").build(),
2348            Err(ModelError::NotNormalized {
2349                kind: IdentifierKind::RobotId,
2350                ..
2351            })
2352        ));
2353        assert!(matches!(
2354            RobotBuilder::new("rover")
2355                .kinematics(Kinematics::Omnidirectional {
2356                    actuators: &["not-a-reference"],
2357                    encoders: &[],
2358                })
2359                .build(),
2360            Err(ModelError::MalformedCapabilityReference { .. })
2361        ));
2362        assert!(matches!(
2363            RobotBuilder::new("rover")
2364                .component("left_drive", "never_declared")
2365                .build(),
2366            Err(ModelError::UnknownComponentType { .. })
2367        ));
2368        // A joint kind the runtime has no controller for is refused, rather
2369        // than becoming a joint nothing can drive.
2370        assert!(matches!(
2371            RobotBuilder::new("rover")
2372                .joint(Joint {
2373                    name: "wobble",
2374                    kind: JointKind::Spherical,
2375                    parent: "base_link",
2376                    child: "head",
2377                    ..Joint::default()
2378                })
2379                .build(),
2380            Err(ModelError::UnsupportedJointKind { .. })
2381        ));
2382        // A joint hanging from a link nothing provides leaves the structure in
2383        // pieces rather than a single tree.
2384        assert!(matches!(
2385            RobotBuilder::new("rover")
2386                .joint(Joint {
2387                    name: "head_joint",
2388                    parent: "neck",
2389                    child: "head",
2390                    ..Joint::default()
2391                })
2392                .build(),
2393            Err(ModelError::Structure(
2394                StructureError::UnknownJointLink { .. }
2395            ))
2396        ));
2397        // The source compiler owns the conservative footprint derivation;
2398        // unsupported collision geometry must not be turned into a missing
2399        // envelope for a runtime to discover later.
2400        assert!(matches!(
2401            RobotBuilder::new("rover")
2402                .link(Link {
2403                    name: "chassis",
2404                    collisions: vec![Collision::new(Geometry::Mesh {
2405                        asset: AssetId::new("meshes/chassis.stl").expect("normalized asset id"),
2406                        scale: None,
2407                    })],
2408                    ..Link::default()
2409                })
2410                .build(),
2411            Err(ModelError::FootprintMesh { .. })
2412        ));
2413        assert!(matches!(
2414            RobotBuilder::new("rover")
2415                .joint(Joint {
2416                    name: "arm_joint",
2417                    kind: JointKind::Revolute,
2418                    parent: "base_link",
2419                    child: "arm",
2420                    ..Joint::default()
2421                })
2422                .link(Link {
2423                    name: "arm",
2424                    collisions: vec![Collision::new(Geometry::Sphere { radius: 0.1 })],
2425                    ..Link::default()
2426                })
2427                .build(),
2428            Err(ModelError::FootprintMovableJoint { .. })
2429        ));
2430    }
2431
2432    /// The general entry point has to reach parameters no shorthand offers,
2433    /// including a target kind the shorthand would not choose.
2434    #[test]
2435    fn the_general_capability_entry_point_carries_every_parameter() {
2436        let robot = RobotBuilder::new("arm-bot")
2437            .component_type("joint_motor", |joint_motor| {
2438                joint_motor.capability(
2439                    "lift",
2440                    Capability::Motor(Motor {
2441                        target: StructuralTarget::Link {
2442                            id: LinkId::new("housing"),
2443                        },
2444                        command: MotorCommand::Position,
2445                        gear_ratio: 50.0,
2446                        max_torque_nm: Some(12.0),
2447                        max_velocity_radps: Some(3.0),
2448                    }),
2449                )
2450            })
2451            .component("arm", "joint_motor")
2452            .build()
2453            .expect("a valid robot");
2454
2455        let (motor, _sign) = robot
2456            .require_motor(&reference("arm.lift"))
2457            .expect("the motor resolves");
2458        assert_eq!(motor.command, MotorCommand::Position);
2459        assert_eq!(motor.gear_ratio, 50.0);
2460        assert_eq!(motor.max_torque_nm, Some(12.0));
2461        // A link-targeted motor is unusual but legal, and its target resolves
2462        // to a link the generated structure carries.
2463        assert_eq!(
2464            robot
2465                .link_target_frame(&reference("arm.lift"))
2466                .expect("the motor targets a link"),
2467            LinkId::new("arm__housing")
2468        );
2469    }
2470
2471    #[test]
2472    fn a_restated_type_or_instance_replaces_the_earlier_one() {
2473        let robot = RobotBuilder::new("rover")
2474            .component_type("rgbd", |camera| camera.camera("rgb", "lens"))
2475            .component_type("rgbd", |camera| camera.camera("mono", "lens"))
2476            .component("front_camera", "rgbd")
2477            .component_with("front_camera", "rgbd", |mounted| mounted.mounted_on("mast"))
2478            .build()
2479            .expect("a valid robot");
2480
2481        assert_eq!(
2482            robot
2483                .capability_refs(|_| true)
2484                .iter()
2485                .map(ToString::to_string)
2486                .collect::<Vec<_>>(),
2487            ["front_camera.mono"]
2488        );
2489        assert_eq!(
2490            robot
2491                .component_instance("front_camera")
2492                .expect("the instance is mounted")
2493                .mount_link(),
2494            &LinkId::new("mast")
2495        );
2496    }
2497
2498    #[test]
2499    fn a_robot_with_nothing_stated_is_still_a_valid_robot() {
2500        let robot = RobotBuilder::new("rover")
2501            .build()
2502            .expect("the defaults compose a valid robot");
2503
2504        assert_eq!(robot.components().len(), 0);
2505        assert_eq!(
2506            robot.structure().root_link(),
2507            &LinkId::new("base_footprint")
2508        );
2509        assert!(robot.structure().link("base_link").is_some());
2510        assert!(matches!(
2511            robot.motion().kinematic(),
2512            KinematicConfig::Omnidirectional { .. }
2513        ));
2514    }
2515}