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