use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use crate::model::asset::AssetId;
use crate::model::compiler::{self, RobotParts};
use crate::model::component::Component;
use crate::model::component::capability::{
Accelerometer, Battery, Camera, CameraMode, Capability, Depth, EmergencyStop, Encoder,
EncoderType, Gnss, GnssCoordinateSystem, Gyroscope, Imu, Led, Lidar, LidarOutput, Magnetometer,
Microphone, Mmwave, Motor, MotorCommand, Range, Speaker, StructuralTarget,
};
use crate::model::error::ModelError;
use crate::model::identity::{
CapabilityId, CapabilityRef, ComponentInstanceId, ComponentTypeId, JointId, LinkId, RobotId,
ServiceId,
};
use crate::model::robot::{KinematicConfig, MotionLimits, Robot};
use crate::model::simulation;
use crate::model::structure::{BASE_FOOTPRINT_LINK, BASE_LINK, Geometry, JointKind, Structure};
pub const COMPONENT_ROOT_LINK: &str = "mount";
const JOINT_CHILD_SUFFIX: &str = "_link";
const LINK_JOINT_SUFFIX: &str = "_joint";
const MOUNT_LINK_SUFFIX: &str = "_mount";
const BASE_JOINT: &str = "base_joint";
const DEFAULT_MOTION_LIMITS: MotionLimits = MotionLimits {
max_linear_speed_mps: 1.0,
max_angular_speed_radps: 1.0,
};
const DEFAULT_PUBLISH_RATE_HZ: f64 = 50.0;
#[derive(Clone, Copy, Debug)]
pub enum Kinematics<'a> {
Differential {
left_actuators: &'a [&'a str],
right_actuators: &'a [&'a str],
left_encoders: &'a [&'a str],
right_encoders: &'a [&'a str],
wheel_radius_m: f64,
wheel_base_m: f64,
},
Mecanum {
front_left_actuator: &'a str,
front_right_actuator: &'a str,
rear_left_actuator: &'a str,
rear_right_actuator: &'a str,
wheel_radius_m: f64,
wheel_base_m: f64,
track_m: f64,
},
Ackermann {
steering_actuator: &'a str,
drive_actuator: &'a str,
steering_encoder: Option<&'a str>,
drive_encoder: Option<&'a str>,
wheel_base_m: f64,
track_m: f64,
max_steering_angle_rad: f64,
},
Omnidirectional {
actuators: &'a [&'a str],
encoders: &'a [&'a str],
},
}
#[derive(Clone, Copy, Debug)]
pub struct Joint<'a> {
pub name: &'a str,
pub kind: JointKind,
pub parent: &'a str,
pub child: &'a str,
pub xyz: [f64; 3],
pub rpy: [f64; 3],
pub axis: [f64; 3],
pub limit: JointLimit,
pub calibration: Option<Calibration>,
pub dynamics: Option<Dynamics>,
pub mimic: Option<Mimic<'a>>,
pub safety: Option<Safety>,
}
impl Default for Joint<'_> {
fn default() -> Self {
Self {
name: "",
kind: JointKind::Fixed,
parent: "",
child: "",
xyz: [0.0; 3],
rpy: [0.0; 3],
axis: [0.0, 0.0, 1.0],
limit: JointLimit::default(),
calibration: None,
dynamics: None,
mimic: None,
safety: None,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct JointLimit {
pub lower: f64,
pub upper: f64,
pub effort: f64,
pub velocity: f64,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Calibration {
pub rising: Option<f64>,
pub falling: Option<f64>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Dynamics {
pub damping: f64,
pub friction: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct Mimic<'a> {
pub joint: &'a str,
pub multiplier: Option<f64>,
pub offset: Option<f64>,
}
impl<'a> Mimic<'a> {
#[must_use]
pub const fn new(joint: &'a str) -> Self {
Self {
joint,
multiplier: None,
offset: None,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Safety {
pub soft_lower_limit: f64,
pub soft_upper_limit: f64,
pub k_position: f64,
pub k_velocity: f64,
}
#[derive(Clone, Debug, Default)]
pub struct Link<'a> {
pub name: &'a str,
pub inertial: Inertial,
pub visuals: Vec<Visual<'a>>,
pub collisions: Vec<Collision<'a>>,
}
#[derive(Clone, Copy, Debug)]
pub struct Inertial {
pub xyz: [f64; 3],
pub rpy: [f64; 3],
pub mass_kg: f64,
pub inertia: Inertia,
}
impl Default for Inertial {
fn default() -> Self {
Self {
xyz: [0.0; 3],
rpy: [0.0; 3],
mass_kg: 1.0,
inertia: Inertia::default(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Inertia {
pub ixx: f64,
pub ixy: f64,
pub ixz: f64,
pub iyy: f64,
pub iyz: f64,
pub izz: f64,
}
impl Default for Inertia {
fn default() -> Self {
Self {
ixx: 1.0,
ixy: 0.0,
ixz: 0.0,
iyy: 1.0,
iyz: 0.0,
izz: 1.0,
}
}
}
#[derive(Clone, Debug)]
pub struct Visual<'a> {
pub name: Option<&'a str>,
pub xyz: [f64; 3],
pub rpy: [f64; 3],
pub geometry: Geometry,
pub material: Option<Material<'a>>,
}
impl Visual<'_> {
#[must_use]
pub fn new(geometry: Geometry) -> Self {
Self {
name: None,
xyz: [0.0; 3],
rpy: [0.0; 3],
geometry,
material: None,
}
}
}
#[derive(Clone, Debug)]
pub struct Collision<'a> {
pub name: Option<&'a str>,
pub xyz: [f64; 3],
pub rpy: [f64; 3],
pub geometry: Geometry,
}
impl Collision<'_> {
#[must_use]
pub fn new(geometry: Geometry) -> Self {
Self {
name: None,
xyz: [0.0; 3],
rpy: [0.0; 3],
geometry,
}
}
}
#[derive(Clone, Debug)]
pub struct Material<'a> {
pub name: &'a str,
pub color: Option<[f64; 4]>,
pub texture: Option<AssetId>,
}
impl<'a> Material<'a> {
#[must_use]
pub const fn new(name: &'a str) -> Self {
Self {
name,
color: None,
texture: None,
}
}
}
#[derive(Debug)]
struct JointSpec {
name: String,
kind: JointKind,
parent: String,
child: String,
xyz: [f64; 3],
rpy: [f64; 3],
axis: [f64; 3],
limit: JointLimit,
calibration: Option<Calibration>,
dynamics: Option<Dynamics>,
mimic: Option<MimicSpec>,
safety: Option<Safety>,
}
#[derive(Debug)]
struct MimicSpec {
joint: String,
multiplier: Option<f64>,
offset: Option<f64>,
}
impl From<Joint<'_>> for JointSpec {
fn from(joint: Joint<'_>) -> Self {
Self {
name: joint.name.to_owned(),
kind: joint.kind,
parent: joint.parent.to_owned(),
child: joint.child.to_owned(),
xyz: joint.xyz,
rpy: joint.rpy,
axis: joint.axis,
limit: joint.limit,
calibration: joint.calibration,
dynamics: joint.dynamics,
mimic: joint.mimic.map(MimicSpec::from),
safety: joint.safety,
}
}
}
impl From<Mimic<'_>> for MimicSpec {
fn from(mimic: Mimic<'_>) -> Self {
Self {
joint: mimic.joint.to_owned(),
multiplier: mimic.multiplier,
offset: mimic.offset,
}
}
}
#[derive(Debug, Default)]
struct Bodies {
links: BTreeMap<String, Value>,
materials: BTreeMap<String, Value>,
}
impl Bodies {
fn link(&mut self, link: &Link<'_>) {
self.links.insert(link.name.to_owned(), link_value(link));
}
fn material(&mut self, material: &Material<'_>) {
self.materials
.insert(material.name.to_owned(), material_value(material));
}
}
#[derive(Debug, Default)]
struct TypeSpec {
capabilities: BTreeMap<String, Capability>,
joints: Vec<JointSpec>,
bodies: Bodies,
simulated: BTreeMap<String, simulation::Capability>,
contact_materials: BTreeMap<String, String>,
}
#[derive(Debug)]
struct InstanceSpec {
component_type: String,
mount_link: Option<String>,
direction_signs: BTreeMap<String, i8>,
driver: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct RobotBuilder {
id: String,
motion_limits: MotionLimits,
services: BTreeMap<String, Option<serde_json::Value>>,
kinematic: Result<KinematicConfig, ModelError>,
joints: Vec<JointSpec>,
bodies: Bodies,
types: BTreeMap<String, TypeSpec>,
instances: BTreeMap<String, InstanceSpec>,
}
#[derive(Debug)]
pub struct ComponentTypeBuilder {
spec: TypeSpec,
}
#[derive(Debug)]
pub struct ComponentBuilder {
spec: InstanceSpec,
}
impl RobotBuilder {
#[must_use]
pub fn new(id: &str) -> Self {
Self {
id: id.to_owned(),
motion_limits: DEFAULT_MOTION_LIMITS,
services: BTreeMap::new(),
kinematic: Ok(KinematicConfig::Omnidirectional {
actuators: Vec::new(),
encoders: Vec::new(),
}),
joints: Vec::new(),
bodies: Bodies::default(),
types: BTreeMap::new(),
instances: BTreeMap::new(),
}
}
#[must_use]
pub fn service(mut self, id: &str, config: Option<serde_json::Value>) -> Self {
self.services.insert(id.to_owned(), config);
self
}
#[must_use]
pub const fn motion_limits(mut self, limits: MotionLimits) -> Self {
self.motion_limits = limits;
self
}
#[must_use]
pub fn kinematics(mut self, kinematics: Kinematics<'_>) -> Self {
self.kinematic = kinematics.into_config();
self
}
#[must_use]
pub fn joint(mut self, joint: Joint<'_>) -> Self {
self.joints.push(joint.into());
self
}
#[must_use]
pub fn link(mut self, link: Link<'_>) -> Self {
self.bodies.link(&link);
self
}
#[must_use]
pub fn material(mut self, material: Material<'_>) -> Self {
self.bodies.material(&material);
self
}
#[must_use]
pub fn component_type(
mut self,
component_type: &str,
declare: impl FnOnce(ComponentTypeBuilder) -> ComponentTypeBuilder,
) -> Self {
self.types.insert(
component_type.to_owned(),
declare(ComponentTypeBuilder {
spec: TypeSpec::default(),
})
.spec,
);
self
}
#[must_use]
pub fn component(self, instance: &str, component_type: &str) -> Self {
self.component_with(instance, component_type, |mounted| mounted)
}
#[must_use]
pub fn component_with(
mut self,
instance: &str,
component_type: &str,
mount: impl FnOnce(ComponentBuilder) -> ComponentBuilder,
) -> Self {
self.instances.insert(
instance.to_owned(),
mount(ComponentBuilder {
spec: InstanceSpec {
component_type: component_type.to_owned(),
mount_link: None,
direction_signs: BTreeMap::new(),
driver: None,
},
})
.spec,
);
self
}
pub fn build(self) -> Result<Robot, ModelError> {
let id = RobotId::new(self.id)?;
let kinematic = self.kinematic?;
let component_types = build_types(self.types)?;
let mut services = BTreeMap::new();
for (service, config) in self.services {
services.insert(ServiceId::new(service)?, compiler::service(config));
}
let mut components = BTreeMap::new();
let mut mounts = BTreeSet::new();
for (instance, spec) in self.instances {
let instance = ComponentInstanceId::new(instance)?;
let mount_link = LinkId::new(
spec.mount_link
.unwrap_or_else(|| format!("{instance}{MOUNT_LINK_SUFFIX}")),
);
mounts.insert(mount_link.clone());
let mut direction_signs = BTreeMap::new();
for (capability, sign) in spec.direction_signs {
direction_signs.insert(CapabilityId::new(capability)?, sign);
}
components.insert(
instance,
compiler::component_instance(
ComponentTypeId::new(spec.component_type)?,
mount_link,
direction_signs,
BTreeMap::new(),
spec.driver,
),
);
}
let structure = robot_structure(&id, self.joints, &mounts, &self.bodies)?;
compiler::robot(RobotParts {
id,
kinematic,
motion_limits: self.motion_limits,
services,
components,
component_types,
structure,
})
}
}
impl ComponentTypeBuilder {
#[must_use]
pub fn capability(mut self, capability: &str, declared: Capability) -> Self {
self.spec
.capabilities
.insert(capability.to_owned(), declared);
self
}
#[must_use]
pub fn joint(mut self, joint: Joint<'_>) -> Self {
self.spec.joints.push(joint.into());
self
}
#[must_use]
pub fn link(mut self, link: Link<'_>) -> Self {
self.spec.bodies.link(&link);
self
}
#[must_use]
pub fn material(mut self, material: Material<'_>) -> Self {
self.spec.bodies.material(&material);
self
}
#[must_use]
pub fn simulated(mut self, capability: &str, simulated: simulation::Capability) -> Self {
self.spec.simulated.insert(capability.to_owned(), simulated);
self
}
#[must_use]
pub fn contact_material(mut self, link: &str, material: &str) -> Self {
self.spec
.contact_materials
.insert(link.to_owned(), material.to_owned());
self
}
#[must_use]
pub fn motor(self, capability: &str, joint: &str) -> Self {
self.capability(
capability,
Capability::Motor(Motor {
target: joint_target(joint),
command: MotorCommand::Velocity,
gear_ratio: 1.0,
max_torque_nm: None,
max_velocity_radps: None,
}),
)
}
#[must_use]
pub fn encoder(self, capability: &str, joint: &str) -> Self {
self.capability(
capability,
Capability::Encoder(Encoder {
target: joint_target(joint),
publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
gear_ratio: 1.0,
encoder_type: EncoderType::Incremental,
counts_per_revolution: 4096,
}),
)
}
#[must_use]
pub fn accelerometer(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Accelerometer(Accelerometer {
target: link_target(link),
publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
axes: None,
}),
)
}
#[must_use]
pub fn gyroscope(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Gyroscope(Gyroscope {
target: link_target(link),
publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
axes: None,
}),
)
}
#[must_use]
pub fn magnetometer(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Magnetometer(Magnetometer {
target: link_target(link),
publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
axes: None,
}),
)
}
#[must_use]
pub fn imu(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Imu(Imu {
target: link_target(link),
publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
axes: None,
}),
)
}
#[must_use]
pub fn gnss(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Gnss(Gnss {
target: link_target(link),
publish_rate_hz: 10.0,
coordinate_system: GnssCoordinateSystem::Local,
}),
)
}
#[must_use]
pub fn camera(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Camera(Camera {
target: link_target(link),
mode: CameraMode::Rgb,
publish_rate_hz: 30.0,
width_px: 640,
height_px: 480,
field_of_view_rad: None,
}),
)
}
#[must_use]
pub fn depth(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Depth(Depth {
target: link_target(link),
publish_rate_hz: 30.0,
width_px: 640,
height_px: 480,
field_of_view_rad: None,
min_range_m: None,
max_range_m: None,
}),
)
}
#[must_use]
pub fn emergency_stop(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::EmergencyStop(EmergencyStop {
target: link_target(link),
}),
)
}
#[must_use]
pub fn range(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Range(Range {
target: link_target(link),
publish_rate_hz: 20.0,
min_range_m: 0.05,
max_range_m: 4.0,
field_of_view_rad: 0.4,
}),
)
}
#[must_use]
pub fn lidar(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Lidar(Lidar {
target: link_target(link),
publish_rate_hz: 10.0,
output: LidarOutput::Ranges,
min_range_m: None,
max_range_m: None,
horizontal_fov_rad: None,
horizontal_resolution_rad: None,
vertical_fov_rad: None,
vertical_resolution_rad: None,
}),
)
}
#[must_use]
pub fn mmwave(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Mmwave(Mmwave {
target: link_target(link),
publish_rate_hz: 20.0,
}),
)
}
#[must_use]
pub fn microphone(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Microphone(Microphone {
target: link_target(link),
publish_rate_hz: DEFAULT_PUBLISH_RATE_HZ,
}),
)
}
#[must_use]
pub fn speaker(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Speaker(Speaker {
target: link_target(link),
}),
)
}
#[must_use]
pub fn battery(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Battery(Battery {
target: link_target(link),
publish_rate_hz: 1.0,
voltage_v: 12.0,
capacity_ah: 5.0,
}),
)
}
#[must_use]
pub fn led(self, capability: &str, link: &str) -> Self {
self.capability(
capability,
Capability::Led(Led {
target: link_target(link),
}),
)
}
}
impl ComponentBuilder {
#[must_use]
pub fn mounted_on(mut self, link: &str) -> Self {
self.spec.mount_link = Some(link.to_owned());
self
}
#[must_use]
pub fn direction_sign(mut self, capability: &str, sign: i8) -> Self {
self.spec
.direction_signs
.insert(capability.to_owned(), sign);
self
}
#[must_use]
pub fn driver(mut self, driver: serde_json::Value) -> Self {
self.spec.driver = Some(driver);
self
}
}
impl Kinematics<'_> {
fn into_config(self) -> Result<KinematicConfig, ModelError> {
Ok(match self {
Self::Differential {
left_actuators,
right_actuators,
left_encoders,
right_encoders,
wheel_radius_m,
wheel_base_m,
} => KinematicConfig::Differential {
left_actuators: references(left_actuators)?,
right_actuators: references(right_actuators)?,
left_encoders: references(left_encoders)?,
right_encoders: references(right_encoders)?,
wheel_radius_m,
wheel_base_m,
},
Self::Mecanum {
front_left_actuator,
front_right_actuator,
rear_left_actuator,
rear_right_actuator,
wheel_radius_m,
wheel_base_m,
track_m,
} => KinematicConfig::Mecanum {
front_left_actuator: front_left_actuator.parse()?,
front_right_actuator: front_right_actuator.parse()?,
rear_left_actuator: rear_left_actuator.parse()?,
rear_right_actuator: rear_right_actuator.parse()?,
wheel_radius_m,
wheel_base_m,
track_m,
},
Self::Ackermann {
steering_actuator,
drive_actuator,
steering_encoder,
drive_encoder,
wheel_base_m,
track_m,
max_steering_angle_rad,
} => KinematicConfig::Ackermann {
steering_actuator: steering_actuator.parse()?,
drive_actuator: drive_actuator.parse()?,
steering_encoder: optional_reference(steering_encoder)?,
drive_encoder: optional_reference(drive_encoder)?,
wheel_base_m,
track_m,
max_steering_angle_rad,
},
Self::Omnidirectional {
actuators,
encoders,
} => KinematicConfig::Omnidirectional {
actuators: references(actuators)?,
encoders: references(encoders)?,
},
})
}
}
fn build_types(
types: BTreeMap<String, TypeSpec>,
) -> Result<BTreeMap<ComponentTypeId, Component>, ModelError> {
let mut component_types = BTreeMap::new();
for (component_type, spec) in types {
let component_type = ComponentTypeId::new(component_type)?;
let mut capabilities = BTreeMap::new();
for (capability, declared) in spec.capabilities {
capabilities.insert(CapabilityId::new(capability)?, declared);
}
let structure =
component_structure(&component_type, &capabilities, spec.joints, &spec.bodies)?;
let simulation = if spec.simulated.is_empty() && spec.contact_materials.is_empty() {
None
} else {
let mut simulated = BTreeMap::new();
for (capability, modelled) in spec.simulated {
simulated.insert(CapabilityId::new(capability)?, modelled);
}
Some(compiler::simulation(
simulated,
spec.contact_materials
.into_iter()
.map(|(link, material)| (LinkId::new(link), Some(material)))
.collect(),
))
};
component_types.insert(
component_type,
compiler::component(capabilities, structure, simulation),
);
}
Ok(component_types)
}
fn component_structure(
component_type: &ComponentTypeId,
capabilities: &BTreeMap<CapabilityId, Capability>,
mut joints: Vec<JointSpec>,
bodies: &Bodies,
) -> Result<Structure, ModelError> {
for capability in capabilities.values() {
match capability.target() {
StructuralTarget::Joint { id } => {
if !joints.iter().any(|joint| joint.name == id.as_str()) {
joints.push(generated_joint(
id.as_str(),
JointKind::Continuous,
COMPONENT_ROOT_LINK,
&format!("{id}{JOINT_CHILD_SUFFIX}"),
));
}
}
StructuralTarget::Link { id } => attach(
&mut joints,
COMPONENT_ROOT_LINK,
COMPONENT_ROOT_LINK,
id.as_str(),
),
}
}
for link in bodies.links.keys() {
attach(&mut joints, COMPONENT_ROOT_LINK, COMPONENT_ROOT_LINK, link);
}
structure(
component_type.as_str(),
COMPONENT_ROOT_LINK,
&joints,
bodies,
)
}
fn robot_structure(
id: &RobotId,
mut joints: Vec<JointSpec>,
mounts: &BTreeSet<LinkId>,
bodies: &Bodies,
) -> Result<Structure, ModelError> {
if !joints.iter().any(|joint| joint.child == BASE_LINK) {
joints.push(generated_joint(
BASE_JOINT,
JointKind::Fixed,
BASE_FOOTPRINT_LINK,
BASE_LINK,
));
}
for link in mounts
.iter()
.map(LinkId::as_str)
.chain(bodies.links.keys().map(String::as_str))
{
attach(&mut joints, BASE_FOOTPRINT_LINK, BASE_LINK, link);
}
structure(id.as_str(), BASE_FOOTPRINT_LINK, &joints, bodies)
}
fn attach(joints: &mut Vec<JointSpec>, root: &str, parent: &str, link: &str) {
if link == root || joints.iter().any(|joint| joint.child == link) {
return;
}
joints.push(generated_joint(
&format!("{link}{LINK_JOINT_SUFFIX}"),
JointKind::Fixed,
parent,
link,
));
}
fn generated_joint(name: &str, kind: JointKind, parent: &str, child: &str) -> JointSpec {
JointSpec::from(Joint {
name,
kind,
parent,
child,
..Joint::default()
})
}
fn structure(
name: &str,
root: &str,
joints: &[JointSpec],
bodies: &Bodies,
) -> Result<Structure, ModelError> {
let body_of = |link: &str| {
bodies.links.get(link).cloned().unwrap_or_else(|| {
link_value(&Link {
name: link,
..Link::default()
})
})
};
let mut links = vec![body_of(root)];
links.extend(joints.iter().map(|joint| body_of(&joint.child)));
compiler::structure(json!({
"name": name,
"links": links,
"joints": joints.iter().map(joint_value).collect::<Vec<_>>(),
"materials": bodies.materials.values().collect::<Vec<_>>()
}))
}
fn link_value(link: &Link<'_>) -> Value {
json!({
"name": link.name,
"inertial": inertial_value(link.inertial),
"visuals": link.visuals.iter().map(visual_value).collect::<Vec<_>>(),
"collisions": link.collisions.iter().map(collision_value).collect::<Vec<_>>()
})
}
fn inertial_value(inertial: Inertial) -> Value {
let Inertia {
ixx,
ixy,
ixz,
iyy,
iyz,
izz,
} = inertial.inertia;
json!({
"origin": pose_value(inertial.xyz, inertial.rpy),
"mass_kg": inertial.mass_kg,
"inertia": { "ixx": ixx, "ixy": ixy, "ixz": ixz, "iyy": iyy, "iyz": iyz, "izz": izz }
})
}
fn visual_value(visual: &Visual<'_>) -> Value {
json!({
"name": visual.name,
"origin": pose_value(visual.xyz, visual.rpy),
"geometry": visual.geometry,
"material": visual.material.as_ref().map(material_value)
})
}
fn collision_value(collision: &Collision<'_>) -> Value {
json!({
"name": collision.name,
"origin": pose_value(collision.xyz, collision.rpy),
"geometry": collision.geometry
})
}
fn material_value(material: &Material<'_>) -> Value {
json!({
"name": material.name,
"color": material.color,
"texture": material.texture
})
}
fn joint_value(joint: &JointSpec) -> Value {
let JointLimit {
lower,
upper,
effort,
velocity,
} = joint.limit;
json!({
"name": joint.name,
"kind": joint.kind,
"origin": pose_value(joint.xyz, joint.rpy),
"parent": joint.parent,
"child": joint.child,
"axis": joint.axis,
"limit": { "lower": lower, "upper": upper, "effort": effort, "velocity": velocity },
"calibration": joint.calibration.map(|calibration| json!({
"rising": calibration.rising,
"falling": calibration.falling
})),
"dynamics": joint.dynamics.map(|dynamics| json!({
"damping": dynamics.damping,
"friction": dynamics.friction
})),
"mimic": joint.mimic.as_ref().map(|mimic| json!({
"joint": mimic.joint,
"multiplier": mimic.multiplier,
"offset": mimic.offset
})),
"safety": joint.safety.map(|safety| json!({
"soft_lower_limit": safety.soft_lower_limit,
"soft_upper_limit": safety.soft_upper_limit,
"k_position": safety.k_position,
"k_velocity": safety.k_velocity
}))
})
}
fn pose_value(xyz: [f64; 3], rpy: [f64; 3]) -> Value {
json!({ "xyz": xyz, "rpy": rpy })
}
fn joint_target(id: &str) -> StructuralTarget {
StructuralTarget::Joint {
id: JointId::new(id),
}
}
fn link_target(id: &str) -> StructuralTarget {
StructuralTarget::Link {
id: LinkId::new(id),
}
}
fn references(values: &[&str]) -> Result<Vec<CapabilityRef>, ModelError> {
values.iter().map(|value| value.parse()).collect()
}
fn optional_reference(value: Option<&str>) -> Result<Option<CapabilityRef>, ModelError> {
value.map(str::parse).transpose()
}
#[cfg(test)]
mod tests {
use super::{
Collision, Dynamics, Inertial, Joint, JointLimit, Kinematics, Link, Material, Mimic,
RobotBuilder, Visual,
};
use crate::model::asset::AssetId;
use crate::model::component::capability::{
Capability, CapabilityKind, Motor, MotorCommand, StructuralTarget,
};
use crate::model::error::{IdentifierKind, ModelError, StructureError};
use crate::model::identity::{CapabilityRef, JointId, LinkId};
use crate::model::robot::{DriveKinematics, KinematicConfig, MotionLimits};
use crate::model::simulation;
use crate::model::structure::{Geometry, JointKind};
fn reference(value: &str) -> CapabilityRef {
value.parse().expect("a well formed capability reference")
}
#[test]
fn every_capability_kind_reaches_a_validated_robot() {
let robot = RobotBuilder::new("rover")
.component_type("everything", |all| {
all.motor("spin", "axle")
.encoder("count", "axle")
.accelerometer("accel", "imu_link")
.gyroscope("gyro", "imu_link")
.magnetometer("mag", "imu_link")
.imu("imu", "imu_link")
.gnss("fix", "antenna")
.camera("rgb", "lens")
.depth("depth", "lens")
.emergency_stop("estop", "panel")
.range("tof", "nose")
.lidar("scan", "dome")
.mmwave("radar", "nose")
.microphone("mic", "panel")
.speaker("horn", "panel")
.battery("pack", "chassis")
.led("beacon", "dome")
})
.component("kitchen_sink", "everything")
.build()
.expect("every capability kind composes a valid robot");
let component = robot
.component("kitchen_sink")
.map(|component| component.component_type())
.expect("the mounted type is loaded");
let mut kinds = component
.capabilities()
.map(|(_, capability)| capability.kind())
.collect::<Vec<_>>();
kinds.sort_unstable();
kinds.dedup();
assert_eq!(
kinds.len(),
17,
"every canonical capability kind must be reachable"
);
assert_eq!(robot.capability_refs(|_| true).len(), 17);
}
#[test]
fn both_structural_target_kinds_resolve() {
let robot = RobotBuilder::new("rover")
.component_type("drive_motor", |motor| {
motor.motor("spin", "axle").encoder("count", "axle")
})
.component_type("rgbd", |camera| camera.camera("rgb", "lens"))
.component("left_drive", "drive_motor")
.component("front_camera", "rgbd")
.build()
.expect("a valid robot");
assert_eq!(
robot
.link_target_frame(&reference("front_camera.rgb"))
.expect("the camera targets a link"),
LinkId::new("front_camera__lens")
);
let component = robot
.component("left_drive")
.map(|component| component.component_type())
.expect("the mounted type is loaded");
assert!(component.structure().joint("axle").is_some());
assert!(component.structure().link("axle_link").is_some());
for capability in ["left_drive.spin", "left_drive.count"] {
let target = robot
.capability(&reference(capability))
.expect("the capability is declared")
.target();
assert_eq!(
target,
&StructuralTarget::Joint {
id: JointId::new("axle")
},
"{capability}"
);
}
}
#[test]
fn every_kinematic_config_validates_and_resolves() {
let wheeled = |builder: RobotBuilder| {
builder
.component_type("drive_motor", |motor| {
motor.motor("spin", "axle").encoder("count", "axle")
})
.component("front_left", "drive_motor")
.component("front_right", "drive_motor")
.component("rear_left", "drive_motor")
.component("rear_right", "drive_motor")
};
let differential = wheeled(RobotBuilder::new("rover"))
.kinematics(Kinematics::Differential {
left_actuators: &["front_left.spin", "rear_left.spin"],
right_actuators: &["front_right.spin", "rear_right.spin"],
left_encoders: &["front_left.count", "rear_left.count"],
right_encoders: &["front_right.count", "rear_right.count"],
wheel_radius_m: 0.1,
wheel_base_m: 0.5,
})
.build()
.expect("a valid differential robot");
assert!(matches!(
differential
.motion()
.kinematic()
.drive_kinematics()
.expect("the geometry is usable"),
DriveKinematics::Differential(geometry) if geometry.wheel_radius_m == 0.1
));
let mecanum = wheeled(RobotBuilder::new("rover"))
.kinematics(Kinematics::Mecanum {
front_left_actuator: "front_left.spin",
front_right_actuator: "front_right.spin",
rear_left_actuator: "rear_left.spin",
rear_right_actuator: "rear_right.spin",
wheel_radius_m: 0.1,
wheel_base_m: 0.4,
track_m: 0.6,
})
.build()
.expect("a valid mecanum robot");
assert!(matches!(
mecanum
.motion()
.kinematic()
.drive_kinematics()
.expect("the geometry is usable"),
DriveKinematics::Mecanum(geometry) if geometry.track_m == 0.6
));
let ackermann = wheeled(RobotBuilder::new("rover"))
.kinematics(Kinematics::Ackermann {
steering_actuator: "front_left.spin",
drive_actuator: "rear_left.spin",
steering_encoder: Some("front_left.count"),
drive_encoder: Some("rear_left.count"),
wheel_base_m: 2.5,
track_m: 1.5,
max_steering_angle_rad: 0.6,
})
.build()
.expect("a valid ackermann robot");
assert!(matches!(
ackermann
.motion()
.kinematic()
.drive_kinematics()
.expect("the geometry is usable"),
DriveKinematics::Ackermann(geometry) if geometry.max_steering_angle_rad == 0.6
));
let omnidirectional = wheeled(RobotBuilder::new("rover"))
.kinematics(Kinematics::Omnidirectional {
actuators: &["front_left.spin"],
encoders: &["front_left.count"],
})
.build()
.expect("a valid omnidirectional robot");
assert_eq!(
omnidirectional
.motion()
.kinematic()
.drive_kinematics()
.expect("an omnidirectional drive carries no scalars to reject"),
DriveKinematics::Omnidirectional
);
}
#[test]
fn a_kinematic_reference_must_name_a_capability_of_the_right_kind() {
let miswired = RobotBuilder::new("rover")
.component_type("drive_motor", |motor| {
motor.motor("spin", "axle").encoder("count", "axle")
})
.component("left_drive", "drive_motor")
.kinematics(Kinematics::Omnidirectional {
actuators: &["left_drive.count"],
encoders: &[],
})
.build();
assert!(matches!(
miswired,
Err(ModelError::CapabilityKindMismatch {
expected: CapabilityKind::Motor,
actual: CapabilityKind::Encoder,
..
})
));
}
#[test]
fn direction_signs_come_back_beside_the_capability() {
let robot = RobotBuilder::new("rover")
.component_type("drive_motor", |motor| {
motor.motor("spin", "axle").encoder("count", "axle")
})
.component("left_drive", "drive_motor")
.component_with("right_drive", "drive_motor", |mounted| {
mounted
.direction_sign("spin", -1)
.direction_sign("count", -1)
})
.build()
.expect("a valid robot");
for (capability, expected) in [("left_drive.spin", 1), ("right_drive.spin", -1)] {
let (_motor, sign) = robot
.require_motor(&reference(capability))
.expect("the motor resolves");
assert_eq!(sign, expected, "{capability}");
}
for (capability, expected) in [("left_drive.count", 1), ("right_drive.count", -1)] {
let (_encoder, sign) = robot
.require_encoder(&reference(capability))
.expect("the encoder resolves");
assert_eq!(sign, expected, "{capability}");
}
}
#[test]
fn a_direction_sign_that_is_not_a_direction_is_refused() {
let rejected = RobotBuilder::new("rover")
.component_type("drive_motor", |motor| motor.motor("spin", "axle"))
.component_with("left_drive", "drive_motor", |mounted| {
mounted.direction_sign("spin", 0)
})
.build();
assert!(matches!(
rejected,
Err(ModelError::DirectionSign { value: 0, .. })
));
}
#[test]
fn identity_services_and_limits_are_carried_as_stated() {
let robot = RobotBuilder::new("rover")
.service("drive", None)
.service("mission", Some(serde_json::json!({ "speed": 1 })))
.motion_limits(MotionLimits {
max_linear_speed_mps: 0.6,
max_angular_speed_radps: 2.0,
})
.build()
.expect("a valid robot");
assert_eq!(robot.id().as_str(), "rover");
assert_eq!(robot.motion().limits().max_linear_speed_mps, 0.6);
assert_eq!(
robot
.services()
.map(|(id, _)| id.as_str())
.collect::<Vec<_>>(),
["drive", "mission"]
);
assert!(robot.service("drive").is_some());
assert_eq!(robot.service_config("drive"), None);
assert_eq!(
robot.service_config("mission"),
Some(&serde_json::json!({ "speed": 1 }))
);
assert!(robot.service("nope").is_none());
}
#[test]
fn stated_structure_is_kept_and_the_rest_is_generated() {
let robot = RobotBuilder::new("rover")
.joint(Joint {
name: "mast_joint",
kind: JointKind::Revolute,
parent: "base_link",
child: "mast",
xyz: [0.1, 0.0, 0.4],
..Joint::default()
})
.component_type("rgbd", |camera| camera.camera("rgb", "lens"))
.component_with("front_camera", "rgbd", |mounted| mounted.mounted_on("mast"))
.component("rear_camera", "rgbd")
.build()
.expect("a valid robot");
let structure = robot.structure();
assert_eq!(structure.root_link(), &LinkId::new("base_footprint"));
let mast = structure.joint("mast_joint").expect("the stated joint");
assert_eq!(mast.kind(), JointKind::Revolute);
assert_eq!(mast.origin().xyz(), [0.1, 0.0, 0.4]);
assert!(structure.link("mast").is_some());
assert!(structure.link("rear_camera_mount").is_some());
assert!(
structure.joint("rear_camera_mount_joint").is_some(),
"an unstated mount link is attached beneath base_link"
);
}
#[test]
fn a_stated_link_carries_its_body_and_leaves_the_rest_generated() {
let robot = RobotBuilder::new("rover")
.link(Link {
name: "base_link",
inertial: Inertial {
mass_kg: 12.0,
..Inertial::default()
},
..Link::default()
})
.link(Link {
name: "mast",
collisions: vec![Collision::new(Geometry::Sphere { radius: 0.2 })],
..Link::default()
})
.build()
.expect("a valid robot");
let structure = robot.structure();
assert_eq!(
structure
.link("base_link")
.expect("the body frame")
.inertial()
.mass_kg(),
12.0
);
assert!(structure.joint("base_link_joint").is_none());
let mast_joint = structure.joint("mast_joint").expect("the generated joint");
assert_eq!(mast_joint.parent(), &LinkId::new("base_link"));
assert_eq!(
structure
.link("mast")
.expect("the stated link")
.collisions()
.len(),
1
);
let root = structure.link("base_footprint").expect("the root link");
assert_eq!(root.inertial().mass_kg(), 1.0);
assert_eq!(root.visuals().len(), 0);
}
#[test]
fn a_component_type_states_its_own_links_joints_and_materials() {
let robot = RobotBuilder::new("rover")
.component_type("pan_tilt", |head| {
head.motor("pan", "pan_joint")
.joint(Joint {
name: "pan_joint",
kind: JointKind::Revolute,
parent: "mount",
child: "lens",
limit: JointLimit {
lower: -3.0,
upper: 3.0,
effort: 1.0,
velocity: 4.0,
},
dynamics: Some(Dynamics {
damping: 0.05,
friction: 0.01,
}),
..Joint::default()
})
.link(Link {
name: "lens",
visuals: vec![Visual::new(Geometry::Cylinder {
radius: 0.02,
length: 0.01,
})],
..Link::default()
})
.link(Link {
name: "shade",
inertial: Inertial {
mass_kg: 0.05,
..Inertial::default()
},
..Link::default()
})
.material(Material {
color: Some([0.0, 0.0, 0.0, 1.0]),
..Material::new("matte")
})
})
.component("head", "pan_tilt")
.build()
.expect("a valid robot");
let structure = robot
.component("head")
.map(|component| component.component_type())
.expect("the mounted type is loaded")
.structure();
assert_eq!(structure.root_link(), &LinkId::new("mount"));
let pan = structure.joint("pan_joint").expect("the stated joint");
assert_eq!(pan.limit().velocity(), 4.0);
assert_eq!(
pan.dynamics().map(|dynamics| dynamics.damping()),
Some(0.05)
);
assert_eq!(
structure
.link("lens")
.expect("the stated link")
.visuals()
.len(),
1
);
assert_eq!(
structure
.joint("shade_joint")
.expect("the generated joint")
.parent(),
&LinkId::new("mount")
);
let catalogue = structure.materials().collect::<Vec<_>>();
assert_eq!(catalogue.len(), 1);
assert_eq!(catalogue[0].name(), "matte");
}
#[test]
fn a_structural_value_the_model_refuses_is_refused_here_too() {
let inverted = |limit| {
RobotBuilder::new("rover")
.joint(Joint {
name: "mast_joint",
kind: JointKind::Revolute,
parent: "base_link",
child: "mast",
limit,
..Joint::default()
})
.build()
};
assert!(matches!(
inverted(JointLimit {
lower: 1.0,
upper: -1.0,
effort: 0.0,
velocity: 0.0,
}),
Err(ModelError::Structure(StructureError::JointLimits { .. }))
));
assert!(matches!(
RobotBuilder::new("rover")
.joint(Joint {
name: "mast_joint",
kind: JointKind::Revolute,
parent: "base_link",
child: "mast",
mimic: Some(Mimic::new("no_such_joint")),
..Joint::default()
})
.build(),
Err(ModelError::Structure(
StructureError::UnknownMimicJoint { .. }
))
));
assert!(matches!(
RobotBuilder::new("rover")
.link(Link {
name: "mast",
inertial: Inertial {
mass_kg: -1.0,
..Inertial::default()
},
..Link::default()
})
.build(),
Err(ModelError::Structure(StructureError::Mass { .. }))
));
assert!(matches!(
RobotBuilder::new("rover")
.link(Link {
name: "mast",
visuals: vec![Visual::new(Geometry::Sphere { radius: 0.0 })],
..Link::default()
})
.build(),
Err(ModelError::Structure(StructureError::Geometry { .. }))
));
}
#[test]
fn a_simulation_is_carried_only_for_the_types_that_state_one() {
let robot = RobotBuilder::new("rover")
.component_type("drive_motor", |motor| {
motor
.motor("spin", "axle")
.simulated(
"spin",
simulation::Capability::Motor(simulation::Motor::default()),
)
.contact_material("axle_link", "rubber")
})
.component_type("rgbd", |camera| camera.camera("rgb", "lens"))
.component("left_drive", "drive_motor")
.component("front_camera", "rgbd")
.build()
.expect("a valid robot");
let simulation = robot
.component("left_drive")
.and_then(|component| component.simulation())
.expect("the drive states a simulation");
assert_eq!(
simulation
.capability("spin")
.expect("the simulated motor")
.kind(),
CapabilityKind::Motor
);
assert_eq!(
simulation
.links()
.next()
.and_then(|(_, link)| link.contact_material()),
Some("rubber")
);
assert!(
robot
.component("front_camera")
.and_then(|component| component.simulation())
.is_none()
);
}
#[test]
fn a_simulation_cannot_model_a_capability_the_component_does_not_declare() {
let rejected = RobotBuilder::new("rover")
.component_type("drive_motor", |motor| {
motor.motor("spin", "axle").simulated(
"nonexistent",
simulation::Capability::Motor(simulation::Motor::default()),
)
})
.component("left_drive", "drive_motor")
.build();
assert!(matches!(
rejected,
Err(ModelError::SimulationWithoutCapability { .. })
));
}
#[test]
fn a_rejected_robot_returns_the_condition_it_violated() {
assert!(matches!(
RobotBuilder::new("Rover").build(),
Err(ModelError::NotNormalized {
kind: IdentifierKind::RobotId,
..
})
));
assert!(matches!(
RobotBuilder::new("rover")
.kinematics(Kinematics::Omnidirectional {
actuators: &["not-a-reference"],
encoders: &[],
})
.build(),
Err(ModelError::MalformedCapabilityReference { .. })
));
assert!(matches!(
RobotBuilder::new("rover")
.component("left_drive", "never_declared")
.build(),
Err(ModelError::UnknownComponentType { .. })
));
assert!(matches!(
RobotBuilder::new("rover")
.joint(Joint {
name: "wobble",
kind: JointKind::Spherical,
parent: "base_link",
child: "head",
..Joint::default()
})
.build(),
Err(ModelError::UnsupportedJointKind { .. })
));
assert!(matches!(
RobotBuilder::new("rover")
.joint(Joint {
name: "head_joint",
parent: "neck",
child: "head",
..Joint::default()
})
.build(),
Err(ModelError::Structure(
StructureError::UnknownJointLink { .. }
))
));
assert!(matches!(
RobotBuilder::new("rover")
.link(Link {
name: "chassis",
collisions: vec![Collision::new(Geometry::Mesh {
asset: AssetId::new("meshes/chassis.stl").expect("normalized asset id"),
scale: None,
})],
..Link::default()
})
.build(),
Err(ModelError::FootprintMesh { .. })
));
assert!(matches!(
RobotBuilder::new("rover")
.joint(Joint {
name: "arm_joint",
kind: JointKind::Revolute,
parent: "base_link",
child: "arm",
..Joint::default()
})
.link(Link {
name: "arm",
collisions: vec![Collision::new(Geometry::Sphere { radius: 0.1 })],
..Link::default()
})
.build(),
Err(ModelError::FootprintMovableJoint { .. })
));
}
#[test]
fn the_general_capability_entry_point_carries_every_parameter() {
let robot = RobotBuilder::new("arm-bot")
.component_type("joint_motor", |joint_motor| {
joint_motor.capability(
"lift",
Capability::Motor(Motor {
target: StructuralTarget::Link {
id: LinkId::new("housing"),
},
command: MotorCommand::Position,
gear_ratio: 50.0,
max_torque_nm: Some(12.0),
max_velocity_radps: Some(3.0),
}),
)
})
.component("arm", "joint_motor")
.build()
.expect("a valid robot");
let (motor, _sign) = robot
.require_motor(&reference("arm.lift"))
.expect("the motor resolves");
assert_eq!(motor.command, MotorCommand::Position);
assert_eq!(motor.gear_ratio, 50.0);
assert_eq!(motor.max_torque_nm, Some(12.0));
assert_eq!(
robot
.link_target_frame(&reference("arm.lift"))
.expect("the motor targets a link"),
LinkId::new("arm__housing")
);
}
#[test]
fn a_restated_type_or_instance_replaces_the_earlier_one() {
let robot = RobotBuilder::new("rover")
.component_type("rgbd", |camera| camera.camera("rgb", "lens"))
.component_type("rgbd", |camera| camera.camera("mono", "lens"))
.component("front_camera", "rgbd")
.component_with("front_camera", "rgbd", |mounted| mounted.mounted_on("mast"))
.build()
.expect("a valid robot");
assert_eq!(
robot
.capability_refs(|_| true)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
["front_camera.mono"]
);
assert_eq!(
robot
.component("front_camera")
.expect("the instance is mounted")
.instance()
.mount_link(),
&LinkId::new("mast")
);
}
#[test]
fn a_robot_with_nothing_stated_is_still_a_valid_robot() {
let robot = RobotBuilder::new("rover")
.build()
.expect("the defaults compose a valid robot");
assert_eq!(robot.component_ids().len(), 0);
assert_eq!(
robot.structure().root_link(),
&LinkId::new("base_footprint")
);
assert!(robot.structure().link("base_link").is_some());
assert!(matches!(
robot.motion().kinematic(),
KinematicConfig::Omnidirectional { .. }
));
}
}