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