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}