use std::path::{Path, PathBuf};
use crate::assets::{Mesh, Texture};
use crate::error::ParseError;
use crate::model::Model;
mod asset;
mod attach;
mod blocks;
mod bodies;
mod compiler_opts;
mod defaults;
mod parse_utils;
mod prototypes;
mod state;
use state::ParseState;
impl Model {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ParseError> {
let path = path.as_ref();
let xml = std::fs::read_to_string(path).map_err(|e| ParseError::Io {
path: path.to_path_buf(),
source: e,
})?;
let base_dir = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
Self::from_str(&xml, &base_dir)
}
pub fn from_str(xml: &str, base_dir: impl AsRef<Path>) -> Result<Self, ParseError> {
let mut state = ParseState::new(base_dir.as_ref().to_path_buf());
state.parse_document(xml)?;
Ok(state.into_model())
}
pub fn resolve_texture_file(&self, texture: &Texture, base_dir: &Path) -> Option<PathBuf> {
let file = texture.file.as_ref()?;
let path = if self.compiler.strip_path {
Path::new(file)
.file_name()
.map(|f| Path::new(f).to_path_buf())
.unwrap_or_else(|| Path::new(file).to_path_buf())
} else {
Path::new(file).to_path_buf()
};
if path.is_absolute() {
return Some(path);
}
let dir = self
.compiler
.texture_dir
.clone()
.or_else(|| self.compiler.asset_dir.clone());
if let Some(dir) = dir {
let dirp = Path::new(&dir);
return Some(if dirp.is_absolute() {
dirp.join(&path)
} else {
base_dir.join(dirp).join(&path)
});
}
Some(base_dir.join(&path))
}
pub fn resolve_mesh_file(&self, mesh: &Mesh, base_dir: &Path) -> Option<PathBuf> {
let file = mesh.file.as_ref()?;
let path = if self.compiler.strip_path {
Path::new(file)
.file_name()
.map(|f| Path::new(f).to_path_buf())
.unwrap_or_else(|| Path::new(file).to_path_buf())
} else {
Path::new(file).to_path_buf()
};
if path.is_absolute() {
return Some(path);
}
let dir = self
.compiler
.mesh_dir
.clone()
.or_else(|| self.compiler.asset_dir.clone());
if let Some(dir) = dir {
let dirp = Path::new(&dir);
return Some(if dirp.is_absolute() {
dirp.join(&path)
} else {
base_dir.join(dirp).join(&path)
});
}
Some(base_dir.join(&path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::body::JointType;
const SIMPLE: &str = r#"
<mujoco model="t">
<compiler angle="degree" eulerseq="xyz"/>
<option gravity="0 0 -9.81"/>
<worldbody>
<body name="b1" pos="0 0 1">
<inertial pos="0 0 0" mass="1" diaginertia="0.1 0.1 0.1"/>
<joint name="j1" type="hinge" axis="0 1 0" range="-90 90"/>
<geom type="box" size="0.1 0.1 0.1"/>
</body>
</worldbody>
</mujoco>
"#;
#[test]
fn parse_simple() {
let m = Model::from_str(SIMPLE, ".").unwrap();
assert_eq!(m.name.as_deref(), Some("t"));
assert_eq!(m.bodies.len(), 2);
let b = &m.bodies[1].body;
assert_eq!(b.name.as_deref(), Some("b1"));
assert_eq!(b.joints.len(), 1);
assert_eq!(b.geoms.len(), 1);
let r = b.joints[0].range.unwrap();
assert!((r[0] + std::f64::consts::FRAC_PI_2).abs() < 1e-12);
assert!((r[1] - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
}
#[test]
fn parse_radians() {
let xml = r#"<mujoco>
<compiler angle="radian"/>
<worldbody>
<body><joint type="hinge" axis="0 0 1" range="-1.0 1.0"/></body>
</worldbody></mujoco>"#;
let m = Model::from_str(xml, ".").unwrap();
let r = m.bodies[1].body.joints[0].range.unwrap();
assert!((r[0] - -1.0).abs() < 1e-12);
assert!((r[1] - 1.0).abs() < 1e-12);
}
#[test]
fn parse_default_inheritance() {
let xml = r#"<mujoco>
<default>
<joint damping="0.5" type="hinge"/>
<default class="leg">
<joint damping="0.3" range="-30 30"/>
</default>
</default>
<worldbody>
<body>
<joint name="j1"/>
<joint class="leg" name="j2"/>
</body>
</worldbody></mujoco>"#;
let m = Model::from_str(xml, ".").unwrap();
let body = &m.bodies[1].body;
assert_eq!(body.joints[0].damping, 0.5);
assert_eq!(body.joints[0].range, None);
assert_eq!(body.joints[1].damping, 0.3);
let r = body.joints[1].range.unwrap();
assert!((r[0] + 30.0_f64.to_radians()).abs() < 1e-12);
}
#[test]
fn parse_freejoint() {
let xml = r#"<mujoco><worldbody>
<body name="free"><freejoint/></body>
</worldbody></mujoco>"#;
let m = Model::from_str(xml, ".").unwrap();
let b = &m.bodies[1].body;
assert_eq!(b.joints.len(), 1);
assert_eq!(b.joints[0].type_, JointType::Free);
}
#[test]
fn asset_name_defaults_to_file_stem() {
let xml = r#"<mujoco>
<asset>
<mesh file="assets/hip.obj"/>
<mesh name="explicit" file="thigh.obj"/>
<mesh file="calf"/>
<hfield file="terrain.png" nrow="2" ncol="2" size="1 1 1 0"/>
<texture type="2d" file="ground.png"/>
</asset>
</mujoco>"#;
let m = Model::from_str(xml, ".").unwrap();
assert!(m.assets.mesh("hip").is_some(), "hip.obj → \"hip\"");
assert!(
m.assets.mesh("explicit").is_some(),
"explicit name preserved"
);
assert!(m.assets.mesh("calf").is_some(), "extension-less file");
assert!(m.assets.hfield("terrain").is_some(), "hfield default name");
assert!(
m.assets
.textures
.iter()
.any(|t| t.name.as_deref() == Some("ground")),
"texture default name"
);
}
#[test]
fn default_geom_propagates_mesh_and_hfield() {
let xml = r#"<mujoco>
<default>
<default class="link_visual">
<geom type="mesh" contype="0" conaffinity="0" mesh="link_proto"/>
</default>
</default>
<asset>
<mesh name="link_proto" file="link.obj"/>
<mesh name="link_override" file="other.obj"/>
</asset>
<worldbody>
<body>
<inertial mass="1" diaginertia="0.1 0.1 0.1"/>
<geom class="link_visual"/>
<geom class="link_visual" mesh="link_override"/>
</body>
</worldbody>
</mujoco>"#;
let m = Model::from_str(xml, ".").unwrap();
let geoms = &m.bodies[1].body.geoms;
assert_eq!(geoms.len(), 2);
assert_eq!(geoms[0].mesh.as_deref(), Some("link_proto"));
assert_eq!(geoms[1].mesh.as_deref(), Some("link_override"));
}
#[test]
fn attach_splices_sub_model_with_prefix() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub.xml");
fs::write(
&sub,
r#"<mujoco>
<asset>
<mesh name="brick" file="brick.stl"/>
</asset>
<worldbody>
<body name="attach_point" pos="0 0 1">
<joint name="hinge" type="hinge" axis="0 0 1"/>
<inertial mass="1" diaginertia="0.1 0.1 0.1"/>
<geom type="box" size="0.1 0.1 0.1" mesh="brick"/>
<body name="tip" pos="0.5 0 0">
<joint name="slider" type="slide" axis="1 0 0"/>
<inertial mass="0.5" diaginertia="0.05 0.05 0.05"/>
</body>
</body>
</worldbody>
<equality>
<weld body1="attach_point"/>
</equality>
</mujoco>"#,
)
.unwrap();
let main = dir.path().join("main.xml");
fs::write(
&main,
r#"<mujoco>
<asset>
<model name="sub_model" file="sub.xml"/>
</asset>
<worldbody>
<body name="rig" pos="1 0 0">
<freejoint/>
<inertial mass="1" diaginertia="0.1 0.1 0.1"/>
<attach model="sub_model" body="attach_point" prefix="A_"/>
</body>
</worldbody>
</mujoco>"#,
)
.unwrap();
let m = Model::from_file(&main).unwrap();
let names: Vec<_> = m
.bodies
.iter()
.filter_map(|b| b.body.name.as_deref())
.collect();
assert!(names.contains(&"rig"), "main body preserved");
assert!(
names.contains(&"A_attach_point"),
"attach root prefixed: {names:?}"
);
assert!(
names.contains(&"A_tip"),
"attach descendant prefixed: {names:?}"
);
let joint_names: Vec<_> = m
.bodies
.iter()
.flat_map(|b| b.body.joints.iter().filter_map(|j| j.name.as_deref()))
.collect();
assert!(
joint_names.contains(&"A_hinge"),
"joint prefixed: {joint_names:?}"
);
assert!(
joint_names.contains(&"A_slider"),
"child joint prefixed: {joint_names:?}"
);
assert!(
m.assets.mesh("A_brick").is_some(),
"mesh prefixed; got {:?}",
m.assets
.meshes
.iter()
.map(|x| x.name.clone())
.collect::<Vec<_>>()
);
let attach_body = m
.bodies
.iter()
.find(|b| b.body.name.as_deref() == Some("A_attach_point"))
.unwrap();
assert_eq!(attach_body.body.geoms[0].mesh.as_deref(), Some("A_brick"));
assert_eq!(m.equality.len(), 1);
match &m.equality[0] {
crate::equality::Equality::Weld(w) => {
assert_eq!(w.body1, "A_attach_point");
}
other => panic!("expected weld, got {other:?}"),
}
}
}