Skip to main content

mujoco_rs/wrappers/
mj_editing.rs

1//! Definitions related to model editing.
2use std::ffi::{c_char, c_int, CStr, CString};
3use crate::error::MjEditError;
4use std::ptr::{self, NonNull};
5use std::marker::PhantomData;
6use std::path::Path;
7use std::fmt;
8
9#[macro_use]
10mod utility;
11use utility::*;
12
13mod traits;
14pub use traits::*;
15
16mod default;
17pub use default::*;
18
19use super::mj_model::{
20    MjModel, MjtObj, MjtGeom, MjtJoint, MjtCamLight,
21    MjtLightType, MjtSensor, MjtDataType, MjtGain,
22    MjtBias, MjtDyn, MjtEq, MjtTexture, MjtColorSpace,
23    MjtTrn, MjtStage, MjtFlexSelf, MjtProjection,
24    MjtSleepPolicy, MjtWrap, MjtTextureRole, MjtCubeFace
25};
26use super::mj_auxiliary::{MjVfs, MjVisual, MjStatistic, MjLROpt};
27use super::mj_option::MjOption;
28use super::mj_primitive::*;
29use crate::mujoco_c::*;
30
31use crate::getter_setter;
32
33// Re-export with lowercase 'f' to fix method generation
34use crate::mujoco_c::{mjs_addHField as mjs_addHfield, mjs_asHField as mjs_asHfield};
35use crate::util::{assert_mujoco_version, ERROR_BUF_LEN};
36
37/* Validation helpers */
38/// Validates that `t` is a real object type, i.e. an [`MjtObj`] discriminant below
39/// [`MjtObj::mjNOBJECT`].
40fn check_objtype(t: MjtObj) -> Result<(), MjEditError> {
41    // A meta variant indexes `mjCModel::FindObject`'s `mjNOBJECT`-sized array out of bounds.
42    if (t as i32) < (MjtObj::mjNOBJECT as i32) {
43        Ok(())
44    } else {
45        Err(MjEditError::InvalidParameter(format!(
46            "object type must be a real MjtObj below MjtObj::mjNOBJECT, got {t:?}"
47        )))
48    }
49}
50
51/// Validates that a `nuser_*` count is at least `-1` (-1 meaning automatic).
52fn check_nuser(count: i32) -> Result<(), MjEditError> {
53    if count < -1 {
54        Err(MjEditError::InvalidParameter(format!(
55            "nuser count must be at least -1 (-1 meaning automatic), got {count}"
56        )))
57    } else {
58        Ok(())
59    }
60}
61
62/// Validates that a custom-numeric array size is non-negative.
63fn check_numeric_size(size: i32) -> Result<(), MjEditError> {
64    // A negative size passes every guard in `mjCNumeric::Compile` and undersizes the shared
65    // `numeric_data` allocation, which another numeric's zero-fill loop then overruns.
66    if size < 0 {
67        Err(MjEditError::InvalidParameter(format!(
68            "numeric size must be non-negative, got {size}"
69        )))
70    } else {
71        Ok(())
72    }
73}
74
75/* Types */
76/// Type of inertia inference.
77pub type MjtGeomInertia = mjtGeomInertia;
78
79/// Type of mesh inertia.
80pub type MjtMeshInertia = mjtMeshInertia;
81
82/// Type of built-in procedural texture.
83pub type MjtBuiltin = mjtBuiltin;
84
85/// Type of built-in procedural mesh.
86pub type MjtMeshBuiltin = mjtMeshBuiltin;
87
88/// Mark type for procedural textures.
89pub type MjtMark = mjtMark;
90
91/// Type of limit specification.
92pub type MjtLimited = mjtLimited;
93
94/// Whether to align free joints with the inertial frame.
95pub type MjtAlignFree = mjtAlignFree;
96
97/// Whether to infer body inertias from child geoms.
98pub type MjtInertiaFromGeom = mjtInertiaFromGeom;
99
100/// Conflict-resolution policy used when attaching specifications.
101pub type MjtConflict = mjtConflict;
102
103/// Type of orientation specifier.
104pub type MjtOrientation = mjtOrientation;
105
106/// Compiler timing categories, used in `mjs_getTimer`.
107pub type MjtCTimer = mjtCTimer;
108/*******************************************************/
109
110/******************************
111** Type aliases
112******************************/
113/// Alternative orientation specifiers.
114pub type MjsOrientation = mjsOrientation;
115impl MjsOrientation {
116    /// Sets orientation in Euler space.
117    pub fn set_euler(&mut self, angle: &[f64; 3]) {
118        self.type_ = MjtOrientation::mjORIENTATION_EULER;
119        self.euler = *angle;
120    }
121
122    /// Sets orientation in axis angle space.
123    pub fn set_axis_angle(&mut self, angle: &[f64; 4]) {
124        self.type_ = MjtOrientation::mjORIENTATION_AXISANGLE;
125        self.axisangle = *angle;
126    }
127
128    /// Sets orientation in XY axes space.
129    pub fn set_xy_axis(&mut self, angle: &[f64; 6]) {
130        self.type_ = MjtOrientation::mjORIENTATION_XYAXES;
131        self.xyaxes = *angle;
132    }
133
134    /// Sets orientation in Z axis space.
135    pub fn set_z_axis(&mut self, angle: &[f64; 3]) {
136        self.type_ = MjtOrientation::mjORIENTATION_ZAXIS;
137        self.zaxis = *angle;
138    }
139
140    /// Changes the orientation mode to quaternions. The orientation must
141    /// be specified via the main angle attribute, not through [`MjsOrientation`].
142    pub fn switch_quat(&mut self) {
143        self.type_ = MjtOrientation::mjORIENTATION_QUAT;
144    }
145}
146
147mjs_opaque!(MjsCompiler <= mjsCompiler,
148    "Compiler options. An opaque handle for the FFI type [`mjsCompiler`], reached through \
149[`ffi`](Self::ffi).");
150
151impl MjsCompiler {
152    getter_setter! {[&] with, get, set, [
153        [ffi, ffi_mut] autolimits: bool;              "infer \"limited\" attribute based on range.";
154        [ffi, ffi_mut] balanceinertia: bool;          "automatically impose A + B >= C rule.";
155        [ffi, ffi_mut] fitaabb: bool;                 "meshfit to aabb instead of inertia box.";
156        [ffi, ffi_mut] degree: bool;                  "angles in radians or degrees.";
157        [ffi, ffi_mut] discardvisual: bool;           "discard visual geoms in parser.";
158        [ffi, ffi_mut] usethread: bool;               "use multiple threads to speed up compiler.";
159        [ffi, ffi_mut] fusestatic: bool;              "fuse static bodies with parent.";
160        [ffi, ffi_mut] saveinertial: bool;            "save explicit inertial clause for all bodies to XML.";
161        [ffi, ffi_mut] alignfree: bool;               "align free joints with inertial frame.";
162    ]}
163
164    getter_setter! {[&] with, get, set, [
165        [ffi, ffi_mut] boundmass: f64;                "enforce minimum body mass.";
166        [ffi, ffi_mut] boundinertia: f64;             "enforce minimum body diagonal inertia.";
167        [ffi, ffi_mut] settotalmass: f64;             "rescale masses and inertias; <=0: ignore.";
168    ]}
169
170    getter_setter! {[&] with, get, set, [
171        [ffi, ffi_mut] inertiafromgeom: MjtInertiaFromGeom [force];  "use geom inertias.";
172        [ffi, ffi_mut] conflict: MjtConflict [force];                "conflict-resolution policy for attach.";
173    ]}
174
175    getter_setter! {[&] with, get, [
176        [ffi, ffi_mut] inertiagrouprange: &[i32; 2];       "range of geom groups used to compute inertia.";
177        [ffi, ffi_mut] eulerseq: &[c_char; 3];             "sequence for euler rotations.";
178        [ffi, ffi_mut] LRopt: &MjLROpt;                    "options for lengthrange computation.";
179    ]}
180
181    string_set_get_with! {[&]
182        meshdir;        "mesh and hfield directory.";
183        texturedir;     "texture directory.";
184    }
185
186    getter_setter! { get, [
187        [ffi] authored: u64; "bitmask of authored compiler fields.";
188    ]}
189}
190
191/// Authored-field tracking bitmasks for [`mjModel`] structs.
192///
193/// Each field records, as a bitmask, which attributes of the corresponding section were
194/// explicitly authored in the specification.
195pub type MjsAuthored = mjsAuthored;
196
197/***************************
198** Model Specification
199***************************/
200/// Model specification. This wraps the FFI type [`mjSpec`] internally.
201///
202/// Model editing is single-threaded. MuJoCo's C++ implementation shares unsynchronized state
203/// between a specification and its elements, so this type is neither [`Send`] nor [`Sync`].
204pub struct MjSpec {
205    /// The specification that MuJoCo owns.
206    ffi: NonNull<mjSpec>,
207}
208
209impl fmt::Debug for MjSpec {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        f.debug_struct("MjSpec").field("ffi", &self.ffi).finish_non_exhaustive()
212    }
213}
214
215impl MjSpec {
216    /// Wraps a specification that MuJoCo allocated.
217    fn from_ffi(ffi: NonNull<mjSpec>) -> Self {
218        Self { ffi }
219    }
220
221    /// Creates an empty [`MjSpec`].
222    ///
223    /// # Panics
224    /// When the linked MuJoCo version does not match the expected from MuJoCo-rs.
225    #[expect(deprecated, reason = "try_new keeps the implementation until it is removed")]
226    pub fn new() -> Self {
227        Self::try_new().expect("MuJoCo failed to allocate MjSpec")
228    }
229
230    /// Fallible version of [`MjSpec::new`].
231    ///
232    /// # Note
233    /// MuJoCo ends the process when the allocation fails, so this never returns `Err`.
234    ///
235    /// # Errors
236    /// Returns [`MjEditError::AllocationFailed`] if MuJoCo fails to allocate
237    /// the specification.
238    ///
239    /// # Panics
240    /// When the linked MuJoCo version does not match the expected from MuJoCo-rs.
241    #[deprecated(
242        since = "6.0.0",
243        note = "always returns Ok; use `new`"
244    )]
245    pub fn try_new() -> Result<Self, MjEditError> {
246        assert_mujoco_version();
247        // SAFETY: mj_makeSpec allocates a new MjSpec; returns null on allocation
248        // failure, handled by ok_or below.
249        let ptr = unsafe { mj_makeSpec() };
250        Ok(Self::from_ffi(NonNull::new(ptr).ok_or(MjEditError::AllocationFailed)?))
251    }
252
253    /// Creates a deep copy of this [`MjSpec`].
254    ///
255    /// A child specification that the model attaches from another file stays shared with the
256    /// original, behind MuJoCo's own reference count.
257    ///
258    /// # Errors
259    /// Returns [`MjEditError::AllocationFailed`] if MuJoCo fails to allocate
260    /// the copy (e.g. out of memory or an internal C++ exception).
261    pub fn try_clone(&self) -> Result<Self, MjEditError> {
262        // SAFETY: self.ffi is a valid non-null mjSpec pointer for the lifetime of self
263        // (struct invariant); mj_copySpec returns null on allocation failure, handled below.
264        let ptr = unsafe { mj_copySpec(self.ffi.as_ptr()) };
265        NonNull::new(ptr).map(Self::from_ffi).ok_or(MjEditError::AllocationFailed)
266    }
267
268    /// Wraps the spec into a [`Send`]-able wrapper, allowing it to be moved to another thread.
269    /// # Safety
270    /// No other live [`MjSpec`] may share data with this spec.
271    /// This includes any type of sharing done by the following:
272    /// - cloning a spec that uses the `<model>` tag inside `<asset>` in the MJCF XML definition;
273    /// - attaching procedurally via [`Attach::attach_by_reference`];
274    /// - attaching procedurally via [`Attach::attach_by_deep_copy`].
275    ///
276    /// The attachments include not just direct attachments of [`MjSpec`], but also any other
277    /// model-editing element. Attachments within the same spec is safe.
278    ///
279    /// Any user value, added by [`UserValued::set_user_value`], must also be Send.
280    /// 
281    /// # Example
282    /// ```
283    /// # use mujoco_rs::prelude::*;
284    /// let spec = MjSpec::new();
285    /// 
286    /// // SAFETY: the source spec doesn't have any attachments.
287    /// let spec2 = unsafe { spec.clone().into_sendable() };
288    /// std::thread::spawn(|| {
289    ///     let moved_spec = spec2.take();
290    /// }).join();
291    /// ```
292    pub unsafe fn into_sendable(self) -> SendableSpec {
293        unsafe { SendableSpec::new(self) }
294    }
295
296    /// Creates a [`MjSpec`] from the `path` to a file.
297    /// # Errors
298    /// - [`MjEditError::InvalidUtf8Path`] if the path contains invalid UTF-8.
299    /// - [`MjEditError::ParseFailed`] if MuJoCo fails to parse the XML.
300    /// # Panics
301    /// - when the `path` contains '\0'.
302    /// - when the linked MuJoCo version does not match the expected from MuJoCo-rs.
303    pub fn from_xml<T: AsRef<Path>>(path: T) -> Result<Self, MjEditError> {
304        Self::from_xml_file(path, None)
305    }
306
307    /// Creates a [`MjSpec`] from the `path` to a file, located in a virtual file system (`vfs`).
308    /// # Errors
309    /// - [`MjEditError::InvalidUtf8Path`] if the path contains invalid UTF-8.
310    /// - [`MjEditError::ParseFailed`] if MuJoCo fails to parse the XML.
311    /// # Panics
312    /// - when the `path` contains '\0'.
313    /// - when the linked MuJoCo version does not match the expected from MuJoCo-rs.
314    pub fn from_xml_vfs<T: AsRef<Path>>(path: T, vfs: &MjVfs) -> Result<Self, MjEditError> {
315        Self::from_xml_file(path, Some(vfs))
316    }
317
318    fn from_xml_file<T: AsRef<Path>>(path: T, vfs: Option<&MjVfs>) -> Result<Self, MjEditError> {
319        assert_mujoco_version();
320
321        let mut error_buffer = [0; ERROR_BUF_LEN];
322        unsafe {
323            let path_str = path.as_ref().to_str()
324                .ok_or(MjEditError::InvalidUtf8Path)?;
325            let path = CString::new(path_str).unwrap();
326            let raw_ptr = mj_parseXML(
327                path.as_ptr(), vfs.map_or(ptr::null(), |v| v.ffi()),
328                error_buffer.as_mut_ptr(), error_buffer.len() as c_int
329            );
330
331            Self::check_spec(raw_ptr, &error_buffer)
332        }
333    }
334
335    /// Creates a [`MjSpec`] from an `xml` string.
336    /// # Errors
337    /// Returns [`MjEditError::ParseFailed`] if MuJoCo encounters an error parsing the string.
338    /// # Panics
339    /// - when the `xml` contains '\0'.
340    /// - when the linked MuJoCo version does not match the expected from MuJoCo-rs.
341    pub fn from_xml_string(xml: &str) -> Result<Self, MjEditError> {
342        assert_mujoco_version();
343
344        let c_xml = CString::new(xml).unwrap();
345        let mut error_buffer = [0; ERROR_BUF_LEN];
346        unsafe {
347            let spec_ptr = mj_parseXMLString(
348                c_xml.as_ptr(), ptr::null(),
349                error_buffer.as_mut_ptr(), error_buffer.len() as c_int
350            );
351            Self::check_spec(spec_ptr, &error_buffer)
352        }
353    }
354
355    /// Parse and create a [`MjSpec`] from `filename`.
356    /// The `content_type` controls the decoder to use.
357    /// This is a wrapper around low-level method [`mj_parse`].
358    /// # Errors
359    /// - [`MjEditError::InvalidUtf8Path`] if the path contains invalid UTF-8.
360    /// - [`MjEditError::ParseFailed`] if MuJoCo fails to parse the file.
361    /// # Panics
362    /// - When `content_type` or the path contain interior `\0` characters.
363    /// - When the linked MuJoCo version does not match the expected from MuJoCo-rs.
364    pub fn from_parse<T: AsRef<Path>>(filename: T, content_type: &str) -> Result<Self, MjEditError> {
365        Self::from_parse_file(filename, content_type, None)
366    }
367
368    /// Same as [`MjSpec::from_parse`], except `filename` is taken from `vfs`.
369    /// # Errors
370    /// - [`MjEditError::InvalidUtf8Path`] if the path contains invalid UTF-8.
371    /// - [`MjEditError::ParseFailed`] if MuJoCo fails to parse the file.
372    /// # Panics
373    /// - When `content_type` or the path contain interior `\0` characters.
374    /// - When the linked MuJoCo version does not match the expected from MuJoCo-rs.
375    pub fn from_parse_vfs<T: AsRef<Path>>(filename: T, content_type: &str, vfs: &MjVfs) -> Result<Self, MjEditError> {
376        Self::from_parse_file(filename, content_type, Some(vfs))
377    }
378
379    /// Parse and create a [`MjSpec`] from `filename`.
380    /// The `content_type` controls the decoder to use.
381    /// This is a wrapper around low-level method [`mj_parse`].
382    /// # Panics
383    /// - When `content_type` or the path contain interior `\0` characters.
384    /// - When the linked MuJoCo version does not match the version MuJoCo-rs was compiled against.
385    fn from_parse_file<T: AsRef<Path>>(filename: T, content_type: &str, vfs: Option<&MjVfs>) -> Result<Self, MjEditError> {
386        assert_mujoco_version();
387        let mut error_buffer = [0; ERROR_BUF_LEN];
388        unsafe {
389            let c_filename = CString::new(
390                filename.as_ref().to_str()
391                .ok_or(MjEditError::InvalidUtf8Path)?
392            ).unwrap();
393            let c_content_type = CString::new(content_type).unwrap();
394            let ptr = mj_parse(
395                c_filename.as_ptr(), c_content_type.as_ptr(),
396                vfs.map_or(ptr::null(), |v| v.ffi()),
397                error_buffer.as_mut_ptr(), error_buffer.len() as i32
398            );
399            Self::check_spec(ptr, &error_buffer)
400        }
401    }
402
403    /// Handles spec pointer input.
404    fn check_spec(spec_ptr: *mut mjSpec, error_buffer: &[c_char]) -> Result<Self, MjEditError> {
405        if spec_ptr.is_null() {
406            // SAFETY: error_buffer is zero-initialised and MuJoCo always
407            // NUL-terminates the message it writes into it.
408            let message = unsafe { CStr::from_ptr(error_buffer.as_ptr()) }
409                .to_string_lossy()
410                .into_owned();
411            Err(MjEditError::ParseFailed(message))
412        }
413        else {
414            // SAFETY: spec_ptr is confirmed non-null by the guard above.
415            Ok(Self::from_ffi(unsafe { NonNull::new_unchecked(spec_ptr) }))
416        }
417    }
418
419    /// An immutable reference to the internal FFI struct.
420    pub fn ffi(&self) -> &mjSpec {
421        // SAFETY: self.ffi is a valid non-null mjSpec pointer for the lifetime of
422        // self (struct invariant).
423        unsafe { self.ffi.as_ref() }
424    }
425
426    /// A mutable reference to the internal FFI struct.
427    ///
428    /// # Safety
429    /// Callers must ensure that any mutations performed through the returned reference
430    /// preserve the invariants that MuJoCo expects for `mjSpec`.
431    pub unsafe fn ffi_mut(&mut self) -> &mut mjSpec {
432        unsafe { self.ffi.as_mut() }
433    }
434
435    /// Delete an element from this specification.
436    ///
437    /// # Deprecated
438    /// Call [`SpecObject::delete`] on the element handle instead.
439    ///
440    /// # Errors
441    /// - [`MjEditError::DeleteFailed`] if `element` is null, does not belong to this spec, or
442    ///   MuJoCo refuses the deletion.
443    /// - [`MjEditError::UnsupportedOperation`] if `element` is a default class, a frame, a tendon
444    ///   wrap, or the world body.
445    ///
446    /// # Safety
447    /// Same contract as [`SpecObject::delete`], and `element` must point to an element of a
448    /// specification.
449    #[deprecated(since = "6.0.0", note = "use SpecObject::delete on the element handle")]
450    pub unsafe fn delete_element(&mut self, element: *mut mjsElement) -> Result<(), MjEditError> {
451        if element.is_null() {
452            return Err(MjEditError::DeleteFailed("null element pointer".to_owned()));
453        }
454
455        // mjCDef is not an mjCBase, so a default class must not reach the owner check below.
456        if unsafe { (*element).elemtype } == MjtObj::mjOBJ_DEFAULT {
457            return Err(MjEditError::UnsupportedOperation);
458        }
459
460        if unsafe { mjs_getSpec(element) } != self.ffi.as_ptr() {
461            return Err(MjEditError::DeleteFailed("element does not belong to this spec".to_owned()));
462        }
463
464        unsafe { utility::delete_element(element) }
465    }
466
467    /// Compile [`MjSpec`] to [`MjModel`].
468    /// A spec can be edited and compiled multiple times,
469    /// returning a new mjModel instance that takes the edits into account.
470    /// # Errors
471    /// Returns [`MjEditError::CompileFailed`] if the model fails to compile, including when a
472    /// texture has a builtin pattern set while its `nchannel` is less than 3, and when a texture
473    /// has a negative dimension or a pixel count that does not fit in an [`i32`].
474    pub fn compile(&mut self) -> Result<MjModel, MjEditError> {
475        self.compile_impl(None)
476    }
477
478    /// Same as [`MjSpec::compile`], compiling [`MjSpec`] to [`MjModel`], but taking assets (meshes, heightfields) from `vfs`.
479    /// # Errors
480    /// Returns [`MjEditError::CompileFailed`] if the model fails to compile, including when a
481    /// texture has a builtin pattern set while its `nchannel` is less than 3, and when a texture
482    /// has a negative dimension or a pixel count that does not fit in an [`i32`].
483    pub fn compile_with_vfs(&mut self, vfs: &MjVfs) -> Result<MjModel, MjEditError> {
484        self.compile_impl(Some(vfs))
485    }
486
487    /// Compilation implementation of [`MjSpec::compile_with_vfs`] and [`MjSpec::compile`].
488    fn compile_impl(&mut self, maybe_vfs: Option<&MjVfs>) -> Result<MjModel, MjEditError> {
489        // The builtin generators write 3 bytes per pixel into an `nchannel*width*height` buffer,
490        // and the setters are independent, so `compile` is the only place to check them together.
491        for texture in self.texture_iter() {
492            if texture.builtin() != MjtBuiltin::mjBUILTIN_NONE && texture.nchannel() < 3 {
493                return Err(MjEditError::CompileFailed(
494                    "texture with a builtin pattern requires nchannel >= 3".to_owned(),
495                ));
496            }
497
498            let (nchannel, width, height) = (texture.nchannel(), texture.width(), texture.height());
499            if nchannel < 0 || width < 0 || height < 0 {
500                return Err(MjEditError::CompileFailed(
501                    "texture nchannel, width and height must be non-negative".to_owned(),
502                ));
503            }
504            if i64::from(nchannel) * i64::from(width) * i64::from(height) > i64::from(i32::MAX) {
505                return Err(MjEditError::CompileFailed(
506                    "texture nchannel*width*height must fit in an i32".to_owned(),
507                ));
508            }
509        }
510
511        let result = unsafe { MjModel::from_raw(
512            mj_compile(self.ffi.as_ptr(), maybe_vfs.map_or(ptr::null(), |vfs| vfs.ffi()))
513        ) };
514
515        // SAFETY: the spec is still valid after a failed compilation.
516        result.map_err(|_| MjEditError::CompileFailed(unsafe { read_spec_error(self.ffi.as_ptr()) }))
517    }
518
519    /// Return the compiler timers, in seconds, in `mjtCTimer` order.
520    pub fn timer(&self) -> &[f64; MjtCTimer::mjNCTIMER as usize] {
521        unsafe { &*mjs_getTimer(self.ffi.as_ptr()).cast() }
522    }
523
524    /// Get number of warnings accumulated in the spec. Wraps [`mjs_numWarnings`].
525    pub fn num_warnings(&self) -> i32 {
526        // SAFETY: self.ffi is a valid non-null mjSpec pointer.
527        unsafe { mjs_numWarnings(self.ffi.as_ptr()) }
528    }
529
530    /// Get the i-th warning message. Returns `None` if the index is out of bounds, or if the
531    /// message is not valid UTF-8. Wraps [`mjs_getWarning`].
532    pub fn warning(&self, index: i32) -> Option<&str> {
533        // SAFETY: the string belongs to the spec and lives as long as the borrow of self.
534        let ptr = unsafe { mjs_getWarning(self.ffi.as_ptr(), index) };
535        if ptr.is_null() {
536            None
537        } else {
538            unsafe { CStr::from_ptr(ptr) }.to_str().ok()
539        }
540    }
541
542    /// Saves the spec to an XML file.
543    /// # Errors
544    /// - [`MjEditError::InvalidUtf8Path`] if the path contains invalid UTF-8.
545    /// - [`MjEditError::SaveFailed`] with MuJoCo's error message if saving fails.
546    /// # Panics
547    /// When `filename` contains interior `\0` characters.
548    pub fn save_xml<T: AsRef<Path>>(&self, filename: T) -> Result<(), MjEditError> {
549        let mut error_buff = [0; ERROR_BUF_LEN];
550        let cname = CString::new(
551            filename.as_ref().to_str()
552            .ok_or(MjEditError::InvalidUtf8Path)?
553        ).unwrap();  // filename is always UTF-8
554        let result = unsafe { mj_saveXML(
555            self.ffi(), cname.as_ptr(),
556            error_buff.as_mut_ptr(), error_buff.len() as i32
557        ) };
558        match result {
559            0 => Ok(()),
560            _ => {
561                // SAFETY: error_buff is zero-initialised and MuJoCo always
562                // NUL-terminates the message it writes into it.
563                let message = unsafe { CStr::from_ptr(error_buff.as_ptr()) }
564                    .to_string_lossy()
565                    .into_owned();
566                Err(MjEditError::SaveFailed(message))
567            }
568        }
569    }
570
571    /// Saves the spec to an XML string.
572    /// `buffer_size` controls how many bytes are allocated for the output.
573    /// # Errors
574    /// - [`MjEditError::XmlBufferTooSmall`] when `buffer_size` is too small.
575    ///   The `required_size` field uses `snprintf`-style semantics (bytes to write, excluding NUL),
576    ///   so retry with `required_size as usize + 1` bytes.
577    /// - [`MjEditError::SaveFailed`] with MuJoCo's error message on any other failure.
578    /// # Panics
579    /// Panics if MuJoCo reports success but returns XML that is not NUL-terminated
580    /// within the allocated output buffer.
581    pub fn save_xml_string(&self, buffer_size: usize) -> Result<String, MjEditError> {
582        let mut error_buff = [0; ERROR_BUF_LEN];
583        let mut result_buff = vec![0u8; buffer_size];
584        let result = unsafe { mj_saveXMLString(
585            self.ffi(), result_buff.as_mut_ptr().cast(), result_buff.len() as i32,
586            error_buff.as_mut_ptr(), error_buff.len() as i32
587        ) };
588        match result {
589            0 => Ok(CStr::from_bytes_until_nul(&result_buff).unwrap().to_string_lossy().into_owned()),
590            r if r > 0 => Err(MjEditError::XmlBufferTooSmall { required_size: r as usize }),
591            _ => {
592                // SAFETY: error_buff is zero-initialised and MuJoCo always
593                // NUL-terminates the message it writes into it.
594                let message = unsafe { CStr::from_ptr(error_buff.as_ptr()) }
595                    .to_string_lossy()
596                    .into_owned();
597                Err(MjEditError::SaveFailed(message))
598            }
599        }
600    }
601
602    /// Encode [`MjSpec`] to `filepath` using an encoder registered for encoding `content_type`.
603    /// When the `filepath`'s extension is '.xml', the MuJoCo's internal
604    /// XML encoder will be used. Similarly, the MuJoCo's internal
605    /// encoders will be used when `content_type` is 'text/xml'.
606    /// 
607    /// Extensions '.mjb' and `.txt` are not supported. Using them will result in erroring [`MjEditError::SaveFailed`].
608    /// Using 'text/plain' for the `content_type` will result in the same error.
609    /// 
610    /// The spec must be compiled first. Changes made without recompilations
611    /// don't reflect in the encoded file.
612    /// 
613    /// This is a wrapper for [`mj_encode`].
614    /// 
615    /// # Errors
616    /// - [`MjEditError::InvalidUtf8Path`] if the path contains invalid UTF-8.
617    /// - [`MjEditError::SaveFailed`] with MuJoCo's error message if encoding fails.
618    /// # Panics
619    /// When `filepath` is empty, or when `filepath` or `content_type` contain interior `\0`
620    /// characters.
621    pub fn encode(&self, filepath: impl AsRef<Path>, content_type: &str) -> Result<(), MjEditError> {
622        self.encode_impl(filepath, content_type, None)
623    }
624
625    /// Same as [`MjSpec::encode`] except data (assets) are taken from `vfs`.
626    ///
627    /// # Errors
628    /// The same as [`MjSpec::encode`].
629    ///
630    /// # Panics
631    /// The same as [`MjSpec::encode`].
632    pub fn encode_with_vfs(&self, filepath: impl AsRef<Path>, content_type: &str, vfs: &MjVfs) -> Result<(), MjEditError> {
633        self.encode_impl(filepath, content_type, Some(vfs))
634    }
635
636    /// Implementation of the wrapper for [`mj_encode`].
637    fn encode_impl(
638        &self,
639        filepath: impl AsRef<Path>, content_type: &str,
640        maybe_vfs: Option<&MjVfs>
641    ) -> Result<(), MjEditError> {
642        let filepath = filepath.as_ref().to_str().ok_or(MjEditError::InvalidUtf8Path)?;
643        encode(Some(self), None, filepath, content_type, maybe_vfs).map_err(MjEditError::SaveFailed)
644    }
645}
646
647/// Encodes `spec` or `model` (at least one) to `filepath` with the encoder registered for
648/// `content_type`, taking assets from `vfs` when given. Wraps [`mj_encode`].
649/// # Panics
650/// When `filepath` is empty, or when `filepath` or `content_type` contain interior `\0`
651/// characters.
652/// 
653/// # Errors
654/// A [`String`] carrying MuJoCo-set error is returned on failure.
655pub(crate) fn encode(
656    spec: Option<&MjSpec>, model: Option<&MjModel>,
657    filepath: &str, content_type: &str, vfs: Option<&MjVfs>
658) -> Result<(), String> {
659    // An empty name makes the C encoders abort in `file_size("")`.
660    assert!(!filepath.is_empty(), "encode: filepath is empty");
661    let mut error_buff = [0; ERROR_BUF_LEN];
662
663    let c_filepath = CString::new(filepath).unwrap();
664    let c_content_type = CString::new(content_type).unwrap();
665
666    // SAFETY: the pointers are null or from live wrappers; the strings and buffer outlive the call.
667    let result = unsafe {
668        mj_encode(
669            spec.map_or(ptr::null(), |spec| spec.ffi()),
670            model.map_or(ptr::null(), |model| model.ffi()),
671            c_filepath.as_ptr(), c_content_type.as_ptr(),
672            vfs.map_or(ptr::null(), |vfs| vfs.ffi()),
673            error_buff.as_mut_ptr(), ERROR_BUF_LEN as i32
674        )
675    };
676
677    // == -1 means error, >= 0 mean the number of bytes written
678    if result == -1 {
679        // SAFETY: MuJoCo NUL-terminates the error buffer.
680        let message = unsafe { CStr::from_ptr(error_buff.as_ptr()) }
681            .to_string_lossy()
682            .into_owned();
683        return Err(message);
684    }
685
686    Ok(())
687}
688
689/// Children accessor methods.
690impl MjSpec {
691    find_x_method! {
692        body, geom, joint, site, camera, light, frame, actuator, sensor, flex, pair, equality, exclude, tendon,
693        numeric, text, tuple, key, mesh, hfield, skin, texture, material, plugin
694    }
695
696    find_x_method_direct! { default }
697
698    /// Returns an immutable reference to the world body.
699    /// # Panics
700    /// Panics if the "world" body is not found.
701    pub fn world_body(&self) -> &MjsBody {
702        self.body("world").unwrap()
703    }
704
705    /// Returns a mutable reference to the world body.
706    /// # Panics
707    /// Panics if the "world" body is not found.
708    pub fn world_body_mut(&mut self) -> &mut MjsBody {
709        self.body_mut("world").unwrap()
710    }
711}
712
713/// Public attributes.
714impl MjSpec {
715    string_set_get_with! {
716        modelname; "model name.";
717        comment; "comment at top of XML.";
718        modelfiledir; "path to model file.";
719    }
720
721    getter_setter! {
722        with, get, [
723            [ffi, ffi_mut] stat: &MjStatistic; "statistic overrides.";
724            [ffi, ffi_mut] visual: &MjVisual; "visualization options.";
725            [ffi, ffi_mut] option: &MjOption; "simulation options.";
726        ]
727    }
728
729    nested_handle!(compiler: MjsCompiler; "compiler options.");
730
731    getter_setter! {
732        get, [
733            [ffi] (allow_mut = false) authored: &MjsAuthored; "authored-field tracking bitmasks.";
734        ]
735    }
736
737    getter_setter! {
738        with, get, set, [
739            [ffi, ffi_mut] strippath: bool; "whether to strip paths from mesh files.";
740            [ffi, ffi_mut] hasImplicitPluginElem: bool; "already encountered an implicit plugin sensor/actuator.";
741        ]
742    }
743
744    getter_setter! {
745        get, [
746            // No setter due to the compiler adding its own count.
747            [ffi] nemax: i32;             "max number of equality constraints.";
748        ]
749    }
750
751    getter_setter! {
752        get, set, [
753            [ffi, ffi_mut] memory: MjtSize;     "number of bytes in arena+stack memory.";
754            [ffi, ffi_mut] nuserdata: i32;              "number of mjtNums in userdata.";
755            [ffi, ffi_mut] nkey: i32;                             "number of keyframes.";
756        ]
757    }
758
759    getter_setter! {
760        get, set, [
761            [ffi, ffi_mut] nuser_body: i32     { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError;     "number of mjtNums in body_user.";
762            [ffi, ffi_mut] nuser_jnt: i32      { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError;      "number of mjtNums in jnt_user.";
763            [ffi, ffi_mut] nuser_geom: i32     { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError;     "number of mjtNums in geom_user.";
764            [ffi, ffi_mut] nuser_site: i32     { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError;     "number of mjtNums in site_user.";
765            [ffi, ffi_mut] nuser_cam: i32      { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError;      "number of mjtNums in cam_user.";
766            [ffi, ffi_mut] nuser_tendon: i32   { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError;   "number of mjtNums in tendon_user.";
767            [ffi, ffi_mut] nuser_actuator: i32 { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError; "number of mjtNums in actuator_user.";
768            [ffi, ffi_mut] nuser_sensor: i32   { check_nuser, "[`MjEditError::InvalidParameter`] when the count is below -1" } => MjEditError;   "number of mjtNums in sensor_user.";
769        ]
770    }
771}
772
773/// Methods for adding non-tree elements.
774impl MjSpec {
775    add_x_method! { actuator, pair, equality, tendon, mesh, material }
776    add_x_method_no_default! {
777        sensor, flex, exclude, numeric, text, tuple, key, plugin,
778        hfield, skin, texture
779        // Wrap
780    }
781
782    /// Adds a new `<default>` element.
783    ///
784    /// # Panics
785    /// Panics when `class_name` already exists or `parent_class_name` doesn't exist.
786    /// Also panics when the `class_name` or `parent_class_name` contain '\0' characters.
787    ///
788    /// Use [`MjSpec::try_add_default`] for a fallible alternative.
789    pub fn add_default(&mut self, class_name: &str, parent_class_name: Option<&str>) -> &mut MjsDefault {
790        self.try_add_default(class_name, parent_class_name).unwrap()
791    }
792
793    /// Fallible version of [`MjSpec::add_default`].
794    /// # Errors
795    /// Returns [`MjEditError::AlreadyExists`] when `class_name` already exists.
796    /// Returns [`MjEditError::NotFound`] when `parent_class_name` doesn't exist.
797    /// # Panics
798    /// When the `class_name` or `parent_class_name` contain '\0' characters, a panic occurs.
799    pub fn try_add_default(&mut self, class_name: &str, parent_class_name: Option<&str>) -> Result<&mut MjsDefault, MjEditError> {
800        let c_class_name = CString::new(class_name).unwrap();
801
802        let parent_ptr = if let Some(name) = parent_class_name {
803                self.default(name).ok_or(MjEditError::NotFound)?.ffi()
804        } else {
805            ptr::null()
806        };
807
808        unsafe {
809            let ptr_default = mjs_addDefault(
810                self.ffi_mut(),
811                c_class_name.as_ptr(),
812                parent_ptr
813            );
814            if ptr_default.is_null() {
815                Err(MjEditError::AlreadyExists)
816            }
817            else {
818                Ok(MjsDefault::from_ffi_ptr_mut(ptr_default).unwrap())
819            }
820        }
821    }
822
823    /// Activates the engine plugin registered under `name`.
824    ///
825    /// Wraps [`mjs_activatePlugin`].
826    /// 
827    /// # Errors
828    /// Returns [`MjEditError::NotFound`] when no plugin is registered under `name`.
829    /// 
830    /// # Panics
831    /// When the `name` contains '\0' characters, a panic occurs.
832    pub fn activate_plugin(&mut self, name: &str) -> Result<(), MjEditError> {
833        let c_name = CString::new(name).unwrap();
834        // SAFETY: the spec pointer is valid and c_name stays alive over the call.
835        let result = unsafe { mjs_activatePlugin(self.ffi_mut(), c_name.as_ptr()) };
836        if result == 0 {
837            Ok(())
838        }
839        else {
840            Err(MjEditError::NotFound)
841        }
842    }
843}
844
845/// Mutable iterator over items in [`MjSpec`].
846#[derive(Debug)]
847pub struct MjsSpecItemIterMut<'a, T> {
848    /// Raw pointer to the spec; a borrow would alias the handles that the iterator yields.
849    ffi_ptr: *mut mjSpec,
850    /// Element that the last `next` yielded. Null marks the end of the iteration.
851    last: *mut mjsElement,
852    item_type: PhantomData<&'a mut T>
853}
854
855/// Immutable iterator over items in [`MjSpec`].
856#[derive(Debug, Clone)]
857pub struct MjsSpecItemIter<'a, T> {
858    ffi_ptr: *const mjSpec,
859    /// Element that the last `next` yielded. Null marks the end of the iteration.
860    last: *mut mjsElement,
861    item_type: PhantomData<&'a T>
862}
863
864impl<'a, T: SpecObject> MjsSpecItemIterMut<'a, T> {
865    fn new(root: &'a mut MjSpec) -> Self {
866        let last = unsafe { mjs_firstElement(root.ffi.as_ptr(), T::OBJ_TYPE) };
867        Self { ffi_ptr: root.ffi.as_ptr(), last, item_type: PhantomData }
868    }
869}
870
871impl<'a, T: SpecObject> MjsSpecItemIter<'a, T> {
872    fn new(root: &'a MjSpec) -> Self {
873        // SAFETY: mjs_firstElement takes a *const mjSpec; the borrow of root keeps the spec
874        // alive for the call.
875        let last = unsafe { mjs_firstElement(root.ffi.as_ptr(), T::OBJ_TYPE) };
876        Self { ffi_ptr: root.ffi.as_ptr(), last, item_type: PhantomData }
877    }
878}
879
880impl<'a, T: SpecObject + 'a> Iterator for MjsSpecItemIterMut<'a, T> {
881    type Item = &'a mut T;
882
883    fn next(&mut self) -> Option<Self::Item> {
884        if self.last.is_null() {
885            return None;
886        }
887        unsafe {
888            let out = T::from_element_as_ptr_mut(self.last).as_mut();
889            // Use as_ptr() instead of ffi_mut() to avoid creating &mut mjSpec,
890            // which would alias with previously yielded &mut items.
891            self.last = mjs_nextElement(self.ffi_ptr, self.last);
892            out
893        }
894    }
895}
896
897impl<'a, T: SpecObject + 'a> Iterator for MjsSpecItemIter<'a, T> {
898    type Item = &'a T;
899
900    fn next(&mut self) -> Option<Self::Item> {
901        if self.last.is_null() {
902            return None;
903        }
904        unsafe {
905            let out = T::from_element_as_ptr_mut(self.last).as_ref();
906            // SAFETY: mjs_nextElement takes *const pointers; ffi_ptr and last stay valid while
907            // the iterator borrows the spec.
908            self.last = mjs_nextElement(self.ffi_ptr, self.last);
909            out
910        }
911    }
912}
913
914impl<'a, T: SpecObject + 'a> std::iter::FusedIterator for MjsSpecItemIterMut<'a, T> {}
915impl<'a, T: SpecObject + 'a> std::iter::FusedIterator for MjsSpecItemIter<'a, T> {}
916
917/// Iterator methods.
918impl MjSpec {
919    spec_get_iter! {
920        geom, joint, site, camera, light, frame, actuator, sensor, flex, pair, equality,
921        exclude, tendon, numeric, text, tuple, key, mesh, hfield, skin, texture, material, plugin
922    }
923
924    // A body owns a subtree, so a flat mutable iterator would hand out an ancestor and that
925    // ancestor's own descendant at once. Reach a body through `world_body_mut` and walk down.
926    spec_get_iter!(read_only: body);
927}
928
929impl Default for MjSpec {
930    fn default() -> Self {
931        Self::new()
932    }
933}
934
935impl Drop for MjSpec {
936    fn drop(&mut self) {
937        // SAFETY: self.ffi is a valid non-null mjSpec pointer; called exactly once
938        // in Drop.
939        unsafe { mj_deleteSpec(self.ffi.as_ptr()); }
940    }
941}
942
943impl Clone for MjSpec {
944    /// Creates a deep copy of this [`MjSpec`].
945    ///
946    /// # Panics
947    /// Panics if MuJoCo raises an error while it copies the spec.
948    /// Use [`MjSpec::try_clone`] for a fallible alternative.
949    fn clone(&self) -> Self {
950        self.try_clone().expect("MuJoCo failed to clone MjSpec")
951    }
952}
953
954/// A wrapper around [`MjSpec`] implementing [`Send`].
955#[derive(Debug)]
956pub struct SendableSpec(MjSpec);
957
958impl SendableSpec {
959    /// Wrap a [`MjSpec`] into a [`Send`]-able wrapper.
960    /// # Safety
961    /// The `spec` must follow the same rules as written in [`MjSpec::into_sendable`].
962    pub unsafe fn new(spec: MjSpec) -> Self {
963        Self(spec)
964    }
965
966    /// Takes the wrapped [`MjSpec`] out of the wrapper.
967    pub fn take(self) -> MjSpec {
968        self.0
969    }
970}
971
972/// Implementation of [`Send`] which allows [`MjSpec`] to be sent across threads.
973/// # Safety
974/// A [`SendableSpec`] can only be instantiated through methods marked as `unsafe`.
975/// These methods are safe provided the conditions of [`MjSpec::into_sendable`] hold.
976unsafe impl Send for SendableSpec {}
977
978/***************************
979** Site specification
980***************************/
981mjs_struct!(Site with SpecObject: MjsSite <= mjsSite);
982impl MjsSite {
983    getter_setter! {
984        [&] with, get, [
985            // frame, size
986            [ffi, ffi_mut] pos:  &[f64; 3];              "position.";
987            [ffi, ffi_mut] quat: &[f64; 4];              "orientation.";
988            [ffi, ffi_mut] alt:  &MjsOrientation;        "alternative orientation.";
989            [ffi, ffi_mut] fromto: &[f64; 6];            "alternative for capsule, cylinder, box, ellipsoid.";
990            [ffi, ffi_mut] size: &[f64; 3];              "geom size.";
991
992            // visual
993            [ffi, ffi_mut] rgba: &[f32; 4];              "rgba when material is omitted.";
994    ]}
995
996    getter_setter!([&] with, get, set, [
997        [ffi, ffi_mut] type_ + _: MjtGeom;               "geom type.";
998        [ffi, ffi_mut] group: i32;                       "group.";
999    ]);
1000
1001    userdata_method!(f64);
1002
1003    string_set_get_with! {[&]
1004        material; "name of material.";
1005    }
1006}
1007
1008/***************************
1009** Joint specification
1010***************************/
1011mjs_struct!(Joint with SpecObject: MjsJoint <= mjsJoint);
1012impl MjsJoint {
1013    getter_setter! {
1014        [&] with, get, [
1015            // kinematics
1016            [ffi, ffi_mut] pos:     &[f64; 3];         "anchor position.";
1017            [ffi, ffi_mut] axis:    &[f64; 3];         "joint axis.";
1018            [ffi, ffi_mut] ref_ + _:    &f64;          "value at reference configuration: qpos0.";
1019            [ffi, ffi_mut] springdamper: &[f64; 2];    "timeconst, dampratio.";
1020
1021            // stiffness
1022            [ffi, ffi_mut] stiffness: &[f64; mjNPOLY as usize + 1];            "stiffness coefficients.";
1023
1024            // limits
1025            [ffi, ffi_mut] range:   &[f64; 2];         "joint limits.";
1026            [ffi, ffi_mut] solref_limit: &[MjtNum; mjNREF as usize];  "solver reference: joint limits.";
1027            [ffi, ffi_mut] solimp_limit: &[MjtNum; mjNIMP as usize];  "solver impedance: joint limits.";
1028            [ffi, ffi_mut] actfrcrange: &[f64; 2];     "actuator force limits.";
1029
1030            // dof properties
1031            [ffi, ffi_mut] damping: &[f64; mjNPOLY as usize + 1];                 "damping coefficients.";
1032            [ffi, ffi_mut] solref_friction: &[MjtNum; mjNREF as usize]; "solver reference: dof friction.";
1033            [ffi, ffi_mut] solimp_friction: &[MjtNum; mjNIMP as usize]; "solver impedance: dof friction.";
1034        ]
1035    }
1036
1037    getter_setter!([&] with, get, set, [
1038        [ffi, ffi_mut] type_ + _: MjtJoint;           "joint type.";
1039        [ffi, ffi_mut] group: i32;                    "joint group.";
1040        [ffi, ffi_mut] springref: f64;               "spring reference value: qpos_spring.";
1041        [ffi, ffi_mut] margin: f64;                  "margin value for joint limit detection.";
1042        [ffi, ffi_mut] armature: f64;                "armature inertia (mass for slider).";
1043        [ffi, ffi_mut] frictionloss: f64;            "friction loss.";
1044    ]);
1045
1046    getter_setter! {
1047        [&] with, get, set, [
1048            [ffi, ffi_mut] align: MjtAlignFree [force];       "align free joint with body com (mjtAlignFree).";
1049            [ffi, ffi_mut] limited: MjtLimited [force];       "does joint have limits (mjtLimited).";
1050            [ffi, ffi_mut] actfrclimited: MjtLimited [force]; "are actuator forces on joint limited (mjtLimited).";
1051        ]
1052    }
1053
1054    getter_setter! {
1055        [&] with, get, set, [
1056            [ffi, ffi_mut] actgravcomp: bool;         "is gravcomp force applied via actuators.";
1057        ]
1058    }
1059
1060    userdata_method!(f64);
1061}
1062
1063/***************************
1064** Geom specification
1065***************************/
1066mjs_struct!(Geom with SpecObject: MjsGeom <= mjsGeom);
1067impl MjsGeom {
1068    getter_setter! {
1069        [&] with, get, [
1070            [ffi, ffi_mut] pos: &[f64; 3];                         "geom position.";
1071            [ffi, ffi_mut] quat: &[f64; 4];                        "geom orientation.";
1072            [ffi, ffi_mut] alt: &MjsOrientation;                   "alternative orientation.";
1073            [ffi, ffi_mut] fromto: &[f64; 6];                      "alternative for capsule, cylinder, box, ellipsoid.";
1074            [ffi, ffi_mut] size: &[f64; 3];                        "geom size.";
1075            [ffi, ffi_mut] rgba: &[f32; 4];                        "rgba when material is omitted.";
1076            [ffi, ffi_mut] friction: &[f64; 3];                    "one-sided friction coefficients: slide, spin, roll.";
1077            [ffi, ffi_mut] solref: &[MjtNum; mjNREF as usize];     "solver reference.";
1078            [ffi, ffi_mut] solimp: &[MjtNum; mjNIMP as usize];     "solver impedance.";
1079            [ffi, ffi_mut] surfacevel: &[f64; 6];                  "surface velocity in local frame: linear, angular.";
1080            [ffi, ffi_mut] fluid_coefs: &[MjtNum; 5];              "ellipsoid-fluid interaction coefs."
1081        ]
1082    }
1083
1084    nested_handle!(plugin: MjsPluginReference; "sdf plugin.");
1085
1086    getter_setter!([&] with, get, set, [
1087        [ffi, ffi_mut] type_ + _: MjtGeom;            "geom type.";
1088        [ffi, ffi_mut] group: i32;                    "group.";
1089        [ffi, ffi_mut] contype: i32;                  "contact type.";
1090        [ffi, ffi_mut] conaffinity: i32;              "contact affinity.";
1091        [ffi, ffi_mut] condim: i32;                   "contact dimensionality.";
1092        [ffi, ffi_mut] priority: i32;                 "contact priority.";
1093        [ffi, ffi_mut] solmix: f64;                   "solver mixing for contact pairs.";
1094        [ffi, ffi_mut] margin: f64;                   "margin for contact detection.";
1095        [ffi, ffi_mut] gap: f64;                      "additional contact detection buffer.";
1096        [ffi, ffi_mut] adhesion: f64;                 "adhesive force of contacts.";
1097        [ffi, ffi_mut] mass: f64;                     "used to compute density.";
1098        [ffi, ffi_mut] density: f64;                  "used to compute mass and inertia from volume or surface.";
1099        [ffi, ffi_mut] typeinertia: MjtGeomInertia;   "selects between surface and volume inertia.";
1100        [ffi, ffi_mut] fluid_ellipsoid: MjtNum;       "whether ellipsoid-fluid model is active.";
1101        [ffi, ffi_mut] fitscale: f64;                 "scale mesh uniformly.";
1102    ]);
1103
1104    userdata_method!(f64);
1105
1106    string_set_get_with! {[&]
1107        meshname;   "mesh attached to geom.";
1108        material;   "name of material.";
1109        hfieldname; "heightfield attached to geom.";
1110    }
1111}
1112
1113/***************************
1114** Camera specification
1115***************************/
1116mjs_struct!(Camera with SpecObject: MjsCamera <= mjsCamera);
1117impl MjsCamera {
1118    getter_setter! {
1119        [&] with, get, [
1120            [ffi, ffi_mut] pos: &[f64; 3];               "camera position.";
1121            [ffi, ffi_mut] quat: &[f64; 4];              "camera orientation.";
1122            [ffi, ffi_mut] alt: &MjsOrientation;         "alternative orientation.";
1123            [ffi, ffi_mut] intrinsic: &[f32; 4];         "intrinsic parameters.";
1124            [ffi, ffi_mut] sensor_size: &[f32; 2];       "sensor size.";
1125            [ffi, ffi_mut] resolution: &[i32; 2];        "resolution.";
1126            [ffi, ffi_mut] focal_length: &[f32; 2];      "focal length (length).";
1127            [ffi, ffi_mut] focal_pixel: &[f32; 2];       "focal length (pixel).";
1128            [ffi, ffi_mut] principal_length: &[f32; 2];  "principal point (length).";
1129            [ffi, ffi_mut] principal_pixel: &[f32; 2];   "principal point (pixel).";
1130        ]
1131    }
1132
1133    getter_setter!([&] with, get, set, [
1134        [ffi, ffi_mut] mode: MjtCamLight;              "camera mode.";
1135        [ffi, ffi_mut] fovy: f64;                      "field of view in y direction.";
1136        [ffi, ffi_mut] ipd: f64;                       "inter-pupillary distance for stereo.";
1137        [ffi, ffi_mut] proj: MjtProjection;            "camera projection type.";
1138        [ffi, ffi_mut] output: i32;                    "bit flags for output type.";
1139    ]);
1140
1141    userdata_method!(f64);
1142
1143    string_set_get_with! {[&]
1144        targetbody; "target body for tracking/targeting.";
1145    }
1146}
1147
1148/***************************
1149** Light specification
1150***************************/
1151mjs_struct!(Light with SpecObject: MjsLight <= mjsLight);
1152impl MjsLight {
1153    getter_setter! {
1154        [&] with, get, [
1155            [ffi, ffi_mut] pos: &[f64; 3];               "light position.";
1156            [ffi, ffi_mut] dir: &[f64; 3];               "light direction.";
1157            [ffi, ffi_mut] ambient: &[f32; 3];           "ambient color.";
1158            [ffi, ffi_mut] diffuse: &[f32; 3];           "diffuse color.";
1159            [ffi, ffi_mut] specular: &[f32; 3];          "specular color.";
1160            [ffi, ffi_mut] attenuation: &[f32; 3];       "OpenGL attenuation (quadratic model).";
1161        ]
1162    }
1163
1164    getter_setter!([&] with, get, set, [
1165        [ffi, ffi_mut] mode: MjtCamLight;             "light mode.";
1166        [ffi, ffi_mut] type_ + _: MjtLightType;       "light type.";
1167        [ffi, ffi_mut] bulbradius: f32;               "bulb radius, for soft shadows.";
1168        [ffi, ffi_mut] intensity: f32;                "intensity, in candelas.";
1169        [ffi, ffi_mut] range: f32;                    "range of effectiveness.";
1170        [ffi, ffi_mut] cutoff: f32;                   "OpenGL cutoff.";
1171        [ffi, ffi_mut] softness: f32;                 "spotlight edge softness.";
1172        [ffi, ffi_mut] exponent: f32;                 "OpenGL exponent.";
1173    ]);
1174
1175    getter_setter! {
1176        [&] with, get, set, [
1177            [ffi, ffi_mut] active: bool;       "active flag.";
1178            [ffi, ffi_mut] castshadow: bool;   "whether light cast shadows."
1179        ]
1180    }
1181
1182    string_set_get_with! {[&]
1183        texture; "texture name for image lights.";
1184        targetbody; "target body for targeting.";
1185    }
1186}
1187
1188/***************************
1189** Frame specification
1190***************************/
1191mjs_struct!(Frame with SpecObject: MjsFrame <= mjsFrame);
1192impl MjsFrame {
1193    add_x_method_by_frame! { body, site, joint, geom, camera, light }
1194
1195    getter_setter! {
1196        [&] with, get, [
1197            [ffi, ffi_mut] pos: &[f64; 3];               "frame position.";
1198            [ffi, ffi_mut] quat: &[f64; 4];              "frame orientation.";
1199            [ffi, ffi_mut] alt: &MjsOrientation;         "alternative orientation.";
1200        ]
1201    }
1202
1203    /// Return the childclass name.
1204    ///
1205    /// # Panics
1206    /// Panics if the stored string is not valid UTF-8.
1207    #[deprecated(since = "6.1.0", note = "the field holds the class of the frame; use `default`")]
1208    pub fn childclass(&self) -> &str {
1209        // SAFETY: the mjString field is valid for the lifetime of self.
1210        unsafe { read_mjs_string(self.ffi().childclass) }
1211    }
1212
1213    /// Set the childclass name.
1214    ///
1215    /// # Panics
1216    /// When the `value` contains '\0' characters, a panic occurs.
1217    #[deprecated(since = "6.1.0", note = "writes an unchecked class name; use `set_default`")]
1218    pub fn set_childclass(&mut self, value: &str) {
1219        // SAFETY: the mjString field is valid for the lifetime of self.
1220        unsafe { write_mjs_string(value, self.ffi_mut().childclass) };
1221    }
1222
1223    /// Builder method for setting the childclass name.
1224    ///
1225    /// # Panics
1226    /// When the `value` contains '\0' characters, a panic occurs.
1227    #[deprecated(since = "6.1.0", note = "writes an unchecked class name; use `set_default`")]
1228    pub fn with_childclass(&mut self, value: &str) -> &mut Self {
1229        // SAFETY: the mjString field is valid for the lifetime of self.
1230        unsafe { write_mjs_string(value, self.ffi_mut().childclass) };
1231        self
1232    }
1233
1234    /// Add and return a child frame.
1235    ///
1236    /// # Note
1237    /// MuJoCo ends the process when the allocation fails.
1238    #[expect(deprecated, reason = "try_add_frame keeps the implementation until it is removed")]
1239    pub fn add_frame(&mut self) -> &mut MjsFrame {
1240        self.try_add_frame().expect("mjs_addFrame returned null; allocation failed")
1241    }
1242
1243    /// Fallible version of [`Self::add_frame`].
1244    ///
1245    /// # Note
1246    /// MuJoCo ends the process when the allocation fails, so this never returns `Err`.
1247    ///
1248    /// # Errors
1249    /// Returns [`MjEditError::AllocationFailed`] when MuJoCo fails to allocate
1250    /// the frame, instead of panicking.
1251    #[deprecated(
1252        since = "6.0.0",
1253        note = "always returns Ok; use `add_frame`"
1254    )]
1255    pub fn try_add_frame(&mut self) -> Result<&mut MjsFrame, MjEditError> {
1256        // SAFETY: mjs_addFrame always calls SetParent(body), so every frame the Rust API hands out
1257        // has a non-null parent. The frame it returns is freshly allocated, so nothing aliases it.
1258        let parent_body = unsafe { mjs_getParent(self.element_mut_pointer()) };
1259        debug_assert!(!parent_body.is_null(), "mjs_getParent returned null; frame has no parent body");
1260        let ptr = unsafe { mjs_addFrame(parent_body, self.ffi_mut()) };
1261        unsafe { MjsFrame::from_ffi_ptr_mut(ptr) }.ok_or(MjEditError::AllocationFailed)
1262    }
1263}
1264
1265/* Non-tree elements */
1266
1267/***************************
1268** Actuator specification
1269***************************/
1270mjs_struct!(Actuator with SpecObject: MjsActuator <= mjsActuator);
1271impl MjsActuator {
1272    getter_setter! {
1273        [&] with, get, [
1274            [ffi, ffi_mut] gear: &[f64; 6];                            "gear parameters.";
1275            [ffi, ffi_mut] gainprm: &[f64; mjNGAIN as usize];          "gain parameters.";
1276            [ffi, ffi_mut] biasprm: &[f64; mjNBIAS as usize];          "bias parameters.";
1277            [ffi, ffi_mut] dynprm: &[f64; mjNDYN as usize];            "dynamic parameters.";
1278            [ffi, ffi_mut] lengthrange: &[f64; 2];                     "transmission length range.";
1279            [ffi, ffi_mut] damping: &[f64; mjNPOLY as usize + 1];      "damping coefficients.";
1280            [ffi, ffi_mut] ctrlrange: &[f64; 2];                       "control range.";
1281            [ffi, ffi_mut] velrange: &[f64; 2];                        "range of the velocity-setpoint input (pid).";
1282            [ffi, ffi_mut] ffrange: &[f64; 2];                         "range of the feedforward input (pid).";
1283            [ffi, ffi_mut] forcerange: &[f64; 2];                      "force range.";
1284            [ffi, ffi_mut] actrange: &[f64; 2];                        "activation range.";
1285        ]
1286    }
1287
1288    nested_handle!(plugin: MjsPluginReference; "actuator plugin.");
1289
1290    getter_setter!([&] with, get, set, [
1291        [ffi, ffi_mut] gaintype: MjtGain;             "gain type.";
1292        [ffi, ffi_mut] biastype: MjtBias;             "bias type.";
1293        [ffi, ffi_mut] dyntype: MjtDyn;               "dyn type.";
1294        [ffi, ffi_mut] group: i32;                    "group.";
1295        [ffi, ffi_mut] actdim: i32;                   "number of activation variables.";
1296        [ffi, ffi_mut] trntype: MjtTrn;               "transmission type.";
1297        [ffi, ffi_mut] cranklength: f64;              "crank length, for slider-crank.";
1298        [ffi, ffi_mut] inheritrange: f64;             "automatic range setting for position and intvelocity.";
1299        [ffi, ffi_mut] armature: f64;                 "armature inertia.";
1300        [ffi, ffi_mut] nsample: i32;                  "number of samples in history buffer.";
1301        [ffi, ffi_mut] interp: i32;                   "interpolation order (0=ZOH, 1=linear, 2=cubic).";
1302        [ffi, ffi_mut] delay: f64;                    "delay time in seconds; 0: no delay.";
1303        [ffi, ffi_mut] ctrlspec: i32;                 "input signature, scoped by gaintype; 0: type default.";
1304    ]);
1305
1306    getter_setter! {
1307        [&] with, get, set, [
1308            [ffi, ffi_mut] ctrllimited: MjtLimited [force];        "are control limits defined.";
1309            [ffi, ffi_mut] forcelimited: MjtLimited [force];       "are force limits defined.";
1310        ]
1311    }
1312
1313    getter_setter! {
1314        [&] with, get, set, [
1315            [ffi, ffi_mut] actlimited: MjtLimited [force];         "are activation limits defined.";
1316        ]
1317    }
1318
1319    getter_setter! {
1320        [&] with, get, set, [
1321            [ffi, ffi_mut] actearly: bool;                "apply next activations to qfrc.";
1322        ]
1323    }
1324
1325    userdata_method!(f64);
1326
1327    string_set_get_with! {[&]
1328        target;                 "name of transmission target.";
1329        refsite;                "reference site, for site transmission.";
1330        slidersite;             "site defining cylinder, for slider-crank.";
1331    }
1332}
1333
1334
1335/// Converts the string that MuJoCo's `mjs_setToX` actuator functions return into a [`Result`].
1336/// An empty string is success; anything else names the rejected parameter.
1337fn actuator_set_result(c_err_msg: *const c_char) -> Result<(), MjEditError> {
1338    // SAFETY: MuJoCo's error messages are always NUL terminated.
1339    let err_msg = unsafe { CStr::from_ptr(c_err_msg) }.to_string_lossy();
1340    if err_msg.is_empty() {
1341        Ok(())
1342    }
1343    else {
1344        Err(MjEditError::InvalidParameter(err_msg.into_owned()))
1345    }
1346}
1347
1348/* Actuator configuration structs. A `None` field reaches MuJoCo as a null pointer, which leaves
1349** the corresponding actuator parameter at its own default. */
1350
1351/// Configuration for [`MjsActuator::set_to_position`].
1352#[derive(Clone, Copy, Debug, Default, PartialEq)]
1353pub struct PositionConfig {
1354    /// Proportional (position) gain.
1355    pub kp: f64,
1356    /// Automatic range-inheritance factor (0 disables it).
1357    pub inheritrange: f64,
1358    /// Velocity feedback gain. Mutually exclusive with `dampratio`.
1359    pub kv: Option<f64>,
1360    /// Damping ratio. Mutually exclusive with `kv`.
1361    pub dampratio: Option<f64>,
1362    /// First-order activation-filter time constant.
1363    pub timeconst: Option<f64>,
1364}
1365
1366impl PositionConfig {
1367    getter_setter! {
1368        with, [
1369            kp: f64;            "the proportional (position) gain.";
1370            inheritrange: f64;  "the automatic range-inheritance factor.";
1371            kv: f64;            "the velocity feedback gain (mutually exclusive with dampratio).";
1372            dampratio: f64;     "the damping ratio (mutually exclusive with kv).";
1373            timeconst: f64;     "the first-order activation-filter time constant.";
1374        ]
1375    }
1376}
1377
1378/// Configuration for [`MjsActuator::set_to_int_velocity`]. Same parameters as [`PositionConfig`].
1379#[derive(Clone, Copy, Debug, Default, PartialEq)]
1380pub struct IntVelocityConfig {
1381    /// Proportional gain.
1382    pub kp: f64,
1383    /// Automatic range-inheritance factor (0 disables it).
1384    pub inheritrange: f64,
1385    /// Velocity feedback gain. Mutually exclusive with `dampratio`.
1386    pub kv: Option<f64>,
1387    /// Damping ratio. Mutually exclusive with `kv`.
1388    pub dampratio: Option<f64>,
1389    /// First-order activation-filter time constant.
1390    pub timeconst: Option<f64>,
1391}
1392
1393impl IntVelocityConfig {
1394    getter_setter! {
1395        with, [
1396            kp: f64;            "the proportional gain.";
1397            inheritrange: f64;  "the automatic range-inheritance factor.";
1398            kv: f64;            "the velocity feedback gain (mutually exclusive with dampratio).";
1399            dampratio: f64;     "the damping ratio (mutually exclusive with kv).";
1400            timeconst: f64;     "the first-order activation-filter time constant.";
1401        ]
1402    }
1403}
1404
1405/// Configuration for [`MjsActuator::set_to_dc_motor`].
1406///
1407/// Each optional field defaults to `None`, disabling the corresponding feature.
1408#[derive(Clone, Copy, Debug, Default, PartialEq)]
1409pub struct DcMotorConfig {
1410    /// Electrical resistance.
1411    pub resistance: f64,
1412    /// Input signature: a bitmask of
1413    /// [`MjtCtrlInput`](crate::wrappers::mj_model::MjtCtrlInput) values.
1414    pub ctrlspec: i32,
1415    /// Torque and back-EMF constants `[Kt, Ke]`.
1416    pub motorconst: Option<[f64; 2]>,
1417    /// Nominal ratings `[voltage, stall_torque, no_load_speed]`.
1418    pub nominal: Option<[f64; 3]>,
1419    /// Saturation `[tau_max, i_max, di_dt_max]`.
1420    pub saturation: Option<[f64; 3]>,
1421    /// Inductance `[L, te]`.
1422    pub inductance: Option<[f64; 2]>,
1423    /// Cogging `[amplitude, periodicity, phase]`.
1424    pub cogging: Option<[f64; 3]>,
1425    /// Controller `[kp, ki, kd, slewmax, Imax, v_max]`.
1426    pub controller: Option<[f64; 6]>,
1427    /// Thermal `[R_th, C, tau_th, alpha, T0, T_ambient]`.
1428    pub thermal: Option<[f64; 6]>,
1429    /// LuGre friction `[stiffness, damping, coulomb, static, stribeck]`.
1430    pub lugre: Option<[f64; 5]>,
1431}
1432
1433impl DcMotorConfig {
1434    getter_setter! {
1435        with, [
1436            resistance: f64;       "the electrical resistance.";
1437            ctrlspec: i32;         "the input signature bitmask ([`MjtCtrlInput`](crate::wrappers::mj_model::MjtCtrlInput)).";
1438            motorconst: [f64; 2];  "the torque and back-EMF constants [Kt, Ke].";
1439            nominal: [f64; 3];     "the nominal ratings [voltage, stall_torque, no_load_speed].";
1440            saturation: [f64; 3];  "the saturation [tau_max, i_max, di_dt_max].";
1441            inductance: [f64; 2];  "the inductance [L, te].";
1442            cogging: [f64; 3];     "the cogging [amplitude, periodicity, phase].";
1443            controller: [f64; 6];  "the controller [kp, ki, kd, slewmax, Imax, v_max].";
1444            thermal: [f64; 6];     "the thermal [R_th, C, tau_th, alpha, T0, T_ambient].";
1445            lugre: [f64; 5];       "the LuGre friction [stiffness, damping, coulomb, static, stribeck].";
1446        ]
1447    }
1448}
1449
1450/// Configuration for [`MjsActuator::set_to_pid`].
1451///
1452/// Each optional field defaults to `None`, disabling the corresponding feature.
1453#[derive(Clone, Copy, Debug, Default, PartialEq)]
1454pub struct PidConfig {
1455    /// Proportional (position) gain.
1456    pub kp: f64,
1457    /// Velocity feedback gain. Mutually exclusive with `dampratio`.
1458    pub kv: Option<f64>,
1459    /// Damping ratio. Mutually exclusive with `kv`.
1460    pub dampratio: Option<f64>,
1461    /// Integral gain on the position error.
1462    pub ki: Option<f64>,
1463    /// Anti-windup limit on the integral state.
1464    pub imax: Option<f64>,
1465    /// Slew rate limit of the position setpoint.
1466    pub slewmax: Option<f64>,
1467    /// Automatic range-inheritance factor for the position-setpoint range (0 disables it).
1468    pub inheritrange: f64,
1469    /// Input signature: a bitmask of
1470    /// [`MjtCtrlInput`](crate::wrappers::mj_model::MjtCtrlInput) values.
1471    pub ctrlspec: i32,
1472}
1473
1474impl PidConfig {
1475    getter_setter! {
1476        with, [
1477            kp: f64;            "the proportional (position) gain.";
1478            kv: f64;            "the velocity feedback gain (mutually exclusive with dampratio).";
1479            dampratio: f64;     "the damping ratio (mutually exclusive with kv).";
1480            ki: f64;            "the integral gain on the position error.";
1481            imax: f64;          "the anti-windup limit on the integral state.";
1482            slewmax: f64;       "the position-setpoint slew rate limit.";
1483            inheritrange: f64;  "the automatic range-inheritance factor.";
1484            ctrlspec: i32;      "the input signature bitmask ([`MjtCtrlInput`](crate::wrappers::mj_model::MjtCtrlInput)).";
1485        ]
1486    }
1487}
1488
1489/// Configuration for [`MjsActuator::set_to_orientation`].
1490///
1491/// Each optional field defaults to `None`, disabling the corresponding feature.
1492#[derive(Clone, Copy, Debug, Default, PartialEq)]
1493pub struct OrientationConfig {
1494    /// Proportional gain, in torque per radian of geodesic error.
1495    pub kp: f64,
1496    /// Damping, per force output. Mutually exclusive with `dampratio`.
1497    pub kv: Option<f64>,
1498    /// Damping ratio. Mutually exclusive with `kv`.
1499    pub dampratio: Option<f64>,
1500    /// Chart of the commanded orientation
1501    /// ([`MjtCtrlChart`](crate::wrappers::mj_model::MjtCtrlChart)).
1502    pub ctrlspec: i32,
1503}
1504
1505impl OrientationConfig {
1506    getter_setter! {
1507        with, [
1508            kp: f64;         "the proportional gain, in torque per radian of geodesic error.";
1509            kv: f64;         "the velocity feedback gain (mutually exclusive with dampratio).";
1510            dampratio: f64;  "the damping ratio (mutually exclusive with kv).";
1511            ctrlspec: i32;   "the chart of the commanded orientation ([`MjtCtrlChart`](crate::wrappers::mj_model::MjtCtrlChart)).";
1512        ]
1513    }
1514}
1515
1516impl MjsActuator {
1517    /// Configure the actuator to be a motor.
1518    pub fn set_to_motor(&mut self) {
1519        // mjs_setToMotor cannot fail; it always returns an empty string.
1520        unsafe { mjs_setToMotor(self.ffi_mut()) };
1521    }
1522
1523    /// Configure the actuator to be a positional-target motor (with a proportional regulator).
1524    /// # Errors
1525    /// Returns [`MjEditError::InvalidParameter`] when the configuration is rejected, e.g. `kv` and
1526    /// `dampratio` are both set, a value that must be non-negative is negative, or `inheritrange`
1527    /// is set together with a control range.
1528    pub fn set_to_position(&mut self, config: PositionConfig) -> Result<(), MjEditError> {
1529        let PositionConfig { kp, inheritrange, mut kv, mut dampratio, mut timeconst } = config;
1530        let c_err_msg = unsafe { mjs_setToPosition(
1531            self.ffi_mut(), kp,
1532            kv.as_mut().map_or(ptr::null_mut(), |x| x),
1533            dampratio.as_mut().map_or(ptr::null_mut(), |x| x),
1534            timeconst.as_mut().map_or(ptr::null_mut(), |x| x),
1535            inheritrange
1536        ) };
1537        actuator_set_result(c_err_msg)
1538    }
1539
1540    /// Configure the actuator to be an integrated-velocity servo. Behaves like
1541    /// [`MjsActuator::set_to_position`], but integrates the control signal into an activation
1542    /// variable.
1543    /// # Errors
1544    /// Returns [`MjEditError::InvalidParameter`] when `inheritrange` is set together with an
1545    /// activation range.
1546    pub fn set_to_int_velocity(&mut self, config: IntVelocityConfig) -> Result<(), MjEditError> {
1547        let IntVelocityConfig { kp, inheritrange, mut kv, mut dampratio, mut timeconst } = config;
1548        let c_err_msg = unsafe { mjs_setToIntVelocity(
1549            self.ffi_mut(), kp,
1550            kv.as_mut().map_or(ptr::null_mut(), |x| x),
1551            dampratio.as_mut().map_or(ptr::null_mut(), |x| x),
1552            timeconst.as_mut().map_or(ptr::null_mut(), |x| x),
1553            inheritrange
1554        ) };
1555        actuator_set_result(c_err_msg)
1556    }
1557
1558    /// Configure the actuator to be a velocity servo with velocity feedback gain `kv`.
1559    pub fn set_to_velocity(&mut self, kv: f64) {
1560        // mjs_setToVelocity cannot fail; it always returns an empty string.
1561        unsafe { mjs_setToVelocity(self.ffi_mut(), kv) };
1562    }
1563
1564    /// Configure the actuator to be a damper with damping coefficient `kv`. The applied force is
1565    /// proportional to velocity and modulated by the (non-negative) control input.
1566    /// # Errors
1567    /// Returns [`MjEditError::InvalidParameter`] when `kv` is negative or the control range is
1568    /// negative.
1569    pub fn set_to_damper(&mut self, kv: f64) -> Result<(), MjEditError> {
1570        actuator_set_result(unsafe { mjs_setToDamper(self.ffi_mut(), kv) })
1571    }
1572
1573    /// Configure the actuator to be a hydraulic or pneumatic cylinder. `timeconst` is the
1574    /// activation filter time constant, `bias` is added to the force, and the effective area is
1575    /// `area`; if `diameter` is non-negative the area is computed from it instead (pass a negative
1576    /// `diameter` to use `area` directly).
1577    pub fn set_to_cylinder(&mut self, timeconst: f64, bias: f64, area: f64, diameter: f64) {
1578        // mjs_setToCylinder cannot fail; it always returns an empty string.
1579        unsafe { mjs_setToCylinder(self.ffi_mut(), timeconst, bias, area, diameter) };
1580    }
1581
1582    /// Configure the actuator to be a muscle. `timeconst` holds the activation and deactivation
1583    /// time constants, `range` the operating-length range, and the remaining scalars the muscle
1584    /// force-length-velocity parameters. A negative value for any array entry or scalar (except
1585    /// `tausmooth`) leaves the corresponding muscle default in place.
1586    /// # Errors
1587    /// Returns [`MjEditError::InvalidParameter`] when `tausmooth` is negative.
1588    #[allow(clippy::too_many_arguments)]
1589    pub fn set_to_muscle(
1590        &mut self, mut timeconst: [f64; 2], tausmooth: f64, mut range: [f64; 2],
1591        force: f64, scale: f64, lmin: f64, lmax: f64, vmax: f64, fpmax: f64, fvmax: f64
1592    ) -> Result<(), MjEditError>
1593    {
1594        let c_err_msg = unsafe { mjs_setToMuscle(
1595            self.ffi_mut(), &mut timeconst, tausmooth, &mut range,
1596            force, scale, lmin, lmax, vmax, fpmax, fvmax
1597        ) };
1598        actuator_set_result(c_err_msg)
1599    }
1600
1601    /// Configure the actuator to be an active-adhesion actuator with the given `gain`.
1602    /// # Errors
1603    /// Returns [`MjEditError::InvalidParameter`] when `gain` is negative or the control range is
1604    /// negative.
1605    pub fn set_to_adhesion(&mut self, gain: f64) -> Result<(), MjEditError> {
1606        actuator_set_result(unsafe { mjs_setToAdhesion(self.ffi_mut(), gain) })
1607    }
1608
1609    /// Configure the actuator to be a DC motor.
1610    /// # Errors
1611    /// Returns [`MjEditError::InvalidParameter`] when MuJoCo cannot derive a positive motor
1612    /// constant or resistance, or when an inductance, thermal resistance, or thermal capacitance
1613    /// value is out of its allowed range.
1614    pub fn set_to_dc_motor(&mut self, config: DcMotorConfig) -> Result<(), MjEditError> {
1615        let DcMotorConfig {
1616            resistance, ctrlspec,
1617            mut motorconst, mut nominal, mut saturation, mut inductance,
1618            mut cogging, mut controller, mut thermal, mut lugre
1619        } = config;
1620        let c_err_msg = unsafe { mjs_setToDCMotor(
1621            self.ffi_mut(),
1622            motorconst.as_mut().map_or(ptr::null_mut(), |x| x),
1623            resistance,
1624            nominal.as_mut().map_or(ptr::null_mut(), |x| x),
1625            saturation.as_mut().map_or(ptr::null_mut(), |x| x),
1626            inductance.as_mut().map_or(ptr::null_mut(), |x| x),
1627            cogging.as_mut().map_or(ptr::null_mut(), |x| x),
1628            controller.as_mut().map_or(ptr::null_mut(), |x| x),
1629            thermal.as_mut().map_or(ptr::null_mut(), |x| x),
1630            lugre.as_mut().map_or(ptr::null_mut(), |x| x),
1631            ctrlspec
1632        ) };
1633        actuator_set_result(c_err_msg)
1634    }
1635
1636    /// Configure the actuator to be a PID controller on a single force output. The force is
1637    /// `kp * (u_pos - length) + kv * (u_vel - velocity) + ki * integral + ff`.
1638    /// # Errors
1639    /// Returns [`MjEditError::InvalidParameter`] when `kv` and `dampratio` are both set, when
1640    /// `kv`, `dampratio` or `slewmax` is negative, or when `inheritrange` is set together with a
1641    /// position-setpoint range.
1642    pub fn set_to_pid(&mut self, config: PidConfig) -> Result<(), MjEditError> {
1643        let PidConfig {
1644            kp, mut kv, mut dampratio, mut ki,
1645            mut imax, mut slewmax, inheritrange, ctrlspec
1646        } = config;
1647
1648        let c_err_msg = unsafe {
1649            mjs_setToPID(
1650                self.ffi_mut(),
1651                kp,
1652                kv.as_mut().map_or(ptr::null_mut(), |x| x),
1653                dampratio.as_mut().map_or(ptr::null_mut(), |x| x),
1654                ki.as_mut().map_or(ptr::null_mut(), |x| x),
1655                imax.as_mut().map_or(ptr::null_mut(), |x| x),
1656                slewmax.as_mut().map_or(ptr::null_mut(), |x| x),
1657                inheritrange, ctrlspec
1658            )
1659        };
1660        actuator_set_result(c_err_msg)
1661    }
1662
1663    /// Configure the actuator to be an orientation servo: a geodesic PD controller on a ball
1664    /// joint or a site with a reference site. The three force outputs carry the torque
1665    /// `kp * log(q^-1 * q_target) - kv * omega`, in the frame of the transmission target.
1666    /// # Errors
1667    /// Returns [`MjEditError::InvalidParameter`] when `kv` and `dampratio` are both set, or when
1668    /// `kv` or `dampratio` is negative.
1669    pub fn set_to_orientation(&mut self, config: OrientationConfig) -> Result<(), MjEditError> {
1670        let OrientationConfig {
1671            kp, mut kv, mut dampratio, ctrlspec
1672        } = config;
1673
1674        let c_err_msg = unsafe {
1675            mjs_setToOrientation(
1676                self.ffi_mut(),
1677                kp,
1678                kv.as_mut().map_or(ptr::null_mut(), |x| x),
1679                dampratio.as_mut().map_or(ptr::null_mut(), |x| x),
1680                ctrlspec
1681            )
1682        };
1683        actuator_set_result(c_err_msg)
1684    }
1685}
1686
1687/***************************
1688** Sensor specification
1689***************************/
1690mjs_struct!(Sensor with SpecObject: MjsSensor <= mjsSensor);
1691impl MjsSensor {
1692    getter_setter! {
1693        [&] with, get, [
1694            [ffi, ffi_mut] intprm: &[i32; mjNSENS as usize];            "integer parameters.";
1695            [ffi, ffi_mut] interval: &[f64; 2];                         "[period, time_prev] in seconds.";
1696        ]
1697    }
1698
1699    nested_handle!(plugin: MjsPluginReference; "sensor plugin.");
1700
1701    getter_setter!([&] with, get, set, [
1702        [ffi, ffi_mut] type_ + _: MjtSensor;          "sensor type.";
1703        [ffi, ffi_mut] objtype: MjtObj { check_objtype, "[`MjEditError::InvalidParameter`] when the object type is not a real object type (i.e. not below [`MjtObj::mjNOBJECT`])" } => MjEditError;
1704                                       "object type the sensor refers to.";
1705        [ffi, ffi_mut] reftype: MjtObj { check_objtype, "[`MjEditError::InvalidParameter`] when the reference type is not a real object type (i.e. not below [`MjtObj::mjNOBJECT`])" } => MjEditError;
1706                                       "type of referenced object.";
1707        [ffi, ffi_mut] datatype: MjtDataType;         "data type.";
1708        [ffi, ffi_mut] cutoff: f64;                   "cutoff for real and positive datatypes.";
1709        [ffi, ffi_mut] noise: f64;                    "noise stdev.";
1710        [ffi, ffi_mut] needstage: MjtStage;           "compute stage needed to simulate sensor.";
1711        [ffi, ffi_mut] dim: i32;                      "number of scalar outputs.";
1712        [ffi, ffi_mut] nsample: i32;                  "number of samples in history buffer.";
1713        [ffi, ffi_mut] interp: i32;                   "interpolation order (0=ZOH, 1=linear, 2=cubic).";
1714        [ffi, ffi_mut] delay: f64;                    "delay time in seconds; 0: no delay.";
1715    ]);
1716
1717    userdata_method!(f64);
1718
1719    string_set_get_with! {[&]
1720        refname; "name of referenced object.";
1721        objname; "name of sensorized object.";
1722    }
1723}
1724
1725/***************************
1726** Flex specification
1727***************************/
1728mjs_struct!(Flex with SpecObject: MjsFlex <= mjsFlex);
1729impl MjsFlex {
1730    getter_setter! {
1731        [&] with, get, [
1732            [ffi, ffi_mut] rgba: &[f32; 4];                                "rgba when material is omitted.";
1733            [ffi, ffi_mut] friction: &[f64; 3];                            "one-sided friction coefficients: slide, spin, roll.";
1734            [ffi, ffi_mut] solref: &[MjtNum; mjNREF as usize];             "solver reference.";
1735            [ffi, ffi_mut] solimp: &[MjtNum; mjNIMP as usize];             "solver impedance.";
1736            [ffi, ffi_mut] size: &[f64; 3];                                "vertex bounding box half sizes in qpos0.";
1737            [ffi, ffi_mut] cellcount: &[i32; 3];                           "grid cell count for finite cell method.";
1738        ]
1739    }
1740
1741    getter_setter! {
1742        [&] with, get, set, [
1743            [ffi, ffi_mut] young: f64;                    "Young's modulus, in units of pressure (force/area).";
1744            [ffi, ffi_mut] group: i32;                    "group.";
1745            [ffi, ffi_mut] contype: i32;                  "contact type.";
1746            [ffi, ffi_mut] conaffinity: i32;              "contact affinity.";
1747            [ffi, ffi_mut] condim: i32;                   "contact dimensionality.";
1748            [ffi, ffi_mut] priority: i32;                 "contact priority.";
1749            [ffi, ffi_mut] solmix: f64;                   "solver mixing for contact pairs.";
1750            [ffi, ffi_mut] margin: f64;                   "margin for contact detection.";
1751            [ffi, ffi_mut] gap: f64;                      "additional contact detection buffer.";
1752
1753            [ffi, ffi_mut] dim: i32;                "element dimensionality.";
1754            [ffi, ffi_mut] radius: f64;             "radius around primitive element.";
1755            [ffi, ffi_mut] activelayers: i32;       "number of active element layers in 3D.";
1756            [ffi, ffi_mut] edgestiffness: f64;      "edge stiffness.";
1757            [ffi, ffi_mut] edgedamping: f64;        "edge damping.";
1758            [ffi, ffi_mut] poisson: f64;            "Poisson's ratio.";
1759            [ffi, ffi_mut] damping: f64;            "Rayleigh's damping.";
1760            [ffi, ffi_mut] thickness: f64;          "thickness (2D only).";
1761            [ffi, ffi_mut] elastic2d: i32;          "2D passive forces; 0: none, 1: bending, 2: stretching, 3: both.";
1762            [ffi, ffi_mut] order: i32;              "interpolation order (1: trilinear, 2: quadratic).";
1763        ]
1764    }
1765
1766    getter_setter! {
1767        [&] with, get, set, [
1768            [ffi, ffi_mut] internal: bool;       "enable internal collisions.";
1769            [ffi, ffi_mut] flatskin: bool;       "render flex skin with flat shading.";
1770            [ffi, ffi_mut] passive: bool;        "mode for passive collisions.";
1771        ]        
1772    }
1773
1774    getter_setter! {
1775        [&] with, get, set, [
1776            [ffi, ffi_mut] selfcollide: MjtFlexSelf [force];        "mode for flex self collision.";
1777        ]
1778    }
1779
1780    string_set_get_with! {[&]
1781        material; "name of material used for rendering.";
1782    }
1783
1784    vec_string_set_append! {
1785        nodebody; "node body names.";
1786        vertbody; "vertex body names.";
1787    }
1788
1789    vec_set_get! {
1790        node: f64;      "node positions.";
1791        vert: f64;      "vertex positions.";
1792    }
1793
1794    vec_set! {
1795        texcoord: f32;          "vertex texture coordinates.";
1796        elem: i32;              "element vertex ids.";
1797    }
1798
1799    vec_set! {
1800        [unsafe: "The slice must have exactly `(dim + 1) * nelem` entries and every entry \
1801                  must be a valid index into the flex texture coordinates."
1802        ] elemtexcoord: i32 => i32; "element texture coordinates.";
1803    }
1804}
1805
1806/***************************
1807** Pair specification
1808***************************/
1809mjs_struct!(Pair with SpecObject: MjsPair <= mjsPair);
1810impl MjsPair {
1811    getter_setter! {
1812        [&] with, get, [
1813            [ffi, ffi_mut] friction: &[f64; 5];                            "contact friction: slide1, slide2, spin, roll1, roll2.";
1814            [ffi, ffi_mut] solref: &[MjtNum; mjNREF as usize];             "solver reference, normal direction.";
1815            [ffi, ffi_mut] solimp: &[MjtNum; mjNIMP as usize];             "solimp for the pair.";
1816            [ffi, ffi_mut] solreffriction: &[MjtNum; mjNREF as usize];     "solver reference, frictional directions.";
1817        ]
1818    }
1819
1820    getter_setter! {
1821        [&] with, get, set, [
1822            [ffi, ffi_mut] margin: f64;             "margin for contact detection.";
1823            [ffi, ffi_mut] gap: f64;         "additional contact detection buffer.";
1824            [ffi, ffi_mut] adhesion: f64;           "adhesive force of contacts.";
1825            [ffi, ffi_mut] condim: i32;                   "contact dimensionality.";
1826        ]
1827    }
1828
1829    string_set_get_with! {[&]
1830        geomname1; "name of geom 1.";
1831        geomname2; "name of geom 2.";
1832    }
1833}
1834
1835/***************************
1836** Exclude specification
1837***************************/
1838mjs_struct!(Exclude with SpecObject: MjsExclude <= mjsExclude);
1839impl MjsExclude {
1840    string_set_get_with! {[&]
1841        bodyname1; "name of body 1.";
1842        bodyname2; "name of body 2.";
1843    }
1844}
1845
1846/***************************
1847** Equality specification
1848***************************/
1849mjs_struct!(Equality with SpecObject: MjsEquality <= mjsEquality);
1850impl MjsEquality {
1851    getter_setter! {
1852        [&] with, get, [
1853            [ffi, ffi_mut] data: &[f64; mjNEQDATA as usize];   "data array for equality parameters.";
1854            [ffi, ffi_mut] solref: &[f64; mjNREF as usize];    "solver reference.";
1855            [ffi, ffi_mut] solimp: &[f64; mjNIMP as usize];    "solver impedance.";
1856        ]
1857    }
1858
1859    getter_setter! {[&] with, get, set, [
1860        [ffi, ffi_mut] active: bool;   "active flag.";
1861    ]}
1862
1863    getter_setter! {[&] with, get, set, [
1864        [ffi, ffi_mut] type_ + _: MjtEq;   "equality type.";
1865        [ffi, ffi_mut] objtype: MjtObj;    "type of both objects.";
1866    ]}
1867
1868    string_set_get_with! {[&]
1869        name1; "name of object 1";
1870        name2; "name of object 2";
1871    }
1872}
1873
1874/***************************
1875** Tendon specification
1876***************************/
1877mjs_struct!(Tendon with SpecObject: MjsTendon <= mjsTendon);
1878impl MjsTendon {
1879    getter_setter! {
1880        [&] with, get, [
1881            [ffi, ffi_mut] damping: &[f64; mjNPOLY as usize + 1];       "damping coefficients.";
1882            [ffi, ffi_mut] stiffness: &[f64; mjNPOLY as usize + 1];     "stiffness coefficients.";
1883            [ffi, ffi_mut] springlength: &[f64; 2];                    "spring length.";
1884            [ffi, ffi_mut] solref_friction: &[f64; mjNREF as usize];   "solver reference: tendon friction.";
1885            [ffi, ffi_mut] solimp_friction: &[f64; mjNIMP as usize];   "solver impedance: tendon friction.";
1886            [ffi, ffi_mut] range: &[f64; 2];                           "range.";
1887            [ffi, ffi_mut] actfrcrange: &[f64; 2];                     "actuator force limits.";
1888            [ffi, ffi_mut] solref_limit: &[f64; mjNREF as usize];      "solver reference: tendon limits.";
1889            [ffi, ffi_mut] solimp_limit: &[f64; mjNIMP as usize];      "solver impedance: tendon limits.";
1890            [ffi, ffi_mut] rgba: &[f32; 4];                            "rgba when material omitted.";
1891        ]
1892    }
1893
1894    getter_setter! {[&] with, get, set, [
1895        [ffi, ffi_mut] group: i32;         "group.";
1896        [ffi, ffi_mut] frictionloss: f64;  "friction loss.";
1897        [ffi, ffi_mut] armature: f64;      "inertia associated with tendon velocity.";
1898        [ffi, ffi_mut] margin: f64;        "margin value for tendon limit detection.";
1899        [ffi, ffi_mut] width: f64;         "width for rendering.";
1900    ]}
1901
1902    getter_setter! {
1903        [&] with, get, set, [
1904            [ffi, ffi_mut] limited: MjtLimited [force];       "does tendon have limits (mjtLimited).";
1905            [ffi, ffi_mut] actfrclimited: MjtLimited [force]; "does tendon have actuator force limits."
1906        ]
1907    }
1908
1909    userdata_method!(f64);
1910    string_set_get_with! {[&]
1911        material; "name of material for rendering.";
1912    }
1913
1914    /// Wrap a site corresponding to `name`, using the tendon.
1915    ///
1916    /// # Panics
1917    /// When the `name` contains '\0' characters.
1918    #[allow(deprecated)]
1919    pub fn wrap_site(&mut self, name: &str) -> &mut MjsWrap {
1920        self.try_wrap_site(name).expect("failed to wrap site")
1921    }
1922
1923    /// Fallible version of [`MjsTendon::wrap_site`].
1924    ///
1925    /// # Note
1926    /// MuJoCo ends the process when the allocation fails, so this never returns `Err`.
1927    ///
1928    /// # Errors
1929    /// Returns [`MjEditError::AllocationFailed`] if MuJoCo returns a null
1930    /// pointer.
1931    ///
1932    /// # Panics
1933    /// When the `name` contains '\0' characters.
1934    #[deprecated(
1935        since = "5.0.0",
1936        note = "always returns Ok; use `wrap_site`"
1937    )]
1938    pub fn try_wrap_site(&mut self, name: &str) -> Result<&mut MjsWrap, MjEditError> {
1939        let cname = CString::new(name).unwrap();
1940        let wrap_ptr = unsafe { mjs_wrapSite(self.ffi_mut(), cname.as_ptr()) };
1941        unsafe { MjsWrap::from_ffi_ptr_mut(wrap_ptr) }.ok_or(MjEditError::AllocationFailed)
1942    }
1943
1944    /// Wrap a geom corresponding to `name`, using the tendon.
1945    ///
1946    /// # Panics
1947    /// When `name` or `sidesite` contain '\0' characters.
1948    #[allow(deprecated)]
1949    pub fn wrap_geom(&mut self, name: &str, sidesite: &str) -> &mut MjsWrap {
1950        self.try_wrap_geom(name, sidesite).expect("failed to wrap geom")
1951    }
1952
1953    /// Fallible version of [`MjsTendon::wrap_geom`].
1954    ///
1955    /// # Note
1956    /// MuJoCo ends the process when the allocation fails, so this never returns `Err`.
1957    ///
1958    /// # Errors
1959    /// Returns [`MjEditError::AllocationFailed`] if MuJoCo returns a null
1960    /// pointer.
1961    ///
1962    /// # Panics
1963    /// When `name` or `sidesite` contain '\0' characters.
1964    #[deprecated(
1965        since = "5.0.0",
1966        note = "always returns Ok; use `wrap_geom`"
1967    )]
1968    pub fn try_wrap_geom(&mut self, name: &str, sidesite: &str) -> Result<&mut MjsWrap, MjEditError> {
1969        let cname = CString::new(name).unwrap();
1970        let csidesite = CString::new(sidesite).unwrap();
1971        let wrap_ptr = unsafe { mjs_wrapGeom(
1972            self.ffi_mut(),
1973            cname.as_ptr(), csidesite.as_ptr()
1974        ) };
1975        unsafe { MjsWrap::from_ffi_ptr_mut(wrap_ptr) }.ok_or(MjEditError::AllocationFailed)
1976    }
1977
1978    /// Wrap a joint corresponding to `name`, using the tendon.
1979    ///
1980    /// # Panics
1981    /// When `name` contains '\0' characters.
1982    #[allow(deprecated)]
1983    pub fn wrap_joint(&mut self, name: &str, coef: f64) -> &mut MjsWrap {
1984        self.try_wrap_joint(name, coef).expect("failed to wrap joint")
1985    }
1986
1987    /// Fallible version of [`MjsTendon::wrap_joint`].
1988    ///
1989    /// # Note
1990    /// MuJoCo ends the process when the allocation fails, so this never returns `Err`.
1991    ///
1992    /// # Errors
1993    /// Returns [`MjEditError::AllocationFailed`] if MuJoCo returns a null
1994    /// pointer.
1995    ///
1996    /// # Panics
1997    /// When `name` contains '\0' characters.
1998    #[deprecated(
1999        since = "5.0.0",
2000        note = "always returns Ok; use `wrap_joint`"
2001    )]
2002    pub fn try_wrap_joint(&mut self, name: &str, coef: f64) -> Result<&mut MjsWrap, MjEditError> {
2003        let cname = CString::new(name).unwrap();
2004        let wrap_ptr = unsafe { mjs_wrapJoint(self.ffi_mut(), cname.as_ptr(), coef) };
2005        unsafe { MjsWrap::from_ffi_ptr_mut(wrap_ptr) }.ok_or(MjEditError::AllocationFailed)
2006    }
2007
2008    /// Wrap a pulley using the tendon.
2009    #[allow(deprecated)]
2010    pub fn wrap_pulley(&mut self, divisor: f64) -> &mut MjsWrap {
2011        self.try_wrap_pulley(divisor).expect("failed to wrap pulley")
2012    }
2013
2014    /// Fallible version of [`MjsTendon::wrap_pulley`].
2015    ///
2016    /// # Note
2017    /// MuJoCo ends the process when the allocation fails, so this never returns `Err`.
2018    ///
2019    /// # Errors
2020    /// Returns [`MjEditError::AllocationFailed`] if MuJoCo returns a null
2021    /// pointer.
2022    #[deprecated(
2023        since = "5.0.0",
2024        note = "always returns Ok; use `wrap_pulley`"
2025    )]
2026    pub fn try_wrap_pulley(&mut self, divisor: f64) -> Result<&mut MjsWrap, MjEditError> {
2027        let wrap_ptr = unsafe { mjs_wrapPulley(self.ffi_mut(), divisor) };
2028        unsafe { MjsWrap::from_ffi_ptr_mut(wrap_ptr) }.ok_or(MjEditError::AllocationFailed)
2029    }
2030
2031    /// Return the number of wrap objects.
2032    pub fn wrap_num(&self) -> usize {
2033        unsafe { mjs_getWrapNum(self.ffi()) as usize }
2034    }
2035
2036    /// Return an indexed wrap object.
2037    ///
2038    /// # Panics
2039    /// Panics if `i >= wrap_num()`. Use [`MjsTendon::try_wrap`] for a fallible alternative.
2040    pub fn wrap(&self, i: usize) -> &MjsWrap {
2041        self.try_wrap(i).unwrap()
2042    }
2043
2044    /// Fallible version of [`MjsTendon::wrap`].
2045    ///
2046    /// # Errors
2047    /// Returns [`MjEditError::IndexOutOfBounds`] if `i >= wrap_num()`.
2048    pub fn try_wrap(&self, i: usize) -> Result<&MjsWrap, MjEditError> {
2049        let len = self.wrap_num();
2050        // mjs_getWrap ends the process via mju_error for an out-of-range index.
2051        if i >= len {
2052            return Err(MjEditError::IndexOutOfBounds { id: i, len });
2053        }
2054        let ptr = unsafe { mjs_getWrap(self.ffi(), i as i32) };
2055        // SAFETY: index validated above; mjs_getWrap returns a non-null pointer for in-range indices.
2056        Ok(unsafe { MjsWrap::from_ffi_ptr(ptr) }.unwrap())
2057    }
2058
2059    /// Return a mutable indexed wrap object.
2060    ///
2061    /// # Panics
2062    /// Panics if `i >= wrap_num()`. Use [`MjsTendon::try_wrap_mut`] for a fallible alternative.
2063    pub fn wrap_mut(&mut self, i: usize) -> &mut MjsWrap {
2064        self.try_wrap_mut(i).unwrap()
2065    }
2066
2067    /// Fallible version of [`MjsTendon::wrap_mut`].
2068    ///
2069    /// # Errors
2070    /// Returns [`MjEditError::IndexOutOfBounds`] if `i >= wrap_num()`.
2071    pub fn try_wrap_mut(&mut self, i: usize) -> Result<&mut MjsWrap, MjEditError> {
2072        let len = self.wrap_num();
2073        if i >= len {
2074            return Err(MjEditError::IndexOutOfBounds { id: i, len });
2075        }
2076        let ptr = unsafe { mjs_getWrap(self.ffi(), i as i32) };
2077        // SAFETY: see try_wrap().
2078        Ok(unsafe { MjsWrap::from_ffi_ptr_mut(ptr) }.unwrap())
2079    }
2080}
2081
2082/***************************
2083** Wrap specification
2084***************************/
2085mjs_struct!(MjsWrap <= mjsWrap {
2086    /// A wrap carries no name of its own; [`SpecItem::name`] reports the wrapped object's name.
2087    ///
2088    /// # Errors
2089    /// Always returns [`MjEditError::UnsupportedOperation`].
2090    fn set_name(&mut self, _name: &str) -> Result<(), MjEditError> {
2091        // mjCWrap leaves elemtype at mjOBJ_UNKNOWN, and mjs_setName hands that to
2092        // mjCModel::CheckRepeat, which indexes object_lists_ and dereferences its null slot 0.
2093        Err(MjEditError::UnsupportedOperation)
2094    }
2095
2096    /// A wrap carries no name of its own.
2097    ///
2098    /// # Panics
2099    /// Always panics.
2100    fn with_name(&mut self, _name: &str) -> &mut Self {
2101        panic!("a wrap carries no name of its own")
2102    }
2103});
2104impl MjsWrap {
2105    getter_setter! {
2106        [&] with, get, set, [
2107            [ffi, ffi_mut] type_ + _: MjtWrap; "wrap type.";
2108        ]
2109    }
2110
2111    /// Return the side site element. Returns `None` when the wrap is not a sphere or cylinder
2112    /// wrap, when it holds no side site, or when the named site is missing from the spec (MuJoCo
2113    /// logs a warning in that last case).
2114    ///
2115    /// There is no mutable counterpart, because it would alias. Edit the site through
2116    /// [`MjSpec::site_mut`] instead.
2117    pub fn side_site(&self) -> Option<&MjsSite> {
2118        let ptr = unsafe { mjs_getWrapSideSite(self.ffi()) };
2119        unsafe { MjsSite::from_ffi_ptr(ptr) }
2120    }
2121
2122    /// Return the wrap divisor. For a wrap whose type is not [`MjtWrap::mjWRAP_PULLEY`], MuJoCo
2123    /// logs a warning and this returns 1.0.
2124    pub fn divisor(&self) -> f64 {
2125        unsafe { mjs_getWrapDivisor(self.ffi()) }
2126    }
2127
2128    /// Return the wrap coefficient. For a wrap whose type is not [`MjtWrap::mjWRAP_JOINT`],
2129    /// MuJoCo logs a warning and this returns 1.0.
2130    pub fn coef(&self) -> f64 {
2131        unsafe { mjs_getWrapCoef(self.ffi()) }
2132    }
2133}
2134
2135/***************************
2136** Numeric specification
2137***************************/
2138mjs_struct!(Numeric with SpecObject: MjsNumeric <= mjsNumeric);
2139impl MjsNumeric {
2140    getter_setter! {
2141        [&] with, get, set, [
2142            [ffi, ffi_mut] size: i32 { check_numeric_size, "[`MjEditError::InvalidParameter`] when the size is negative" } => MjEditError;     "size of the numeric array.";
2143        ]
2144    }
2145
2146    vec_set_get! {
2147        data: f64; "initialization data.";
2148    }
2149}
2150
2151/***************************
2152** Text specification
2153***************************/
2154mjs_struct!(Text with SpecObject: MjsText <= mjsText);
2155impl MjsText {
2156    string_set_get_with! {[&]
2157        data; "text string.";
2158    }
2159}
2160
2161/***************************
2162** Tuple specification
2163***************************/
2164mjs_struct!(Tuple with SpecObject: MjsTuple <= mjsTuple);
2165impl MjsTuple {
2166    vec_set! {
2167        // `compile()` indexes `object_lists_` (size `mjNOBJECT`) with each value and no bounds
2168        // check, so the per-element check is what makes this setter safe.
2169        objtype: MjtObj => i32 { check_objtype, "[`MjEditError::InvalidParameter`] when any value is not a real object type (i.e. not below [`MjtObj::mjNOBJECT`])" } => MjEditError;
2170            "object types. Every value must be a real object type (an `MjtObj` below `mjNOBJECT`).";
2171    }
2172
2173    vec_string_set_append! {
2174        objname; "object names.";
2175    }
2176
2177    vec_set_get! {
2178        objprm: f64; "object parameters.";
2179    }
2180}
2181
2182/***************************
2183** Key specification
2184***************************/
2185mjs_struct!(Key with SpecObject: MjsKey <= mjsKey);
2186impl MjsKey {
2187    getter_setter! {
2188        [&] with, get, set, [
2189            [ffi, ffi_mut] time: f64; "time."
2190        ]
2191    }
2192
2193    vec_set_get! {
2194        qpos: f64; "qpos.";
2195        qvel: f64; "qvel.";
2196        act: f64; "act.";
2197        mpos: f64; "mocap pos.";
2198        mquat: f64; "mocap quat.";
2199        ctrl: f64; "ctrl.";
2200    }
2201}
2202
2203/***************************
2204** Plugin specification
2205***************************/
2206mjs_struct!(Plugin with SpecObject: MjsPlugin <= mjsPlugin);
2207
2208mjs_opaque!(MjsPluginReference <= mjsPlugin,
2209    "Reference to the plugin instance that an element embeds.\n\n\
2210     A body, geom, mesh, actuator or sensor names the instance it uses through this reference. \
2211     The reference carries no element of its own: MuJoCo resolves the `element` field to the \
2212     [`MjsPlugin`] instance that the name selects. Reach the instance itself through \
2213     [`MjSpec::plugin`].");
2214
2215impl MjsPluginReference {
2216    string_set_get_with! {[&]
2217        name; "instance name.";
2218        plugin_name; "plugin name.";
2219    }
2220
2221    getter_setter! {
2222        [&] with, get, set, [
2223            [ffi, ffi_mut] active: bool; "is the plugin active.";
2224        ]
2225    }
2226}
2227
2228impl MjsPlugin {
2229    string_set_get_with! {[&]
2230        name; "instance name.";
2231        plugin_name; "plugin name.";
2232    }
2233
2234    getter_setter! {
2235        [&] with, get, set, [
2236            [ffi, ffi_mut] active: bool; "is the plugin active.";
2237        ]
2238    }
2239}
2240
2241/* Assets */
2242
2243/***************************
2244** Mesh specification
2245***************************/
2246mjs_struct!(Mesh with SpecObject: MjsMesh <= mjsMesh);
2247impl MjsMesh {
2248    getter_setter! {
2249        [&] with, get, [
2250            [ffi, ffi_mut] refpos: &[f64; 3];            "reference position.";
2251            [ffi, ffi_mut] refquat: &[f64; 4];           "reference orientation.";
2252            [ffi, ffi_mut] scale: &[f64; 3];             "scale vector.";
2253        ]
2254    }
2255
2256    nested_handle!(plugin: MjsPluginReference; "sdf plugin.");
2257
2258    getter_setter! {
2259        [&] with, get, set, [
2260            [ffi, ffi_mut] inertia: MjtMeshInertia;      "inertia type (convex, legacy, exact, shell).";
2261            [ffi, ffi_mut] maxhullvert: i32;             "maximum vertex count for the convex hull.";
2262            [ffi, ffi_mut] octree_maxdepth: i32;         "max octree depth.";
2263        ]
2264    }
2265
2266    getter_setter! {
2267        [&] with, get,set, [
2268            [ffi, ffi_mut] smoothnormal: bool;           "do not exclude large-angle faces from normals.";
2269            [ffi, ffi_mut] needsdf: bool;                "compute sdf from mesh.";
2270        ]
2271    }
2272
2273    string_set_get_with! {[&]
2274        content_type; "content type of file.";
2275        file; "mesh file.";
2276        material; "name of material.";
2277    }
2278
2279    vec_set! {
2280        uservert: f32;               "user vertex data.";
2281        usernormal: f32;             "user normal data.";
2282        usertexcoord: f32;           "user texcoord data.";
2283        userface: i32;               "user vertex indices.";
2284    }
2285
2286    vec_set! {
2287        [unsafe: "Every entry must be in `0..N`, where `N` is the number of user normals: the \
2288                  length of the slice passed to `set_usernormal` divided by 3 (each normal is 3 \
2289                  `f32`: x, y, z)."]
2290            userfacenormal: i32 => i32; "user face normal indices.";
2291        [unsafe: "Every entry must be in `0..ntexcoord` (the number of user texture coordinates), and \
2292                  the slice length must equal the length of the slice passed to `set_userface` (3 per \
2293                  face). Unlike face-normal data, MuJoCo does not validate the texcoord-index length, \
2294                  so an oversized slice overflows the model's face-texcoord buffer at compile time."]
2295            userfacetexcoord: i32 => i32; "user texcoord indices.";
2296    }
2297}
2298
2299/***************************
2300** Hfield specification
2301***************************/
2302mjs_struct!(HField with SpecObject: MjsHfield <= mjsHField);
2303impl MjsHfield {
2304    getter_setter! {
2305        [&] with, get, [
2306            [ffi, ffi_mut] size: &[f64; 4];              "size of the hfield.";
2307        ]
2308    }
2309
2310    getter_setter! { [&] with, get, set, [
2311        [ffi, ffi_mut] nrow: i32;  "number of rows.";
2312        [ffi, ffi_mut] ncol: i32;  "number of columns.";
2313    ]}
2314
2315    string_set_get_with! {[&]
2316        content_type; "content type of file.";
2317        file; "file: (nrow, ncol, [elevation data]).";
2318    }
2319
2320    /// Sets `userdata`.
2321    pub fn set_userdata<T: AsRef<[f32]>>(&mut self, userdata: T) {
2322        // SAFETY: self.userdata is a valid mjFloatVec pointer for the lifetime of self.
2323        unsafe { write_mjs_vec_f32(userdata.as_ref(), self.ffi().userdata) };
2324    }
2325}
2326
2327/***************************
2328** Skin specification
2329***************************/
2330mjs_struct!(Skin with SpecObject: MjsSkin <= mjsSkin);
2331impl MjsSkin {
2332    getter_setter! {
2333        [&] with, get, [
2334            [ffi, ffi_mut] rgba: &[f32; 4];    "rgba when material is omitted.";
2335        ]
2336    }
2337
2338    getter_setter! {
2339        [&] with, get, set, [
2340            [ffi, ffi_mut] inflate: f32;       "inflate in normal direction.";
2341            [ffi, ffi_mut] group: i32;         "group for visualization.";
2342        ]
2343    }
2344
2345    string_set_get_with! {[&]
2346        material;               "name of material used for rendering.";
2347        file;                   "skin file.";
2348    }
2349
2350    vec_string_set_append! {
2351        bodyname;               "body names.";
2352    }
2353
2354    vec_set! {
2355        vert: f32;              "vertex positions.";
2356        texcoord: f32;          "texture coordinates.";
2357        bindpos: f32;           "bind pos.";
2358        bindquat: f32;          "bind quat.";
2359    }
2360
2361    vec_set! {
2362        [
2363            unsafe:
2364                "The slice length must be a multiple of 3 and every entry must be in `0..nvert`  (the number of skin vertices)."
2365        ] face: i32 => i32; "faces.";
2366    }
2367
2368    vec_vec_append! {
2369        vertid: i32;                     "vertex ids.";
2370        vertweight: f32;                 "vertex weights.";
2371    }
2372}
2373
2374/***************************
2375** Texture specification
2376***************************/
2377mjs_struct!(Texture with SpecObject: MjsTexture <= mjsTexture);
2378
2379/// # Note: cube-map files
2380///
2381/// `cubefiles` is a pre-sized string vector of 6 entries, one per cube face. Assign one face with
2382/// [`set_cubefile`](Self::set_cubefile); [`set_cubefiles`](Self::set_cubefiles) and
2383/// [`append_cubefiles`](Self::append_cubefiles) replace or extend the vector as a whole.
2384impl MjsTexture {
2385    getter_setter! {
2386        [&] with, get, [
2387            [ffi, ffi_mut] rgb1: &[f64; 3];               "first color for builtin.";
2388            [ffi, ffi_mut] rgb2: &[f64; 3];               "second color for builtin.";
2389            [ffi, ffi_mut] markrgb: &[f64; 3];            "mark color.";
2390            [ffi, ffi_mut] gridsize: &[i32; 2];           "size of grid for composite file; (1,1)-repeat.";
2391            [ffi, ffi_mut] gridlayout: &[c_char; 12];     "row-major: L,R,F,B,U,D for faces; . for unused.";
2392        ]
2393    }
2394
2395    getter_setter! {
2396        [&] with, get, set, [
2397            [ffi, ffi_mut] random: f64;                  "probability of random dots.";
2398            [ffi, ffi_mut] width: i32;                   "image width.";
2399            [ffi, ffi_mut] height: i32;                  "image height.";
2400        ]
2401    }
2402
2403    getter_setter! {
2404        [&] with, get, set, [
2405            // A builtin pattern needs `nchannel >= 3`; `MjSpec::compile` enforces that.
2406            [ffi, ffi_mut] nchannel: i32; "number of channels.";
2407        ]
2408    }
2409
2410    getter_setter! {
2411        [&] with, get, set, [
2412            [ffi, ffi_mut] type_ + _: MjtTexture [force];        "texture type.";
2413            [ffi, ffi_mut] colorspace: MjtColorSpace [force];    "colorspace.";
2414            [ffi, ffi_mut] builtin: MjtBuiltin [force];          "builtin type.";
2415            [ffi, ffi_mut] mark: MjtMark [force];                "mark type.";
2416        ]
2417    }
2418
2419    vec_string_set_append! {
2420        cubefiles[MjtCubeFace] => cubefile; "different file for each side of the cube.";
2421    }
2422
2423    getter_setter! {[&] with, get, set, [
2424        [ffi, ffi_mut] hflip: bool;    "horizontal flip.";
2425        [ffi, ffi_mut] vflip: bool;    "vertical flip.";
2426    ]}
2427
2428    /// Sets texture `data`.
2429    pub fn set_data<T: bytemuck::NoUninit>(&mut self, data: &[T]) {
2430        // SAFETY: self.data is a valid mjByteVec pointer for the lifetime of self.
2431        unsafe { write_mjs_vec_byte(data, self.ffi().data) };
2432    }
2433
2434    string_set_get_with! {[&]
2435        file; "png file to load; use for all sides of cube.";
2436        content_type; "content type of file.";
2437    }
2438}
2439
2440/***************************
2441** Material specification
2442***************************/
2443mjs_struct!(Material with SpecObject: MjsMaterial <= mjsMaterial);
2444
2445/// # Note: texture assignment
2446///
2447/// `textures` is a pre-sized string vector of `mjNTEXROLE` entries, one per [`MjtTextureRole`].
2448/// Assign one role with [`set_texture`](Self::set_texture); [`set_textures`](Self::set_textures)
2449/// and [`append_textures`](Self::append_textures) replace or extend the vector as a whole and
2450/// break the pre-sized layout.
2451impl MjsMaterial {
2452    getter_setter! {
2453        [&] with, get, [
2454            [ffi, ffi_mut] rgba: &[f32; 4];                               "rgba color.";
2455            [ffi, ffi_mut] texrepeat: &[f32; 2];    "texture repetition for 2D mapping.";
2456        ]
2457    }
2458
2459    getter_setter! {[&] with, get, set, [
2460        [ffi, ffi_mut] texuniform: bool;       "make texture cube uniform.";
2461    ]}
2462
2463    getter_setter! {
2464        [&] with, get, set, [
2465            [ffi, ffi_mut] emission: f32;                           "emission.";
2466            [ffi, ffi_mut] specular: f32;                           "specular.";
2467            [ffi, ffi_mut] shininess: f32;                         "shininess.";
2468            [ffi, ffi_mut] reflectance: f32;                     "reflectance.";
2469            [ffi, ffi_mut] metallic: f32;                           "metallic.";
2470            [ffi, ffi_mut] roughness: f32;                         "roughness.";
2471        ]
2472    }
2473
2474    vec_string_set_append! {
2475        textures[MjtTextureRole] => texture; "names of textures (empty: none).";
2476    }
2477}
2478
2479
2480/***************************
2481** Body specification
2482***************************/
2483mjs_struct!(Body with SpecObject: MjsBody <= mjsBody);
2484
2485impl MjsBody {
2486    add_x_method! { body, site, joint, geom, camera, light }
2487
2488    /// Obtain an immutable reference to a body with the given `name` in this body's subtree.
2489    /// The search is recursive and returns this body when its own name matches.
2490    ///
2491    /// # Panics
2492    /// When the `name` contains '\0' characters, a panic occurs.
2493    pub fn child(&self, name: &str) -> Option<&MjsBody> {
2494        let c_name = CString::new(name).unwrap();
2495        unsafe {
2496            let ptr = mjs_findChild(self.ffi(), c_name.as_ptr());
2497            MjsBody::from_ffi_ptr(ptr)
2498        }
2499    }
2500
2501    /// Obtain a mutable reference to a body with the given `name` in this body's subtree.
2502    /// The search is recursive and returns this body when its own name matches.
2503    ///
2504    /// # Panics
2505    /// When the `name` contains '\0' characters, a panic occurs.
2506    ///
2507    /// # Examples
2508    /// ```
2509    /// # use mujoco_rs::prelude::*;
2510    /// let mut spec = MjSpec::new();
2511    /// spec.world_body_mut().add_body().with_name("ball");
2512    /// spec.world_body_mut().child_mut("ball").unwrap().set_gravcomp(1.0);
2513    /// ```
2514    pub fn child_mut(&mut self, name: &str) -> Option<&mut MjsBody> {
2515        let c_name = CString::new(name).unwrap();
2516        unsafe {
2517            let ptr = mjs_findChild(self.ffi(), c_name.as_ptr());
2518            MjsBody::from_ffi_ptr_mut(ptr)
2519        }
2520    }
2521    // Special case
2522    /// Add and return a child frame.
2523    ///
2524    /// # Note
2525    /// MuJoCo ends the process when the allocation fails.
2526    #[expect(deprecated, reason = "try_add_frame keeps the implementation until it is removed")]
2527    pub fn add_frame(&mut self) -> &mut MjsFrame {
2528        self.try_add_frame().expect("mjs_addFrame returned null; allocation failed")
2529    }
2530
2531    /// Fallible version of [`Self::add_frame`].
2532    ///
2533    /// # Note
2534    /// MuJoCo ends the process when the allocation fails, so this never returns `Err`.
2535    ///
2536    /// # Errors
2537    /// Returns [`MjEditError::AllocationFailed`] when MuJoCo fails to allocate
2538    /// the frame, instead of panicking.
2539    #[deprecated(
2540        since = "6.0.0",
2541        note = "always returns Ok; use `add_frame`"
2542    )]
2543    pub fn try_add_frame(&mut self) -> Result<&mut MjsFrame, MjEditError> {
2544        // SAFETY: a null parentframe attaches the frame directly to the body. The frame that
2545        // mjs_addFrame returns is freshly allocated, so nothing aliases it.
2546        let ptr = unsafe { mjs_addFrame(self.ffi_mut(), ptr::null_mut()) };
2547        unsafe { MjsFrame::from_ffi_ptr_mut(ptr) }.ok_or(MjEditError::AllocationFailed)
2548    }
2549
2550    /// Add and return a child `<freejoint/>` element, which is a [`MjsJoint`] of type
2551    /// [`MjtJoint::mjJNT_FREE`]. Unlike [`Self::add_joint`], the returned joint does not
2552    /// inherit the class (`<default>`) configuration. Wraps [`mjs_addFreeJoint`].
2553    ///
2554    /// # Note
2555    /// MuJoCo ends the process when the allocation fails.
2556    pub fn add_free_joint(&mut self) -> &mut MjsJoint {
2557        // SAFETY: This function cannot fail unless the memory runs out, which aborts the process.
2558        let ptr = unsafe { mjs_addFreeJoint(self.ffi_mut()) };
2559        unsafe { MjsJoint::from_ffi_ptr_mut(ptr) }
2560            .expect("mjs_addFreeJoint returned null; allocation failed")
2561    }
2562}
2563
2564/* ----------------------------------------------------------------------------
2565** Procedural flex creation (mjs_makeFlex)
2566** ------------------------------------------------------------------------- */
2567
2568/// Configuration for [`MjsBody::add_flexcomp`], mirroring the `flexcomp` element.
2569///
2570/// An unset array, string or VFS field reaches MuJoCo as null, so the compiler applies its own
2571/// default. `dim` is 2 and `radius` is 0.005; every other scalar is zero, which MuJoCo also
2572/// reads as its own default.
2573///
2574/// # Example
2575/// ```
2576/// # use mujoco_rs::prelude::*;
2577/// let mut spec = MjSpec::new();
2578///
2579/// // Configure a 3x3 cloth-like 2D grid flex.
2580/// let config = MjFlexcompConfig::default()
2581///     .with_type("grid")
2582///     .with_dim(2)
2583///     .with_count([3, 3, 1])
2584///     .with_spacing([0.1, 0.1, 0.1])
2585///     .with_mass(1.0);
2586///
2587/// let flex = spec.world_body_mut().add_flexcomp("cloth", &config);
2588/// assert_eq!(flex.dim(), 2);
2589///
2590/// // The configured flex is now part of the model and it compiles.
2591/// spec.compile().unwrap();
2592/// ```
2593#[derive(Debug, Clone)]
2594pub struct MjFlexcompConfig<'a> {
2595    /// Flexcomp type: "grid", "box", "cylinder", "ellipsoid", "square", "disc", "circle", "mesh",
2596    /// "gmsh" or "direct". MuJoCo falls back to "grid" for `None` and for any other string.
2597    pub r#type: Option<&'a str>,
2598    /// Dimensionality of the flex object (1, 2, or 3); ignored for types that imply it.
2599    /// Defaults to MuJoCo's default (2).
2600    pub dim: u8,
2601    /// Dof parametrization: "full", "radial", "trilinear", "quadratic", or "2d" (default "full").
2602    pub dof: Option<&'a str>,
2603    /// Number of generated points in each dimension (grid/box/cylinder/ellipsoid).
2604    pub count: Option<[u16; 3]>,
2605    /// Number of interpolation-grid cells in each dimension (trilinear/quadratic dofs).
2606    pub cellcount: Option<[u16; 3]>,
2607    /// Spacing between generated points in each dimension.
2608    pub spacing: Option<[f64; 3]>,
2609    /// Scaling of all point coordinates (applied after the pose transformation).
2610    pub scale: Option<[f64; 3]>,
2611    /// Radius of the flex elements. Defaults to MuJoCo's default (0.005).
2612    pub radius: f64,
2613    /// Total mass, divided evenly over the generated points. A value of 0 keeps MuJoCo's default.
2614    pub mass: f64,
2615    /// Equivalent-inertia box size used to set each body's rotational inertia. A value of 0 keeps
2616    /// MuJoCo's default.
2617    pub inertiabox: f64,
2618    /// Edge equality constraint: 0 none, 1 edge, 2 vertex, 3 strain.
2619    pub equality: u8,
2620    /// Whether all points are vertices in the parent body (no new bodies created).
2621    pub rigid: bool,
2622    /// Whether to render the flex skin with flat shading. MuJoCo forces this on for the "box" and
2623    /// "cylinder" types and for a 3D "grid".
2624    pub flatskin: bool,
2625    /// 2D passive force mode: 0 none, 1 bending, 2 stretching, 3 both.
2626    pub elastic2d: u8,
2627    /// Translation of all points relative to the parent body frame.
2628    pub pos: Option<[f64; 3]>,
2629    /// Quaternion rotation of all points around the position offset.
2630    pub quat: Option<[f64; 4]>,
2631    /// Flexcomp origin used to build a volumetric mesh from a surface mesh.
2632    pub origin: Option<[f64; 3]>,
2633    /// File to load the surface or volumetric mesh from.
2634    pub file: Option<&'a str>,
2635    /// Virtual file system used to resolve the mesh file.
2636    pub vfs: Option<&'a MjVfs>,
2637}
2638
2639// `mjs_makeFlex` applies `dim` and `radius` unconditionally, so both must carry MuJoCo's own
2640// defaults (2 and 0.005) rather than zero.
2641impl Default for MjFlexcompConfig<'_> {
2642    fn default() -> Self {
2643        Self {
2644            r#type: None,
2645            dim: 2,
2646            dof: None,
2647            count: None,
2648            cellcount: None,
2649            spacing: None,
2650            scale: None,
2651            radius: 0.005,
2652            mass: 0.0,
2653            inertiabox: 0.0,
2654            equality: 0,
2655            rigid: false,
2656            flatskin: false,
2657            elastic2d: 0,
2658            pos: None,
2659            quat: None,
2660            origin: None,
2661            file: None,
2662            vfs: None,
2663        }
2664    }
2665}
2666
2667impl<'a> MjFlexcompConfig<'a> {
2668    getter_setter! {
2669        with, [
2670            r#type: &'a str;      "the flexcomp type: \"grid\", \"box\", \"cylinder\", \"ellipsoid\", \"square\", \"disc\", \"circle\", \"mesh\", \"gmsh\", or \"direct\" (default \"grid\").";
2671            dim: u8;              "the dimensionality of the flex object (1, 2, or 3); ignored for types that imply it.";
2672            dof: &'a str;         "the dof parametrization: \"full\", \"radial\", \"trilinear\", \"quadratic\", or \"2d\" (default \"full\").";
2673            count: [u16; 3];      "the number of generated points in each dimension (grid/box/cylinder/ellipsoid).";
2674            cellcount: [u16; 3];  "the number of interpolation-grid cells in each dimension (trilinear/quadratic dofs).";
2675            spacing: [f64; 3];    "the spacing between generated points in each dimension.";
2676            scale: [f64; 3];      "the scaling of all point coordinates (applied after the pose transformation).";
2677            radius: f64;          "the radius of the flex elements.";
2678            mass: f64;            "the total mass, divided evenly over the generated points.";
2679            inertiabox: f64;      "the equivalent-inertia box size used to set each body's rotational inertia.";
2680            equality: u8;         "the edge equality constraint: 0 none, 1 edge, 2 vertex, 3 strain.";
2681            rigid: bool;          "whether all points are vertices in the parent body (no new bodies created).";
2682            flatskin: bool;       "render flex skin with flat shading.";
2683            elastic2d: u8;        "the 2D passive force mode: 0 none, 1 bending, 2 stretching, 3 both.";
2684            pos: [f64; 3];        "the translation of all points relative to the parent body frame.";
2685            quat: [f64; 4];       "the quaternion rotation of all points around the position offset.";
2686            origin: [f64; 3];     "the flexcomp origin used to build a volumetric mesh from a surface mesh.";
2687            file: &'a str;        "the file to load the surface or volumetric mesh from.";
2688            vfs: &'a MjVfs;       "the virtual file system used to resolve the mesh file.";
2689        ]
2690    }
2691}
2692
2693impl MjsBody {
2694    /// Add and return a child [`MjsFlex`].
2695    ///
2696    /// Creates a flex with auto-generated bodies, joints, and optional equality constraints, the
2697    /// programmatic equivalent of the `flexcomp` element, configured via
2698    /// [`MjFlexcompConfig`]. Wraps [`mjs_makeFlex`].
2699    ///
2700    /// # Panics
2701    /// Panics if MuJoCo fails to create the flex, or if `name` or any string in
2702    /// `config` contains an interior NUL byte.
2703    pub fn add_flexcomp(&mut self, name: &str, config: &MjFlexcompConfig) -> &mut MjsFlex {
2704        self.try_add_flexcomp(name, config).expect("mjs_makeFlex returned null")
2705    }
2706
2707    /// Fallible version of [`Self::add_flexcomp`]. Wraps [`mjs_makeFlex`].
2708    ///
2709    /// # Errors
2710    /// Returns [`MjEditError::AllocationFailed`] when MuJoCo fails to create the
2711    /// flex (returns null).
2712    ///
2713    /// # Panics
2714    /// Panics if `name` or any string in `config` contains an interior NUL byte.
2715    pub fn try_add_flexcomp(&mut self, name: &str, config: &MjFlexcompConfig)
2716        -> Result<&mut MjsFlex, MjEditError>
2717    {
2718        // Owned C strings must outlive the FFI call below.
2719        let c_name = CString::new(name).unwrap();
2720        let c_type = config.r#type.map(|s| CString::new(s).unwrap());
2721        let c_dof = config.dof.map(|s| CString::new(s).unwrap());
2722        let c_file = config.file.map(|s| CString::new(s).unwrap());
2723
2724        // Widen the small count types to C ints; the locals must outlive the FFI call.
2725        let count = config.count.map(|c| c.map(|v| v as c_int));
2726        let cellcount = config.cellcount.map(|c| c.map(|v| v as c_int));
2727        let count_ptr = count.as_ref().map_or(ptr::null(), |a| a as *const [c_int; 3]);
2728        let cellcount_ptr = cellcount.as_ref().map_or(ptr::null(), |a| a as *const [c_int; 3]);
2729        let spacing_ptr = config.spacing.as_ref().map_or(ptr::null(), |a| a as *const [f64; 3]);
2730        let scale_ptr = config.scale.as_ref().map_or(ptr::null(), |a| a as *const [f64; 3]);
2731        let pos_ptr = config.pos.as_ref().map_or(ptr::null(), |a| a as *const [f64; 3]);
2732        let quat_ptr = config.quat.as_ref().map_or(ptr::null(), |a| a as *const [f64; 4]);
2733        let origin_ptr = config.origin.as_ref().map_or(ptr::null(), |a| a as *const [f64; 3]);
2734
2735        // SAFETY: every pointer below is either null, which mjs_makeFlex reads as "use the
2736        // default", or borrows a local that outlives the call. MuJoCo retains no pointer of its own.
2737        let ptr = unsafe {
2738            mjs_makeFlex(
2739                self.ffi_mut(),
2740                c_name.as_ptr(),
2741                c_type.as_ref().map_or(ptr::null(), |c| c.as_ptr()),
2742                config.dim as c_int,
2743                c_dof.as_ref().map_or(ptr::null(), |c| c.as_ptr()),
2744                count_ptr,
2745                cellcount_ptr,
2746                spacing_ptr,
2747                scale_ptr,
2748                config.radius,
2749                config.mass,
2750                config.inertiabox,
2751                config.equality as c_int,
2752                config.rigid as c_int,
2753                config.flatskin as c_int,
2754                config.elastic2d as c_int,
2755                pos_ptr,
2756                quat_ptr,
2757                origin_ptr,
2758                c_file.as_ref().map_or(ptr::null(), |c| c.as_ptr()),
2759                config.vfs.map_or(ptr::null(), |v| v.ffi() as *const mjVFS),
2760            )
2761        };
2762        unsafe { MjsFlex::from_ffi_ptr_mut(ptr) }.ok_or(MjEditError::AllocationFailed)
2763    }
2764}
2765
2766impl MjsBody {
2767    getter_setter! {
2768        [&] with, get, [
2769            // body frame
2770            [ffi, ffi_mut] pos: &[f64; 3];                   "frame position.";
2771            [ffi, ffi_mut] quat: &[f64; 4];                  "frame orientation.";
2772            [ffi, ffi_mut] alt: &MjsOrientation;             "frame alternative orientation.";
2773
2774            //inertial frame
2775            [ffi, ffi_mut] ipos: &[f64; 3];                  "inertial frame position.";
2776            [ffi, ffi_mut] iquat: &[f64; 4];                 "inertial frame orientation.";
2777            [ffi, ffi_mut] inertia: &[f64; 3];               "diagonal inertia (in i-frame).";
2778            [ffi, ffi_mut] ialt: &MjsOrientation;            "inertial frame alternative orientation.";
2779            [ffi, ffi_mut] fullinertia: &[f64; 6];           "non-axis-aligned inertia matrix.";
2780        ]
2781    }
2782
2783    nested_handle!(plugin: MjsPluginReference; "passive force plugin.");
2784
2785    getter_setter! {
2786        [&] with, get, set, [
2787            [ffi, ffi_mut] mass: f64;                     "mass.";
2788            [ffi, ffi_mut] gravcomp: f64;                 "gravity compensation.";
2789            [ffi, ffi_mut] sleep: MjtSleepPolicy;           "sleep policy.";
2790        ]
2791    }
2792
2793    getter_setter! {
2794        [&] with, get, set, [
2795            [ffi, ffi_mut] mocap: bool;                   "whether this is a mocap body.";
2796            [ffi, ffi_mut] explicitinertial: bool;        "whether to save the body with explicit inertial clause.";
2797            [ffi, ffi_mut] simple: bool;                  "simple body optimization (false: disabled, true: auto).";
2798        ]
2799    }
2800
2801    userdata_method!(f64);
2802}
2803
2804/// Mutable iterator over items in [`MjsBody`].
2805#[derive(Debug)]
2806pub struct MjsBodyItemIterMut<'a, T> {
2807    /// Raw pointer to the body; a borrow would alias the handles that the iterator yields.
2808    ffi_ptr: *mut mjsBody,
2809    /// Element that the last `next` yielded. Null marks the end of the iteration.
2810    last: *mut mjsElement,
2811    recurse: bool,
2812    item_type: PhantomData<&'a mut T>
2813}
2814
2815impl<'a, T: SpecObject> MjsBodyItemIterMut<'a, T> {
2816    fn new(root: &'a mut MjsBody, recurse: bool) -> Self {
2817        // SAFETY: the iterator walks the children of the body and never exchanges its contents.
2818        let ffi_ptr = unsafe { root.ffi_mut() } as *mut mjsBody;
2819        let last = unsafe { mjs_firstChild(ffi_ptr, T::OBJ_TYPE, recurse.into()) };
2820        Self { ffi_ptr, last, recurse, item_type: PhantomData }
2821    }
2822}
2823
2824impl<'a, T: SpecObject + 'a> Iterator for MjsBodyItemIterMut<'a, T> {
2825    type Item = &'a mut T;
2826
2827    fn next(&mut self) -> Option<Self::Item> {
2828        if self.last.is_null() {
2829            return None;
2830        }
2831
2832        unsafe {
2833            let out = T::from_element_as_ptr_mut(self.last).as_mut();
2834            self.last = mjs_nextChild(self.ffi_ptr, self.last, self.recurse.into());
2835            out
2836        }
2837    }
2838}
2839
2840impl<'a, T: SpecObject + 'a> std::iter::FusedIterator for MjsBodyItemIterMut<'a, T> {}
2841
2842/// Immutable iterator over items in [`MjsBody`].
2843#[derive(Debug, Clone)]
2844pub struct MjsBodyItemIter<'a, T> {
2845    ffi_ptr: *const mjsBody,
2846    /// Element that the last `next` yielded. Null marks the end of the iteration.
2847    last: *const mjsElement,
2848    recurse: bool,
2849    item_type: PhantomData<&'a T>
2850}
2851
2852
2853impl<'a, T: SpecObject> MjsBodyItemIter<'a, T> {
2854    fn new(root: &'a MjsBody, recurse: bool) -> Self {
2855        let ffi_ptr = root.ffi() as *const mjsBody;
2856        // SAFETY: mjs_firstChild takes a *const mjsBody; the borrow of root keeps the body
2857        // alive for the call.
2858        let last = unsafe {
2859            mjs_firstChild(
2860                ffi_ptr,
2861                T::OBJ_TYPE,
2862                recurse.into()
2863            )
2864        };
2865        Self { ffi_ptr, last, recurse, item_type: PhantomData }
2866    }
2867}
2868
2869impl<'a, T: SpecObject + 'a> Iterator for MjsBodyItemIter<'a, T> {
2870    type Item = &'a T;
2871
2872    fn next(&mut self) -> Option<Self::Item> {
2873        if self.last.is_null() {
2874            return None;
2875        }
2876        unsafe {
2877            let out = T::from_element_as_ptr_mut(self.last as *mut _).as_ref();
2878            // SAFETY: mjs_nextChild takes *const pointers; ffi_ptr and last stay valid while
2879            // the iterator borrows the body.
2880            self.last = mjs_nextChild(self.ffi_ptr, self.last, self.recurse.into());
2881            out
2882        }
2883    }
2884}
2885
2886impl<'a, T: SpecObject + 'a> std::iter::FusedIterator for MjsBodyItemIter<'a, T> {}
2887
2888/// Iterator methods.
2889impl MjsBody {
2890    body_get_iter! {[joint, geom, site, camera, light, frame] }
2891    body_get_iter! { direct_children_mut: [body] }
2892}
2893
2894/******************************
2895** Tests
2896******************************/
2897#[cfg(test)]
2898mod tests {
2899    use std::io::Write;
2900    use std::path::{Path, PathBuf};
2901    use std::fs;
2902
2903    use super::*;
2904
2905    const MODEL: &str = "\
2906<mujoco>
2907  <worldbody>
2908    <light ambient=\"0.2 0.2 0.2\"/>
2909    <body name=\"ball\" pos=\".2 .2 .1\">
2910        <geom name=\"green_sphere\" size=\".1\" rgba=\"0 1 0 1\" solref=\"0.004 1.0\"/>
2911        <joint name=\"ball\" type=\"free\"/>
2912    </body>
2913    <geom name=\"floor1\" type=\"plane\" size=\"10 10 1\" solref=\"0.004 1.0\"/>
2914  </worldbody>
2915</mujoco>";
2916
2917    #[test]
2918    fn test_activate_plugin() {
2919        use crate::wrappers::mj_plugin::load_all_plugin_libraries;
2920        const PLUGIN: &str = "mujoco.elasticity.cable";
2921
2922        let mut spec = MjSpec::new();
2923        assert_eq!(spec.activate_plugin("not.a.plugin"), Err(MjEditError::NotFound));
2924
2925        let Ok(lib_dir) = std::env::var("MUJOCO_DYNAMIC_LINK_DIR") else { return };
2926        let plugin_dir = Path::new(&lib_dir).parent().unwrap().join("bin/mujoco_plugin");
2927        load_all_plugin_libraries(&plugin_dir, None).unwrap();
2928
2929        spec.activate_plugin(PLUGIN).unwrap();
2930        spec.compile().unwrap();
2931
2932        let xml = spec.save_xml_string(4096).unwrap();
2933        assert!(xml.contains(&format!("<plugin plugin=\"{PLUGIN}\"/>")), "{xml}");
2934    }
2935
2936    #[test]
2937    fn test_spec_authored_accessor() {
2938        use crate::mujoco_c::{mjtDisableBit, mjtEnableBit};
2939
2940        // A model that explicitly authors option flags: disables the contact flag,
2941        // enables the energy flag, and disables actuator group 3.
2942        const AUTHORED: &str = "\
2943<mujoco>
2944  <option actuatorgroupdisable=\"3\">
2945    <flag contact=\"disable\" energy=\"enable\"/>
2946  </option>
2947  <worldbody>
2948    <light ambient=\"0.2 0.2 0.2\"/>
2949    <body name=\"ball\" pos=\".2 .2 .1\">
2950        <geom name=\"green_sphere\" size=\".1\" rgba=\"0 1 0 1\"/>
2951        <joint name=\"ball\" type=\"free\"/>
2952    </body>
2953  </worldbody>
2954</mujoco>";
2955
2956        let contact = mjtDisableBit::mjDSBL_CONTACT as i32;
2957        let gravity = mjtDisableBit::mjDSBL_GRAVITY as i32;
2958        let energy = mjtEnableBit::mjENBL_ENERGY as i32;
2959
2960        let spec = MjSpec::from_xml_string(AUTHORED).unwrap();
2961        let authored = spec.authored();
2962
2963        // Flags that were explicitly set must be marked authored at the matching bit...
2964        assert_eq!(authored.disableflags & contact, contact, "contact disable must be authored");
2965        assert_eq!(authored.enableflags & energy, energy, "energy enable must be authored");
2966        assert_eq!(authored.disableactuator & (1 << 3), 1 << 3, "actuator group 3 must be authored");
2967        // ...while flags that were never touched must stay unauthored.
2968        assert_eq!(authored.disableflags & gravity, 0, "gravity was never authored");
2969
2970        // A model that authors none of these leaves the bitmasks clear.
2971        let plain = MjSpec::from_xml_string(MODEL).unwrap();
2972        let plain_authored = plain.authored();
2973        assert_eq!(plain_authored.disableflags, 0);
2974        assert_eq!(plain_authored.enableflags, 0);
2975        assert_eq!(plain_authored.disableactuator, 0);
2976    }
2977
2978    #[test]
2979    fn test_parse_xml_string() {
2980        assert!(MjSpec::from_xml_string(MODEL).is_ok(), "failed to parse the model");
2981    }
2982
2983    #[test]
2984    fn test_parse_xml_file() {
2985        const PATH: &str = "./mj_spec_test_parse_xml_file.xml";
2986        let mut file = fs::File::create(PATH).expect("file creation failed");
2987        file.write_all(MODEL.as_bytes()).expect("unable to write to file");
2988        file.flush().unwrap();
2989
2990        let spec = MjSpec::from_xml(PATH);
2991        fs::remove_file(PATH).expect("file removal failed");
2992        assert!(spec.is_ok(), "failed to parse the model");
2993    }
2994
2995    #[test]
2996    fn test_parse_xml_vfs() {
2997        const PATH: &str = "./mj_spec_test_parse_xml_vfs.xml";
2998        let mut vfs = MjVfs::new();
2999        vfs.add_from_buffer(PATH, MODEL.as_bytes()).unwrap();
3000        assert!(MjSpec::from_xml_vfs(PATH, &vfs).is_ok(), "failed to parse the model");
3001    }
3002
3003    #[test]
3004    fn test_basic_edit_compile() {
3005        const TIMESTEP: f64 = 0.010;
3006        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3007        spec.option_mut().timestep = TIMESTEP;  // change time step to 10 ms.
3008
3009        let compiled = spec.compile().expect("could not compile the model");
3010        assert_eq!(compiled.opt().timestep, TIMESTEP);
3011
3012        spec.compile().unwrap();
3013    }
3014
3015    /// A spec whose only asset is a fake heightfield (`N_ROW_COLUMN` for width, height and data)
3016    /// and a VFS that holds that heightfield.
3017    fn heightfield_spec_and_vfs() -> (MjSpec, MjVfs) {
3018        const HEIGHTMAP_NAME: &str = "test_height.bin";
3019        const N_ROW_COLUMN: u32 = 10;
3020
3021        let mut heightfield_bytes = vec![N_ROW_COLUMN.to_ne_bytes(), N_ROW_COLUMN.to_ne_bytes()];
3022        heightfield_bytes.extend_from_slice(&[[0; 4]; (N_ROW_COLUMN * N_ROW_COLUMN) as usize]);
3023
3024        let mut vfs = MjVfs::new();
3025        vfs.add_from_buffer(HEIGHTMAP_NAME, heightfield_bytes.as_flattened())
3026            .expect("failed to add heightfield to VFS");
3027
3028        let mut spec = MjSpec::new();
3029        spec.add_hfield()
3030            .with_file(HEIGHTMAP_NAME)
3031            .with_name(HEIGHTMAP_NAME)
3032            .with_size([1000.0, 1000.0, 5.0, 5.0]);
3033
3034        spec.world_body_mut().add_geom()
3035            .with_type(MjtGeom::mjGEOM_HFIELD)
3036            .with_hfieldname(HEIGHTMAP_NAME);
3037
3038        (spec, vfs)
3039    }
3040
3041    #[test]
3042    fn test_compile_vfs() {
3043        let (mut spec, vfs) = heightfield_spec_and_vfs();
3044
3045        // The file should fail loading as it doesn't exist in local directory.
3046        assert!(matches!(
3047            spec.compile().unwrap_err(),
3048            MjEditError::CompileFailed(e) if e.starts_with("Error: Error opening file")
3049        ));
3050
3051        // The file should exist in the VFS.
3052        spec.compile_with_vfs(&vfs).expect("compilation with vfs failed");
3053    }
3054
3055    #[test]
3056    fn test_encode_xml() {
3057        const PATH_XML: &str = "mj_spec_test_encode_xml.xml";
3058
3059        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3060        let model = spec.compile().expect("could not compile the model");
3061
3062        spec.encode(PATH_XML, "text/xml").expect("XML encoding failed");
3063        let mut spec_xml = MjSpec::from_xml(PATH_XML).expect("XML parsing failed");
3064        std::fs::remove_file(PATH_XML).unwrap();
3065        assert!(model.is_compatible_with_model(&spec_xml.compile().unwrap()));
3066
3067        assert!(matches!(spec.encode("mj_spec_test_encode_xml.txt", ""), Err(MjEditError::SaveFailed(_))));
3068        assert!(matches!(spec.encode("/nonexistent/dir/model.xml", ""), Err(MjEditError::SaveFailed(_))));
3069    }
3070
3071    #[test]
3072    fn test_encode_mjz_vfs() {
3073        const PATH_MJZ: &str = "mj_spec_test_encode_mjz_vfs.mjz";
3074        const PATH_MJZ_NO_ASSETS: &str = "mj_spec_test_encode_mjz_no_assets.mjz";
3075
3076        let (mut spec, vfs) = heightfield_spec_and_vfs();
3077        let model = spec.compile_with_vfs(&vfs).expect("compilation with vfs failed");
3078
3079        // The heightfield, located in the VFS, is written into the ZIP alongside the model. 
3080        spec.encode_with_vfs(PATH_MJZ, "application/zip", &vfs).expect("MJZ encoding failed");
3081        // The heightfield, located in the VFS, is skipped from being written into the ZIP.
3082        // Only the model is stored.
3083        spec.encode(PATH_MJZ_NO_ASSETS, "application/zip").expect("MJZ encoding failed");
3084
3085
3086        let vfs_mjz = MjVfs::new();
3087        let vfs_no_assets = MjVfs::new();
3088
3089        // Assets that were stored in the ZIP get written into the VFS.
3090        let mut spec_mjz = MjSpec::from_parse_vfs(PATH_MJZ, "application/zip", &vfs_mjz)
3091            .expect("MJZ parsing failed");
3092        // No assets are part of the zip. MjSpec loads only the model, without any assets added to the VFS.
3093        let mut spec_no_assets = MjSpec::from_parse_vfs(PATH_MJZ_NO_ASSETS, "application/zip", &vfs_no_assets)
3094            .expect("MJZ parsing failed");
3095
3096        std::fs::remove_file(PATH_MJZ).unwrap();
3097        std::fs::remove_file(PATH_MJZ_NO_ASSETS).unwrap();
3098
3099        // Read both from their VFS. Only the first has the heightfield in the VFS.
3100        assert!(model.is_compatible_with_model(&spec_mjz.compile_with_vfs(&vfs_mjz).unwrap()));
3101        assert!(matches!(
3102            spec_no_assets.compile_with_vfs(&vfs_no_assets),
3103            Err(MjEditError::CompileFailed(_))
3104        ));
3105    }
3106
3107    #[test]
3108    fn test_model_name() {
3109        const DEFAULT_MODEL_NAME: &str = "MuJoCo Model";
3110        const NEW_MODEL_NAME: &str = "Test model";
3111
3112        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3113
3114        /* Test read */
3115        assert_eq!(spec.modelname(), DEFAULT_MODEL_NAME);
3116        /* Test write */
3117        spec.set_modelname(NEW_MODEL_NAME);
3118        assert_eq!(spec.modelname(), NEW_MODEL_NAME);
3119
3120        spec.compile().unwrap();
3121    }
3122
3123    #[test]
3124    fn test_item_name() {
3125        const NEW_MODEL_NAME: &str = "Test model";
3126
3127        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3128        let world = spec.world_body_mut();
3129        let body = world.add_body();
3130        assert_eq!(body.name(), "");
3131        body.set_name(NEW_MODEL_NAME).unwrap();
3132        assert_eq!(body.name(), NEW_MODEL_NAME);
3133
3134        spec.compile().unwrap();
3135    }
3136
3137    #[test]
3138    fn test_body_remove() {
3139        const NEW_MODEL_NAME: &str = "Test model";
3140
3141        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3142        let world = spec.world_body_mut();
3143        let body = world.add_body();
3144        body.set_name(NEW_MODEL_NAME).unwrap();
3145
3146        /* Test normal body deletion */
3147        assert!(
3148            unsafe { spec.body_mut(NEW_MODEL_NAME).unwrap().delete() }.is_ok(),
3149            "failed to delete model"
3150        );
3151        assert!(spec.body(NEW_MODEL_NAME).is_none(), "body was not removed from spec");
3152
3153        /* Test world body deletion */
3154        let world = unsafe { spec.world_body_mut().delete() };
3155        assert!(world.is_err(), "the world model should not be deletable");
3156
3157        spec.compile().unwrap();
3158    }
3159
3160    #[test]
3161    fn test_joint_remove() {
3162        const NEW_NAME: &str = "Test model";
3163
3164        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3165        let world = spec.world_body_mut();
3166        let joint = world.add_joint();
3167        joint.set_name(NEW_NAME).unwrap();
3168
3169        /* Test normal body deletion */
3170        assert!(
3171            unsafe { spec.joint_mut(NEW_NAME).unwrap().delete() }.is_ok(),
3172            "failed to delete model"
3173        );
3174        assert!(spec.joint(NEW_NAME).is_none(), "body was not removed fom spec");
3175
3176        spec.compile().unwrap();
3177    }
3178
3179    #[test]
3180    fn test_add_free_joint() {
3181        const ARMATURE: f64 = 0.25;
3182
3183        let mut spec = MjSpec::new();
3184        spec.default_mut("main").unwrap().joint_mut().set_armature(ARMATURE);
3185
3186        let free_body = spec.world_body_mut().add_body();
3187        free_body.add_geom().with_size([0.010; 3]);
3188        let free_joint = free_body.add_free_joint();
3189        assert_eq!(free_joint.type_(), MjtJoint::mjJNT_FREE);
3190        assert_eq!(free_joint.armature(), 0.0, "the free joint must not inherit the default class");
3191
3192        let hinge_body = spec.world_body_mut().add_body();
3193        hinge_body.add_geom().with_size([0.010; 3]);
3194        assert_eq!(hinge_body.add_joint().armature(), ARMATURE);
3195
3196        let model = spec.compile().unwrap();
3197        // 7 qpos of the free joint and 1 qpos of the hinge joint.
3198        assert_eq!(model.nq(), 8);
3199        assert_eq!(model.dof_armature()[..6], [0.0; 6]);
3200        assert_eq!(model.dof_armature()[6], ARMATURE);
3201    }
3202
3203    #[test]
3204    fn test_add_with_class() {
3205        const MARGIN: f64 = 0.5;
3206        const GROUP: i32 = 2;
3207
3208        let mut spec = MjSpec::new();
3209        let class = spec.add_default("cls", None);
3210        class.geom_mut().set_margin(MARGIN);
3211        class.actuator_mut().set_group(GROUP);
3212
3213        let body = spec.world_body_mut().add_body();
3214        body.add_joint().with_name("hinge");
3215        assert_eq!(body.add_geom_with_class("cls").unwrap().with_size([0.010; 3]).margin(), MARGIN);
3216        assert_eq!(
3217            body.add_frame().add_geom_with_class("cls").unwrap().with_size([0.010; 3]).margin(),
3218            MARGIN
3219        );
3220
3221        assert_eq!(body.add_geom().with_size([0.010; 3]).margin(), 0.0);
3222        assert!(matches!(body.add_geom_with_class("nope"), Err(MjEditError::NotFound)));
3223
3224        let actuator = spec.add_actuator_with_class("cls").unwrap().with_trntype(MjtTrn::mjTRN_JOINT);
3225        assert_eq!(actuator.group(), GROUP);
3226        actuator.set_target("hinge");
3227
3228        let model = spec.compile().unwrap();
3229        assert_eq!(model.geom_margin(), [MARGIN, MARGIN, 0.0]);
3230        assert_eq!(model.actuator_group(), [GROUP]);
3231    }
3232
3233    #[test]
3234    fn test_default_class_of_parent() {
3235        const MARGIN: f64 = 0.5;
3236
3237        let mut spec = MjSpec::new();
3238        spec.add_default("cls", None).geom_mut().set_margin(MARGIN);
3239
3240        let body = spec.world_body_mut().add_body();
3241        body.set_default("cls").unwrap();
3242        assert_eq!(body.add_geom().with_size([0.010; 3]).margin(), MARGIN);
3243        assert_eq!(body.add_body().add_geom().with_size([0.010; 3]).margin(), MARGIN);
3244
3245        // The frame carries the class, yet its geom lands on the parent body, which carries none.
3246        let frame = spec.world_body_mut().add_body().add_frame();
3247        frame.set_default("cls").unwrap();
3248        assert!(frame.default().is_some());
3249        assert_eq!(frame.add_geom().with_size([0.010; 3]).margin(), 0.0);
3250    }
3251
3252    #[test]
3253    fn test_hfield_remove() {
3254        const NEW_NAME: &str = "Test hfield";
3255
3256        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3257        let hfield = spec.add_hfield();
3258        hfield.set_name(NEW_NAME).unwrap();
3259
3260        /* Test normal hfield deletion */
3261        let hfield = spec.hfield_mut(NEW_NAME).expect("failed to obtain the hfield");
3262        assert!(unsafe { hfield.delete() }.is_ok(), "failed to delete hfield");
3263        assert!(spec.hfield(NEW_NAME).is_none(), "hfield was not removed from spec");
3264
3265        spec.compile().unwrap();
3266    }
3267
3268    #[test]
3269    fn test_body_userdata() {
3270        const NEW_USERDATA: [f64; 3] = [1.0, 2.0, 3.0];
3271
3272        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3273        let world = spec.world_body_mut();
3274
3275        assert_eq!(world.userdata(), []);
3276
3277        world.set_userdata(NEW_USERDATA);
3278        assert_eq!(world.userdata(), NEW_USERDATA);
3279
3280        spec.compile().unwrap();
3281    }
3282
3283    #[test]
3284    fn test_body_attrs() {
3285        const TEST_VALUE_F64: f64 = 5.25;
3286
3287        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3288        let world = spec.world_body_mut();
3289
3290        world.set_gravcomp(TEST_VALUE_F64);
3291        assert_eq!(world.gravcomp(), TEST_VALUE_F64);
3292
3293        world.pos_mut()[0] = TEST_VALUE_F64;
3294        assert_eq!(world.pos()[0], TEST_VALUE_F64);
3295
3296        spec.compile().unwrap();
3297    }
3298
3299    #[test]
3300    fn test_default() {
3301        const DEFAULT_NAME: &str = "floor";
3302        const NOT_DEFAULT_NAME: &str = "floor-not";
3303
3304        let mut spec = MjSpec::from_xml_string(MODEL).expect("unable to load the spec");
3305
3306        /* Test search */
3307        spec.add_default(DEFAULT_NAME, None);
3308
3309        /* Test delete */
3310        assert!(spec.default(DEFAULT_NAME).is_some());
3311        assert!(spec.default(NOT_DEFAULT_NAME).is_none());
3312
3313        let world = spec.world_body_mut();
3314        let some_body = world.add_body();
3315        some_body.add_joint().with_name("test");
3316        some_body.add_geom().with_size([0.010, 0.0, 0.0]);
3317
3318        let actuator = spec.add_actuator()
3319            .with_trntype(MjtTrn::mjTRN_JOINT);
3320        actuator.set_target("test");
3321        
3322        assert!(actuator.set_default(DEFAULT_NAME).is_ok());
3323
3324        spec.compile().unwrap();
3325    }
3326
3327    #[test]
3328    fn test_actuator_set_to() {
3329        let mut spec = MjSpec::new();
3330        let body = spec.world_body_mut().add_body();
3331        body.add_geom().with_size([0.01, 0.0, 0.0]);
3332        body.add_joint().with_name("hinge").with_type(MjtJoint::mjJNT_HINGE);
3333
3334        let actuator = spec.add_actuator().with_trntype(MjtTrn::mjTRN_JOINT);
3335        actuator.set_target("hinge");
3336
3337        /* motor */
3338        actuator.set_to_motor();
3339        assert_eq!(actuator.gaintype(), MjtGain::mjGAIN_FIXED);
3340        assert_eq!(actuator.biastype(), MjtBias::mjBIAS_NONE);
3341        assert_eq!(actuator.dyntype(), MjtDyn::mjDYN_NONE);
3342        assert_eq!(actuator.gainprm()[0], 1.0);
3343
3344        /* velocity servo */
3345        actuator.set_to_velocity(2.0);
3346        assert_eq!(actuator.gaintype(), MjtGain::mjGAIN_FIXED);
3347        assert_eq!(actuator.biastype(), MjtBias::mjBIAS_AFFINE);
3348        assert_eq!(actuator.gainprm()[0], 2.0);
3349        assert_eq!(actuator.biasprm()[2], -2.0);
3350
3351        /* damper: negative feedback gain is rejected, otherwise affine gain */
3352        assert!(actuator.set_to_damper(-1.0).is_err());
3353        assert!(actuator.set_to_damper(5.0).is_ok());
3354        assert_eq!(actuator.gaintype(), MjtGain::mjGAIN_AFFINE);
3355        assert_eq!(actuator.gainprm()[2], -5.0);
3356
3357        /* adhesion: negative gain is rejected */
3358        assert!(actuator.set_to_adhesion(-1.0).is_err());
3359        assert!(actuator.set_to_adhesion(1.0).is_ok());
3360
3361        /* cylinder: filter dynamics, always succeeds (negative diameter keeps the area) */
3362        actuator.set_to_cylinder(0.1, 0.0, 1.0, -1.0);
3363        assert_eq!(actuator.dyntype(), MjtDyn::mjDYN_FILTER);
3364
3365        /* muscle: negative tausmooth is rejected; negative entries keep MuJoCo's defaults */
3366        assert!(actuator.set_to_muscle([-1.0, -1.0], -1.0, [-1.0, -1.0], -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0).is_err());
3367        actuator.set_to_muscle([-1.0, -1.0], 0.0, [-1.0, -1.0], -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0).unwrap();
3368        assert_eq!(actuator.gaintype(), MjtGain::mjGAIN_MUSCLE);
3369        assert_eq!(actuator.biastype(), MjtBias::mjBIAS_MUSCLE);
3370        assert_eq!(actuator.dyntype(), MjtDyn::mjDYN_MUSCLE);
3371
3372        /* position (builder API): kv and dampratio are mutually exclusive */
3373        assert!(actuator.set_to_position(
3374            PositionConfig::default().with_kp(1.0).with_kv(1.0).with_dampratio(1.0)
3375        ).is_err());
3376        actuator.set_to_position(PositionConfig::default().with_kp(1.0).with_kv(1.0)).unwrap();
3377
3378        /* integrated velocity: integrator dynamics */
3379        actuator.set_to_int_velocity(IntVelocityConfig::default().with_kp(1.0)).unwrap();
3380        assert_eq!(actuator.dyntype(), MjtDyn::mjDYN_INTEGRATOR);
3381
3382        /* dc motor (builder API): without a motor constant MuJoCo cannot derive K > 0 */
3383        assert!(actuator.set_to_dc_motor(DcMotorConfig::default()).is_err());
3384        actuator.set_to_dc_motor(
3385            DcMotorConfig::default().with_motorconst([1.0, 1.0]).with_resistance(1.0)
3386        ).unwrap();
3387        assert_eq!(actuator.gaintype(), MjtGain::mjGAIN_DCMOTOR);
3388        assert_eq!(actuator.biastype(), MjtBias::mjBIAS_DCMOTOR);
3389        assert_eq!(actuator.dyntype(), MjtDyn::mjDYN_DCMOTOR);
3390
3391        /* reset to a plain motor and confirm the spec still compiles */
3392        actuator.set_to_motor();
3393        actuator.set_actearly(false);
3394        actuator.set_ctrllimited(MjtLimited::mjLIMITED_FALSE);
3395        spec.compile().unwrap();
3396    }
3397
3398    #[test]
3399    fn test_save() {
3400        const EXPECTED_XML: &str = "\
3401<mujoco model=\"MuJoCo Model\">
3402  <compiler angle=\"radian\"/>
3403
3404  <worldbody>
3405    <body>
3406      <geom size=\"0.01\"/>
3407      <site/>
3408      <camera/>
3409      <light/>
3410    </body>
3411  </worldbody>
3412</mujoco>
3413";
3414
3415        let mut spec = MjSpec::new();
3416        let world = spec.world_body_mut();
3417        let body = world.add_body();
3418        body.add_camera();
3419        body.add_geom().with_size([0.010, 0.0, 0.0]);
3420        body.add_light();
3421        body.add_site();
3422
3423        spec.compile().unwrap();
3424        assert_eq!(spec.save_xml_string(1000).unwrap(), EXPECTED_XML);
3425
3426        spec.compile().unwrap();
3427    }
3428
3429    /// `save_xml_string` with a 1-byte buffer must return `XmlBufferTooSmall` and
3430    /// the reported `required_size` must be enough to succeed on retry.
3431    #[test]
3432    fn test_save_xml_string_buffer_too_small() {
3433        let mut spec = MjSpec::new();
3434        spec.world_body_mut().add_body().add_geom().with_size([0.01, 0.0, 0.0]);
3435        spec.compile().unwrap();
3436
3437        let err = spec.save_xml_string(1)
3438            .expect_err("expected XmlBufferTooSmall with a 1-byte buffer");
3439        let required_size = match err {
3440            MjEditError::XmlBufferTooSmall { required_size } => required_size,
3441            other => panic!("expected XmlBufferTooSmall, got {other:?}"),
3442        };
3443        assert!(required_size > 1, "required_size must exceed the original 1-byte buffer");
3444
3445        // `required_size` excludes the NUL, so the retry needs one byte more.
3446        let xml = spec.save_xml_string(required_size + 1)
3447            .expect("save_xml_string should succeed with required_size + 1 bytes");
3448        assert!(!xml.is_empty(), "saved XML must be non-empty");
3449    }
3450
3451    #[test]
3452    fn test_site() {
3453        const TEST_MATERIAL: &str = "material 1";
3454        const TEST_POSITION: [f64; 3] = [1.0, 2.0, 3.0];
3455        const SITE_NAME: &str = "test_site";
3456
3457        let mut spec = MjSpec::new();
3458
3459        /* add material */
3460        spec.add_material().with_name(TEST_MATERIAL);
3461
3462        /* add site */
3463        let world = spec.world_body_mut();
3464        world.add_site()
3465            .with_name(SITE_NAME);
3466        let site = spec.site_mut(SITE_NAME).unwrap();
3467
3468        /* material */
3469        assert_eq!(site.material(), "");
3470        site.set_material(TEST_MATERIAL);
3471        assert_eq!(site.material(), TEST_MATERIAL);
3472
3473        /* userdata */
3474        let test_userdata: Vec<f64> = vec![0.0; 5];
3475        assert_eq!(site.userdata(), []);
3476        site.set_userdata(&test_userdata);
3477        assert_eq!(site.userdata(), test_userdata);
3478
3479        /* position */
3480        assert_eq!(site.pos(), &[0.0; 3]);
3481        *site.pos_mut() = TEST_POSITION;
3482        assert_eq!(site.pos(), &TEST_POSITION);
3483
3484        spec.compile().unwrap();
3485    }
3486
3487    #[test]
3488    fn test_frame() {
3489        let mut spec = MjSpec::new();
3490        let world = spec.world_body_mut()
3491            .with_gravcomp(10.0);
3492
3493        world.add_frame()
3494            .with_name("frame_a")
3495            .with_pos([0.5, 0.5, 0.05])
3496            .add_body()
3497            .add_geom()
3498            .with_size([1.0, 0.0, 0.0]);
3499
3500        assert!(spec.frame("frame_a").is_some());
3501        assert!(spec.frame_mut("frame_a").is_some());
3502
3503        spec.compile().unwrap();
3504    }
3505
3506    #[test]
3507    fn test_wrap() {
3508        let mut spec = MjSpec::new();
3509        let world = spec.world_body_mut();
3510        let body1= world.add_body().with_pos([0.0, 0.0, 0.5]);
3511        body1.add_geom().with_size([0.010;3]);
3512        body1.add_site().with_name("ball1");
3513        body1.add_joint().with_type(MjtJoint::mjJNT_FREE);
3514
3515        let body2= world.add_body().with_pos([0.0, 0.0, 0.5]);
3516        body2.add_geom().with_size([0.010;3]);
3517        body2.add_site().with_name("ball2");
3518        body2.add_joint().with_type(MjtJoint::mjJNT_FREE);
3519
3520        let tendon = spec.add_tendon()
3521            .with_range([0.0, 0.25])
3522            .with_rgba([1.0, 0.5, 0.0, 1.0]);  // orange
3523        tendon.wrap_site("ball1");
3524        tendon.wrap_site("ball2");
3525
3526        spec.world_body_mut().add_geom().with_type(MjtGeom::mjGEOM_PLANE).with_size([1.0; 3]);
3527
3528        spec.compile().unwrap();
3529    }
3530
3531    #[test]
3532    fn test_body_child_and_id() {
3533        let mut spec = MjSpec::new();
3534        let parent = spec.world_body_mut().add_body().with_name("parent");
3535        parent.add_body().with_name("child");
3536
3537        let parent_ref = spec.body("parent").unwrap();
3538        assert!(parent_ref.child("child").is_some());
3539        assert!(parent_ref.child("missing").is_none());
3540        assert_eq!(parent_ref.id(), None);
3541
3542        let parent_mut = spec.body_mut("parent").unwrap();
3543        assert!(parent_mut.child_mut("child").is_some());
3544        assert!(parent_mut.child_mut("missing").is_none());
3545
3546        spec.compile().unwrap();
3547        assert!(spec.body("parent").unwrap().id().is_some());
3548    }
3549
3550    #[test]
3551    fn test_geom() {
3552        const GEOM_NAME: &str = "test_geom";
3553        const GEOM_INVALID_NAME: &str = "geom_test";
3554        let mut spec = MjSpec::new();
3555        spec.world_body_mut().add_geom()
3556            .with_name(GEOM_NAME);
3557
3558        assert!(spec.geom(GEOM_NAME).is_some());
3559        assert!(spec.geom(GEOM_INVALID_NAME).is_none());
3560    }
3561
3562    #[test]
3563    fn test_camera() {
3564        const CAMERA_NAME: &str = "test_cam";
3565        const CAMERA_INVALID_NAME: &str = "cam_test";
3566        let mut spec = MjSpec::new();
3567        spec.world_body_mut().add_camera()
3568            .with_name(CAMERA_NAME);
3569
3570        assert!(spec.camera(CAMERA_NAME).is_some());
3571        assert!(spec.camera(CAMERA_INVALID_NAME).is_none());
3572    }
3573
3574    #[test]
3575    fn test_light() {
3576        const LIGHT_NAME: &str = "test_light";
3577        const LIGHT_INVALID_NAME: &str = "light_test";
3578        let mut spec = MjSpec::new();
3579        spec.world_body_mut().add_light()
3580            .with_name(LIGHT_NAME);
3581
3582        assert!(spec.light(LIGHT_NAME).is_some());
3583        assert!(spec.light(LIGHT_INVALID_NAME).is_none());
3584    }
3585
3586    #[test]
3587    fn test_exclude() {
3588        const EXCLUDE_NAME: &str = "test_exclude";
3589        const EXCLUDE_INVALID_NAME: &str = "exclude_test";
3590        let mut spec = MjSpec::new();
3591
3592        spec.world_body_mut().add_body().with_name("body1-left");
3593        spec.world_body_mut().add_body().with_name("body2-right");
3594
3595        spec.add_exclude()
3596            .with_name(EXCLUDE_NAME)
3597            .with_bodyname1("body1-left")
3598            .with_bodyname2("body2-right");
3599
3600        assert!(spec.exclude(EXCLUDE_NAME).is_some());
3601        assert!(spec.exclude(EXCLUDE_INVALID_NAME).is_none());
3602
3603        assert!(spec.compile().is_ok());
3604    }
3605
3606    #[test]
3607    fn test_mesh() {
3608        let mut spec = MjSpec::new();
3609        let mesh = spec.add_mesh();
3610        assert!(!mesh.needsdf());
3611        mesh.set_needsdf(true);
3612        assert!(mesh.needsdf());
3613
3614        assert!(!mesh.smoothnormal());
3615        mesh.set_smoothnormal(true);
3616        assert!(mesh.smoothnormal());
3617    }
3618
3619    #[test]
3620    fn test_iteration() {
3621        const LAST_BODY_NAME: &str = "subbody";
3622        const LAST_WORLD_BODY_NAME: &str = "body2";
3623        const N_GEOM:   usize = 3;
3624        const N_BODY:   usize = 4;  // three added + world
3625        const N_SITE:   usize = 2;
3626        const N_TENDON: usize = 1;
3627        const N_MESH:   usize = 0;
3628
3629        let mut spec = MjSpec::new();
3630        let world = spec.world_body_mut();
3631        let body1= world.add_body().with_pos([0.0, 0.0, 0.5]);
3632        body1.add_geom().with_size([0.010;3]);
3633        body1.add_site().with_name("ball1");
3634        body1.add_joint().with_type(MjtJoint::mjJNT_FREE);
3635
3636        let body2= world.add_body().with_pos([0.0, 0.0, 0.5]).with_name(LAST_WORLD_BODY_NAME);
3637        body2.add_geom().with_size([0.010;3]);
3638        body2.add_site().with_name("ball2");
3639        body2.add_joint().with_type(MjtJoint::mjJNT_FREE);
3640
3641        body2.add_body().with_name(LAST_BODY_NAME);
3642
3643        let tendon = spec.add_tendon()
3644            .with_range([0.0, 0.25])
3645            .with_rgba([1.0, 0.5, 0.0, 1.0]);  // orange
3646        tendon.wrap_site("ball1");
3647        tendon.wrap_site("ball2");
3648
3649        spec.world_body_mut().add_geom().with_type(MjtGeom::mjGEOM_PLANE).with_size([1.0; 3]);
3650
3651        // Iter MjSpec
3652        assert_eq!(spec.geom_iter_mut().count(), N_GEOM);
3653        assert_eq!(spec.body_iter().count(), N_BODY);
3654        assert_eq!(spec.site_iter_mut().count(), N_SITE);
3655        assert_eq!(spec.tendon_iter_mut().count(), N_TENDON);
3656        assert_eq!(spec.mesh_iter_mut().count(), N_MESH);
3657        assert_eq!(spec.body_iter().last().unwrap().name(), LAST_BODY_NAME);
3658
3659        // Iter MjsBody
3660        let world = spec.world_body_mut();
3661        assert_eq!(world.geom_iter_mut(true).count(), N_GEOM);
3662        assert_eq!(world.body_iter(true).count(), N_BODY - 1);  // world must now be excluded
3663        assert_eq!(world.site_iter_mut(true).count(), N_SITE);
3664        assert_eq!(world.body_iter_mut().last().unwrap().name(), LAST_WORLD_BODY_NAME);
3665    }
3666
3667    /// Tests wrapper method of [`mj_parse`] with VFS.
3668    #[test]
3669    fn test_parse_vfs() {
3670        let mut vfs = MjVfs::new();
3671        vfs.add_from_buffer("hello.xml", MODEL.as_bytes()).unwrap();
3672        let mut spec = MjSpec::from_parse_vfs("hello.xml", "XML", &vfs).unwrap();
3673        let model = spec.compile().unwrap();
3674        assert!(model.geom("floor1").is_some());
3675    }
3676
3677    /// Tests wrapper method of [`mj_parse`] without VFS.
3678    #[test]
3679    fn test_parse_file() {
3680        std::fs::write("test_parse_vfs.xml", MODEL).unwrap();
3681        let mut spec = MjSpec::from_parse("test_parse_vfs.xml", "XML").unwrap();
3682        std::fs::remove_file("test_parse_vfs.xml").unwrap();
3683        let model = spec.compile().unwrap();
3684        assert!(model.geom("floor1").is_some());
3685    }
3686
3687    #[test]
3688    fn test_tendon_wrap_methods() {
3689        let mut spec = MjSpec::new();
3690        spec.world_body_mut().add_body().with_name("body1");
3691        spec.world_body_mut().add_body().with_name("body2");
3692        spec.world_body_mut().add_site().with_name("site1");
3693
3694        let tendon = spec.add_tendon();
3695        tendon.wrap_site("site1");
3696        tendon.wrap_joint("joint1", 0.5);
3697        tendon.wrap_pulley(1.5);
3698
3699        assert_eq!(tendon.wrap_num(), 3);
3700
3701        let wrap = tendon.wrap(1);
3702        assert_eq!(wrap.coef(), 0.5);
3703
3704        let wrap_pulley = tendon.wrap(2);
3705        assert_eq!(wrap_pulley.divisor(), 1.5);
3706    }
3707
3708    #[test]
3709    fn test_tendon_wrap_out_of_bounds() {
3710        let mut spec = MjSpec::new();
3711        spec.world_body_mut().add_site().with_name("site1");
3712
3713        let tendon = spec.add_tendon();
3714        tendon.wrap_site("site1");
3715        assert_eq!(tendon.wrap_num(), 1);
3716
3717        // Index 3 is out of range; the fallible accessor reports it rather than aborting in C.
3718        match tendon.try_wrap(3) {
3719            Err(MjEditError::IndexOutOfBounds { id, len }) => {
3720                assert_eq!(id, 3);
3721                assert_eq!(len, 1);
3722            }
3723            _ => panic!("expected IndexOutOfBounds"),
3724        }
3725    }
3726
3727    #[test]
3728    #[should_panic]
3729    fn test_tendon_wrap_out_of_bounds_panics() {
3730        let mut spec = MjSpec::new();
3731        spec.world_body_mut().add_site().with_name("site1");
3732
3733        let tendon = spec.add_tendon();
3734        tendon.wrap_site("site1");
3735
3736        // The panicking accessor must panic in Rust, not abort the process in C.
3737        let _ = tendon.wrap(3);
3738    }
3739
3740    #[test]
3741    fn test_numeric_vec() {
3742        let mut spec = MjSpec::new();
3743        let numeric = spec.add_numeric();
3744        let name = "test_numeric";
3745        numeric.set_name(name).unwrap();
3746        assert_eq!(numeric.name(), name);
3747
3748        let data = [1.5, 2.5, 3.5, 4.5];
3749        numeric.set_data(&data);
3750        assert_eq!(numeric.data(), &data);
3751
3752        spec.compile().unwrap();
3753    }
3754
3755    #[test]
3756    fn test_text_string() {
3757        let mut spec = MjSpec::new();
3758        let text = spec.add_text();
3759        let name = "test_text";
3760        text.set_name(name).unwrap();
3761        assert_eq!(text.name(), name);
3762
3763        let content = "Hello MuJoCo!";
3764        text.set_data(content);
3765        assert_eq!(text.data(), content);
3766
3767        spec.compile().unwrap();
3768    }
3769
3770    #[test]
3771    fn test_tuple_names_and_params() {
3772        let mut spec = MjSpec::new();
3773        spec.world_body_mut().add_body().with_name("body1");
3774        spec.world_body_mut().add_body().with_name("body2");
3775
3776        let tuple_name = "test_tuple";
3777        let obj_param = [1.0, 2.0];
3778
3779        let tuple = spec.add_tuple();
3780        tuple.set_name(tuple_name).unwrap();
3781        assert_eq!(tuple.name(), tuple_name);
3782
3783        tuple.set_objname("body1 body2");
3784        tuple.set_objprm(&obj_param);
3785        tuple.set_objtype(&[MjtObj::mjOBJ_BODY, MjtObj::mjOBJ_BODY]).unwrap();
3786
3787        assert_eq!(tuple.objprm(), &obj_param);
3788
3789        spec.compile().unwrap();
3790
3791        // Verify via XML as objname has no spec getter
3792        let xml = spec.save_xml_string(2000).unwrap();
3793        assert!(xml.contains("objname=\"body1\""));
3794        assert!(xml.contains("objname=\"body2\""));
3795        assert!(xml.contains("prm=\"1\""));
3796        assert!(xml.contains("prm=\"2\""));
3797    }
3798
3799    /// Tests that wrapping sites on a tendon produces a compiled model
3800    /// with correct ntendon, nwrap, wrap types, and wrap object IDs.
3801    #[test]
3802    fn test_tendon_wrap_site_compiled_model() {
3803        let mut spec = MjSpec::new();
3804        let world = spec.world_body_mut();
3805
3806        let b1 = world.add_body().with_pos([0.0, 0.0, 0.5]);
3807        b1.add_geom().with_size([0.01; 3]);
3808        b1.add_site().with_name("s1");
3809        b1.add_joint().with_type(MjtJoint::mjJNT_FREE);
3810
3811        let b2 = world.add_body().with_pos([1.0, 0.0, 0.5]);
3812        b2.add_geom().with_size([0.01; 3]);
3813        b2.add_site().with_name("s2");
3814        b2.add_joint().with_type(MjtJoint::mjJNT_FREE);
3815
3816        world.add_geom().with_type(MjtGeom::mjGEOM_PLANE).with_size([1.0; 3]);
3817
3818        let tendon = spec.add_tendon().with_range([0.0, 1.0]);
3819        tendon.wrap_site("s1");
3820        tendon.wrap_site("s2");
3821
3822        let model = spec.compile().unwrap();
3823
3824        assert_eq!(model.ffi().ntendon, 1, "expected one tendon");
3825        assert_eq!(model.ffi().nwrap, 2, "expected two wrap elements");
3826
3827        // Verify wrap types are mjWRAP_SITE
3828        let wrap_types = model.wrap_type();
3829        assert_eq!(wrap_types[0], MjtWrap::mjWRAP_SITE);
3830        assert_eq!(wrap_types[1], MjtWrap::mjWRAP_SITE);
3831
3832        // Verify wrap object IDs point to the correct sites
3833        let wrap_objid = model.wrap_objid();
3834        let s1_id = model.site("s1").unwrap().id as i32;
3835        let s2_id = model.site("s2").unwrap().id as i32;
3836        assert_eq!(wrap_objid[0], s1_id);
3837        assert_eq!(wrap_objid[1], s2_id);
3838    }
3839
3840    /// Test that MjsTendon `limited` correctly round-trips all three enum states
3841    /// (FALSE, TRUE, AUTO), which would fail if the field were `bool`.
3842    #[test]
3843    fn test_tendon_limited_tristate() {
3844        use crate::mujoco_c::mjtLimited::*;
3845
3846        let mut spec = MjSpec::new();
3847        let world = spec.world_body_mut();
3848
3849        let b1 = world.add_body().with_pos([0.0, 0.0, 0.5]);
3850        b1.add_geom().with_size([0.01; 3]);
3851        b1.add_site().with_name("s1");
3852        b1.add_joint().with_type(MjtJoint::mjJNT_FREE);
3853
3854        let b2 = world.add_body().with_pos([1.0, 0.0, 0.5]);
3855        b2.add_geom().with_size([0.01; 3]);
3856        b2.add_site().with_name("s2");
3857        b2.add_joint().with_type(MjtJoint::mjJNT_FREE);
3858
3859        world.add_geom().with_type(MjtGeom::mjGEOM_PLANE).with_size([1.0; 3]);
3860
3861        // Create 3 tendons, one for each LIMITED enum value
3862        for (name, val) in [("t_false", mjLIMITED_FALSE), ("t_true", mjLIMITED_TRUE), ("t_auto", mjLIMITED_AUTO)] {
3863            let t = spec.add_tendon()
3864                .with_name(name)
3865                .with_range([0.0, 1.0])
3866                .with_limited(val);
3867            t.wrap_site("s1");
3868            t.wrap_site("s2");
3869        }
3870
3871        // Verify before compilation: getter round-trip
3872        for (name, expected) in [("t_false", mjLIMITED_FALSE), ("t_true", mjLIMITED_TRUE), ("t_auto", mjLIMITED_AUTO)] {
3873            let t = spec.tendon(name).expect("tendon not found");
3874            assert_eq!(t.limited(), expected,
3875                "Before compile: tendon '{}' limited should be {:?}", name, expected);
3876        }
3877
3878        // Compile and verify the compiled model's tendon_limited field
3879        let model = spec.compile().unwrap();
3880        let tendon_limited = model.tendon_limited();
3881        // After compilation, enum values resolve to bool: FALSE->false, TRUE->true, AUTO->resolved
3882        assert!(!tendon_limited[0], "Compiled tendon 0 limited should be false");
3883        assert!(tendon_limited[1], "Compiled tendon 1 limited should be true");
3884        // AUTO resolves to a concrete bool in the compiled model (always true or false)
3885        let _ = tendon_limited[2];
3886    }
3887
3888    /// Test that MjsTendon `actfrclimited` correctly round-trips all three enum
3889    /// states (FALSE, TRUE, AUTO).
3890    #[test]
3891    fn test_tendon_actfrclimited_tristate() {
3892        use crate::mujoco_c::mjtLimited::*;
3893
3894        let mut spec = MjSpec::new();
3895        let world = spec.world_body_mut();
3896
3897        let b1 = world.add_body().with_pos([0.0, 0.0, 0.5]);
3898        b1.add_geom().with_size([0.01; 3]);
3899        b1.add_site().with_name("s1");
3900        b1.add_joint().with_type(MjtJoint::mjJNT_FREE);
3901
3902        let b2 = world.add_body().with_pos([1.0, 0.0, 0.5]);
3903        b2.add_geom().with_size([0.01; 3]);
3904        b2.add_site().with_name("s2");
3905        b2.add_joint().with_type(MjtJoint::mjJNT_FREE);
3906
3907        world.add_geom().with_type(MjtGeom::mjGEOM_PLANE).with_size([1.0; 3]);
3908
3909        // Set actfrclimited to each variant (with actfrcrange for TRUE/AUTO)
3910        for (name, val) in [("t_false", mjLIMITED_FALSE), ("t_true", mjLIMITED_TRUE), ("t_auto", mjLIMITED_AUTO)] {
3911            let t = spec.add_tendon()
3912                .with_name(name)
3913                .with_range([0.0, 1.0])
3914                .with_actfrcrange([-1.0, 1.0])
3915                .with_actfrclimited(val);
3916            t.wrap_site("s1");
3917            t.wrap_site("s2");
3918        }
3919
3920        // Verify round-trip before compilation
3921        for (name, expected) in [("t_false", mjLIMITED_FALSE), ("t_true", mjLIMITED_TRUE), ("t_auto", mjLIMITED_AUTO)] {
3922            let t = spec.tendon(name).expect("tendon not found");
3923            assert_eq!(t.actfrclimited(), expected,
3924                "Before compile: tendon '{}' actfrclimited should be {:?}", name, expected);
3925        }
3926
3927        // Must compile without error
3928        spec.compile().unwrap();
3929    }
3930
3931    /// Test that MjsJoint `align` correctly round-trips all three enum states
3932    /// (FALSE, TRUE, AUTO), which would fail if the field were `i32`.
3933    #[test]
3934    fn test_joint_align_tristate() {
3935        use crate::mujoco_c::mjtAlignFree::*;
3936
3937        let mut spec = MjSpec::new();
3938        let world = spec.world_body_mut();
3939        world.add_geom().with_type(MjtGeom::mjGEOM_PLANE).with_size([1.0; 3]);
3940
3941        // Create 3 free joints, one for each align state
3942        for (name, val) in [("j_false", mjALIGNFREE_FALSE), ("j_true", mjALIGNFREE_TRUE), ("j_auto", mjALIGNFREE_AUTO)] {
3943            let body = world.add_body().with_pos([0.0, 0.0, 1.0]);
3944            body.add_geom().with_size([0.1; 3]);
3945            body.add_joint()
3946                .with_name(name)
3947                .with_type(MjtJoint::mjJNT_FREE)
3948                .with_align(val);
3949        }
3950
3951        // Verify round-trip before compilation
3952        for (name, expected) in [("j_false", mjALIGNFREE_FALSE), ("j_true", mjALIGNFREE_TRUE), ("j_auto", mjALIGNFREE_AUTO)] {
3953            let j = spec.joint(name).expect("joint not found");
3954            assert_eq!(j.align(), expected,
3955                "Before compile: joint '{}' align should be {:?}", name, expected);
3956        }
3957
3958        // Compiling proves MuJoCo accepted every enum value.
3959        let model = spec.compile().unwrap();
3960        assert_eq!(model.ffi().njnt as usize, 3, "expected 3 joints");
3961    }
3962
3963    /// Test that MjsJoint `limited` also correctly round-trips all three states
3964    /// since it was also changed to MjtLimited.
3965    #[test]
3966    fn test_joint_limited_tristate() {
3967        use crate::mujoco_c::mjtLimited::*;
3968
3969        let mut spec = MjSpec::new();
3970        let world = spec.world_body_mut();
3971        world.add_geom().with_type(MjtGeom::mjGEOM_PLANE).with_size([1.0; 3]);
3972
3973        for (name, val) in [("j_false", mjLIMITED_FALSE), ("j_true", mjLIMITED_TRUE), ("j_auto", mjLIMITED_AUTO)] {
3974            let body = world.add_body().with_pos([0.0, 0.0, 1.0]);
3975            body.add_geom().with_size([0.1; 3]);
3976            body.add_joint()
3977                .with_name(name)
3978                .with_type(MjtJoint::mjJNT_SLIDE)
3979                .with_range([0.0, 1.0])
3980                .with_limited(val);
3981        }
3982
3983        // Verify round-trip before compilation
3984        for (name, expected) in [("j_false", mjLIMITED_FALSE), ("j_true", mjLIMITED_TRUE), ("j_auto", mjLIMITED_AUTO)] {
3985            let j = spec.joint(name).expect("joint not found");
3986            assert_eq!(j.limited(), expected,
3987                "Before compile: joint '{}' limited should be {:?}", name, expected);
3988        }
3989
3990        // AUTO resolves from the presence of a range.
3991        let model = spec.compile().unwrap();
3992        let jnt_limited = model.jnt_limited();
3993        assert!(!jnt_limited[0]);
3994        assert!(jnt_limited[1]);
3995        assert!(jnt_limited[2], "Joint with limited=AUTO and range should resolve to true");
3996    }
3997
3998    /// Parsing invalid XML must return Err with a non-empty message.
3999    #[test]
4000    fn test_parse_xml_string_invalid() {
4001        let result = MjSpec::from_xml_string("<not valid mujoco xml>");
4002        assert!(result.is_err(), "parsing invalid XML must return Err");
4003        let msg = result.unwrap_err().to_string();
4004        assert!(!msg.is_empty(), "error message must not be empty for invalid XML");
4005    }
4006
4007    /// Parsing via VFS must produce a model with the expected properties, not just succeed.
4008    #[test]
4009    fn test_parse_xml_vfs_content() {
4010        const PATH: &str = "./mj_spec_test_parse_xml_vfs_content.xml";
4011        let mut vfs = MjVfs::new();
4012        vfs.add_from_buffer(PATH, MODEL.as_bytes()).unwrap();
4013        let mut spec = MjSpec::from_xml_vfs(PATH, &vfs).expect("VFS parse failed");
4014        let model = spec.compile().expect("compile failed");
4015
4016        // MODEL has: worldbody + 1 named body ("ball") = 2 bodies total (world + ball)
4017        assert_eq!(model.ffi().nbody, 2, "expected 2 bodies (world + ball)");
4018        // MODEL has: 1 free joint on "ball"
4019        assert_eq!(model.ffi().njnt, 1, "expected 1 joint");
4020        // MODEL has: 1 sphere geom + 1 plane geom = 2 geoms
4021        assert_eq!(model.ffi().ngeom, 2, "expected 2 geoms (sphere + floor)");
4022    }
4023
4024    /// Parsing via XML file must produce a model with the expected properties.
4025    #[test]
4026    fn test_parse_xml_file_content() {
4027        const PATH: &str = "./mj_spec_test_parse_xml_file_content.xml";
4028        let mut file = fs::File::create(PATH).expect("file creation failed");
4029        file.write_all(MODEL.as_bytes()).expect("unable to write");
4030        file.flush().unwrap();
4031
4032        let result = MjSpec::from_xml(PATH);
4033        fs::remove_file(PATH).expect("file removal failed");
4034
4035        let mut spec = result.expect("file parse failed");
4036        let model = spec.compile().expect("compile failed");
4037
4038        assert_eq!(model.ffi().nbody, 2, "expected 2 bodies (world + ball)");
4039        assert_eq!(model.ffi().njnt, 1, "expected 1 joint");
4040        assert_eq!(model.ffi().ngeom, 2, "expected 2 geoms (sphere + floor)");
4041    }
4042
4043    /// `from_parse` accepts `PathBuf` and `&Path` in addition to `&str`.
4044    #[test]
4045    fn test_from_parse_path_types() {
4046        const PATH: &str = "./mj_spec_test_from_parse_path_types.xml";
4047        let mut file = fs::File::create(PATH).expect("file creation failed");
4048        file.write_all(MODEL.as_bytes()).expect("write failed");
4049        file.flush().unwrap();
4050
4051        // &str
4052        assert!(MjSpec::from_parse(PATH, "").is_ok());
4053        // String
4054        assert!(MjSpec::from_parse(String::from(PATH), "").is_ok());
4055        // &Path
4056        assert!(MjSpec::from_parse(Path::new(PATH), "").is_ok());
4057        // PathBuf
4058        assert!(MjSpec::from_parse(PathBuf::from(PATH), "").is_ok());
4059
4060        fs::remove_file(PATH).expect("file removal failed");
4061    }
4062
4063    /// `from_parse_vfs` accepts `PathBuf` and `&Path`.
4064    #[test]
4065    fn test_from_parse_vfs_path_types() {
4066        const PATH: &str = "./mj_spec_test_from_parse_vfs_path_types.xml";
4067        let mut vfs = MjVfs::new();
4068        vfs.add_from_buffer(PATH, MODEL.as_bytes()).unwrap();
4069
4070        // &str
4071        assert!(MjSpec::from_parse_vfs(PATH, "", &vfs).is_ok());
4072        // PathBuf
4073        assert!(MjSpec::from_parse_vfs(PathBuf::from(PATH), "", &vfs).is_ok());
4074        // &Path
4075        assert!(MjSpec::from_parse_vfs(Path::new(PATH), "", &vfs).is_ok());
4076    }
4077
4078    /// `save_xml` accepts `PathBuf` and `&Path` in addition to `&str`.
4079    #[test]
4080    fn test_save_xml_path_types() {
4081        let mut spec = MjSpec::new();
4082        spec.world_body_mut().add_body().add_geom().with_size([0.01, 0.0, 0.0]);
4083        spec.compile().unwrap();
4084
4085        let paths: [PathBuf; 3] = [
4086            PathBuf::from("./mj_spec_save_xml_str.xml"),
4087            PathBuf::from("./mj_spec_save_xml_pathbuf.xml"),
4088            PathBuf::from("./mj_spec_save_xml_path.xml"),
4089        ];
4090
4091        // &str
4092        spec.save_xml(paths[0].to_str().unwrap()).unwrap();
4093        // PathBuf
4094        spec.save_xml(paths[1].clone()).unwrap();
4095        // &Path
4096        spec.save_xml(paths[2].as_path()).unwrap();
4097
4098        for p in &paths {
4099            let content = fs::read_to_string(p).expect("saved file should be readable");
4100            assert!(content.contains("<mujoco"), "saved XML should contain <mujoco tag");
4101            fs::remove_file(p).expect("cleanup failed");
4102        }
4103    }
4104
4105    #[test]
4106    fn test_material_set_texture() {
4107        let mut spec = MjSpec::new();
4108        let world = spec.world_body_mut();
4109        world.add_geom()
4110            .with_type(MjtGeom::mjGEOM_PLANE)
4111            .with_size([1.0, 1.0, 0.01])
4112            .with_material("floor");
4113
4114        spec.add_texture()
4115            .with_name("floor")
4116            .with_type(MjtTexture::mjTEXTURE_2D)
4117            .with_builtin(MjtBuiltin::mjBUILTIN_CHECKER)
4118            .with_rgb1([0.9, 0.9, 0.9])
4119            .with_rgb2([0.1, 0.1, 0.1])
4120            .with_width(512)
4121            .with_height(512);
4122
4123        let mat = spec.add_material().with_name("floor");
4124        mat.set_texture(MjtTextureRole::mjTEXROLE_RGB, "floor");
4125
4126        let model = spec.compile().unwrap();
4127        let xml = spec.save_xml_string(8192).unwrap();
4128        assert!(xml.contains("texture=\"floor\""), "XML should reference the floor texture");
4129
4130        let mat_info = model.material("floor").unwrap();
4131        let mat_view = mat_info.view(&model);
4132        let tex_id = mat_view.texid;
4133        assert_ne!(tex_id[MjtTextureRole::mjTEXROLE_RGB as usize], -1,
4134            "RGB texture slot should be resolved (not -1)");
4135    }
4136
4137    /// A builtin texture with `nchannel < 3` must be rejected by `compile()` rather than
4138    /// heap-overflowing in MuJoCo's builtin generators (which write 3 bytes per pixel).
4139    #[test]
4140    fn test_builtin_texture_nchannel_rejected() {
4141        let mut spec = MjSpec::new();
4142        spec.add_texture()
4143            .with_name("badtex")
4144            .with_type(MjtTexture::mjTEXTURE_2D)
4145            .with_builtin(MjtBuiltin::mjBUILTIN_CHECKER)
4146            .with_rgb1([0.9, 0.9, 0.9])
4147            .with_rgb2([0.1, 0.1, 0.1])
4148            .with_width(64)
4149            .with_height(64)
4150            .set_nchannel(1);
4151
4152        let err = spec.compile().unwrap_err();
4153        assert!(matches!(&err, MjEditError::CompileFailed(msg) if msg.contains("nchannel")),
4154            "compile must reject nchannel < 3 builtin texture, got {err:?}");
4155
4156        // nchannel == 3 (and a builtin pattern) compiles cleanly.
4157        let mut ok_spec = MjSpec::new();
4158        ok_spec.add_texture()
4159            .with_name("goodtex")
4160            .with_type(MjtTexture::mjTEXTURE_2D)
4161            .with_builtin(MjtBuiltin::mjBUILTIN_CHECKER)
4162            .with_rgb1([0.9, 0.9, 0.9])
4163            .with_rgb2([0.1, 0.1, 0.1])
4164            .with_width(64)
4165            .with_height(64)
4166            .set_nchannel(3);
4167        assert!(ok_spec.compile().is_ok(), "nchannel == 3 builtin texture should compile");
4168
4169        // Without a builtin pattern the texture still fails to compile, but through MuJoCo's own
4170        // error, which scopes the guard to the builtin path.
4171        let mut no_builtin = MjSpec::new();
4172        no_builtin.add_texture()
4173            .with_name("plain")
4174            .with_type(MjtTexture::mjTEXTURE_2D)
4175            .with_width(64)
4176            .with_height(64)
4177            .set_nchannel(1);
4178        assert!(matches!(no_builtin.compile().unwrap_err(),
4179                MjEditError::CompileFailed(msg) if !msg.contains("nchannel")),
4180            "nchannel < 3 without a builtin pattern should not trip the nchannel guard");
4181    }
4182
4183    /// Verifies `MjsFlex::cellcount` (read-only `&[i32; 3]`) and `order` (read-write `i32`)
4184    /// by parsing a minimal flexcomp model and reading/writing through the spec.
4185    #[test]
4186    fn test_mjs_flex_cellcount_and_order() {
4187        const FLEX_MODEL: &str = "\
4188<mujoco>\
4189  <worldbody>\
4190    <body name=\"pin\" pos=\"0 0 1\">\
4191      <flexcomp type=\"grid\" count=\"3 3 1\" spacing=\".1 .1 .1\" mass=\"1\"\
4192                name=\"myflex\" radius=\"0.001\" dim=\"2\">\
4193        <elasticity young=\"1e4\" poisson=\"0.0\"/>\
4194      </flexcomp>\
4195    </body>\
4196  </worldbody>\
4197</mujoco>";
4198
4199        let mut spec = MjSpec::from_xml_string(FLEX_MODEL).expect("failed to parse flex model");
4200        let flex = spec.flex("myflex").expect("flex 'myflex' not found in spec");
4201
4202        /* Verify field dimensions */
4203        assert_eq!(flex.cellcount().len(), 3);
4204
4205        /* Verify write-read roundtrip for order */
4206        {
4207            let flex_mut = spec.flex_mut("myflex").unwrap();
4208            flex_mut.set_order(2);
4209        }
4210        assert_eq!(spec.flex("myflex").unwrap().order(), 2);
4211
4212        {
4213            let flex_mut = spec.flex_mut("myflex").unwrap();
4214            flex_mut.set_order(1);
4215        }
4216        assert_eq!(spec.flex("myflex").unwrap().order(), 1);
4217    }
4218
4219    /// Verifies procedural flex creation via [`MjsBody::add_flexcomp`] (wraps `mjs_makeFlex`):
4220    /// the returned flex reflects the requested config, is registered in the spec, and the
4221    /// spec still compiles into a valid model.
4222    #[test]
4223    fn test_add_flexcomp() {
4224        let mut spec = MjSpec::new();
4225
4226        let config = MjFlexcompConfig::default()
4227            .with_type("grid")
4228            .with_dim(2)
4229            .with_count([3, 3, 1])
4230            .with_spacing([0.1, 0.1, 0.1])
4231            .with_radius(0.001)
4232            .with_mass(1.0);
4233
4234        {
4235            let flex = spec.world_body_mut().add_flexcomp("genflex", &config);
4236            assert_eq!(flex.dim(), 2);
4237            assert!((flex.radius() - 0.001).abs() < 1e-12);
4238            flex.set_edgedamping(1.0);
4239        }
4240
4241        /* The generated flex is registered in the spec and addressable by name. */
4242        let flex = spec.flex("genflex").expect("generated flex not found in spec");
4243        assert_eq!(flex.cellcount().len(), 3);
4244
4245        /* The spec with the generated flex still compiles into a valid model. */
4246        spec.compile().expect("spec with generated flex failed to compile");
4247    }
4248
4249    /// A config that sets only the structural fields must compile and keep MuJoCo's own default
4250    /// dim (2) and radius (0.005).
4251    #[test]
4252    fn test_flexcomp_config_defaults() {
4253        let mut spec = MjSpec::new();
4254
4255        let config = MjFlexcompConfig::default()
4256            .with_count([3, 3, 1])
4257            .with_spacing([0.1, 0.1, 0.1])
4258            .with_mass(1.0);
4259
4260        {
4261            let flex = spec.world_body_mut().add_flexcomp("genflex", &config);
4262            assert_eq!(flex.dim(), 2);
4263            assert!((flex.radius() - 0.005).abs() < 1e-12);
4264            flex.set_edgedamping(1.0);
4265        }
4266
4267        spec.compile().expect("spec with defaulted flex failed to compile");
4268    }
4269
4270    /// Verifies the sensor's objtype protection works.
4271    #[test]
4272    #[should_panic]
4273    fn test_sensor_objtype_failure() {
4274        let mut spec = MjSpec::new();
4275        spec.add_sensor()
4276            .with_objtype(MjtObj::mjOBJ_FRAME);
4277    }
4278
4279    /// Verifies the sensor's reftype protection works.
4280    #[test]
4281    #[should_panic]
4282    fn test_sensor_reftype_failure() {
4283        let mut spec = MjSpec::new();
4284        spec.add_sensor()
4285            .with_reftype(MjtObj::mjOBJ_FRAME);
4286    }
4287
4288    /// Verifies the fallible sensor objtype/reftype setters reject meta variants and accept real ones.
4289    #[test]
4290    fn test_sensor_objtype_reftype_setters() {
4291        let mut spec = MjSpec::new();
4292        let sensor = spec.add_sensor();
4293
4294        assert!(matches!(
4295            sensor.set_objtype(MjtObj::mjOBJ_MODEL),
4296            Err(MjEditError::InvalidParameter(_))
4297        ));
4298        assert!(matches!(
4299            sensor.set_reftype(MjtObj::mjOBJ_DEFAULT),
4300            Err(MjEditError::InvalidParameter(_))
4301        ));
4302        assert!(sensor.set_objtype(MjtObj::mjOBJ_SITE).is_ok());
4303        assert!(sensor.set_reftype(MjtObj::mjOBJ_BODY).is_ok());
4304    }
4305
4306    /// Verifies the tuple's objtype protection rejects meta object types and leaves the
4307    /// slice unwritten, while accepting real object types.
4308    #[test]
4309    fn test_tuple_objtype_validation() {
4310        let mut spec = MjSpec::new();
4311        let tuple = spec.add_tuple();
4312
4313        assert!(matches!(
4314            tuple.set_objtype(&[MjtObj::mjOBJ_BODY, MjtObj::mjOBJ_FRAME]),
4315            Err(MjEditError::InvalidParameter(_))
4316        ));
4317        assert!(tuple.set_objtype(&[MjtObj::mjOBJ_BODY, MjtObj::mjOBJ_GEOM]).is_ok());
4318    }
4319
4320    /// Verifies the numeric size setter rejects a negative size (which would undersize the
4321    /// `numeric_data` allocation in the model compiler) and accepts a non-negative one.
4322    #[test]
4323    fn test_numeric_size_validation() {
4324        let mut spec = MjSpec::new();
4325        let numeric = spec.add_numeric();
4326
4327        assert!(matches!(
4328            numeric.set_size(-1),
4329            Err(MjEditError::InvalidParameter(_))
4330        ));
4331        assert!(numeric.set_size(4).is_ok());
4332    }
4333
4334    #[test]
4335    fn test_spec_size_setters() {
4336        let mut spec = MjSpec::new();
4337
4338        assert!(matches!(spec.set_nuser_geom(-2), Err(MjEditError::InvalidParameter(_))));
4339        assert_eq!(spec.nuser_geom(), -1, "a rejected count must leave the field unchanged");
4340
4341        spec.set_nuser_geom(3).unwrap();
4342        spec.set_nuserdata(7);
4343        spec.set_nkey(2);
4344        spec.set_memory(1 << 20); // 1 MiB
4345
4346        let model = spec.compile().unwrap();
4347        assert_eq!(model.nuser_geom(), 3);
4348        assert_eq!(model.nuserdata(), 7);
4349        assert_eq!(model.nkey(), 2);
4350        assert_eq!(model.narena(), 1 << 20);
4351    }
4352
4353    /// A frame carries no default class name, so `default()` reports `None` for it, while a geom
4354    /// and a body added with a default class still yield one.
4355    #[test]
4356    fn test_frame_has_no_default() {
4357        let mut spec = MjSpec::new();
4358        assert!(spec.world_body_mut().add_frame().default().is_none());
4359        assert!(spec.world_body_mut().add_geom().default().is_some());
4360        assert!(spec.world_body_mut().default().is_some());
4361    }
4362
4363    /// `mjs_getId` casts to `mjCBase`, which `mjCDef` does not derive from, so a default class
4364    /// must report no id instead of the value read at that offset.
4365    #[test]
4366    fn test_default_has_no_id() {
4367        assert!(MjSpec::new().add_default("cls", None).id().is_none());
4368    }
4369
4370    /// A name that MuJoCo parses need not be valid UTF-8, so `delete` must reach the world body
4371    /// through its address; a name comparison would panic instead of deleting the body.
4372    #[test]
4373    fn test_delete_non_utf8_name() {
4374        // tinyxml2 encodes a character reference without validating it, so the surrogate U+D800
4375        // becomes the three bytes ED A0 80, which no UTF-8 string may hold.
4376        let mut spec = MjSpec::from_xml_string(
4377            "<mujoco><worldbody><body name=\"a&#xD800;b\"/></worldbody></mujoco>"
4378        ).unwrap();
4379
4380        let body = spec.world_body_mut().body_iter_mut().next().unwrap();
4381        // SAFETY: the spec owns the name string for as long as the element lives.
4382        let name = unsafe { CStr::from_ptr(mjs_getString(mjs_getName(body.element_mut_pointer()))) };
4383        assert!(
4384            std::str::from_utf8(name.to_bytes()).is_err(), "the test needs a non-UTF-8 name"
4385        );
4386
4387        unsafe { body.delete() }.unwrap();
4388        assert_eq!(spec.body_iter().count(), 1);
4389    }
4390
4391    /// `mjCWrap` leaves its elemtype at `mjOBJ_UNKNOWN`, which makes MuJoCo's duplicate-name check
4392    /// index a null list and crash, so naming a wrap must be rejected before the FFI call.
4393    #[test]
4394    fn test_wrap_set_name_rejected() {
4395        let mut spec = MjSpec::new();
4396        spec.world_body_mut().add_site().with_name("s0");
4397        let wrap = spec.add_tendon().wrap_site("s0");
4398        assert!(matches!(wrap.set_name("w0"), Err(MjEditError::UnsupportedOperation)));
4399        assert_eq!(wrap.name(), "s0");
4400    }
4401
4402    /// A frame's element type lies past the end of MuJoCo's element-list array, so `mjs_delete`
4403    /// corrupts the spec and still reports success.
4404    #[test]
4405    fn test_delete_rejects_frame() {
4406        let mut spec = MjSpec::new();
4407        let frame = spec.world_body_mut().add_frame();
4408        assert!(matches!(unsafe { frame.delete() }, Err(MjEditError::UnsupportedOperation)));
4409        assert_eq!(spec.world_body().frame_iter(false).count(), 1, "the frame stays in the body");
4410    }
4411
4412    /// The deprecated raw-pointer path reaches what a handle cannot: a null pointer, a default
4413    /// class, a tendon wrap, and an element of another spec.
4414    #[test]
4415    #[expect(deprecated, reason = "the test covers the deprecated method itself")]
4416    fn test_delete_element_guards() {
4417        let mut spec = MjSpec::new();
4418        assert!(matches!(
4419            unsafe { spec.delete_element(ptr::null_mut()) }, Err(MjEditError::DeleteFailed(_))
4420        ));
4421
4422        let default = spec.add_default("cls", None).element_mut_pointer();
4423        assert!(matches!(
4424            unsafe { spec.delete_element(default) }, Err(MjEditError::UnsupportedOperation)
4425        ));
4426
4427        spec.world_body_mut().add_site().with_name("s0");
4428        let wrap = spec.add_tendon().wrap_site("s0").element_mut_pointer();
4429        assert!(matches!(
4430            unsafe { spec.delete_element(wrap) }, Err(MjEditError::UnsupportedOperation)
4431        ));
4432
4433        let mut other = MjSpec::new();
4434        let foreign = other.world_body_mut().add_body().element_mut_pointer();
4435        assert!(matches!(
4436            unsafe { spec.delete_element(foreign) }, Err(MjEditError::DeleteFailed(_))
4437        ));
4438        assert_eq!(other.body_iter().count(), 2, "the foreign body stays in its own spec");
4439
4440        let hfield = spec.add_hfield().element_mut_pointer();
4441        unsafe { spec.delete_element(hfield) }.unwrap();
4442        assert_eq!(spec.hfield_iter().count(), 0, "an element of this spec still deletes");
4443    }
4444
4445    /// Deleting a body deletes its subtree, which the safety contract of `delete` builds on.
4446    #[test]
4447    fn test_delete_body_removes_subtree() {
4448        let mut spec = MjSpec::new();
4449        let parent = spec.world_body_mut().add_body();
4450        parent.set_name("parent").unwrap();
4451        parent.add_body().with_name("child");
4452        spec.body_mut("child").unwrap().add_geom().with_name("g0");
4453
4454        unsafe { spec.body_mut("parent").unwrap().delete() }.unwrap();
4455        assert!(spec.body("child").is_none(), "the child left the spec with its parent");
4456        assert!(spec.geom("g0").is_none(), "so did the geom of the child");
4457        assert_eq!(spec.body_iter().count(), 1, "only the world body stays");
4458    }
4459
4460    /// Deleting a body frees the elements that referenced it, so their address returns to the
4461    /// allocator and a later element can take it.
4462    #[test]
4463    fn test_delete_body_releases_dependents() {
4464        const MODEL_WITH_ACTUATOR: &str = "\
4465<mujoco>
4466  <worldbody>
4467    <body name=\"arm\"><joint name=\"j0\" type=\"hinge\"/><geom size=\".1\"/></body>
4468  </worldbody>
4469  <actuator><motor name=\"a0\" joint=\"j0\"/></actuator>
4470</mujoco>";
4471
4472        let mut spec = MjSpec::from_xml_string(MODEL_WITH_ACTUATOR).unwrap();
4473        unsafe { spec.body_mut("arm").unwrap().delete() }.unwrap();
4474        assert!(spec.actuator("a0").is_none(), "the delete released the dependent actuator");
4475        assert_eq!(spec.actuator_iter().count(), 0, "no walk reaches it either");
4476    }
4477
4478    /// Compiling with `discardvisual` frees the visual geoms and their assets, so a later element
4479    /// can take the address of a discarded one, and a walk must still reach only live geoms.
4480    ///
4481    /// The test walks instead of looking a name up: MuJoCo leaves the discarded name in its id map,
4482    /// so `MjSpec::geom("visual")` throws out of C++ here. That is a separate defect of the lookup.
4483    #[test]
4484    fn test_delete_after_discardvisual() {
4485        const VISUAL: &str = "\
4486<mujoco>
4487  <worldbody>
4488    <geom name=\"solid\" size=\".1\"/>
4489    <geom name=\"visual\" size=\".1\" contype=\"0\" conaffinity=\"0\" group=\"3\"/>
4490  </worldbody>
4491</mujoco>";
4492
4493        let mut spec = MjSpec::from_xml_string(VISUAL).unwrap();
4494        spec.compiler_mut().set_discardvisual(true);
4495        spec.compile().unwrap();
4496        assert_eq!(spec.geom_iter().count(), 1, "the compile discarded the visual geom");
4497        assert_eq!(spec.geom_iter().next().unwrap().name(), "solid");
4498
4499        for i in 0..64 {
4500            spec.world_body_mut().add_geom().with_name(&format!("fresh{i}"))
4501                .with_size([0.1, 0.0, 0.0]);
4502        }
4503        assert!(
4504            unsafe { spec.geom_iter_mut().next().unwrap().delete() }.is_ok(), "the survivor deletes"
4505        );
4506        assert_eq!(spec.geom_iter().count(), 64);
4507    }
4508
4509    /// A walk that deletes the first element of each round removes a whole selection, which is how
4510    /// a caller deletes in bulk without holding a handle across a deletion.
4511    #[test]
4512    fn test_delete_removes_a_whole_selection() {
4513        let mut spec = MjSpec::new();
4514        let world = spec.world_body_mut();
4515        for name in ["g0", "g1", "g2"] {
4516            world.add_geom().with_name(name).with_size([0.1, 0.0, 0.0]);
4517        }
4518
4519        let mut deleted = 0;
4520        while let Some(geom) = spec.geom_iter_mut().next() {
4521            unsafe { geom.delete() }.unwrap();
4522            deleted += 1;
4523        }
4524        assert_eq!(deleted, 3);
4525        assert_eq!(spec.geom_iter().count(), 0);
4526    }
4527
4528    /// Every element kind that `delete` accepts removes through it.
4529    #[test]
4530    fn test_delete_every_element_kind() {
4531        let mut spec = MjSpec::new();
4532        macro_rules! delete_added {
4533            ($($kind:ident),*) => {paste::paste! {$({
4534                let element = spec.[<$kind _iter_mut>]().next().unwrap();
4535                assert!(unsafe { element.delete() }.is_ok(), stringify!($kind));
4536                assert_eq!(spec.[<$kind _iter>]().count(), 0, stringify!($kind));
4537            })*}};
4538        }
4539
4540        let world = spec.world_body_mut();
4541        world.add_body();
4542        world.add_geom();
4543        world.add_site();
4544        world.add_camera();
4545        world.add_light();
4546        world.body_iter_mut().last().unwrap().add_joint();
4547        spec.add_actuator();
4548        spec.add_pair();
4549        spec.add_equality();
4550        spec.add_tendon();
4551        spec.add_mesh();
4552        spec.add_material();
4553        spec.add_sensor();
4554        spec.add_flex();
4555        spec.add_exclude();
4556        spec.add_numeric();
4557        spec.add_text();
4558        spec.add_tuple();
4559        spec.add_key();
4560        spec.add_hfield();
4561        spec.add_skin();
4562        spec.add_texture();
4563        spec.add_plugin();
4564
4565        delete_added!(
4566            joint, geom, site, camera, light, actuator, pair, equality, tendon, mesh, material,
4567            sensor, flex, exclude, numeric, text, tuple, key, hfield, skin, texture, plugin
4568        );
4569
4570        // The world body stays, so the body list never empties.
4571        assert!(unsafe { spec.world_body_mut().body_iter_mut().last().unwrap().delete() }.is_ok(), "body");
4572        assert_eq!(spec.body_iter().count(), 1);
4573    }
4574
4575    /// Tests the attachment mechanism (wrapper around [`mjs_attach`]).
4576    #[test]
4577    fn test_attachment() {
4578        const BASE_MODEL: &str = r#"
4579            <mujoco>
4580                <worldbody>
4581                    <frame name="base_frame_1">
4582                        <body name="base_frame_1_body_1">
4583                            <geom size="5" name="base_frame_1_body_1_sphere"/>
4584                        </body>
4585                    </frame>
4586                </worldbody>
4587            </mujoco>
4588        "#;
4589
4590        for prefix in ["", "attached_", "added"] {
4591            for suffix in ["", "_attached", "_added"] {
4592                let mut frame_spec = MjSpec::from_xml_string(BASE_MODEL).unwrap();
4593                let mut main_spec = MjSpec::new();
4594                let frame = frame_spec.frame_mut("base_frame_1").unwrap();
4595                main_spec.world_body_mut().add_frame()
4596                    .attach_by_deep_copy(frame, prefix, suffix).expect("attachment failed");
4597
4598                let renamed = |name: &str| format!("{prefix}{name}{suffix}");
4599
4600                // Deep copy renames the copy that the parent holds, thus the child keeps its
4601                // own names.
4602                assert!(
4603                    frame_spec.geom("base_frame_1_body_1_sphere").is_some(),
4604                    "the child spec lost its own name"
4605                );
4606                assert_eq!(
4607                    frame_spec.geom(&renamed("base_frame_1_body_1_sphere")).is_some(),
4608                    prefix.is_empty() && suffix.is_empty(),
4609                    "the renamed element appeared in the child spec"
4610                );
4611
4612                main_spec.frame(&renamed("base_frame_1")).expect("frame not attached");
4613                main_spec.body(&renamed("base_frame_1_body_1")).expect("body not attached");
4614                main_spec.geom(&renamed("base_frame_1_body_1_sphere")).expect("geom not attached");
4615
4616                // The original name survives only when both prefix and prefix are empty.
4617                assert_eq!(
4618                    main_spec.geom("base_frame_1_body_1_sphere").is_some(),
4619                    prefix.is_empty() && suffix.is_empty()
4620                );
4621
4622                // An invalid name to validate that the lookups don't just look like they work.
4623                assert!(main_spec.geom("base_frame_1_body_1_sphereinvalid").is_none());
4624
4625                let model = main_spec.compile().expect("compilation of the attached spec failed");
4626                assert_eq!(model.nbody(), 2, "the world body plus the attached one");
4627                assert_eq!(model.ngeom(), 1);
4628            }
4629        }
4630    }
4631
4632    /// Tests every parent/child pair that [`Attach`] and [`AttachTo`] support.
4633    #[test]
4634    fn test_attachment_pairs() {
4635        const PARENT_MODEL: &str = r#"
4636            <mujoco>
4637                <worldbody>
4638                    <body name="parent_body"/>
4639                    <frame name="parent_frame"/>
4640                    <site name="parent_site"/>
4641                </worldbody>
4642            </mujoco>
4643        "#;
4644
4645        const CHILD_MODEL: &str = r#"
4646            <mujoco>
4647                <worldbody>
4648                    <frame name="child_frame">
4649                        <body name="child_body">
4650                            <geom size="5" name="child_geom"/>
4651                        </body>
4652                    </frame>
4653                </worldbody>
4654            </mujoco>
4655        "#;
4656
4657        // Compiles the parent and checks that the child subtree arrived once, under the new names.
4658        #[track_caller]
4659        fn assert_attached(parent: &mut MjSpec) {
4660            assert!(parent.geom("p_child_geom_s").is_some(), "the geom is not namespaced");
4661            let model = parent.compile().expect("cannot compile the attached spec");
4662            assert_eq!((model.nbody(), model.ngeom()), (3, 1), "wrong element counts");
4663        }
4664
4665        // A fresh pair for every attachment, as an attachment changes both specs.
4666        let specs = || (
4667            MjSpec::from_xml_string(PARENT_MODEL).unwrap(),
4668            MjSpec::from_xml_string(CHILD_MODEL).unwrap()
4669        );
4670
4671        /* A body as the child. */
4672        let (mut parent, mut child) = specs();
4673        parent.frame_mut("parent_frame").unwrap()
4674            .attach_by_deep_copy(child.body_mut("child_body").unwrap(), "p_", "_s").unwrap();
4675        assert_attached(&mut parent);
4676
4677        let (mut parent, mut child) = specs();
4678        parent.site_mut("parent_site").unwrap()
4679            .attach_by_deep_copy(child.body_mut("child_body").unwrap(), "p_", "_s").unwrap();
4680        assert_attached(&mut parent);
4681
4682        /* A frame as the child. */
4683        let (mut parent, mut child) = specs();
4684        parent.frame_mut("parent_frame").unwrap()
4685            .attach_by_deep_copy(child.frame_mut("child_frame").unwrap(), "p_", "_s").unwrap();
4686        assert_attached(&mut parent);
4687
4688        let (mut parent, mut child) = specs();
4689        parent.site_mut("parent_site").unwrap()
4690            .attach_by_deep_copy(child.frame_mut("child_frame").unwrap(), "p_", "_s").unwrap();
4691        assert_attached(&mut parent);
4692
4693        /* A whole specification as the child. */
4694        let (mut parent, mut child) = specs();
4695        parent.body_mut("parent_body").unwrap().attach_by_deep_copy(&mut child, "p_", "_s").unwrap();
4696        assert_attached(&mut parent);
4697
4698        let (mut parent, mut child) = specs();
4699        parent.frame_mut("parent_frame").unwrap().attach_by_deep_copy(&mut child, "p_", "_s").unwrap();
4700        assert_attached(&mut parent);
4701
4702        let (mut parent, mut child) = specs();
4703        parent.site_mut("parent_site").unwrap().attach_by_deep_copy(&mut child, "p_", "_s").unwrap();
4704        assert_attached(&mut parent);
4705    }
4706
4707    /// Tests whether deep-copy works during attachment.
4708    #[test]
4709    fn test_deep_copy_attach() {
4710        // Deep attach off.
4711        let mut child_spec = MjSpec::new();
4712        let mut parent_spec = MjSpec::new();
4713        // SAFETY: the test takes no element handle of the child after the attachment.
4714        unsafe {
4715            parent_spec.world_body_mut().add_frame()
4716                .attach_by_reference(&mut child_spec, "", "")
4717        }.unwrap();
4718
4719        // Should error with attached reference errors
4720        let result = child_spec.compile().unwrap_err();
4721        assert!(
4722            matches!(result, MjEditError::CompileFailed(e) if e.contains("attached by reference")),
4723            "a child attached by reference compiled without reference, which is wrong as the parent shares the references"
4724        );
4725
4726        // Should compile regulary, both parent and attached child.
4727        parent_spec.compile().unwrap();
4728
4729        // A fresh pair, which keeps the deep copy that a new specification enables.
4730        let mut new_parent_spec = MjSpec::new();
4731        let mut new_child_spec = MjSpec::new();
4732        new_parent_spec.world_body_mut().attach_by_deep_copy(&mut new_child_spec, "", "").unwrap();
4733        assert!(
4734            new_child_spec.compile().is_ok(),
4735            "child spec should not be attached by reference when deep-copy is enabled"
4736        );
4737        new_parent_spec.compile().unwrap();
4738    }
4739}