Skip to content

init

DEFAULT_MUSCULOSKELETAL_XML = MUSCULOSKELETAL_MODEL_DIR / 'best_combined_arm_damping_stiff_cvt3.xml' module-attribute

FlyMimic's muscle-driven fly with passive joint stiffness + spring refs.

This is the arm_damping_stiff variant: 15 left-front-leg muscles and body geometry, plus biologically-motivated passive joint stiffness (0.4) and per-joint spring reference angles.

DEFAULT_SCENE_CAMERA = 'scene' module-attribute

Name assigned to FlyMimic's (otherwise unnamed) world camera so it can be selected for rendering via Simulation.set_renderer — e.g. when recording a rollout video of a trained policy.

MUSCULOSKELETAL_MODEL_DIR = assets_dir / 'model/musculoskeletal' module-attribute

Directory holding FlyMimic's musculoskeletal MJCF. The body meshes it references are not bundled here (see MUSCULOSKELETAL_MESH_DIR), and the imitation-learning mocap clips live with the demo, in flygym_demo.muscle_imitation.

ActuatorType

Bases: Enum

Actuator types supported by MuJoCo. See MuJoCo XML reference for details on each type.

Source code in src/flygym/compose/fly/base_fly.py
class ActuatorType(Enum):
    """Actuator types supported by MuJoCo.
    See [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator)
    for details on each type."""

    MOTOR = "motor"
    POSITION = "position"
    VELOCITY = "velocity"
    INTVELOCITY = "intvelocity"
    DAMPER = "damper"
    CYLINDER = "cylinder"
    MUSCLE = "muscle"
    ADHESION = "adhesion"
    TENDON = "tendon"

BaseFly

Bases: BaseCompositionElement

Abstract base for all fly body models (e.g. NeuroMechFly, FlyBody).

FlyGym supports multiple fly body models that can be used interchangeably within the same simulation API. Concrete subclasses provide model-specific geometry assets, anatomy classes, and parameter defaults, while all model-agnostic composition logic lives here. Choose the model that best fits your experiment:

  • NeuroMechFly — The default model, derived from micro-CT imaging. Suitable for most locomotion and sensorimotor experiments.
  • FlyBody — A biomechanically detailed model from Vaxenburg et al. (2025), with wing and abdomen degrees of freedom and anatomically grounded parameters.

This class is not meant to be instantiated directly — use a concrete subclass.

Holds the model-agnostic composition logic shared by every fly. Concrete subclasses supply the model identity (asset paths, anatomy classes, scale) and may override individual build steps.

Represents a complete fly with body segments, joints, actuators, sensors, and cameras. The fly is built from mesh assets and configured via config files that define rigging (joint positions), visuals (colors/textures), and global MuJoCo parameters.

The fly uses a hierarchical body structure with a root segment (typically the thorax) from which all other segments branch. Joints and actuators are added separately after initialization to allow flexible model configurations.

PyMJCF -> MjSpec migration (v2.1.0)

FlyGym 2.1.0 dropped the PyMJCF backend in favour of MuJoCo's native MjSpec API. If you are upgrading from an earlier version, see the v2.1.0 changelog for breaking changes and a migration guide.

Parameters:

Name Type Description Default
name str

Identifier for this fly instance.

'fly'
rigging_config_path PathLike

Path to YAML file defining body segment positions, orientations, and masses.

required
mesh_basedir PathLike

Directory containing STL mesh files for body segments.

required
mujoco_globals_path PathLike

Path to YAML file with global MuJoCo parameters (timestep, gravity, etc.).

required
root_segment BodySegment | str

Root body segment for the kinematic tree (e.g., c_thorax).

'c_thorax'
mirror_left2right bool

If True, mirror left-side meshes for right side instead of loading separate mesh files. Reduces asset size and ensures symmetry.

required
mesh_type MeshType

Mesh resolution to use.

required
geom_fitting_option GeomFittingOption

How to fit collision geometries.

UNMODIFIED

Attributes:

Name Type Description
skeleton Skeleton | None

Joint structure of the fly, set when add_joints() is called.

bodyseg_to_mjcfmesh

Maps body segments to MJCF mesh elements.

bodyseg_to_mjcfbody

Maps body segments to MJCF body elements.

bodyseg_to_mjcfgeom

Maps body segments to MJCF geometry elements.

jointdof_to_mjcfjoint

Maps joint DOFs to MJCF joint elements.

anatomicaljoint_to_mjcfsites

Maps anatomical joints to MJCF site elements.

jointdof_to_mjcfactuator_by_type

Maps actuator type to a further dictionary, which maps joint DOFs to MJCF actuator elements (only if the actuator exists).

sensorname_to_mjcfsensor

Maps sensor names to MJCF sensor elements.

cameraname_to_mjcfcamera

Maps camera names to MJCF camera elements.

jointdof_to_neutralangle

Neutral (resting) angle for each joint DOF.

jointdof_to_neutralaction_by_type

Neutral actuator input for each (actuator_type, joint_dof) pair. Maps actuator type to a further dictionary, which maps joint DOFs to their neutral actuator input (only if the actuator exists).

Source code in src/flygym/compose/fly/base_fly.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
class BaseFly(BaseCompositionElement):
    """Abstract base for all fly body models (e.g. `NeuroMechFly`, `FlyBody`).

    FlyGym supports multiple fly body models that can be used interchangeably within
    the same simulation API. Concrete subclasses provide model-specific geometry assets,
    anatomy classes, and parameter defaults, while all model-agnostic composition logic
    lives here. Choose the model that best fits your experiment:

    - `NeuroMechFly` — The default model, derived from micro-CT imaging. Suitable for
      most locomotion and sensorimotor experiments.
    - `FlyBody` — A biomechanically detailed model from Vaxenburg et al. (2025), with
      wing and abdomen degrees of freedom and anatomically grounded parameters.

    This class is not meant to be instantiated directly — use a concrete subclass.

    Holds the model-agnostic composition logic shared by every fly. Concrete
    subclasses supply the model identity (asset paths, anatomy classes, scale)
    and may override individual build steps.

    Represents a complete fly with body segments, joints, actuators, sensors, and
    cameras. The fly is built from mesh assets and configured via config files that
    define rigging (joint positions), visuals (colors/textures), and global MuJoCo
    parameters.

    The fly uses a hierarchical body structure with a root segment (typically the
    thorax) from which all other segments branch. Joints and actuators are added
    separately after initialization to allow flexible model configurations.

    !!! warning "PyMJCF -> MjSpec migration (v2.1.0)"

        FlyGym 2.1.0 dropped the PyMJCF backend in favour of MuJoCo's native
        ``MjSpec`` API. If you are upgrading from an earlier version, see the
        [v2.1.0 changelog](https://neuromechfly.org/changelog/#version-210)
        for breaking changes and a migration guide.

    Args:
        name:
            Identifier for this fly instance.
        rigging_config_path:
            Path to YAML file defining body segment positions, orientations, and masses.
        mesh_basedir:
            Directory containing STL mesh files for body segments.
        mujoco_globals_path:
            Path to YAML file with global MuJoCo parameters (timestep, gravity, etc.).
        root_segment:
            Root body segment for the kinematic tree (e.g., ``c_thorax``).
        mirror_left2right:
            If True, mirror left-side meshes for right side instead of loading separate
            mesh files. Reduces asset size and ensures symmetry.
        mesh_type:
            Mesh resolution to use.
        geom_fitting_option:
            How to fit collision geometries.

    Attributes:
        skeleton:
            Joint structure of the fly, set when add_joints() is called.
        bodyseg_to_mjcfmesh:
            Maps body segments to MJCF mesh elements.
        bodyseg_to_mjcfbody:
            Maps body segments to MJCF body elements.
        bodyseg_to_mjcfgeom:
            Maps body segments to MJCF geometry elements.
        jointdof_to_mjcfjoint:
            Maps joint DOFs to MJCF joint elements.
        anatomicaljoint_to_mjcfsites:
            Maps anatomical joints to MJCF site elements.
        jointdof_to_mjcfactuator_by_type:
            Maps actuator type to a further dictionary, which maps joint DOFs to MJCF
            actuator elements (only if the actuator exists).
        sensorname_to_mjcfsensor:
            Maps sensor names to MJCF sensor elements.
        cameraname_to_mjcfcamera:
            Maps camera names to MJCF camera elements.
        jointdof_to_neutralangle:
            Neutral (resting) angle for each joint DOF.
        jointdof_to_neutralaction_by_type:
            Neutral actuator input for each (actuator_type, joint_dof) pair. Maps
            actuator type to a further dictionary, which maps joint DOFs to their
            neutral actuator input (only if the actuator exists).
    """

    # For numerical reasons, we simulate length in mm, not m. This changes the units
    # of other quantities as well, for example acceleration is now in mm/s^2.
    SCALE = 1000
    BODY_SEGMENT_CLASS = BodySegment
    JOINT_DOF_CLASS = JointDOF
    AXIS_ORDER_CLASS = AxisOrder
    BASE_SKELETON_CLASS = Skeleton
    CONTACT_BODIES_PRESET_CLASS = ContactBodiesPreset
    LEG_LINKS = LEG_LINKS

    def __init__(
        self,
        name: str = "fly",
        *,
        rigging_config_path: PathLike,
        mesh_basedir: PathLike,
        mujoco_globals_path: PathLike,
        mirror_left2right: bool,
        mesh_type: MeshType,
        vision_config_path: PathLike,
        root_segment: BodySegment | str = "c_thorax",
        geom_fitting_option: GeomFittingOption = GeomFittingOption.UNMODIFIED,
    ) -> None:
        self._name = name
        self._mjcf_root = mj.MjSpec()
        self._mjcf_root.modelname = name
        # Keep the globals path so the world can inherit the fly's physics settings:
        # MjSpec.attach() does not merge the child's <option>/<compiler> into the
        # parent, so these must be (re)applied to the world spec that gets compiled.
        self.mujoco_globals_path = mujoco_globals_path
        set_mujoco_globals(self.mjcf_root, mujoco_globals_path)

        self.skeleton: Skeleton | None = None

        self.bodyseg_to_mjcfmesh = {}
        self.bodyseg_to_mjcfbody = {}
        self.bodyseg_to_mjcfgeom = {}
        self.jointdof_to_mjcfjoint = {}
        self.jointdof_to_mjcfactuator_by_type = {ty: {} for ty in ActuatorType}
        self.leg_to_adhesionactuator = {}
        self.anatomicaljoint_to_mjcfsites = {}
        self.sensorname_to_mjcfsensor = {}
        self.cameraname_to_mjcfcamera = {}
        self.eyecameraname_to_mjcfcamera = {}

        self.jointdof_to_neutralangle = {}
        self.jointdof_to_neutralaction_by_type = {ty: {} for ty in ActuatorType}

        if isinstance(root_segment, str):
            root_segment = self.BODY_SEGMENT_CLASS(root_segment)
        self.root_segment = root_segment

        self._neutral_keyframe = self.mjcf_root.add_key(name="neutral", time=0)

        self._add_mesh_assets(mesh_basedir, mirror_left2right, mesh_type)
        self._add_bodies_and_geoms(
            rigging_config_path, geom_fitting_option, vision_config_path
        )
        self.vision_config_path = vision_config_path

    @override
    @property
    def mjcf_root(self) -> mj.MjSpec:
        return self._mjcf_root

    @property
    def name(self) -> str:
        """Name of this fly instance."""
        return self._name

    @override
    def compile(self) -> tuple[mj.MjModel, mj.MjData]:
        """Compile the fly on its own (e.g. for `preview_model` or `save_xml`).

        Disables `fusestatic` for the standalone compile. A lone fly has no free joint,
        so its root segment is a static body that the optimization would fuse into the
        worldbody -- which breaks the `track`-mode tracking camera parented to it (the
        camera would fall back to tracking the worldbody and mis-place itself relative to
        the fly). A standalone fly is only ever inspected/previewed, never simulated, so
        the lost optimization does not matter here. The setting is applied to the
        compiled copy only, leaving the live spec untouched so a world can still fuse
        the fly's other static bodies once it is attached.

        As in the base method, we always compile a *copy* rather than the live spec:
        compiling mutates the spec in place, which would invalidate the element
        references FlyGym holds.
        """
        spec = self.mjcf_root.copy()
        if spec.compiler.fusestatic:
            warnings.warn(
                "Compiling a fly model that is not attached to a world. "
                "`fusestatic` is changed to false to prevent the root body segment "
                "from being fused with the MJCF root, which would impair the placement "
                "of the tracking camera."
            )
            spec.compiler.fusestatic = False
        model = spec.compile()
        return model, mj.MjData(model)

    def get_bodysegs_order(self) -> list[BodySegment]:
        """Get the canonical order of body segments. The exact order is not important,
        but it should be respected consistently throughout. For example, during
        simulation, the fly body state returned by the simulator will be in this order.
        """
        return list(self.bodyseg_to_mjcfbody.keys())

    @property
    def n_bodysegs(self) -> int:
        """Number of body segments in this fly."""
        return len(self.bodyseg_to_mjcfbody)

    def get_jointdofs_order(self) -> list[JointDOF]:
        """Same as `get_bodysegs_order()`, but for joint DoFs instead of body segments."""
        return list(self.jointdof_to_mjcfjoint.keys())

    @property
    def n_jointdofs(self) -> int:
        """Number of joint DoFs in this fly."""
        return len(self.jointdof_to_mjcfjoint)

    def get_actuated_jointdofs_order(
        self, actuator_type: "ActuatorType | str"
    ) -> list[JointDOF]:
        """Same as `get_jointdofs_order()`, but only for the subset of joint DoFs that
        are actuated by the specified actuator type. During simulation, the user should
        provide control input in this order."""
        actuator_type = ActuatorType(actuator_type)
        return list(self.jointdof_to_mjcfactuator_by_type[actuator_type].keys())

    @property
    def n_actuated_jointdofs(self) -> int:
        raise AttributeError(
            "`n_actuated_jointdofs` is ambiguous because there might be different "
            "actuator types. Use `get_n_actuated_jointdofs(actuator_type)` instead, "
            "similar to `fly.get_actuated_jointdofs_order(actuator_type)`."
        )

    def get_n_actuated_jointdofs(self, actuator_type: "ActuatorType | str") -> int:
        """Number of joint DoFs actuated by the specified actuator type."""
        actuator_type = ActuatorType(actuator_type)
        return len(self.jointdof_to_mjcfactuator_by_type[actuator_type])

    def get_legs_order(self) -> list[str]:
        """Get the ordered list of leg position identifiers (same as `anatomy.LEGS`)."""
        return LEGS

    def get_pose_lookup(
        self, neutral_pose: KinematicPose | KinematicPosePreset | None
    ) -> dict[str, float]:
        """Get a lookup dictionary mapping joint DOF names to neutral angles for a given
        neutral pose."""

        if self.skeleton is None:
            raise FlyGymInternalError("Skeleton must be defined to get pose lookup.")

        if neutral_pose is None:
            return {}
        elif isinstance(neutral_pose, KinematicPose):
            return neutral_pose.joint_angles_lookup_rad
        elif isinstance(neutral_pose, KinematicPosePreset):
            neutral_pose = neutral_pose.get_pose_by_axis_order(self.skeleton.axis_order)
            return neutral_pose.joint_angles_lookup_rad
        else:
            raise ValueError(
                "When specified, `neutral_pose` must be a "
                "`KinematicPose` or `KinematicPosePreset`."
            )

    def _resolve_neutral_input(
        self,
        actuator_type: "ActuatorType",
        neutral_input: "dict[str, float] | KinematicPose | KinematicPosePreset | None",
    ) -> dict[str, float]:
        """Resolve ``neutral_input`` to a ``{DoF name: value}`` lookup.

        A ``dict`` is already a ``{DoF name: value}`` lookup and is used as-is for
        any actuator type. For POSITION actuators the values are joint angles, so a
        ``KinematicPose`` / ``KinematicPosePreset`` is also accepted and resolved via
        :meth:`get_pose_lookup`. For other actuator types the values are raw actuator
        inputs (torque, velocity, ...), for which a pose object is meaningless and so
        rejected.
        """
        if actuator_type == ActuatorType.POSITION:
            # A dict already maps DoF names to angles; pass it through. Only
            # None / KinematicPose / KinematicPosePreset need resolving.
            if isinstance(neutral_input, dict):
                return neutral_input
            return self.get_pose_lookup(neutral_input)
        if isinstance(neutral_input, (KinematicPose, KinematicPosePreset)):
            raise ValueError(
                "When actuator_type is not POSITION, neutral_input cannot be a "
                "KinematicPose or KinematicPosePreset since those specify joint "
                "angles, not actuator inputs."
            )
        return {} if neutral_input is None else neutral_input

    def get_sites_order(self) -> list[AnatomicalJoint]:
        """Get the canonical order of anatomical joints with associated MJCF sites.

        This is the order used by simulation site-state readout methods such as
        ``Simulation.get_site_positions``.
        """
        return list(self.anatomicaljoint_to_mjcfsites.keys())

    def add_joints(
        self,
        skeleton: Skeleton,
        neutral_pose: KinematicPose | KinematicPosePreset | None = None,
        *,
        stiffness: float = 10.0,
        damping: float = 0.5,
        armature: float = 1e-6,
        **kwargs: Any,
    ) -> dict[JointDOF, mj.MjsJoint]:
        """Add joints to the fly model based on a skeleton definition.

        Creates hinge joints connecting body segments according to the skeleton's
        kinematic tree structure. Each joint is configured with passive spring-damper
        dynamics and a neutral (resting) angle.

        Args:
            skeleton:
                Skeleton defining which joints to create and their DOFs.
            neutral_pose:
                Resting angles for joints. If provided, must match skeleton's axis
                order. If not provided, all neutral angles default to 0.
            stiffness:
                Joint stiffness (spring constant).
            damping:
                Joint damping coefficient.
            armature:
                Additional inertia added to the joint for numerical stability. Should be
                small enough to not affect dynamics.
            **kwargs:
                Additional arguments passed to MJCF joint creation. See
                [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-joint)
                for details on supported attributes.

        Returns:
            Dictionary mapping JointDOF to created MJCF joint elements.
        """

        if missing := [
            s for s in skeleton.body_segments if s not in self.bodyseg_to_mjcfbody
        ]:
            raise ValueError(
                f"Skeleton contains body segments that {type(self).__name__} does "
                f"not have: {[s.name for s in missing]}. Was the skeleton built from "
                "a joint preset for another fly model?"
            )
        self.skeleton = skeleton
        neutral_angle_lookup = self.get_pose_lookup(neutral_pose)

        return_dict = {}
        for jointdof in skeleton.iter_jointdofs(self.root_segment):
            child_body = self.bodyseg_to_mjcfbody[jointdof.child]
            neutral_angle = neutral_angle_lookup.get(jointdof.name, 0.0)
            self.jointdof_to_neutralangle[jointdof] = neutral_angle

            # Flip axis direction for right side's roll and yaw so that axes are defined
            # symmetrically (e.g., positive roll is always "outward").
            vec = np.array(jointdof.axis.to_vector())
            if jointdof.child.pos[0] == "r" and not self._is_pitch(jointdof):
                vec = -vec

            return_dict[jointdof] = child_body.add_joint(
                name=jointdof.name,
                type=JOINT_TYPES["hinge"],
                axis=vec,
                stiffness=stiffness,
                damping=damping,
                armature=armature,
                springref=neutral_angle,
                **kwargs,
            )

        self.jointdof_to_mjcfjoint.update(return_dict)
        self._rebuild_neutral_keyframe()
        return return_dict

    def add_actuators(
        self,
        jointdofs: Iterable[JointDOF],
        actuator_type: "ActuatorType | str",
        neutral_input: "dict[str, float] | KinematicPose | KinematicPosePreset | None" = None,
        *,
        forcelimited: bool = True,
        forcerange: tuple[float, float] = (-30.0, 30.0),
        **kwargs: Any,
    ) -> dict[JointDOF, mj.MjsActuator]:
        """Add actuators to specified joints.

        Creates actuators that can apply forces/torques to joints. Multiple actuator
        types can be added to the same joints.

        Args:
            jointdofs:
                Joint DOFs to actuate.
            actuator_type:
                Type of actuator (motor, position, velocity, etc.).
            neutral_input:
                Default actuator inputs. Accepts a ``dict`` mapping DoF names to
                values, a `KinematicPose`, or a `KinematicPosePreset`. If None,
                defaults to 0 for all actuators. For position actuators the values
                are joint angles and must match the skeleton axis order.
            forcelimited:
                If True, actuators cannot exceed forcerange.
            forcerange:
                Force limit as a (min, max) tuple.
            **kwargs:
                Additional arguments passed to MJCF actuator creation (e.g., kp for
                position actuators, kv for velocity actuators). See
                [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator)
                for details on supported attributes.

        Returns:
            Dictionary mapping JointDOF to created MJCF actuator elements.
        """
        actuator_type = ActuatorType(actuator_type)
        neutral_input = self._resolve_neutral_input(actuator_type, neutral_input)

        return_dict = {}
        for jointdof in jointdofs:
            self.jointdof_to_neutralaction_by_type[actuator_type][jointdof] = (
                neutral_input.get(jointdof.name, 0.0)
            )
            actuator = add_actuator(
                self.mjcf_root,
                actuator_type.value,
                name=f"{jointdof.name}-{actuator_type.value}",
                joint=jointdof.name,
                forcelimited=forcelimited,
                forcerange=forcerange,
                **kwargs,
            )

            return_dict[jointdof] = actuator
        self.jointdof_to_mjcfactuator_by_type[actuator_type].update(return_dict)
        self._rebuild_neutral_keyframe()
        return return_dict

    def add_joint_sites(
        self, anatomical_joints: list[AnatomicalJoint]
    ) -> dict[AnatomicalJoint, mj.MjsSite]:
        """Add MJCF sites at the origins of selected anatomical joints.

        Each site is placed at ``(0, 0, 0)`` in the child body frame. Since body
        origins are defined at their parent-child joint locations in this model,
        these sites track anatomical joint positions in world coordinates during
        simulation.

        Args:
            anatomical_joints: Anatomical joints to materialize as MJCF sites.

        Returns:
            Dictionary mapping each anatomical joint to its created MJCF site
            element (same entries added into ``self.anatomicaljoint_to_mjcfsites``).

        Raises:
            ValueError: If a site for a requested anatomical joint already exists.
        """
        return_dict = {}
        for joint in anatomical_joints:
            if joint in self.anatomicaljoint_to_mjcfsites:
                raise ValueError(
                    f"A site has already been added for anatomical joint '{joint.name}'."
                )
            child_body_element = self.bodyseg_to_mjcfbody[joint.child]
            site = child_body_element.add_site(
                name=joint.name,
                pos=(0, 0, 0),  # origin of child body is defined at joint to parent
            )
            return_dict[joint] = site
        self.anatomicaljoint_to_mjcfsites.update(return_dict)
        return return_dict

    def add_leg_adhesion(
        self, gain: float | dict[str, float] = 1.0
    ) -> dict[str, mj.MjsActuator]:
        """Add adhesion actuators to the tarsus5 segments of all legs.

        Adhesion actuators apply a normal attraction force, enabling the fly to grip
        surfaces. The control input per leg ranges from 0 to 1, where 0 fully
        releases adhesion and 1 applies the configured gain.

        Args:
            gain: Adhesion actuator gain. Either a single float applied to all legs,
                or a dict mapping leg position identifiers to per-leg gain values.

        Returns:
            Dict mapping leg position identifier to the created MJCF adhesion
            actuator element (same as ``self.leg_to_adhesionactuator``).

        Raises:
            ValueError: If adhesion actuators have already been added.
        """
        if len(self.leg_to_adhesionactuator) > 0:
            raise ValueError("Leg adhesion actuators have already been added.")
        for leg in LEGS:
            tarsus5 = BodySegment(f"{leg}_tarsus5")
            if isinstance(gain, dict):
                gain_this_leg = gain[leg]
            else:
                gain_this_leg = gain
            self.leg_to_adhesionactuator[leg] = add_actuator(
                self.mjcf_root,
                "adhesion",
                name=f"{tarsus5.name}-adhesion",
                body=self.bodyseg_to_mjcfbody[tarsus5].name,
                gain=gain_this_leg,
                ctrlrange=(0, 1),
            )
        return self.leg_to_adhesionactuator

    def add_vision(self, draw_sensor_markers: bool = False) -> None:
        with open(self.vision_config_path) as f:
            info = yaml.safe_load(f)

        return_dict = {}

        for sensor_name, sensor_info in info["sensors"].items():
            parent_body = self.mjcf_root.body(sensor_info["parent"])
            sensor_body = parent_body.add_body(
                name=f"{sensor_name}_body",
                pos=sensor_info["rel_pos"],
            )
            cam = sensor_body.add_camera(
                name=f"{sensor_name}_camera",
                mode=CAMERA_MODES["fixed"],
                euler=sensor_info["orientation"],
                fovy=info["fovy_per_eye"],
            )

            # Add visual markers indicating where the eye sensors are
            # The MuJoCo renderer by default renders geoms of groups 0, 1, 2.
            # By convention, group 0 is for main visual/collision bodies, group 1 is for
            # helper/mocap geoms, and group 2 is for debug geoms. So if the user wants
            # to draw sensor markers, we put them in group 1. Among the groups that are
            # invisible by default, group 3 is often used for simplified physics geoms,
            # and group 4 is often for additional stuff. So if the user doesn't want to
            # draw sensor markers, we put them in group 4.
            geom_group = 1 if draw_sensor_markers else 4
            sensor_body.add_geom(
                name=f"{sensor_name}_marker",
                type=GEOM_TYPES["sphere"],
                size=[0.06, 0, 0],
                rgba=sensor_info["marker_rgba"],
                mass=0,
                contype=0,
                conaffinity=0,
                group=geom_group,
            )

            return_dict[sensor_name] = cam

        self.eyecameraname_to_mjcfcamera.update(return_dict)

    def colorize(self, visuals_config_path: PathLike) -> None:
        """Apply colors and textures to the fly model.

        Args:
            visuals_config_path: Path to the YAML file defining per-segment material
                and texture assignments.
        """
        if len(self.bodyseg_to_mjcfgeom) == 0:
            raise ValueError("Must first add geoms via `_add_bodies_and_geoms`.")

        vis_sets_all, lookup = self._parse_visuals_config(visuals_config_path)

        for vis_set_name, params in vis_sets_all.items():
            texture_name = None
            if texture_params := params.get("texture"):
                add_texture(self.mjcf_root, name=vis_set_name, **texture_params)
                texture_name = vis_set_name
            add_material(
                self.mjcf_root,
                name=vis_set_name,
                texture=texture_name,
                **params["material"],
            )

        for _, geoms in self.bodyseg_to_mjcfgeom.items():
            for geom in geoms:
                geom_name = geom.name
                vis_set_name = lookup[geom_name]
                geom.material = vis_set_name

    def add_tracking_camera(
        self,
        name: str = "trackcam",
        mode: str = "track",
        pos_offset: Vec3 = (-0.5, -7.5, 5),
        rotation: Rotation3D = Rotation3D("xyaxes", (1, 0, 0, 0, 0.6, 0.8)),
        fovy: float = 30.0,
        **kwargs: Any,
    ) -> mj.MjsCamera:
        """Add a camera that tracks the fly's root body.

        The camera is added *inside* the root segment's body element. MuJoCo's
        ``track``/``trackcom`` modes follow the camera's parent body, so the camera
        must be a child of the fly body to follow it; a camera placed in the world
        body would stay put. ``track`` follows the body's position while keeping a
        constant orientation in the world frame (a "follow" camera that pans but does
        not rotate with the fly).

        !!! warning

            ``pos_offset`` is expressed in the root segment's (thorax) body frame, not
            in world coordinates. This differs from FlyGym versions before the MjSpec
            migration, where the tracking camera lived in the world body and the offset
            was effectively a world-frame position. The default changed accordingly,
            from ``(0, -7.5, 6)`` to ``(-0.5, -7.5, 5)``. Hard-coded ``pos_offset``
            values tuned for the old world-frame placement must be re-tuned: the root
            segment sits roughly ``(0.5, 0, 1.3)`` mm from the fly's attachment point
            (plus the spawn height) in the neutral pose, so the same offset now places
            the camera higher and shifted toward the head. The upside is that a given
            ``pos_offset`` now yields the same camera position relative to the fly in
            every world and when the fly is compiled on its own.

        Args:
            name: Camera name.
            mode: MuJoCo camera tracking mode (``"track"``, ``"trackcom"``, or
                ``"fixed"``). ``"fixed"`` rigidly attaches the camera to the body so it
                also rotates with the fly.
            pos_offset: Camera position offset from the tracked root segment in mm.
            rotation: Camera orientation as a `Rotation3D`.
            fovy: Vertical field of view in degrees.
            **kwargs: Additional attributes passed to the MJCF camera element. See
                [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-camera).

        Returns:
            The created MJCF camera element.
        """
        root_body = self.bodyseg_to_mjcfbody[self.root_segment]
        camera = root_body.add_camera(
            name=name,
            mode=CAMERA_MODES[mode],
            pos=pos_offset,
            fovy=fovy,
            **rotation.as_kwargs(),
            **kwargs,
        )
        self.cameraname_to_mjcfcamera[name] = camera
        return camera

    def _add_mesh_assets(
        self, mesh_basedir: PathLike, mirror_left2right: bool, mesh_type: MeshType
    ) -> None:
        """Add this model's body meshes to the MJCF spec.

        Implemented by each concrete model: mesh file naming and the choice between
        package-bundled (simplified) and S3-hosted (fullsize) mesh directories are
        model-specific.
        """
        raise NotImplementedError

    def _all_possible_joint_preset(self):
        return JointPreset.ALL_POSSIBLE

    def _get_base_skeleton(self) -> Skeleton:
        return self.BASE_SKELETON_CLASS(
            joint_preset=self._all_possible_joint_preset(),
            axis_order=self.AXIS_ORDER_CLASS.DONTCARE,
        )

    def _is_pitch(self, jointdof: JointDOF) -> bool:
        return jointdof.axis == RotationAxis.PITCH

    def _add_bodies_and_geoms(
        self,
        rigging_config_path: PathLike,
        geom_fitting_option: GeomFittingOption,
        vision_config_path: PathLike,
    ) -> None:
        # Load rigging config
        with open(rigging_config_path) as f:
            rigging_config = yaml.safe_load(f)

        # Load vision config to find out which geoms should be invisible to the eye
        # cameras to avoid occlusion (e.g., the eye geoms themselves)
        with open(vision_config_path) as f:
            info = yaml.safe_load(f)

        # Add root body and geom. The root can also be hidden from eye cameras if
        # requested in the vision config, so we apply the same group assignment rule
        # used for all other body segments.
        root_geom_group = 2 if self.root_segment.name in info["hidden_segments"] else 0
        body, geoms = self._add_one_body_and_geoms(
            self.mjcf_root.worldbody,
            self.root_segment,
            rigging_config[self.root_segment.name],
            geom_group=root_geom_group,
        )
        self.bodyseg_to_mjcfbody[self.root_segment] = body
        self.bodyseg_to_mjcfgeom[self.root_segment] = geoms

        # Add remaining bodies and geoms by traversing the kinematic tree defined by
        # the skeleton
        full_skeleton = self._get_base_skeleton()
        for jointdof in full_skeleton.iter_jointdofs(self.root_segment):
            if not self._is_pitch(jointdof):
                # Look at only 1 DoF per joint as we're still just adding bodies/geoms
                continue
            parent_body = self.bodyseg_to_mjcfbody.get(jointdof.parent)
            if parent_body is None:
                raise FlyGymInternalError("Parent not found during kinematic tree DFS")
            my_rigging_config = rigging_config.get(jointdof.child.name)
            if my_rigging_config is None:
                raise FlyGymInternalError(
                    f"Missing rigging config for body segment {jointdof.child.name}"
                )

            # If the geom should be invisible to eye cameras, we put it in group 2.
            # Otherwise, it goes in group 0. The MuJoCo renderer renders geoms in groups
            # 0, 1, 2 by default, so a default renderer renders all body geoms, but the
            # eye cameras can be configured to ignore group 2 geoms to avoid visual
            # occlusion. This makes the behavior of FlyGym less surprising to users who
            # wish to add their own renderers manually. We avoid group 1 because it's by
            # convention meant for mocap markers and helper geoms.
            # Geom group is based on parent body segment name because it's more
            # intuitive to specify the entire segment.
            geom_group = 2 if jointdof.child.name in info["hidden_segments"] else 0

            # Actually add the body and geom to the MJCF model
            body, geoms = self._add_one_body_and_geoms(
                parent_body, jointdof.child, my_rigging_config, geom_group
            )
            self.bodyseg_to_mjcfbody[jointdof.child] = body
            self.bodyseg_to_mjcfgeom[jointdof.child] = geoms

        # Optionally fit certain geoms to capsule shapes for simpler physics
        for bodyseg, mjcf_elements in self.bodyseg_to_mjcfgeom.items():
            for mjcf_element in mjcf_elements:
                if (geom_fitting_option == GeomFittingOption.ALL_TO_CAPSULES) or (
                    bodyseg.is_claw()
                    and geom_fitting_option == GeomFittingOption.CLAWS_TO_CAPSULES
                ):
                    mjcf_element.type = GEOM_TYPES["capsule"]

    def _add_one_body_and_geoms(
        self,
        parent_body: mj.MjsBody,
        segment: BodySegment,
        my_rigging_config: dict[str, Any],
        geom_group: int,
    ) -> tuple[mj.MjsBody, list[mj.MjsGeom]]:
        body_element = parent_body.add_body(
            name=segment.name,
            pos=my_rigging_config["pos"],
            quat=my_rigging_config["quat"],
        )
        geom_element = body_element.add_geom(
            name=segment.name,
            type=GEOM_TYPES["mesh"],
            meshname=segment.name,
            mass=my_rigging_config["mass"],
            contype=0,  # contact pairs to be added explicitly later
            conaffinity=0,  # contact pairs to be added explicitly later
            group=geom_group,
        )
        return body_element, [geom_element]

    def _parse_visuals_config(
        self,
        visuals_config_path: PathLike,
    ) -> tuple[dict[str, dict], dict[BodySegment, dict]]:
        # Load visuals config and assign vis sets to geometry name
        all_geom_names = [
            geom.name for geoms in self.bodyseg_to_mjcfgeom.values() for geom in geoms
        ]
        with open(visuals_config_path) as f:
            vis_set_params_all = yaml.safe_load(f)
        all_matches_by_geomname = {k: [] for k in all_geom_names}
        for vis_set_name, vis_set_params in vis_set_params_all.items():
            apply_to = vis_set_params.get("apply_to")
            material = vis_set_params.get("material")
            if not apply_to or not material:
                raise ValueError(
                    f"Invalid visualization set: {vis_set_name}."
                    "Must specify a non-empty 'apply_to' and 'material'."
                )
            allowed_keys = {"apply_to", "material", "texture"}
            if invalid_keys := (set(vis_set_params.keys()) - allowed_keys):
                raise ValueError(
                    f"Invalid keys in visualization set {vis_set_name}: "
                    f"{invalid_keys}. Must be one of {allowed_keys}."
                )
            target_geomnames = set()
            for pattern in [apply_to] if isinstance(apply_to, str) else apply_to:
                target_geomnames |= set(filter_with_wildcard(all_geom_names, pattern))
            for geomname in target_geomnames:
                all_matches_by_geomname[geomname].append(vis_set_name)
        for geomname, vis_set_names in all_matches_by_geomname.items():
            if len(vis_set_names) != 1:
                raise ValueError(
                    f"Zero or multiple vis sets matched for body segment {geomname}: "
                    f"{vis_set_names}. Only one should apply."
                )
        lookup_by_geomname = {
            geomname: matches[0]
            for geomname, matches in all_matches_by_geomname.items()
        }
        return vis_set_params_all, lookup_by_geomname

    def _rebuild_neutral_keyframe(self):
        # This standalone compile is intentional and internal: we only read the
        # neutral qpos/ctrl off the resulting model. The `fusestatic` flip it does
        # is irrelevant here, so suppress its (otherwise noisy) warning.
        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message="Compiling a fly model that is not attached to a world",
            )
            mj_model, _ = self.compile()
        self._neutral_keyframe.qpos = self._get_neutral_qpos(mj_model)
        self._neutral_keyframe.ctrl = self._get_neutral_ctrl(mj_model)

    def _get_neutral_qpos(self, mj_model: mj.MjModel) -> np.ndarray:
        neutral_qpos = np.zeros(mj_model.nq)
        for jointdof, angle in self.jointdof_to_neutralangle.items():
            joint_element = self.jointdof_to_mjcfjoint[jointdof]
            internal_jointid = mj.mj_name2id(
                mj_model, mj.mjtObj.mjOBJ_JOINT, joint_element.name
            )
            qposadr = mj_model.jnt_qposadr[internal_jointid]
            neutral_qpos[qposadr] = angle
        return neutral_qpos

    def _get_neutral_ctrl(self, mj_model: mj.MjModel) -> np.ndarray:
        neutral_ctrl = np.zeros(mj_model.nu)
        for ty, jointdof_to_actuator in self.jointdof_to_mjcfactuator_by_type.items():
            for jointdof, actuator in jointdof_to_actuator.items():
                internal_actuatorid = mj.mj_name2id(
                    mj_model, mj.mjtObj.mjOBJ_ACTUATOR, actuator.name
                )
                neutral_input = self.jointdof_to_neutralaction_by_type[ty][jointdof]
                neutral_ctrl[internal_actuatorid] = neutral_input
        return neutral_ctrl

n_bodysegs property

Number of body segments in this fly.

n_jointdofs property

Number of joint DoFs in this fly.

name property

Name of this fly instance.

add_actuators(jointdofs, actuator_type, neutral_input=None, *, forcelimited=True, forcerange=(-30.0, 30.0), **kwargs)

Add actuators to specified joints.

Creates actuators that can apply forces/torques to joints. Multiple actuator types can be added to the same joints.

Parameters:

Name Type Description Default
jointdofs Iterable[JointDOF]

Joint DOFs to actuate.

required
actuator_type ActuatorType | str

Type of actuator (motor, position, velocity, etc.).

required
neutral_input dict[str, float] | KinematicPose | KinematicPosePreset | None

Default actuator inputs. Accepts a dict mapping DoF names to values, a KinematicPose, or a KinematicPosePreset. If None, defaults to 0 for all actuators. For position actuators the values are joint angles and must match the skeleton axis order.

None
forcelimited bool

If True, actuators cannot exceed forcerange.

True
forcerange tuple[float, float]

Force limit as a (min, max) tuple.

(-30.0, 30.0)
**kwargs Any

Additional arguments passed to MJCF actuator creation (e.g., kp for position actuators, kv for velocity actuators). See MuJoCo XML reference for details on supported attributes.

{}

Returns:

Type Description
dict[JointDOF, MjsActuator]

Dictionary mapping JointDOF to created MJCF actuator elements.

Source code in src/flygym/compose/fly/base_fly.py
def add_actuators(
    self,
    jointdofs: Iterable[JointDOF],
    actuator_type: "ActuatorType | str",
    neutral_input: "dict[str, float] | KinematicPose | KinematicPosePreset | None" = None,
    *,
    forcelimited: bool = True,
    forcerange: tuple[float, float] = (-30.0, 30.0),
    **kwargs: Any,
) -> dict[JointDOF, mj.MjsActuator]:
    """Add actuators to specified joints.

    Creates actuators that can apply forces/torques to joints. Multiple actuator
    types can be added to the same joints.

    Args:
        jointdofs:
            Joint DOFs to actuate.
        actuator_type:
            Type of actuator (motor, position, velocity, etc.).
        neutral_input:
            Default actuator inputs. Accepts a ``dict`` mapping DoF names to
            values, a `KinematicPose`, or a `KinematicPosePreset`. If None,
            defaults to 0 for all actuators. For position actuators the values
            are joint angles and must match the skeleton axis order.
        forcelimited:
            If True, actuators cannot exceed forcerange.
        forcerange:
            Force limit as a (min, max) tuple.
        **kwargs:
            Additional arguments passed to MJCF actuator creation (e.g., kp for
            position actuators, kv for velocity actuators). See
            [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator)
            for details on supported attributes.

    Returns:
        Dictionary mapping JointDOF to created MJCF actuator elements.
    """
    actuator_type = ActuatorType(actuator_type)
    neutral_input = self._resolve_neutral_input(actuator_type, neutral_input)

    return_dict = {}
    for jointdof in jointdofs:
        self.jointdof_to_neutralaction_by_type[actuator_type][jointdof] = (
            neutral_input.get(jointdof.name, 0.0)
        )
        actuator = add_actuator(
            self.mjcf_root,
            actuator_type.value,
            name=f"{jointdof.name}-{actuator_type.value}",
            joint=jointdof.name,
            forcelimited=forcelimited,
            forcerange=forcerange,
            **kwargs,
        )

        return_dict[jointdof] = actuator
    self.jointdof_to_mjcfactuator_by_type[actuator_type].update(return_dict)
    self._rebuild_neutral_keyframe()
    return return_dict

add_joint_sites(anatomical_joints)

Add MJCF sites at the origins of selected anatomical joints.

Each site is placed at (0, 0, 0) in the child body frame. Since body origins are defined at their parent-child joint locations in this model, these sites track anatomical joint positions in world coordinates during simulation.

Parameters:

Name Type Description Default
anatomical_joints list[AnatomicalJoint]

Anatomical joints to materialize as MJCF sites.

required

Returns:

Type Description
dict[AnatomicalJoint, MjsSite]

Dictionary mapping each anatomical joint to its created MJCF site

dict[AnatomicalJoint, MjsSite]

element (same entries added into self.anatomicaljoint_to_mjcfsites).

Raises:

Type Description
ValueError

If a site for a requested anatomical joint already exists.

Source code in src/flygym/compose/fly/base_fly.py
def add_joint_sites(
    self, anatomical_joints: list[AnatomicalJoint]
) -> dict[AnatomicalJoint, mj.MjsSite]:
    """Add MJCF sites at the origins of selected anatomical joints.

    Each site is placed at ``(0, 0, 0)`` in the child body frame. Since body
    origins are defined at their parent-child joint locations in this model,
    these sites track anatomical joint positions in world coordinates during
    simulation.

    Args:
        anatomical_joints: Anatomical joints to materialize as MJCF sites.

    Returns:
        Dictionary mapping each anatomical joint to its created MJCF site
        element (same entries added into ``self.anatomicaljoint_to_mjcfsites``).

    Raises:
        ValueError: If a site for a requested anatomical joint already exists.
    """
    return_dict = {}
    for joint in anatomical_joints:
        if joint in self.anatomicaljoint_to_mjcfsites:
            raise ValueError(
                f"A site has already been added for anatomical joint '{joint.name}'."
            )
        child_body_element = self.bodyseg_to_mjcfbody[joint.child]
        site = child_body_element.add_site(
            name=joint.name,
            pos=(0, 0, 0),  # origin of child body is defined at joint to parent
        )
        return_dict[joint] = site
    self.anatomicaljoint_to_mjcfsites.update(return_dict)
    return return_dict

add_joints(skeleton, neutral_pose=None, *, stiffness=10.0, damping=0.5, armature=1e-06, **kwargs)

Add joints to the fly model based on a skeleton definition.

Creates hinge joints connecting body segments according to the skeleton's kinematic tree structure. Each joint is configured with passive spring-damper dynamics and a neutral (resting) angle.

Parameters:

Name Type Description Default
skeleton Skeleton

Skeleton defining which joints to create and their DOFs.

required
neutral_pose KinematicPose | KinematicPosePreset | None

Resting angles for joints. If provided, must match skeleton's axis order. If not provided, all neutral angles default to 0.

None
stiffness float

Joint stiffness (spring constant).

10.0
damping float

Joint damping coefficient.

0.5
armature float

Additional inertia added to the joint for numerical stability. Should be small enough to not affect dynamics.

1e-06
**kwargs Any

Additional arguments passed to MJCF joint creation. See MuJoCo XML reference for details on supported attributes.

{}

Returns:

Type Description
dict[JointDOF, MjsJoint]

Dictionary mapping JointDOF to created MJCF joint elements.

Source code in src/flygym/compose/fly/base_fly.py
def add_joints(
    self,
    skeleton: Skeleton,
    neutral_pose: KinematicPose | KinematicPosePreset | None = None,
    *,
    stiffness: float = 10.0,
    damping: float = 0.5,
    armature: float = 1e-6,
    **kwargs: Any,
) -> dict[JointDOF, mj.MjsJoint]:
    """Add joints to the fly model based on a skeleton definition.

    Creates hinge joints connecting body segments according to the skeleton's
    kinematic tree structure. Each joint is configured with passive spring-damper
    dynamics and a neutral (resting) angle.

    Args:
        skeleton:
            Skeleton defining which joints to create and their DOFs.
        neutral_pose:
            Resting angles for joints. If provided, must match skeleton's axis
            order. If not provided, all neutral angles default to 0.
        stiffness:
            Joint stiffness (spring constant).
        damping:
            Joint damping coefficient.
        armature:
            Additional inertia added to the joint for numerical stability. Should be
            small enough to not affect dynamics.
        **kwargs:
            Additional arguments passed to MJCF joint creation. See
            [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-joint)
            for details on supported attributes.

    Returns:
        Dictionary mapping JointDOF to created MJCF joint elements.
    """

    if missing := [
        s for s in skeleton.body_segments if s not in self.bodyseg_to_mjcfbody
    ]:
        raise ValueError(
            f"Skeleton contains body segments that {type(self).__name__} does "
            f"not have: {[s.name for s in missing]}. Was the skeleton built from "
            "a joint preset for another fly model?"
        )
    self.skeleton = skeleton
    neutral_angle_lookup = self.get_pose_lookup(neutral_pose)

    return_dict = {}
    for jointdof in skeleton.iter_jointdofs(self.root_segment):
        child_body = self.bodyseg_to_mjcfbody[jointdof.child]
        neutral_angle = neutral_angle_lookup.get(jointdof.name, 0.0)
        self.jointdof_to_neutralangle[jointdof] = neutral_angle

        # Flip axis direction for right side's roll and yaw so that axes are defined
        # symmetrically (e.g., positive roll is always "outward").
        vec = np.array(jointdof.axis.to_vector())
        if jointdof.child.pos[0] == "r" and not self._is_pitch(jointdof):
            vec = -vec

        return_dict[jointdof] = child_body.add_joint(
            name=jointdof.name,
            type=JOINT_TYPES["hinge"],
            axis=vec,
            stiffness=stiffness,
            damping=damping,
            armature=armature,
            springref=neutral_angle,
            **kwargs,
        )

    self.jointdof_to_mjcfjoint.update(return_dict)
    self._rebuild_neutral_keyframe()
    return return_dict

add_leg_adhesion(gain=1.0)

Add adhesion actuators to the tarsus5 segments of all legs.

Adhesion actuators apply a normal attraction force, enabling the fly to grip surfaces. The control input per leg ranges from 0 to 1, where 0 fully releases adhesion and 1 applies the configured gain.

Parameters:

Name Type Description Default
gain float | dict[str, float]

Adhesion actuator gain. Either a single float applied to all legs, or a dict mapping leg position identifiers to per-leg gain values.

1.0

Returns:

Type Description
dict[str, MjsActuator]

Dict mapping leg position identifier to the created MJCF adhesion

dict[str, MjsActuator]

actuator element (same as self.leg_to_adhesionactuator).

Raises:

Type Description
ValueError

If adhesion actuators have already been added.

Source code in src/flygym/compose/fly/base_fly.py
def add_leg_adhesion(
    self, gain: float | dict[str, float] = 1.0
) -> dict[str, mj.MjsActuator]:
    """Add adhesion actuators to the tarsus5 segments of all legs.

    Adhesion actuators apply a normal attraction force, enabling the fly to grip
    surfaces. The control input per leg ranges from 0 to 1, where 0 fully
    releases adhesion and 1 applies the configured gain.

    Args:
        gain: Adhesion actuator gain. Either a single float applied to all legs,
            or a dict mapping leg position identifiers to per-leg gain values.

    Returns:
        Dict mapping leg position identifier to the created MJCF adhesion
        actuator element (same as ``self.leg_to_adhesionactuator``).

    Raises:
        ValueError: If adhesion actuators have already been added.
    """
    if len(self.leg_to_adhesionactuator) > 0:
        raise ValueError("Leg adhesion actuators have already been added.")
    for leg in LEGS:
        tarsus5 = BodySegment(f"{leg}_tarsus5")
        if isinstance(gain, dict):
            gain_this_leg = gain[leg]
        else:
            gain_this_leg = gain
        self.leg_to_adhesionactuator[leg] = add_actuator(
            self.mjcf_root,
            "adhesion",
            name=f"{tarsus5.name}-adhesion",
            body=self.bodyseg_to_mjcfbody[tarsus5].name,
            gain=gain_this_leg,
            ctrlrange=(0, 1),
        )
    return self.leg_to_adhesionactuator

add_tracking_camera(name='trackcam', mode='track', pos_offset=(-0.5, -7.5, 5), rotation=Rotation3D('xyaxes', (1, 0, 0, 0, 0.6, 0.8)), fovy=30.0, **kwargs)

Add a camera that tracks the fly's root body.

The camera is added inside the root segment's body element. MuJoCo's track/trackcom modes follow the camera's parent body, so the camera must be a child of the fly body to follow it; a camera placed in the world body would stay put. track follows the body's position while keeping a constant orientation in the world frame (a "follow" camera that pans but does not rotate with the fly).

Warning

pos_offset is expressed in the root segment's (thorax) body frame, not in world coordinates. This differs from FlyGym versions before the MjSpec migration, where the tracking camera lived in the world body and the offset was effectively a world-frame position. The default changed accordingly, from (0, -7.5, 6) to (-0.5, -7.5, 5). Hard-coded pos_offset values tuned for the old world-frame placement must be re-tuned: the root segment sits roughly (0.5, 0, 1.3) mm from the fly's attachment point (plus the spawn height) in the neutral pose, so the same offset now places the camera higher and shifted toward the head. The upside is that a given pos_offset now yields the same camera position relative to the fly in every world and when the fly is compiled on its own.

Parameters:

Name Type Description Default
name str

Camera name.

'trackcam'
mode str

MuJoCo camera tracking mode ("track", "trackcom", or "fixed"). "fixed" rigidly attaches the camera to the body so it also rotates with the fly.

'track'
pos_offset Vec3

Camera position offset from the tracked root segment in mm.

(-0.5, -7.5, 5)
rotation Rotation3D

Camera orientation as a Rotation3D.

Rotation3D('xyaxes', (1, 0, 0, 0, 0.6, 0.8))
fovy float

Vertical field of view in degrees.

30.0
**kwargs Any

Additional attributes passed to the MJCF camera element. See MuJoCo XML reference.

{}

Returns:

Type Description
MjsCamera

The created MJCF camera element.

Source code in src/flygym/compose/fly/base_fly.py
def add_tracking_camera(
    self,
    name: str = "trackcam",
    mode: str = "track",
    pos_offset: Vec3 = (-0.5, -7.5, 5),
    rotation: Rotation3D = Rotation3D("xyaxes", (1, 0, 0, 0, 0.6, 0.8)),
    fovy: float = 30.0,
    **kwargs: Any,
) -> mj.MjsCamera:
    """Add a camera that tracks the fly's root body.

    The camera is added *inside* the root segment's body element. MuJoCo's
    ``track``/``trackcom`` modes follow the camera's parent body, so the camera
    must be a child of the fly body to follow it; a camera placed in the world
    body would stay put. ``track`` follows the body's position while keeping a
    constant orientation in the world frame (a "follow" camera that pans but does
    not rotate with the fly).

    !!! warning

        ``pos_offset`` is expressed in the root segment's (thorax) body frame, not
        in world coordinates. This differs from FlyGym versions before the MjSpec
        migration, where the tracking camera lived in the world body and the offset
        was effectively a world-frame position. The default changed accordingly,
        from ``(0, -7.5, 6)`` to ``(-0.5, -7.5, 5)``. Hard-coded ``pos_offset``
        values tuned for the old world-frame placement must be re-tuned: the root
        segment sits roughly ``(0.5, 0, 1.3)`` mm from the fly's attachment point
        (plus the spawn height) in the neutral pose, so the same offset now places
        the camera higher and shifted toward the head. The upside is that a given
        ``pos_offset`` now yields the same camera position relative to the fly in
        every world and when the fly is compiled on its own.

    Args:
        name: Camera name.
        mode: MuJoCo camera tracking mode (``"track"``, ``"trackcom"``, or
            ``"fixed"``). ``"fixed"`` rigidly attaches the camera to the body so it
            also rotates with the fly.
        pos_offset: Camera position offset from the tracked root segment in mm.
        rotation: Camera orientation as a `Rotation3D`.
        fovy: Vertical field of view in degrees.
        **kwargs: Additional attributes passed to the MJCF camera element. See
            [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-camera).

    Returns:
        The created MJCF camera element.
    """
    root_body = self.bodyseg_to_mjcfbody[self.root_segment]
    camera = root_body.add_camera(
        name=name,
        mode=CAMERA_MODES[mode],
        pos=pos_offset,
        fovy=fovy,
        **rotation.as_kwargs(),
        **kwargs,
    )
    self.cameraname_to_mjcfcamera[name] = camera
    return camera

colorize(visuals_config_path)

Apply colors and textures to the fly model.

Parameters:

Name Type Description Default
visuals_config_path PathLike

Path to the YAML file defining per-segment material and texture assignments.

required
Source code in src/flygym/compose/fly/base_fly.py
def colorize(self, visuals_config_path: PathLike) -> None:
    """Apply colors and textures to the fly model.

    Args:
        visuals_config_path: Path to the YAML file defining per-segment material
            and texture assignments.
    """
    if len(self.bodyseg_to_mjcfgeom) == 0:
        raise ValueError("Must first add geoms via `_add_bodies_and_geoms`.")

    vis_sets_all, lookup = self._parse_visuals_config(visuals_config_path)

    for vis_set_name, params in vis_sets_all.items():
        texture_name = None
        if texture_params := params.get("texture"):
            add_texture(self.mjcf_root, name=vis_set_name, **texture_params)
            texture_name = vis_set_name
        add_material(
            self.mjcf_root,
            name=vis_set_name,
            texture=texture_name,
            **params["material"],
        )

    for _, geoms in self.bodyseg_to_mjcfgeom.items():
        for geom in geoms:
            geom_name = geom.name
            vis_set_name = lookup[geom_name]
            geom.material = vis_set_name

compile()

Compile the fly on its own (e.g. for preview_model or save_xml).

Disables fusestatic for the standalone compile. A lone fly has no free joint, so its root segment is a static body that the optimization would fuse into the worldbody -- which breaks the track-mode tracking camera parented to it (the camera would fall back to tracking the worldbody and mis-place itself relative to the fly). A standalone fly is only ever inspected/previewed, never simulated, so the lost optimization does not matter here. The setting is applied to the compiled copy only, leaving the live spec untouched so a world can still fuse the fly's other static bodies once it is attached.

As in the base method, we always compile a copy rather than the live spec: compiling mutates the spec in place, which would invalidate the element references FlyGym holds.

Source code in src/flygym/compose/fly/base_fly.py
@override
def compile(self) -> tuple[mj.MjModel, mj.MjData]:
    """Compile the fly on its own (e.g. for `preview_model` or `save_xml`).

    Disables `fusestatic` for the standalone compile. A lone fly has no free joint,
    so its root segment is a static body that the optimization would fuse into the
    worldbody -- which breaks the `track`-mode tracking camera parented to it (the
    camera would fall back to tracking the worldbody and mis-place itself relative to
    the fly). A standalone fly is only ever inspected/previewed, never simulated, so
    the lost optimization does not matter here. The setting is applied to the
    compiled copy only, leaving the live spec untouched so a world can still fuse
    the fly's other static bodies once it is attached.

    As in the base method, we always compile a *copy* rather than the live spec:
    compiling mutates the spec in place, which would invalidate the element
    references FlyGym holds.
    """
    spec = self.mjcf_root.copy()
    if spec.compiler.fusestatic:
        warnings.warn(
            "Compiling a fly model that is not attached to a world. "
            "`fusestatic` is changed to false to prevent the root body segment "
            "from being fused with the MJCF root, which would impair the placement "
            "of the tracking camera."
        )
        spec.compiler.fusestatic = False
    model = spec.compile()
    return model, mj.MjData(model)

get_actuated_jointdofs_order(actuator_type)

Same as get_jointdofs_order(), but only for the subset of joint DoFs that are actuated by the specified actuator type. During simulation, the user should provide control input in this order.

Source code in src/flygym/compose/fly/base_fly.py
def get_actuated_jointdofs_order(
    self, actuator_type: "ActuatorType | str"
) -> list[JointDOF]:
    """Same as `get_jointdofs_order()`, but only for the subset of joint DoFs that
    are actuated by the specified actuator type. During simulation, the user should
    provide control input in this order."""
    actuator_type = ActuatorType(actuator_type)
    return list(self.jointdof_to_mjcfactuator_by_type[actuator_type].keys())

get_bodysegs_order()

Get the canonical order of body segments. The exact order is not important, but it should be respected consistently throughout. For example, during simulation, the fly body state returned by the simulator will be in this order.

Source code in src/flygym/compose/fly/base_fly.py
def get_bodysegs_order(self) -> list[BodySegment]:
    """Get the canonical order of body segments. The exact order is not important,
    but it should be respected consistently throughout. For example, during
    simulation, the fly body state returned by the simulator will be in this order.
    """
    return list(self.bodyseg_to_mjcfbody.keys())

get_jointdofs_order()

Same as get_bodysegs_order(), but for joint DoFs instead of body segments.

Source code in src/flygym/compose/fly/base_fly.py
def get_jointdofs_order(self) -> list[JointDOF]:
    """Same as `get_bodysegs_order()`, but for joint DoFs instead of body segments."""
    return list(self.jointdof_to_mjcfjoint.keys())

get_legs_order()

Get the ordered list of leg position identifiers (same as anatomy.LEGS).

Source code in src/flygym/compose/fly/base_fly.py
def get_legs_order(self) -> list[str]:
    """Get the ordered list of leg position identifiers (same as `anatomy.LEGS`)."""
    return LEGS

get_n_actuated_jointdofs(actuator_type)

Number of joint DoFs actuated by the specified actuator type.

Source code in src/flygym/compose/fly/base_fly.py
def get_n_actuated_jointdofs(self, actuator_type: "ActuatorType | str") -> int:
    """Number of joint DoFs actuated by the specified actuator type."""
    actuator_type = ActuatorType(actuator_type)
    return len(self.jointdof_to_mjcfactuator_by_type[actuator_type])

get_pose_lookup(neutral_pose)

Get a lookup dictionary mapping joint DOF names to neutral angles for a given neutral pose.

Source code in src/flygym/compose/fly/base_fly.py
def get_pose_lookup(
    self, neutral_pose: KinematicPose | KinematicPosePreset | None
) -> dict[str, float]:
    """Get a lookup dictionary mapping joint DOF names to neutral angles for a given
    neutral pose."""

    if self.skeleton is None:
        raise FlyGymInternalError("Skeleton must be defined to get pose lookup.")

    if neutral_pose is None:
        return {}
    elif isinstance(neutral_pose, KinematicPose):
        return neutral_pose.joint_angles_lookup_rad
    elif isinstance(neutral_pose, KinematicPosePreset):
        neutral_pose = neutral_pose.get_pose_by_axis_order(self.skeleton.axis_order)
        return neutral_pose.joint_angles_lookup_rad
    else:
        raise ValueError(
            "When specified, `neutral_pose` must be a "
            "`KinematicPose` or `KinematicPosePreset`."
        )

get_sites_order()

Get the canonical order of anatomical joints with associated MJCF sites.

This is the order used by simulation site-state readout methods such as Simulation.get_site_positions.

Source code in src/flygym/compose/fly/base_fly.py
def get_sites_order(self) -> list[AnatomicalJoint]:
    """Get the canonical order of anatomical joints with associated MJCF sites.

    This is the order used by simulation site-state readout methods such as
    ``Simulation.get_site_positions``.
    """
    return list(self.anatomicaljoint_to_mjcfsites.keys())

Fly

Bases: NeuroMechFly

Deprecated alias for NeuroMechFly. Will be removed in a future release.

Source code in src/flygym/compose/fly/neuromechfly.py
class Fly(NeuroMechFly):
    """Deprecated alias for `NeuroMechFly`. Will be removed in a future release."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        warnings.warn(
            "`Fly` is deprecated and will be removed in a future release; "
            "use `NeuroMechFly` instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        super().__init__(*args, **kwargs)

FlyBody

Bases: BaseFly

The FlyBody body model for Drosophila melanogaster.

FlyBody is published in:

Vaxenburg, R., et al. (2025). A whole-body model of *Drosophila* with
precise neuromuscular connectivity. *Nature*.
https://doi.org/10.1038/s41586-025-09029-4

Source code for the original model: https://github.com/TuragaLab/flybody

FlyBody provides an anatomically detailed model from the Turaga lab with wing and abdomen degrees of freedom and biomechanically grounded joint parameters, and integrates with the FlyGym API for scene composition, simulation, and control.

Warning

Support for the FlyBody body model is experimental. The API may change in future releases, and not all features available for the default NeuroMechFly model are currently supported.

Both FlyBody and NeuroMechFly inherit from BaseFly and expose the same composition API, so they can be used interchangeably for locomotion experiments. FlyBody additionally provides:

  • Wing degrees of freedom (pitch, roll, yaw per wing).
  • Abdomen degrees of freedom (pitch and yaw via tendon actuators).
  • Biomechanically calibrated joint stiffness, damping, and ctrl-range parameters loaded from per-joint YAML config files.
  • Tendon-coupled tarsus and abdomen joints via add_tendons().

The FlyBody XML is already in mm units; mesh files use .obj format.

Parameters:

Name Type Description Default
name str

Identifier for this fly instance. Defaults to "flybody".

'flybody'
rigging_config_path PathLike

Path to YAML file defining body segment positions, orientations, and masses.

FLYBODY_RIGGING_CONFIG_PATH
mesh_basedir PathLike

Directory containing OBJ mesh files for body segments.

FLYBODY_MESH_DIR
mujoco_globals_path PathLike

Path to YAML file with global MuJoCo parameters.

FLYBODY_MUJOCO_GLOBALS_PATH
all_geom_suffixes_path PathLike

Path to YAML file listing per-segment geometry name suffixes (FlyBody uses multiple geoms per body segment).

FLYBODY_ALL_GEOM_SUFFIXES_PATH
mirror_left2right bool

If True, mirror left-side meshes for the right side. Defaults to False (FlyBody ships separate left/right meshes).

False
root_segment FlyBodyBodySegment | str

Root body segment for the kinematic tree.

'c_thorax'
mesh_type MeshType

Mesh resolution to use.

FULLSIZE
geom_fitting_option GeomFittingOption

How to fit collision geometries.

UNMODIFIED
joint_config_path PathLike

Path to YAML file with per-joint parameters (stiffness, damping, range).

FLYBODY_JOINT_CONFIG_PATH
actuator_config_path PathLike

Path to YAML file with per-actuator gain parameters.

FLYBODY_ACTUATOR_CONFIG_PATH
vision_config_path PathLike

Path to YAML file with vision sensor configuration.

FLYBODY_DEFAULT_VISION_CONFIG_PATH
Source code in src/flygym/compose/fly/flybody.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
class FlyBody(BaseFly):
    """The FlyBody body model for *Drosophila melanogaster*.

    FlyBody is published in:

        Vaxenburg, R., et al. (2025). A whole-body model of *Drosophila* with
        precise neuromuscular connectivity. *Nature*.
        https://doi.org/10.1038/s41586-025-09029-4

    Source code for the original model: https://github.com/TuragaLab/flybody

    FlyBody provides an anatomically detailed model from the Turaga lab with
    wing and abdomen degrees of freedom and biomechanically grounded joint
    parameters, and integrates with the FlyGym API for scene composition,
    simulation, and control.

    !!! warning

        Support for the FlyBody body model is **experimental**. The API may
        change in future releases, and not all features available for the
        default `NeuroMechFly` model are currently supported.

    Both `FlyBody` and `NeuroMechFly` inherit from `BaseFly` and expose the
    same composition API, so they can be used interchangeably for locomotion
    experiments. FlyBody additionally provides:

    - Wing degrees of freedom (pitch, roll, yaw per wing).
    - Abdomen degrees of freedom (pitch and yaw via tendon actuators).
    - Biomechanically calibrated joint stiffness, damping, and ctrl-range
      parameters loaded from per-joint YAML config files.
    - Tendon-coupled tarsus and abdomen joints via `add_tendons()`.

    The FlyBody XML is already in mm units; mesh files use ``.obj`` format.

    Args:
        name: Identifier for this fly instance. Defaults to ``"flybody"``.
        rigging_config_path: Path to YAML file defining body segment positions,
            orientations, and masses.
        mesh_basedir: Directory containing OBJ mesh files for body segments.
        mujoco_globals_path: Path to YAML file with global MuJoCo parameters.
        all_geom_suffixes_path: Path to YAML file listing per-segment geometry
            name suffixes (FlyBody uses multiple geoms per body segment).
        mirror_left2right: If True, mirror left-side meshes for the right side.
            Defaults to ``False`` (FlyBody ships separate left/right meshes).
        root_segment: Root body segment for the kinematic tree.
        mesh_type: Mesh resolution to use.
        geom_fitting_option: How to fit collision geometries.
        joint_config_path: Path to YAML file with per-joint parameters (stiffness,
            damping, range).
        actuator_config_path: Path to YAML file with per-actuator gain parameters.
        vision_config_path: Path to YAML file with vision sensor configuration.
    """

    # The parsed flybody assets are already in mm (FlyGym's convention), so the
    # composer applies no further scaling (SCALE = 1.0). The original flybody XML is
    # authored in cm; parse_flybody.py converts it to mm -- lengths x10, gravity x10
    # (-981 cm/s^2 -> -9810 mm/s^2), and (for the all-hinge skeleton) torque-valued
    # quantities x100: stiffness, damping, armature, forcerange, and actuator gains.
    # See units_mapping and parse_globals in parse_flybody.py.
    SCALE = 1.0

    BODY_SEGMENT_CLASS = FlyBodyBodySegment
    JOINT_DOF_CLASS = FlyBodyJointDOF
    AXIS_ORDER_CLASS = FlyBodyAxisOrder
    BASE_SKELETON_CLASS = FlyBodySkeleton
    CONTACT_BODIES_PRESET_CLASS = FlyBodyContactBodiesPreset
    LEG_LINKS = FLYBODY_LEG_LINKS

    def _all_possible_joint_preset(self):
        return FlyBodyJointPreset.ALL_POSSIBLE

    def __init__(
        self,
        name: str = "flybody",
        *,
        rigging_config_path: PathLike = FLYBODY_RIGGING_CONFIG_PATH,
        mesh_basedir: PathLike = FLYBODY_MESH_DIR,
        mujoco_globals_path: PathLike = FLYBODY_MUJOCO_GLOBALS_PATH,
        all_geom_suffixes_path: PathLike = FLYBODY_ALL_GEOM_SUFFIXES_PATH,
        mirror_left2right: bool = False,
        root_segment: FlyBodyBodySegment | str = "c_thorax",
        mesh_type: MeshType = MeshType.FULLSIZE,
        geom_fitting_option: GeomFittingOption = GeomFittingOption.UNMODIFIED,
        joint_config_path: PathLike = FLYBODY_JOINT_CONFIG_PATH,
        actuator_config_path: PathLike = FLYBODY_ACTUATOR_CONFIG_PATH,
        vision_config_path: PathLike = FLYBODY_DEFAULT_VISION_CONFIG_PATH,
    ) -> None:
        with open(all_geom_suffixes_path) as f:
            self.multi_geom_lookup = yaml.safe_load(f)

        with open(joint_config_path) as f:
            self.joint_config = yaml.safe_load(f)

        with open(actuator_config_path) as f:
            self.actuator_config = yaml.safe_load(f)

        super().__init__(
            name=name,
            rigging_config_path=rigging_config_path,
            mesh_basedir=mesh_basedir,
            mujoco_globals_path=mujoco_globals_path,
            root_segment=root_segment,
            mirror_left2right=mirror_left2right,
            mesh_type=mesh_type,
            geom_fitting_option=geom_fitting_option,
            vision_config_path=vision_config_path,
        )

        self.jointdof_to_mjcftendon = {}
        self._correct_wing_default_pose()

    def _resolve_joint_params(self, joint_name: str) -> dict[str, Any]:
        """Resolve joint parameters from grouped or legacy joint config format."""
        if not isinstance(self.joint_config, dict):
            raise ValueError("Invalid joint config format: expected a dictionary.")

        # Backward compatibility: legacy flat format keyed by joint name.
        if "params" not in self.joint_config or "ranges" not in self.joint_config:
            if joint_name not in self.joint_config:
                raise ValueError(f"Joint {joint_name} not found in joint config.")
            return self.joint_config[joint_name].copy()

        resolved_params = {}

        for group_cfg in self.joint_config.get("params", {}).values():
            apply_to = group_cfg.get("apply_to", [])
            patterns = [apply_to] if isinstance(apply_to, str) else apply_to

            if any(filter_with_wildcard([joint_name], pattern) for pattern in patterns):
                resolved_params.update(
                    {k: v for k, v in group_cfg.items() if k != "apply_to"}
                )

        per_joint_cfg = self.joint_config.get("ranges", {}).get(joint_name, {})
        if isinstance(per_joint_cfg, str):
            resolved_params["range"] = per_joint_cfg
        elif isinstance(per_joint_cfg, dict):
            resolved_params.update(per_joint_cfg)

        if not resolved_params:
            warnings.warn(
                f"No parameters resolved for joint {joint_name} from joint config; "
                "using defaults."
            )
            resolved_params = {
                "range": [-180, 180],
                "stiffness": 0.01,
                "damping": 0.0005,
                "limited": True,
            }
        return resolved_params

    def _is_pitch(self, jointdof):
        if jointdof.child.is_wing():
            return jointdof.axis == WingFlyBodyRotationAxis.PITCH
        else:
            return jointdof.axis == FlyBodyRotationAxis.PITCH

    @staticmethod
    def _coerce_mjcf_value(value: Any) -> Any:
        """Convert YAML-loaded values to MJCF-friendly python types."""
        if isinstance(value, list):
            return tuple(FlyBody._coerce_mjcf_value(v) for v in value)
        if isinstance(value, tuple):
            return tuple(FlyBody._coerce_mjcf_value(v) for v in value)
        if not isinstance(value, str):
            return value

        lowered = value.lower()
        if lowered == "true":
            return True
        if lowered == "false":
            return False

        try:
            return float(value)
        except ValueError:
            return value

    @classmethod
    def _normalize_mjcf_params(cls, params: dict[str, Any]) -> dict[str, Any]:
        return {k: cls._coerce_mjcf_value(v) for k, v in params.items()}

    # PyMJCF joint attribute names that MjSpec exposes under different identifiers.
    _MJSPEC_JOINT_KEY_RENAMES = {
        "solreflimit": "solref_limit",
        "solimplimit": "solimp_limit",
        "solreffriction": "solref_friction",
        "solimpfriction": "solimp_friction",
    }

    @classmethod
    def _rename_mjspec_joint_keys(cls, params: dict[str, Any]) -> dict[str, Any]:
        return {cls._MJSPEC_JOINT_KEY_RENAMES.get(k, k): v for k, v in params.items()}

    def _add_one_body_and_geoms(
        self,
        parent_body: mj.MjsBody,
        segment: Any,
        my_rigging_config: dict[str, Any],
        geom_group: int,
    ) -> tuple[mj.MjsBody, list[mj.MjsGeom]]:

        body_element = parent_body.add_body(
            name=segment.name,
            pos=self._coerce_mjcf_value(my_rigging_config["pos"]),
            quat=self._coerce_mjcf_value(my_rigging_config["quat"]),
        )

        all_geom_elements = []
        for geom_name, geom_config in my_rigging_config["geoms"].items():
            # PyMJCF used `mesh=`; MjSpec's geom attribute is `meshname=`. Also coerce
            # YAML string values (pos/quat/...) to the numeric types MjSpec expects.
            geom_config = {
                ("meshname" if k == "mesh" else k): self._coerce_mjcf_value(v)
                for k, v in geom_config.items()
            }
            geom_element = body_element.add_geom(
                type=GEOM_TYPES["mesh"],
                name=geom_name,
                contype=0,  # contact pairs to be added explicitly later
                conaffinity=0,  # contact pairs to be added explicitly later
                group=geom_group,
                **geom_config,
            )
            all_geom_elements.append(geom_element)

        return body_element, all_geom_elements

    def _add_mesh_assets(
        self, mesh_basedir: PathLike, mirror_left2right: bool, mesh_type: MeshType
    ) -> None:
        # FlyBody's high-resolution meshes are downloaded from S3 and cached on
        # first use; only the (default) fullsize set exists for this model.
        if mesh_type == MeshType.FULLSIZE:
            mesh_dir = lazy_load_asset_dir(FLYBODY_FULLSIZE_MESH_DIR)
        else:
            raise NotImplementedError(
                f"Mesh type {mesh_type} not supported for FlyBody; "
                "only FULLSIZE is available."
            )

        for segment_name in FLYBODY_ALL_SEGMENT_NAMES:
            if mirror_left2right and segment_name[0] == "r":
                mesh_to_use = f"l{segment_name[1:]}"
                y_sign = -1
            else:
                mesh_to_use = segment_name
                y_sign = 1
            for suffix in self.multi_geom_lookup.get(mesh_to_use, [None]):
                if suffix:
                    mesh_name = f"{mesh_to_use}_{suffix}"
                else:
                    warnings.warn(
                        f"No mesh suffix found for segment {mesh_to_use}; "
                        "using segment name as mesh name."
                    )
                    mesh_name = mesh_to_use
                mesh_path = (mesh_dir / f"{mesh_name}.obj").resolve()
                if not mesh_path.exists():
                    raise FileNotFoundError(
                        f"Mesh file not found for segment {segment_name}: {mesh_path}"
                    )

                mesh = self.mjcf_root.add_mesh(
                    name=mesh_name,
                    file=str(mesh_path),
                    scale=(self.SCALE, y_sign * self.SCALE, self.SCALE),
                )
                if segment_name not in self.bodyseg_to_mjcfmesh:
                    self.bodyseg_to_mjcfmesh[segment_name] = [mesh]
                else:
                    self.bodyseg_to_mjcfmesh[segment_name].append(mesh)

        # add abdomen8 mesh (just a mesh connected to c_abdomen7)
        self.bodyseg_to_mjcfmesh["c_abdomen7"].append(
            self.mjcf_root.add_mesh(
                name="c_abdomen8_body",
                file=str(mesh_dir / "c_abdomen8_body.obj"),
                scale=(self.SCALE, self.SCALE, self.SCALE),
            )
        )

    def colorize(
        self, visuals_config_path: PathLike = FLYBODY_VISUALS_CONFIG_PATH
    ) -> None:
        """Apply colors and textures to the FlyBody model.

        Args:
            visuals_config_path: Path to the YAML file defining per-segment material
                and texture assignments. Defaults to the bundled FlyBody visuals.
        """
        super().colorize(visuals_config_path)

    def add_joints(
        self,
        skeleton: Skeleton,
        neutral_pose: KinematicPose | KinematicPosePreset | None = None,
        **kwargs: Any,
    ) -> dict[JointDOF, mj.MjsJoint]:
        """Add joints to the FlyBody model based on a skeleton definition.

        Like `BaseFly.add_joints`, but instead of taking uniform ``stiffness``,
        ``damping``, and ``armature`` arguments, the per-joint values are loaded from
        the flybody joint config (via ``_resolve_joint_params``). ``neutral_pose`` sets
        each joint's ``springref`` (the angle at which passive spring forces are zero),
        and ``kwargs`` override the config-derived values.

        Args:
            skeleton:
                Skeleton defining which joints to create and their DOFs.
            neutral_pose:
                Resting angles for joints. If provided, must match the skeleton's axis
                order; otherwise all neutral angles default to 0.
            **kwargs:
                Per-joint MJCF attributes that override the config-derived values. See
                the [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-joint)
                for supported attributes.

        Returns:
            Dictionary mapping JointDOF to created MJCF joint elements.
        """

        self.skeleton = skeleton
        neutral_pose_lookup = self.get_pose_lookup(neutral_pose)

        return_dict = {}
        for jointdof in skeleton.iter_jointdofs(self.root_segment):
            child_body = self.bodyseg_to_mjcfbody[jointdof.child]
            joint_params = self._resolve_joint_params(jointdof.name)

            # Override default springref with neutral pose value if provided
            joint_params.update(
                {"springref": neutral_pose_lookup.get(jointdof.name, 0.0)}
            )
            # Override any joint config values with values provided in kwargs
            joint_params.update(kwargs)
            joint_params = self._normalize_mjcf_params(joint_params)
            joint_params = self._rename_mjspec_joint_keys(joint_params)

            return_dict[jointdof] = child_body.add_joint(
                name=jointdof.name,
                type=JOINT_TYPES["hinge"],
                axis=jointdof.axis.to_vector(),
                **joint_params,
            )

        self.jointdof_to_mjcfjoint.update(return_dict)
        self._rebuild_neutral_keyframe()
        return return_dict

    def translate_generalactparams_to_specificactparams(
        self, general_params: dict[str, Any], actuator_type: ActuatorType
    ) -> dict[str, Any]:
        """Full gainprm/biasprm/dynprm -> kp/kv/timeconst translation.

        NOTE: As of v2.1.0 this is dead code -- nothing calls it. `add_actuators`
        uses the simplified
        `translate_generaljointparams_to_specificjointparams_simplified` instead.
        It is intentionally retained as the intended complete implementation, to be
        wired up in a future release. See #272 for details.
        """

        def _parse_param_values(raw_value: Any, name: str) -> list[float]:
            if isinstance(raw_value, str):
                tokens = raw_value.split()
            elif isinstance(raw_value, (list, tuple, np.ndarray)):
                tokens = list(raw_value)
            else:
                tokens = [raw_value]

            try:
                return [float(x) for x in tokens]
            except (TypeError, ValueError) as exc:
                raise ValueError(
                    f"Invalid {name} value {raw_value!r}. Expected a whitespace-delimited string or a numeric list."
                ) from exc

        specific_params = general_params.copy()
        if actuator_type == ActuatorType.POSITION:
            # Set kp and kv from gainprm, biasprm and dynprm as specified here:
            # https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-position
            if "gainprm" in general_params:
                gainprm = general_params["gainprm"]
                gainprm_parsed = _parse_param_values(gainprm, "gainprm")
                kp = gainprm_parsed[0]
                if len(gainprm_parsed) > 1 and np.sum(gainprm_parsed[1:]) != 0:
                    warnings.warn(
                        "gainprm has more than one value with non-zero entries after "
                        "the first; only the first value is used as kp for position "
                        "actuators (per MuJoCo docs)."
                    )
            else:
                kp = 1.0
            if "biasprm" in general_params:
                biasprm = general_params["biasprm"]
                biasprm_parsed = _parse_param_values(biasprm, "biasprm")
                assert (
                    len(biasprm_parsed) >= 2
                    and biasprm_parsed[0] == 0.0
                    and biasprm_parsed[1] == -1 * kp
                ), "Conflicting kp: according to MuJoCo docs biasprm is [0, -kp, -kv]"
                if len(biasprm_parsed) > 2:
                    kv = -biasprm_parsed[2]
                else:
                    kv = 0.0  # default for position according to mujoco
                if len(biasprm_parsed) > 3 and np.sum(biasprm_parsed[3:]) != 0:
                    warnings.warn(
                        "biasprm has non-zero entries after the third; only the first "
                        "three values are used as biasprm for position actuators "
                        "(per MuJoCo docs)."
                    )
            else:
                kv = 0.0
            if "dynprm" in general_params:
                dynprm = general_params["dynprm"]
                dynprm_parsed = _parse_param_values(dynprm, "dynprm")
                timeconst = dynprm_parsed[0]
                if len(dynprm_parsed) > 1 and np.sum(dynprm_parsed[1:]) != 0:
                    warnings.warn(
                        "dynprm has non-zero entries after the first; only the first "
                        "value is used as timeconst for position actuators "
                        "(per MuJoCo docs)."
                    )
            else:
                timeconst = 1.0  # default for general according to mujoco
            specific_params = {
                "kp": kp,
                "kv": kv,
                "timeconst": timeconst,
            }
        elif actuator_type == ActuatorType.VELOCITY:
            # Setting parameters values according to
            # https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-velocity
            if "gainprm" in general_params:
                gainprm = general_params["gainprm"]
                gainprm_parsed = _parse_param_values(gainprm, "gainprm")
                kv = gainprm_parsed[0]
                if len(gainprm_parsed) > 1 and np.sum(gainprm_parsed[1:]) != 0:
                    warnings.warn(
                        "gainprm has non-zero entries after the first; only the first "
                        "value is used as kv for velocity actuators (per MuJoCo docs)."
                    )
            else:
                kv = 1.0
            if "biasprm" in general_params:
                biasprm = general_params["biasprm"]
                biasprm_parsed = _parse_param_values(biasprm, "biasprm")
                assert (
                    len(biasprm_parsed) >= 3
                    and biasprm_parsed[0] == 0.0
                    and biasprm_parsed[1] == 0.0
                    and biasprm_parsed[2] == -1 * kv
                ), "Conflicting kvs: according to MuJoCo docs biasprm is [0, 0, -kv]"
                if len(biasprm_parsed) > 3 and np.sum(biasprm_parsed[3:]) != 0:
                    warnings.warn(
                        "biasprm has non-zero entries after the fourth; only the first "
                        "four values are used as biasprm for velocity actuators "
                        "(per MuJoCo docs)."
                    )
            if "dynprm" in general_params:
                warnings.warn(
                    "dynprm is not used for velocity actuators (per MuJoCo docs); "
                    "ignoring the dynprm values from the general actuator config."
                )
            specific_params = {
                "kv": kv,
            }
        elif actuator_type == ActuatorType.MOTOR:
            # Setting parameters values according to
            # https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-motor
            if "gainprm" in general_params:
                warnings.warn(
                    "Ignoring default gainprm: it is not used by classical motor "
                    "actuators (per MuJoCo docs)."
                )
            if "biasprm" in general_params:
                warnings.warn(
                    "Ignoring default biasprm: it is not used by classical motor "
                    "actuators (per MuJoCo docs)."
                )
            if "dynprm" in general_params:
                warnings.warn(
                    "Ignoring default dynprm: it is not used by classical motor "
                    "actuators (per MuJoCo docs)."
                )
            specific_params = {}
        else:
            raise ValueError(f"Unsupported actuator type: {actuator_type}")

        for params in ["gainprm", "biasprm", "dynprm"]:
            if params in specific_params:
                # remove them from specific params
                specific_params.pop(params)

        return specific_params

    def translate_generaljointparams_to_specificjointparams_simplified(
        self, general_params: dict[str, Any], actuator_type: ActuatorType
    ) -> dict[str, Any]:
        if actuator_type == ActuatorType.POSITION:
            # gainprm comes from YAML as a string (e.g. '30') or list of strings;
            # coerce to a numeric type and use the first entry as kp (per MuJoCo,
            # kp is gainprm[0] for position actuators).
            gainprm = self._coerce_mjcf_value(general_params.get("gainprm", 1.0))
            kp = gainprm[0] if isinstance(gainprm, tuple) else gainprm
            return {
                "kp": kp,
            }
        else:
            return {}

    def add_actuators(
        self,
        jointdofs: Iterable[JointDOF],
        actuator_type: "ActuatorType | str",
        neutral_input: "dict[str, float] | KinematicPose | KinematicPosePreset | None" = None,
        *,
        forcelimited: bool = False,
        forcerange: tuple[float, float] = (-0.3, 0.3),
        **kwargs: Any,
    ) -> dict[JointDOF, mj.MjsActuator]:
        """Add actuators to specified joints.

        Like `BaseFly.add_actuators`, but actuator parameters default to the values in
        the flybody ``actuator_config.yaml`` unless overridden. The FlyBody defaults
        also differ: ``forcelimited`` is False and ``forcerange`` is ``(-0.3, 0.3)``.

        Args:
            jointdofs:
                Joint DOFs to actuate.
            actuator_type:
                Type of actuator (motor, position, velocity, etc.).
            neutral_input:
                Default actuator inputs. Accepts a ``dict`` mapping DoF names to
                values, a `KinematicPose`, or a `KinematicPosePreset`. If None,
                defaults to 0 for all actuators. For position actuators the values
                are joint angles and must match the skeleton axis order.
            forcelimited:
                If True, clamp actuator force to ``forcerange``. If False (default),
                the actuator force is unlimited; both this ``forcerange`` argument and
                any ``forcerange`` in ``actuator_config.yaml`` are ignored (the
                simplified parameter translation does not apply config force limits).
            forcerange:
                Force limit as a (min, max) tuple. Only applied when
                ``forcelimited=True``.
            **kwargs:
                MJCF actuator attributes (e.g. ``kp`` for position actuators, ``kv`` for
                velocity actuators) that override the defaults from
                ``actuator_config.yaml``. See the
                [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator)
                for supported attributes.

        Returns:
            Dictionary mapping JointDOF to created MJCF actuator elements.
        """
        actuator_type = ActuatorType(actuator_type)
        neutral_input = self._resolve_neutral_input(actuator_type, neutral_input)

        remove_ctrl_limits = False
        if (
            actuator_type == ActuatorType.MOTOR
            or actuator_type == ActuatorType.VELOCITY
        ):
            warnings.warn(
                "Setting ctrllimited=False for MOTOR and VELOCITY actuators; "
                "flybody ctrl limits are meant for POSITION actuators."
            )
            remove_ctrl_limits = True
            if not forcelimited:
                warnings.warn(
                    "Without ctrl limits, MOTOR and VELOCITY actuators can generate "
                    "extreme forces; consider setting forcelimited=True for stability."
                )

        return_dict = {}
        for jointdof in jointdofs:
            self.jointdof_to_neutralaction_by_type[actuator_type][jointdof] = (
                neutral_input.get(jointdof.name, 0.0)
            )

            default_actuator_params = {}
            for _, val in self.actuator_config.items():
                if jointdof.name in val["apply_to"]:
                    default_actuator_params = val["general"]
                    break
            if not default_actuator_params:
                warnings.warn(f"No actuator config found for joint {jointdof.name}.")

            default_actuator_params_specific = (
                self.translate_generaljointparams_to_specificjointparams_simplified(
                    default_actuator_params, actuator_type
                )
            )

            if remove_ctrl_limits:
                default_actuator_params_specific["ctrllimited"] = False
            else:
                # recover ctrllimits from joint limits
                jnt = self.jointdof_to_mjcfjoint[jointdof]
                # MjSpec returns an all-zero range (not None) for joints with no
                # range, so check the value rather than `is not None`. Deriving a
                # [0, 0] ctrlrange would silently pin the actuator's input to 0.
                if "ctrlrange" not in kwargs and np.allclose(jnt.range, 0.0):
                    raise ValueError(
                        f"Joint {jointdof.name} has no range specified, so a "
                        "default ctrlrange cannot be derived for its actuator. "
                        "Specify a range on the joint, or pass an explicit "
                        "`ctrlrange`."
                    )
                default_actuator_params_specific["ctrlrange"] = jnt.range

            if forcelimited:
                default_actuator_params_specific["forcelimited"] = forcelimited
                default_actuator_params_specific["forcerange"] = forcerange

            if actuator_type == ActuatorType.POSITION:
                has_kp = "kp" in kwargs
                warning_str = "WARNING: actuator type is POSITION but "
                has_missing_param = False
                for param, has_it in [
                    ("kp", has_kp)
                ]:  # , ("kv", has_kv), ("timeconst", has_timeconst)]:
                    if not has_it:
                        warning_str += (
                            f"{param} not specified, using default value from "
                            "general actuator config if specified there, otherwise "
                            "using MuJoCo default. "
                        )
                        has_missing_param = True
                if has_missing_param:
                    warnings.warn(warning_str)

            elif actuator_type == ActuatorType.VELOCITY:
                has_kv = "kv" in kwargs
                warning_str = "WARNING: actuator type is VELOCITY but "
                if not has_kv:
                    warning_str += (
                        "kv not specified, using default value from general actuator "
                        "config if specified there, otherwise using MuJoCo default."
                    )
                    warnings.warn(warning_str)

            default_actuator_params_specific.update(kwargs)

            actuator = add_actuator(
                self.mjcf_root,
                actuator_type.value,
                name=f"{jointdof.name}-{actuator_type.value}",
                joint=jointdof.name,
                **default_actuator_params_specific,
            )

            return_dict[jointdof] = actuator
        self.jointdof_to_mjcfactuator_by_type[actuator_type].update(return_dict)
        self._rebuild_neutral_keyframe()
        return return_dict

    def add_leg_adhesion(
        self,
        gain: float | dict[str, float] = 0.985,
        add_labrum: bool = True,
        labrum_gain: float = 1.0,
    ) -> dict[str, mj.MjsActuator]:
        """Add adhesion actuators to the tarsus5 segments of all legs and optionally to the labrum.

        Adhesion actuators apply a normal attraction force, enabling the fly to grip
        surfaces. The control input per leg ranges from 0 (released) to 1 (full gain).

        Args:
            gain: Adhesion actuator gain. Either a single float applied to all legs,
                or a dict mapping leg position identifiers to per-leg gain values.
            add_labrum: Whether to also add an adhesion actuator for the labrum (mouthpart).
            labrum_gain: Adhesion actuator gain for the labrum (used when ``add_labrum``).

        Returns:
            Dict mapping leg position identifier to the created MJCF adhesion
            actuator element (same as ``self.leg_to_adhesionactuator``).

        Raises:
            ValueError: If adhesion actuators have already been added.
        """
        if len(self.leg_to_adhesionactuator) > 0:
            raise ValueError("Leg adhesion actuators have already been added.")
        for leg in LEGS:
            tarsus5_segment = FlyBodyBodySegment(f"{leg}_tarsus5")
            if isinstance(gain, dict):
                gain_this_leg = gain[leg]
            else:
                gain_this_leg = gain
            self.leg_to_adhesionactuator[leg] = add_actuator(
                self.mjcf_root,
                "adhesion",
                name=f"{tarsus5_segment.name}-adhesion",
                body=self.bodyseg_to_mjcfbody[tarsus5_segment].name,
                gain=gain_this_leg,
                ctrlrange=(0, 1),
            )
        if add_labrum:
            for s in "lr":
                labrum = FlyBodyBodySegment(f"{s}_labrum")
                self.leg_to_adhesionactuator[f"{s}_labrum"] = add_actuator(
                    self.mjcf_root,
                    "adhesion",
                    name=f"{labrum.name}-adhesion",
                    body=self.bodyseg_to_mjcfbody[labrum].name,
                    gain=labrum_gain,
                    ctrlrange=(0, 1),
                )
        return self.leg_to_adhesionactuator

    def add_tendons(
        self, coef: float | dict[str, float] | None = None
    ) -> dict[JointDOF, mj.MjsTendon]:
        # check joints have been added
        if len(self.jointdof_to_mjcfjoint) == 0:
            raise ValueError(
                "Must first add joints via `add_joints` before adding tendons."
            )
        if len(self.jointdof_to_mjcftendon) > 0:
            raise ValueError("Tendons have already been added.")

        def get_coef_for_joint(joint: JointDOF) -> float:
            if coef is None or (isinstance(coef, dict) and joint.name not in coef):
                return 1.0  # default in flybody
            elif isinstance(coef, dict):
                return coef[joint.name]
            else:
                return coef

        abd_bodyseg = FlyBodyBodySegment("c_abdomen1")
        if abd_bodyseg in self.skeleton.body_segments:
            tree = self.skeleton.get_tree()
            for axis in [FlyBodyRotationAxis.PITCH, FlyBodyRotationAxis.YAW]:
                tendon_name = f"abdomen_{axis.name.lower()}"
                tendon = self.mjcf_root.add_tendon(name=tendon_name)
                added_tendon = False
                for parent, child in tree.dfs_edges(abd_bodyseg):
                    joint = FlyBodyJointDOF(parent=parent, child=child, axis=axis)
                    tendon.wrap_joint(
                        self.jointdof_to_mjcfjoint[joint].name,
                        get_coef_for_joint(joint),
                    )
                    if not added_tendon:
                        self.jointdof_to_mjcftendon[joint] = tendon
                        added_tendon = True
        else:
            warnings.warn(
                "abdomen1 not found in skeleton; skipping abdomen tendon creation."
            )

        for leg in LEGS:
            tarsus_bodyseg = FlyBodyBodySegment(f"{leg}_tarsus1")
            if tarsus_bodyseg not in self.skeleton.body_segments:
                warnings.warn(
                    f"{tarsus_bodyseg} not found in skeleton; "
                    "skipping tendon creation for it."
                )
                continue
            else:
                joints = self.skeleton.iter_jointdofs(tarsus_bodyseg)
                first_joint = next(joints)
                tendon_name = f"{leg}_tarsus"
                tendon = self.mjcf_root.add_tendon(name=tendon_name)
                self.jointdof_to_mjcftendon[first_joint] = tendon
                coef_to_use = get_coef_for_joint(first_joint)
                tendon.wrap_joint(
                    self.jointdof_to_mjcfjoint[first_joint].name, coef_to_use
                )
                for joint in joints:
                    coef_to_use = get_coef_for_joint(joint)
                    tendon.wrap_joint(
                        self.jointdof_to_mjcfjoint[joint].name, coef_to_use
                    )

    def add_tendon_actuators(self, **kwargs: Any) -> dict[JointDOF, mj.MjsActuator]:
        if len(self.jointdof_to_mjcftendon) == 0:
            raise ValueError(
                "Must first add tendons via `add_tendons` "
                "before adding tendon actuators."
            )
        if len(self.jointdof_to_mjcfactuator_by_type[ActuatorType.TENDON]) > 0:
            raise ValueError(
                "Tendon actuators have already been added, cannot add tendon actuators "
                "as MOTOR actuators are used for tendons in this implementation."
            )

        # Motor-actuator gains, in mm units (the original flybody XML, authored in cm,
        # uses affine-servo gains of 0.1 for the abdomen and 0.4 for the tarsus tendons;
        # the tendons drive hinge joints, so these torque-valued gains scale x100).
        ABDOMEN_TENDON_GAIN = 10.0
        TARSUS_TENDON_GAIN = 40.0
        for jointdof, tendon in self.jointdof_to_mjcftendon.items():
            default_params = {}
            if "abdomen" in jointdof.name:
                if jointdof.axis == FlyBodyRotationAxis.PITCH:
                    default_params = {
                        "ctrlrange": [-1.05, 0.7],
                        "gain": ABDOMEN_TENDON_GAIN,
                    }
                elif jointdof.axis == FlyBodyRotationAxis.YAW:
                    default_params = {
                        "ctrlrange": [-0.7, 0.7],
                        "gain": ABDOMEN_TENDON_GAIN,
                    }
                else:
                    warnings.warn(
                        "No default ctrlrange for abdomen tendon joint "
                        f"{jointdof.name} with axis {jointdof.axis}; using no defaults."
                    )
            elif "tarsus" in jointdof.name:
                default_params = {"ctrlrange": [-0.9, 0.9], "gain": TARSUS_TENDON_GAIN}
            else:
                warnings.warn(
                    f"No default tendon actuator params for joint {jointdof.name}; "
                    "using no defaults."
                )

            if jointdof.name in kwargs:
                warnings.warn(
                    "Overriding default tendon actuator params for "
                    f"joint {jointdof.name} with kwargs."
                )
                default_params.update(kwargs[jointdof.name])
            else:
                default_params.update(kwargs)

            actuator = add_actuator(
                self.mjcf_root,
                "general",
                name=f"{tendon.name}-tendon",
                tendon=tendon.name,
                **default_params,
            )

            self.jointdof_to_mjcfactuator_by_type[ActuatorType.TENDON][jointdof] = (
                actuator
            )
            self.jointdof_to_neutralaction_by_type[ActuatorType.TENDON][jointdof] = 0.0

        return self.jointdof_to_mjcfactuator_by_type[ActuatorType.TENDON]

    def _correct_wing_default_pose(self) -> None:
        """Rotate the wing bodies into their resting pose.

        In the flybody model the wings are held in place by the joint's spring
        property. With the original general actuators, a zero input applies no force,
        so the wings fall back to this resting pose. Position actuators do not behave
        this way, so we rotate the wing bodies into the resting pose explicitly here.
        """
        for side in ["l", "r"]:
            wing_bodyseg = FlyBodyBodySegment(f"{side}_wing")
            if wing_bodyseg in self.bodyseg_to_mjcfbody:
                mjcf_body = self.bodyseg_to_mjcfbody[wing_bodyseg]
                bquat = R.from_quat(mjcf_body.quat, scalar_first=True)
                correction_quat = R.from_euler(
                    "xyz", [0, 0, -90 if side == "r" else 90], degrees=True
                )
                new_quat = (correction_quat * bquat).as_quat(scalar_first=True)
                mjcf_body.quat = tuple(new_quat)
            else:
                raise ValueError(
                    f"Expected wing body segment {wing_bodyseg} not found in model, "
                    "cannot apply wing default pose correction."
                )

add_actuators(jointdofs, actuator_type, neutral_input=None, *, forcelimited=False, forcerange=(-0.3, 0.3), **kwargs)

Add actuators to specified joints.

Like BaseFly.add_actuators, but actuator parameters default to the values in the flybody actuator_config.yaml unless overridden. The FlyBody defaults also differ: forcelimited is False and forcerange is (-0.3, 0.3).

Parameters:

Name Type Description Default
jointdofs Iterable[JointDOF]

Joint DOFs to actuate.

required
actuator_type ActuatorType | str

Type of actuator (motor, position, velocity, etc.).

required
neutral_input dict[str, float] | KinematicPose | KinematicPosePreset | None

Default actuator inputs. Accepts a dict mapping DoF names to values, a KinematicPose, or a KinematicPosePreset. If None, defaults to 0 for all actuators. For position actuators the values are joint angles and must match the skeleton axis order.

None
forcelimited bool

If True, clamp actuator force to forcerange. If False (default), the actuator force is unlimited; both this forcerange argument and any forcerange in actuator_config.yaml are ignored (the simplified parameter translation does not apply config force limits).

False
forcerange tuple[float, float]

Force limit as a (min, max) tuple. Only applied when forcelimited=True.

(-0.3, 0.3)
**kwargs Any

MJCF actuator attributes (e.g. kp for position actuators, kv for velocity actuators) that override the defaults from actuator_config.yaml. See the MuJoCo XML reference for supported attributes.

{}

Returns:

Type Description
dict[JointDOF, MjsActuator]

Dictionary mapping JointDOF to created MJCF actuator elements.

Source code in src/flygym/compose/fly/flybody.py
def add_actuators(
    self,
    jointdofs: Iterable[JointDOF],
    actuator_type: "ActuatorType | str",
    neutral_input: "dict[str, float] | KinematicPose | KinematicPosePreset | None" = None,
    *,
    forcelimited: bool = False,
    forcerange: tuple[float, float] = (-0.3, 0.3),
    **kwargs: Any,
) -> dict[JointDOF, mj.MjsActuator]:
    """Add actuators to specified joints.

    Like `BaseFly.add_actuators`, but actuator parameters default to the values in
    the flybody ``actuator_config.yaml`` unless overridden. The FlyBody defaults
    also differ: ``forcelimited`` is False and ``forcerange`` is ``(-0.3, 0.3)``.

    Args:
        jointdofs:
            Joint DOFs to actuate.
        actuator_type:
            Type of actuator (motor, position, velocity, etc.).
        neutral_input:
            Default actuator inputs. Accepts a ``dict`` mapping DoF names to
            values, a `KinematicPose`, or a `KinematicPosePreset`. If None,
            defaults to 0 for all actuators. For position actuators the values
            are joint angles and must match the skeleton axis order.
        forcelimited:
            If True, clamp actuator force to ``forcerange``. If False (default),
            the actuator force is unlimited; both this ``forcerange`` argument and
            any ``forcerange`` in ``actuator_config.yaml`` are ignored (the
            simplified parameter translation does not apply config force limits).
        forcerange:
            Force limit as a (min, max) tuple. Only applied when
            ``forcelimited=True``.
        **kwargs:
            MJCF actuator attributes (e.g. ``kp`` for position actuators, ``kv`` for
            velocity actuators) that override the defaults from
            ``actuator_config.yaml``. See the
            [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator)
            for supported attributes.

    Returns:
        Dictionary mapping JointDOF to created MJCF actuator elements.
    """
    actuator_type = ActuatorType(actuator_type)
    neutral_input = self._resolve_neutral_input(actuator_type, neutral_input)

    remove_ctrl_limits = False
    if (
        actuator_type == ActuatorType.MOTOR
        or actuator_type == ActuatorType.VELOCITY
    ):
        warnings.warn(
            "Setting ctrllimited=False for MOTOR and VELOCITY actuators; "
            "flybody ctrl limits are meant for POSITION actuators."
        )
        remove_ctrl_limits = True
        if not forcelimited:
            warnings.warn(
                "Without ctrl limits, MOTOR and VELOCITY actuators can generate "
                "extreme forces; consider setting forcelimited=True for stability."
            )

    return_dict = {}
    for jointdof in jointdofs:
        self.jointdof_to_neutralaction_by_type[actuator_type][jointdof] = (
            neutral_input.get(jointdof.name, 0.0)
        )

        default_actuator_params = {}
        for _, val in self.actuator_config.items():
            if jointdof.name in val["apply_to"]:
                default_actuator_params = val["general"]
                break
        if not default_actuator_params:
            warnings.warn(f"No actuator config found for joint {jointdof.name}.")

        default_actuator_params_specific = (
            self.translate_generaljointparams_to_specificjointparams_simplified(
                default_actuator_params, actuator_type
            )
        )

        if remove_ctrl_limits:
            default_actuator_params_specific["ctrllimited"] = False
        else:
            # recover ctrllimits from joint limits
            jnt = self.jointdof_to_mjcfjoint[jointdof]
            # MjSpec returns an all-zero range (not None) for joints with no
            # range, so check the value rather than `is not None`. Deriving a
            # [0, 0] ctrlrange would silently pin the actuator's input to 0.
            if "ctrlrange" not in kwargs and np.allclose(jnt.range, 0.0):
                raise ValueError(
                    f"Joint {jointdof.name} has no range specified, so a "
                    "default ctrlrange cannot be derived for its actuator. "
                    "Specify a range on the joint, or pass an explicit "
                    "`ctrlrange`."
                )
            default_actuator_params_specific["ctrlrange"] = jnt.range

        if forcelimited:
            default_actuator_params_specific["forcelimited"] = forcelimited
            default_actuator_params_specific["forcerange"] = forcerange

        if actuator_type == ActuatorType.POSITION:
            has_kp = "kp" in kwargs
            warning_str = "WARNING: actuator type is POSITION but "
            has_missing_param = False
            for param, has_it in [
                ("kp", has_kp)
            ]:  # , ("kv", has_kv), ("timeconst", has_timeconst)]:
                if not has_it:
                    warning_str += (
                        f"{param} not specified, using default value from "
                        "general actuator config if specified there, otherwise "
                        "using MuJoCo default. "
                    )
                    has_missing_param = True
            if has_missing_param:
                warnings.warn(warning_str)

        elif actuator_type == ActuatorType.VELOCITY:
            has_kv = "kv" in kwargs
            warning_str = "WARNING: actuator type is VELOCITY but "
            if not has_kv:
                warning_str += (
                    "kv not specified, using default value from general actuator "
                    "config if specified there, otherwise using MuJoCo default."
                )
                warnings.warn(warning_str)

        default_actuator_params_specific.update(kwargs)

        actuator = add_actuator(
            self.mjcf_root,
            actuator_type.value,
            name=f"{jointdof.name}-{actuator_type.value}",
            joint=jointdof.name,
            **default_actuator_params_specific,
        )

        return_dict[jointdof] = actuator
    self.jointdof_to_mjcfactuator_by_type[actuator_type].update(return_dict)
    self._rebuild_neutral_keyframe()
    return return_dict

add_joints(skeleton, neutral_pose=None, **kwargs)

Add joints to the FlyBody model based on a skeleton definition.

Like BaseFly.add_joints, but instead of taking uniform stiffness, damping, and armature arguments, the per-joint values are loaded from the flybody joint config (via _resolve_joint_params). neutral_pose sets each joint's springref (the angle at which passive spring forces are zero), and kwargs override the config-derived values.

Parameters:

Name Type Description Default
skeleton Skeleton

Skeleton defining which joints to create and their DOFs.

required
neutral_pose KinematicPose | KinematicPosePreset | None

Resting angles for joints. If provided, must match the skeleton's axis order; otherwise all neutral angles default to 0.

None
**kwargs Any

Per-joint MJCF attributes that override the config-derived values. See the MuJoCo XML reference for supported attributes.

{}

Returns:

Type Description
dict[JointDOF, MjsJoint]

Dictionary mapping JointDOF to created MJCF joint elements.

Source code in src/flygym/compose/fly/flybody.py
def add_joints(
    self,
    skeleton: Skeleton,
    neutral_pose: KinematicPose | KinematicPosePreset | None = None,
    **kwargs: Any,
) -> dict[JointDOF, mj.MjsJoint]:
    """Add joints to the FlyBody model based on a skeleton definition.

    Like `BaseFly.add_joints`, but instead of taking uniform ``stiffness``,
    ``damping``, and ``armature`` arguments, the per-joint values are loaded from
    the flybody joint config (via ``_resolve_joint_params``). ``neutral_pose`` sets
    each joint's ``springref`` (the angle at which passive spring forces are zero),
    and ``kwargs`` override the config-derived values.

    Args:
        skeleton:
            Skeleton defining which joints to create and their DOFs.
        neutral_pose:
            Resting angles for joints. If provided, must match the skeleton's axis
            order; otherwise all neutral angles default to 0.
        **kwargs:
            Per-joint MJCF attributes that override the config-derived values. See
            the [MuJoCo XML reference](https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-joint)
            for supported attributes.

    Returns:
        Dictionary mapping JointDOF to created MJCF joint elements.
    """

    self.skeleton = skeleton
    neutral_pose_lookup = self.get_pose_lookup(neutral_pose)

    return_dict = {}
    for jointdof in skeleton.iter_jointdofs(self.root_segment):
        child_body = self.bodyseg_to_mjcfbody[jointdof.child]
        joint_params = self._resolve_joint_params(jointdof.name)

        # Override default springref with neutral pose value if provided
        joint_params.update(
            {"springref": neutral_pose_lookup.get(jointdof.name, 0.0)}
        )
        # Override any joint config values with values provided in kwargs
        joint_params.update(kwargs)
        joint_params = self._normalize_mjcf_params(joint_params)
        joint_params = self._rename_mjspec_joint_keys(joint_params)

        return_dict[jointdof] = child_body.add_joint(
            name=jointdof.name,
            type=JOINT_TYPES["hinge"],
            axis=jointdof.axis.to_vector(),
            **joint_params,
        )

    self.jointdof_to_mjcfjoint.update(return_dict)
    self._rebuild_neutral_keyframe()
    return return_dict

add_leg_adhesion(gain=0.985, add_labrum=True, labrum_gain=1.0)

Add adhesion actuators to the tarsus5 segments of all legs and optionally to the labrum.

Adhesion actuators apply a normal attraction force, enabling the fly to grip surfaces. The control input per leg ranges from 0 (released) to 1 (full gain).

Parameters:

Name Type Description Default
gain float | dict[str, float]

Adhesion actuator gain. Either a single float applied to all legs, or a dict mapping leg position identifiers to per-leg gain values.

0.985
add_labrum bool

Whether to also add an adhesion actuator for the labrum (mouthpart).

True
labrum_gain float

Adhesion actuator gain for the labrum (used when add_labrum).

1.0

Returns:

Type Description
dict[str, MjsActuator]

Dict mapping leg position identifier to the created MJCF adhesion

dict[str, MjsActuator]

actuator element (same as self.leg_to_adhesionactuator).

Raises:

Type Description
ValueError

If adhesion actuators have already been added.

Source code in src/flygym/compose/fly/flybody.py
def add_leg_adhesion(
    self,
    gain: float | dict[str, float] = 0.985,
    add_labrum: bool = True,
    labrum_gain: float = 1.0,
) -> dict[str, mj.MjsActuator]:
    """Add adhesion actuators to the tarsus5 segments of all legs and optionally to the labrum.

    Adhesion actuators apply a normal attraction force, enabling the fly to grip
    surfaces. The control input per leg ranges from 0 (released) to 1 (full gain).

    Args:
        gain: Adhesion actuator gain. Either a single float applied to all legs,
            or a dict mapping leg position identifiers to per-leg gain values.
        add_labrum: Whether to also add an adhesion actuator for the labrum (mouthpart).
        labrum_gain: Adhesion actuator gain for the labrum (used when ``add_labrum``).

    Returns:
        Dict mapping leg position identifier to the created MJCF adhesion
        actuator element (same as ``self.leg_to_adhesionactuator``).

    Raises:
        ValueError: If adhesion actuators have already been added.
    """
    if len(self.leg_to_adhesionactuator) > 0:
        raise ValueError("Leg adhesion actuators have already been added.")
    for leg in LEGS:
        tarsus5_segment = FlyBodyBodySegment(f"{leg}_tarsus5")
        if isinstance(gain, dict):
            gain_this_leg = gain[leg]
        else:
            gain_this_leg = gain
        self.leg_to_adhesionactuator[leg] = add_actuator(
            self.mjcf_root,
            "adhesion",
            name=f"{tarsus5_segment.name}-adhesion",
            body=self.bodyseg_to_mjcfbody[tarsus5_segment].name,
            gain=gain_this_leg,
            ctrlrange=(0, 1),
        )
    if add_labrum:
        for s in "lr":
            labrum = FlyBodyBodySegment(f"{s}_labrum")
            self.leg_to_adhesionactuator[f"{s}_labrum"] = add_actuator(
                self.mjcf_root,
                "adhesion",
                name=f"{labrum.name}-adhesion",
                body=self.bodyseg_to_mjcfbody[labrum].name,
                gain=labrum_gain,
                ctrlrange=(0, 1),
            )
    return self.leg_to_adhesionactuator

colorize(visuals_config_path=FLYBODY_VISUALS_CONFIG_PATH)

Apply colors and textures to the FlyBody model.

Parameters:

Name Type Description Default
visuals_config_path PathLike

Path to the YAML file defining per-segment material and texture assignments. Defaults to the bundled FlyBody visuals.

FLYBODY_VISUALS_CONFIG_PATH
Source code in src/flygym/compose/fly/flybody.py
def colorize(
    self, visuals_config_path: PathLike = FLYBODY_VISUALS_CONFIG_PATH
) -> None:
    """Apply colors and textures to the FlyBody model.

    Args:
        visuals_config_path: Path to the YAML file defining per-segment material
            and texture assignments. Defaults to the bundled FlyBody visuals.
    """
    super().colorize(visuals_config_path)

translate_generalactparams_to_specificactparams(general_params, actuator_type)

Full gainprm/biasprm/dynprm -> kp/kv/timeconst translation.

NOTE: As of v2.1.0 this is dead code -- nothing calls it. add_actuators uses the simplified translate_generaljointparams_to_specificjointparams_simplified instead. It is intentionally retained as the intended complete implementation, to be wired up in a future release. See #272 for details.

Source code in src/flygym/compose/fly/flybody.py
def translate_generalactparams_to_specificactparams(
    self, general_params: dict[str, Any], actuator_type: ActuatorType
) -> dict[str, Any]:
    """Full gainprm/biasprm/dynprm -> kp/kv/timeconst translation.

    NOTE: As of v2.1.0 this is dead code -- nothing calls it. `add_actuators`
    uses the simplified
    `translate_generaljointparams_to_specificjointparams_simplified` instead.
    It is intentionally retained as the intended complete implementation, to be
    wired up in a future release. See #272 for details.
    """

    def _parse_param_values(raw_value: Any, name: str) -> list[float]:
        if isinstance(raw_value, str):
            tokens = raw_value.split()
        elif isinstance(raw_value, (list, tuple, np.ndarray)):
            tokens = list(raw_value)
        else:
            tokens = [raw_value]

        try:
            return [float(x) for x in tokens]
        except (TypeError, ValueError) as exc:
            raise ValueError(
                f"Invalid {name} value {raw_value!r}. Expected a whitespace-delimited string or a numeric list."
            ) from exc

    specific_params = general_params.copy()
    if actuator_type == ActuatorType.POSITION:
        # Set kp and kv from gainprm, biasprm and dynprm as specified here:
        # https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-position
        if "gainprm" in general_params:
            gainprm = general_params["gainprm"]
            gainprm_parsed = _parse_param_values(gainprm, "gainprm")
            kp = gainprm_parsed[0]
            if len(gainprm_parsed) > 1 and np.sum(gainprm_parsed[1:]) != 0:
                warnings.warn(
                    "gainprm has more than one value with non-zero entries after "
                    "the first; only the first value is used as kp for position "
                    "actuators (per MuJoCo docs)."
                )
        else:
            kp = 1.0
        if "biasprm" in general_params:
            biasprm = general_params["biasprm"]
            biasprm_parsed = _parse_param_values(biasprm, "biasprm")
            assert (
                len(biasprm_parsed) >= 2
                and biasprm_parsed[0] == 0.0
                and biasprm_parsed[1] == -1 * kp
            ), "Conflicting kp: according to MuJoCo docs biasprm is [0, -kp, -kv]"
            if len(biasprm_parsed) > 2:
                kv = -biasprm_parsed[2]
            else:
                kv = 0.0  # default for position according to mujoco
            if len(biasprm_parsed) > 3 and np.sum(biasprm_parsed[3:]) != 0:
                warnings.warn(
                    "biasprm has non-zero entries after the third; only the first "
                    "three values are used as biasprm for position actuators "
                    "(per MuJoCo docs)."
                )
        else:
            kv = 0.0
        if "dynprm" in general_params:
            dynprm = general_params["dynprm"]
            dynprm_parsed = _parse_param_values(dynprm, "dynprm")
            timeconst = dynprm_parsed[0]
            if len(dynprm_parsed) > 1 and np.sum(dynprm_parsed[1:]) != 0:
                warnings.warn(
                    "dynprm has non-zero entries after the first; only the first "
                    "value is used as timeconst for position actuators "
                    "(per MuJoCo docs)."
                )
        else:
            timeconst = 1.0  # default for general according to mujoco
        specific_params = {
            "kp": kp,
            "kv": kv,
            "timeconst": timeconst,
        }
    elif actuator_type == ActuatorType.VELOCITY:
        # Setting parameters values according to
        # https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-velocity
        if "gainprm" in general_params:
            gainprm = general_params["gainprm"]
            gainprm_parsed = _parse_param_values(gainprm, "gainprm")
            kv = gainprm_parsed[0]
            if len(gainprm_parsed) > 1 and np.sum(gainprm_parsed[1:]) != 0:
                warnings.warn(
                    "gainprm has non-zero entries after the first; only the first "
                    "value is used as kv for velocity actuators (per MuJoCo docs)."
                )
        else:
            kv = 1.0
        if "biasprm" in general_params:
            biasprm = general_params["biasprm"]
            biasprm_parsed = _parse_param_values(biasprm, "biasprm")
            assert (
                len(biasprm_parsed) >= 3
                and biasprm_parsed[0] == 0.0
                and biasprm_parsed[1] == 0.0
                and biasprm_parsed[2] == -1 * kv
            ), "Conflicting kvs: according to MuJoCo docs biasprm is [0, 0, -kv]"
            if len(biasprm_parsed) > 3 and np.sum(biasprm_parsed[3:]) != 0:
                warnings.warn(
                    "biasprm has non-zero entries after the fourth; only the first "
                    "four values are used as biasprm for velocity actuators "
                    "(per MuJoCo docs)."
                )
        if "dynprm" in general_params:
            warnings.warn(
                "dynprm is not used for velocity actuators (per MuJoCo docs); "
                "ignoring the dynprm values from the general actuator config."
            )
        specific_params = {
            "kv": kv,
        }
    elif actuator_type == ActuatorType.MOTOR:
        # Setting parameters values according to
        # https://mujoco.readthedocs.io/en/stable/XMLreference.html#actuator-motor
        if "gainprm" in general_params:
            warnings.warn(
                "Ignoring default gainprm: it is not used by classical motor "
                "actuators (per MuJoCo docs)."
            )
        if "biasprm" in general_params:
            warnings.warn(
                "Ignoring default biasprm: it is not used by classical motor "
                "actuators (per MuJoCo docs)."
            )
        if "dynprm" in general_params:
            warnings.warn(
                "Ignoring default dynprm: it is not used by classical motor "
                "actuators (per MuJoCo docs)."
            )
        specific_params = {}
    else:
        raise ValueError(f"Unsupported actuator type: {actuator_type}")

    for params in ["gainprm", "biasprm", "dynprm"]:
        if params in specific_params:
            # remove them from specific params
            specific_params.pop(params)

    return specific_params

GeomFittingOption

Bases: Enum

How to fit collision geometries to the mesh shapes.

Attributes:

Name Type Description
UNMODIFIED

Keep the original mesh-based geometries.

ALL_TO_CAPSULES

Replace all geometries with capsule approximations.

CLAWS_TO_CAPSULES

Replace only tarsus5 geometries with capsules.

Source code in src/flygym/compose/fly/base_fly.py
class GeomFittingOption(Enum):
    """How to fit collision geometries to the mesh shapes.

    Attributes:
        UNMODIFIED: Keep the original mesh-based geometries.
        ALL_TO_CAPSULES: Replace all geometries with capsule approximations.
        CLAWS_TO_CAPSULES: Replace only tarsus5 geometries with capsules.
    """

    UNMODIFIED = "unmodified"
    ALL_TO_CAPSULES = "all_to_capsules"
    CLAWS_TO_CAPSULES = "claws_to_capsules"

MeshType

Bases: Enum

Mesh resolution to use for fly body geometry.

Attributes:

Name Type Description
FULLSIZE

Original high-resolution meshes.

SIMPLIFIED_MAX2000FACES

Simplified meshes with at most 2000 faces per segment. Faster to render and simulate. Used by default.

Source code in src/flygym/compose/fly/base_fly.py
class MeshType(Enum):
    """Mesh resolution to use for fly body geometry.

    Attributes:
        FULLSIZE: Original high-resolution meshes.
        SIMPLIFIED_MAX2000FACES: Simplified meshes with at most 2000 faces per
            segment. Faster to render and simulate. Used by default.
    """

    FULLSIZE = "fullsize"
    SIMPLIFIED_MAX2000FACES = "simplified_max2000faces"

MjWarpCompatibilityReport dataclass

Outcome of probing whether MuJoCo-Warp accepts the muscle model.

Attributes:

Name Type Description
mjwarp_available bool

Whether mujoco_warp could be imported.

put_model_ok bool | None

Whether mjw.put_model succeeded on the compiled model. None if mjwarp was unavailable (probe not run).

error str | None

The exception text if put_model failed, else None.

message str

A human-readable summary.

Source code in src/flygym/compose/fly/musculoskeletal.py
@dataclass
class MjWarpCompatibilityReport:
    """Outcome of probing whether MuJoCo-Warp accepts the muscle model.

    Attributes:
        mjwarp_available: Whether ``mujoco_warp`` could be imported.
        put_model_ok: Whether ``mjw.put_model`` succeeded on the compiled
            model. ``None`` if mjwarp was unavailable (probe not run).
        error: The exception text if ``put_model`` failed, else ``None``.
        message: A human-readable summary.
    """

    mjwarp_available: bool
    put_model_ok: bool | None
    error: str | None
    message: str

    def __bool__(self) -> bool:
        """True only if mjwarp is available *and* accepted the model."""
        return bool(self.mjwarp_available and self.put_model_ok)

__bool__()

True only if mjwarp is available and accepted the model.

Source code in src/flygym/compose/fly/musculoskeletal.py
def __bool__(self) -> bool:
    """True only if mjwarp is available *and* accepted the model."""
    return bool(self.mjwarp_available and self.put_model_ok)

MusculoskeletalFly

Bases: BaseCompositionElement

FlyGym-compatible wrapper around FlyMimic's musculoskeletal MJCF.

The musculoskeletal body model is published in:

Ozdil, P. G., et al. (2026). Musculoskeletal simulation of limb
movement biomechanics in *Drosophila melanogaster*. *ICLR 2026*.
https://arxiv.org/abs/2509.06426

Source code for the original model: https://github.com/gizemozd/FlyMimic

Plain flygym — not GPU-accelerated

MusculoskeletalFly works with plain flygym.Simulation (CPU, single world). It does not require flygym.warp.

Unlike NeuroMechFly and FlyBody, which compose a body from meshes and YAML rigging configs via BaseFly, this class loads FlyMimic's pre-authored self-contained MJCF and wraps it so that Simulation and its sensor suite work unchanged. Dict keys on all tracking attributes are the model's own MJCF element-name strings (e.g. "LFFemur", "joint_LFCoxa_yaw").

Parameters:

Name Type Description Default
xml_path PathLike

Path to the FlyMimic MJCF. Defaults to the bundled arm_damping_stiff musculoskeletal model (DEFAULT_MUSCULOSKELETAL_XML).

DEFAULT_MUSCULOSKELETAL_XML
name str

Logical fly name used by Simulation lookups. Defaults to "nmf".

'nmf'

Attributes:

Name Type Description
bodyseg_to_mjcfbody dict[str, MjsBody]

Maps body-segment name → MJCF body element.

bodyseg_to_mjcfgeom dict[str, list[MjsGeom]]

Maps body-segment name → list of MJCF geom elements (one entry per geom on that body).

jointdof_to_mjcfjoint dict[str, MjsJoint]

Maps joint-DoF name → MJCF joint element.

jointdof_to_mjcfactuator_by_type

Maps ActuatorType → dict of actuator-name → MJCF actuator element.

leg_to_adhesionactuator dict[str, MjsActuator]

Always empty (FlyMimic has no adhesion).

anatomicaljoint_to_mjcfsites dict[str, MjsSite]

Always empty.

eyecameraname_to_mjcfcamera dict[str, MjsCamera]

Camera elements added via add_vision; empty until add_vision is called.

cameraname_to_mjcfcamera dict[str, MjsCamera]

All scene cameras, including the world camera named DEFAULT_SCENE_CAMERA.

muscle_names list[str]

Names of the 15 Hill-type muscle actuators, in MJCF order (read-only property).

Source code in src/flygym/compose/fly/musculoskeletal.py
class MusculoskeletalFly(BaseCompositionElement):
    """FlyGym-compatible wrapper around FlyMimic's musculoskeletal MJCF.

    The musculoskeletal body model is published in:

        Ozdil, P. G., et al. (2026). Musculoskeletal simulation of limb
        movement biomechanics in *Drosophila melanogaster*. *ICLR 2026*.
        https://arxiv.org/abs/2509.06426

    Source code for the original model: https://github.com/gizemozd/FlyMimic

    !!! info "Plain flygym — not GPU-accelerated"

        `MusculoskeletalFly` works with plain `flygym.Simulation` (CPU,
        single world). It does **not** require `flygym.warp`.

    Unlike `NeuroMechFly` and `FlyBody`, which compose a body from meshes
    and YAML rigging configs via `BaseFly`, this class loads FlyMimic's
    pre-authored self-contained MJCF and wraps it so that `Simulation` and
    its sensor suite work unchanged. Dict keys on all tracking attributes
    are the model's own MJCF element-name strings (e.g. ``"LFFemur"``,
    ``"joint_LFCoxa_yaw"``).

    Args:
        xml_path: Path to the FlyMimic MJCF. Defaults to the bundled
            ``arm_damping_stiff`` musculoskeletal model
            (`DEFAULT_MUSCULOSKELETAL_XML`).
        name: Logical fly name used by `Simulation` lookups. Defaults to
            ``"nmf"``.

    Attributes:
        bodyseg_to_mjcfbody: Maps body-segment name → MJCF body element.
        bodyseg_to_mjcfgeom: Maps body-segment name → list of MJCF geom
            elements (one entry per geom on that body).
        jointdof_to_mjcfjoint: Maps joint-DoF name → MJCF joint element.
        jointdof_to_mjcfactuator_by_type: Maps `ActuatorType` → dict of
            actuator-name → MJCF actuator element.
        leg_to_adhesionactuator: Always empty (FlyMimic has no adhesion).
        anatomicaljoint_to_mjcfsites: Always empty.
        eyecameraname_to_mjcfcamera: Camera elements added via `add_vision`;
            empty until `add_vision` is called.
        cameraname_to_mjcfcamera: All scene cameras, including the world
            camera named `DEFAULT_SCENE_CAMERA`.
        muscle_names: Names of the 15 Hill-type muscle actuators, in MJCF
            order (read-only property).
    """

    def __init__(
        self,
        xml_path: PathLike = DEFAULT_MUSCULOSKELETAL_XML,
        *,
        name: str = "nmf",
    ) -> None:
        self._name = name
        self._mjcf_root = _load_mjcf(xml_path)

        # Make the simulation's reset target ("neutral") resolve: rename the
        # model's existing default pose keyframe.
        self._neutral_keyframe = self._mjcf_root.key("default-pose")
        if self._neutral_keyframe is not None:
            self._neutral_keyframe.name = "neutral"
        else:
            self._neutral_keyframe = self._mjcf_root.add_key(name="neutral", time=0)

        # Build the tracking dicts that Simulation introspects. Like
        # `BaseFly`, ``bodyseg_to_mjcfgeom`` maps each segment to the *list* of
        # its geoms (Simulation and the sensor APIs iterate over them).
        # ``spec.bodies`` includes the unnamed worldbody (its MjSpec name is
        # ``"world"``); skip it so only real body segments are registered. Its
        # only direct geom is the floor, which the world exposes via
        # ``ground_geoms`` instead.
        worldbody = self._mjcf_root.worldbody
        self.bodyseg_to_mjcfbody: dict[str, mj.MjsBody] = {}
        self.bodyseg_to_mjcfgeom: dict[str, list[mj.MjsGeom]] = {}
        for body in self._mjcf_root.bodies:
            if body.name == worldbody.name or not body.name:
                continue
            self.bodyseg_to_mjcfbody[body.name] = body
            geoms = list(body.geoms)
            if geoms:
                self.bodyseg_to_mjcfgeom[body.name] = geoms

        self.jointdof_to_mjcfjoint: dict[str, mj.MjsJoint] = {
            j.name: j for j in self._mjcf_root.joints if j.name
        }

        self.jointdof_to_mjcfactuator_by_type = {ty: {} for ty in ActuatorType}
        for actuator in self._mjcf_root.actuators:
            if not actuator.name:
                continue
            ty = self._classify_actuator(actuator)
            self.jointdof_to_mjcfactuator_by_type[ty][actuator.name] = actuator

        # FlyMimic has no adhesion, no anatomical-joint sites, no eye cameras
        # by default.
        self.leg_to_adhesionactuator: dict[str, mj.MjsActuator] = {}
        self.anatomicaljoint_to_mjcfsites: dict[str, mj.MjsSite] = {}
        self.eyecameraname_to_mjcfcamera: dict[str, mj.MjsCamera] = {}

        # Register scene cameras so they can be selected for rendering. FlyMimic
        # ships a single unnamed world camera; name any unnamed camera so
        # ``Simulation.set_renderer(DEFAULT_SCENE_CAMERA)`` resolves it. (Eye
        # cameras are added later, named, via add_vision().) MjSpec gives an
        # unnamed camera an empty-string name rather than ``None``.
        self.cameraname_to_mjcfcamera: dict[str, mj.MjsCamera] = {}
        for idx, cam in enumerate(self._mjcf_root.cameras):
            if not cam.name:
                cam.name = (
                    DEFAULT_SCENE_CAMERA
                    if idx == 0
                    else f"{DEFAULT_SCENE_CAMERA}_{idx}"
                )
            self.cameraname_to_mjcfcamera[cam.name] = cam

    @property
    def mjcf_root(self) -> mj.MjSpec:
        return self._mjcf_root

    @property
    def name(self) -> str:
        return self._name

    @staticmethod
    def _classify_actuator(actuator: mj.MjsActuator) -> ActuatorType:
        """Best-effort mapping of an MjSpec actuator element to an ActuatorType.

        MjSpec exposes every actuator as a low-level "general" element, so there
        is no shortcut tag (``motor``/``position``/...) to read. The dynamics
        type is the reliable discriminator: muscles use ``mjDYN_MUSCLE`` (FlyMimic
        sets this on the ``"muscle"`` default class, and MjSpec resolves the class
        default onto the element). Everything else falls back to ``MOTOR``, which
        is all FlyMimic's non-muscle actuators are.
        """
        if actuator.dyntype == mj.mjtDyn.mjDYN_MUSCLE:
            return ActuatorType.MUSCLE
        return ActuatorType.MOTOR

    # ---- Fly-compatible accessors used by Simulation / the imitation env ----

    def get_bodysegs_order(self) -> list[str]:
        """Return all body-segment names in MJCF order."""
        return list(self.bodyseg_to_mjcfbody.keys())

    def get_jointdofs_order(self) -> list[str]:
        """Return all joint-DoF names in MJCF order."""
        return list(self.jointdof_to_mjcfjoint.keys())

    def get_actuated_jointdofs_order(
        self, actuator_type: "ActuatorType | str"
    ) -> list[str]:
        """Return actuator names of the given type, in MJCF order.

        Args:
            actuator_type: An `ActuatorType` value or its string name (e.g.
                ``"muscle"`` or ``ActuatorType.MUSCLE``).
        """
        actuator_type = ActuatorType(actuator_type)
        return list(self.jointdof_to_mjcfactuator_by_type[actuator_type].keys())

    def get_sites_order(self) -> list[str]:
        """Return anatomical-joint site names (always empty for this model)."""
        return list(self.anatomicaljoint_to_mjcfsites.keys())

    def get_legs_order(self) -> list[str]:
        """Return leg names (always empty — no ground-contact grouping defined)."""
        return []

    @property
    def muscle_names(self) -> list[str]:
        """Names of the muscle actuators, in MJCF order."""
        return self.get_actuated_jointdofs_order(ActuatorType.MUSCLE)

    def add_vision(
        self,
        *,
        fovy: float = 145.0,
        draw_sensor_markers: bool = False,
    ) -> dict[str, mj.MjsCamera]:
        """Attach left/right eye cameras to the model's eye bodies.

        Enables `Simulation.get_raw_vision` / `get_ommatidia_readouts`. Note
        that FlyGym's fisheye `Retina` is calibrated for FlyGym's own eye
        placement, so ommatidia readouts on this body are approximate.
        """
        added: dict[str, mj.MjsCamera] = {}
        for eye_body_name, rgba in _EYE_BODIES.items():
            body = self.bodyseg_to_mjcfbody.get(eye_body_name)
            if body is None:
                continue
            cam = body.add_camera(
                name=f"{eye_body_name}_camera",
                mode=CAMERA_MODES["fixed"],
                # Point the camera laterally outward from each eye.
                euler=(0.0, 0.0, 0.0),
                fovy=fovy,
            )
            if draw_sensor_markers:
                body.add_site(
                    name=f"{eye_body_name}_marker",
                    type=GEOM_TYPES["sphere"],
                    size=(0.02, 0.02, 0.02),
                    rgba=rgba,
                    group=1,
                )
            added[eye_body_name] = cam
        self.eyecameraname_to_mjcfcamera.update(added)
        return added

muscle_names property

Names of the muscle actuators, in MJCF order.

add_vision(*, fovy=145.0, draw_sensor_markers=False)

Attach left/right eye cameras to the model's eye bodies.

Enables Simulation.get_raw_vision / get_ommatidia_readouts. Note that FlyGym's fisheye Retina is calibrated for FlyGym's own eye placement, so ommatidia readouts on this body are approximate.

Source code in src/flygym/compose/fly/musculoskeletal.py
def add_vision(
    self,
    *,
    fovy: float = 145.0,
    draw_sensor_markers: bool = False,
) -> dict[str, mj.MjsCamera]:
    """Attach left/right eye cameras to the model's eye bodies.

    Enables `Simulation.get_raw_vision` / `get_ommatidia_readouts`. Note
    that FlyGym's fisheye `Retina` is calibrated for FlyGym's own eye
    placement, so ommatidia readouts on this body are approximate.
    """
    added: dict[str, mj.MjsCamera] = {}
    for eye_body_name, rgba in _EYE_BODIES.items():
        body = self.bodyseg_to_mjcfbody.get(eye_body_name)
        if body is None:
            continue
        cam = body.add_camera(
            name=f"{eye_body_name}_camera",
            mode=CAMERA_MODES["fixed"],
            # Point the camera laterally outward from each eye.
            euler=(0.0, 0.0, 0.0),
            fovy=fovy,
        )
        if draw_sensor_markers:
            body.add_site(
                name=f"{eye_body_name}_marker",
                type=GEOM_TYPES["sphere"],
                size=(0.02, 0.02, 0.02),
                rgba=rgba,
                group=1,
            )
        added[eye_body_name] = cam
    self.eyecameraname_to_mjcfcamera.update(added)
    return added

get_actuated_jointdofs_order(actuator_type)

Return actuator names of the given type, in MJCF order.

Parameters:

Name Type Description Default
actuator_type ActuatorType | str

An ActuatorType value or its string name (e.g. "muscle" or ActuatorType.MUSCLE).

required
Source code in src/flygym/compose/fly/musculoskeletal.py
def get_actuated_jointdofs_order(
    self, actuator_type: "ActuatorType | str"
) -> list[str]:
    """Return actuator names of the given type, in MJCF order.

    Args:
        actuator_type: An `ActuatorType` value or its string name (e.g.
            ``"muscle"`` or ``ActuatorType.MUSCLE``).
    """
    actuator_type = ActuatorType(actuator_type)
    return list(self.jointdof_to_mjcfactuator_by_type[actuator_type].keys())

get_bodysegs_order()

Return all body-segment names in MJCF order.

Source code in src/flygym/compose/fly/musculoskeletal.py
def get_bodysegs_order(self) -> list[str]:
    """Return all body-segment names in MJCF order."""
    return list(self.bodyseg_to_mjcfbody.keys())

get_jointdofs_order()

Return all joint-DoF names in MJCF order.

Source code in src/flygym/compose/fly/musculoskeletal.py
def get_jointdofs_order(self) -> list[str]:
    """Return all joint-DoF names in MJCF order."""
    return list(self.jointdof_to_mjcfjoint.keys())

get_legs_order()

Return leg names (always empty — no ground-contact grouping defined).

Source code in src/flygym/compose/fly/musculoskeletal.py
def get_legs_order(self) -> list[str]:
    """Return leg names (always empty — no ground-contact grouping defined)."""
    return []

get_sites_order()

Return anatomical-joint site names (always empty for this model).

Source code in src/flygym/compose/fly/musculoskeletal.py
def get_sites_order(self) -> list[str]:
    """Return anatomical-joint site names (always empty for this model)."""
    return list(self.anatomicaljoint_to_mjcfsites.keys())

NeuroMechFly

Bases: BaseFly

The NeuroMechFly body model for Drosophila melanogaster.

NeuroMechFly is derived from a micro-CT scan of a real fly. It is the default body model in FlyGym and supports all locomotion, sensorimotor, and vision experiments. For an alternative anatomically detailed model with wing and abdomen degrees of freedom, see FlyBody.

Both NeuroMechFly and FlyBody inherit from BaseFly and expose the same composition API, so they can be used interchangeably.

See NeuroMechFlyWithSeparateTrochanterFemur for a variant of this model where the trochanter and femur are separate body segments (instead of fused into a single segment). The user can still effectively fuse the two by not adding any joint between them (which is the case by default). When MuJoCo compiler parameter fusestatic is set to true, the two segment will actually be fused at compile time. Tentatively, NeuroMechFlyWithSeparateTrochanterFemur will fully replace NeuroMechFly in FlyGym 3.0.

Parameters:

Name Type Description Default
name str

Identifier for this fly instance. Defaults to "nmf".

'nmf'
rigging_config_path PathLike

Path to YAML file defining body segment positions, orientations, and masses.

DEFAULT_RIGGING_CONFIG_PATH
mesh_basedir PathLike

Directory containing STL mesh files for body segments.

DEFAULT_MESH_DIR
mujoco_globals_path PathLike

Path to YAML file with global MuJoCo parameters.

DEFAULT_MUJOCO_GLOBALS_PATH
root_segment BodySegment | str

Root body segment for the kinematic tree.

'c_thorax'
mirror_left2right bool

If True, mirror left-side meshes for the right side.

True
mesh_type MeshType

Mesh resolution to use.

SIMPLIFIED_MAX2000FACES
geom_fitting_option GeomFittingOption

How to fit collision geometries.

UNMODIFIED
vision_config_path PathLike

Path to YAML file with vision sensor configuration.

DEFAULT_VISION_CONFIG_PATH
Source code in src/flygym/compose/fly/neuromechfly.py
class NeuroMechFly(BaseFly):
    """The NeuroMechFly body model for *Drosophila melanogaster*.

    NeuroMechFly is derived from a micro-CT scan of a real fly. It is the
    default body model in FlyGym and supports all locomotion, sensorimotor,
    and vision experiments. For an alternative anatomically detailed model
    with wing and abdomen degrees of freedom, see `FlyBody`.

    Both `NeuroMechFly` and `FlyBody` inherit from `BaseFly` and expose the
    same composition API, so they can be used interchangeably.

    See `NeuroMechFlyWithSeparateTrochanterFemur` for a variant of this model
    where the trochanter and femur are separate body segments (instead of fused
    into a single segment). The user can still effectively fuse the two by not
    adding any joint between them (which is the case by default). When MuJoCo
    compiler parameter `fusestatic` is set to true, the two segment will
    actually be fused at compile time. Tentatively,
    `NeuroMechFlyWithSeparateTrochanterFemur` will fully replace `NeuroMechFly`
    in FlyGym 3.0.

    Args:
        name: Identifier for this fly instance. Defaults to ``"nmf"``.
        rigging_config_path: Path to YAML file defining body segment positions,
            orientations, and masses.
        mesh_basedir: Directory containing STL mesh files for body segments.
        mujoco_globals_path: Path to YAML file with global MuJoCo parameters.
        root_segment: Root body segment for the kinematic tree.
        mirror_left2right: If True, mirror left-side meshes for the right side.
        mesh_type: Mesh resolution to use.
        geom_fitting_option: How to fit collision geometries.
        vision_config_path: Path to YAML file with vision sensor configuration.
    """

    def __init__(
        self,
        name: str = "nmf",
        *,
        rigging_config_path: PathLike = DEFAULT_RIGGING_CONFIG_PATH,
        mesh_basedir: PathLike = DEFAULT_MESH_DIR,
        mujoco_globals_path: PathLike = DEFAULT_MUJOCO_GLOBALS_PATH,
        root_segment: BodySegment | str = "c_thorax",
        mirror_left2right: bool = True,
        mesh_type: MeshType = MeshType.SIMPLIFIED_MAX2000FACES,
        geom_fitting_option: GeomFittingOption = GeomFittingOption.UNMODIFIED,
        vision_config_path: PathLike = DEFAULT_VISION_CONFIG_PATH,
    ) -> None:
        super().__init__(
            name=name,
            rigging_config_path=rigging_config_path,
            mesh_basedir=mesh_basedir,
            mujoco_globals_path=mujoco_globals_path,
            root_segment=root_segment,
            mirror_left2right=mirror_left2right,
            mesh_type=mesh_type,
            geom_fitting_option=geom_fitting_option,
            vision_config_path=vision_config_path,
        )

    def _add_mesh_assets(
        self, mesh_basedir: PathLike, mirror_left2right: bool, mesh_type: MeshType
    ) -> None:
        _add_neuromechfly_mesh_assets(
            self, ALL_SEGMENT_NAMES, mesh_basedir, mirror_left2right, mesh_type
        )

    def colorize(
        self, visuals_config_path: PathLike = DEFAULT_VISUALS_CONFIG_PATH
    ) -> None:
        """Apply colors and textures to the NeuroMechFly model.

        Args:
            visuals_config_path: Path to the YAML file defining per-segment material
                and texture assignments. Defaults to the bundled NeuroMechFly visuals.
        """
        super().colorize(visuals_config_path)

colorize(visuals_config_path=DEFAULT_VISUALS_CONFIG_PATH)

Apply colors and textures to the NeuroMechFly model.

Parameters:

Name Type Description Default
visuals_config_path PathLike

Path to the YAML file defining per-segment material and texture assignments. Defaults to the bundled NeuroMechFly visuals.

DEFAULT_VISUALS_CONFIG_PATH
Source code in src/flygym/compose/fly/neuromechfly.py
def colorize(
    self, visuals_config_path: PathLike = DEFAULT_VISUALS_CONFIG_PATH
) -> None:
    """Apply colors and textures to the NeuroMechFly model.

    Args:
        visuals_config_path: Path to the YAML file defining per-segment material
            and texture assignments. Defaults to the bundled NeuroMechFly visuals.
    """
    super().colorize(visuals_config_path)

NeuroMechFlyWithSeparateTrochanterFemur

Bases: BaseFly

NeuroMechFly with the trochanter and the femur as separate body segments.

In NeuroMechFly, the trochanter and the femur form a single trochanterfemur segment. Here, they are separate segments (e.g. lf_trochanter and lf_femur), so that each has its own body and geom (e.g. for segmentation maps). Use SeparateTrochanterFemurJointPreset and SeparateTrochanterFemurContactBodiesPreset instead of JointPreset and ContactBodiesPreset with this model.

The two are often considered to be fused in real flies (at least in Lobato-Rios et al., 2022), so biological joint presets add no DoF between them and the kinematics are the same as in NeuroMechFly. The physics is close but not bit-for-bit identical, because the mass distribution of the two segments is derived from separate meshes. Joint DoFs of the fused anatomy are renamed: the coxa-trochanterfemur joint becomes the coxa-trochanter joint, and the trochanterfemur-tibia joint becomes the femur-tibia joint (see flygym.anatomy.separate_trochanterfemur_in_dof_name). Poses defined for the fused anatomy, such as KinematicPosePreset.NEUTRAL, are renamed automatically.

The user can still effectively fuse the two segments by not adding any joint between them (which is the case by default). When MuJoCo compiler parameter fusestatic is set to true, the two segment will actually be fused at compile time.

This model will tentatively replace NeuroMechFly in FlyGym 3.0.

Source code in src/flygym/compose/fly/neuromechfly.py
class NeuroMechFlyWithSeparateTrochanterFemur(BaseFly):
    """NeuroMechFly with the trochanter and the femur as separate body segments.

    In `NeuroMechFly`, the trochanter and the femur form a single `trochanterfemur`
    segment. Here, they are separate segments (e.g. `lf_trochanter` and `lf_femur`),
    so that each has its own body and geom (e.g. for segmentation maps). Use
    `SeparateTrochanterFemurJointPreset` and
    `SeparateTrochanterFemurContactBodiesPreset` instead of `JointPreset` and
    `ContactBodiesPreset` with this model.

    The two are often considered to be fused in real flies (at least in Lobato-Rios et
    al., 2022), so biological joint presets add no DoF between them and the kinematics
    are the same as in `NeuroMechFly`. The physics is close but not bit-for-bit
    identical, because the mass distribution of the two segments is derived from
    separate meshes. Joint DoFs of the fused anatomy are renamed: the
    coxa-trochanterfemur joint becomes the coxa-trochanter joint, and the
    trochanterfemur-tibia joint becomes the femur-tibia joint (see
    `flygym.anatomy.separate_trochanterfemur_in_dof_name`). Poses defined for the fused
    anatomy, such as `KinematicPosePreset.NEUTRAL`, are renamed automatically.

    The user can still effectively fuse the two segments by not adding any joint between
    them (which is the case by default). When MuJoCo compiler parameter `fusestatic`
    is set to true, the two segment will actually be fused at compile time.

    This model will _tentatively_ replace `NeuroMechFly` in FlyGym 3.0.

    Args:
        Same as `NeuroMechFly`.
    """

    LEG_LINKS = LEG_LINKS_SEPARATE_TROCHANTERFEMUR
    CONTACT_BODIES_PRESET_CLASS = SeparateTrochanterFemurContactBodiesPreset

    def __init__(
        self,
        name: str = "nmf",
        *,
        rigging_config_path: PathLike = SEPARATE_TROCHANTERFEMUR_RIGGING_CONFIG_PATH,
        mesh_basedir: PathLike = DEFAULT_MESH_DIR,
        mujoco_globals_path: PathLike = DEFAULT_MUJOCO_GLOBALS_PATH,
        root_segment: BodySegment | str = "c_thorax",
        mirror_left2right: bool = True,
        mesh_type: MeshType = MeshType.SIMPLIFIED_MAX2000FACES,
        geom_fitting_option: GeomFittingOption = GeomFittingOption.UNMODIFIED,
        vision_config_path: PathLike = DEFAULT_VISION_CONFIG_PATH,
    ) -> None:
        super().__init__(
            name=name,
            rigging_config_path=rigging_config_path,
            mesh_basedir=mesh_basedir,
            mujoco_globals_path=mujoco_globals_path,
            root_segment=root_segment,
            mirror_left2right=mirror_left2right,
            mesh_type=mesh_type,
            geom_fitting_option=geom_fitting_option,
            vision_config_path=vision_config_path,
        )

    def get_pose_lookup(
        self, neutral_pose: KinematicPose | KinematicPosePreset | None
    ) -> dict[str, float]:
        """Same as `BaseFly.get_pose_lookup`, but DoF names of the fused
        trochanterfemur are renamed to their equivalents in this model."""
        lookup = super().get_pose_lookup(neutral_pose)
        return {separate_trochanterfemur_in_dof_name(k): v for k, v in lookup.items()}

    def colorize(
        self, visuals_config_path: PathLike = DEFAULT_VISUALS_CONFIG_PATH
    ) -> None:
        """Same as `NeuroMechFly.colorize`."""
        super().colorize(visuals_config_path)

    def _all_possible_joint_preset(self):
        return SeparateTrochanterFemurJointPreset.ALL_POSSIBLE

    def _add_mesh_assets(
        self, mesh_basedir: PathLike, mirror_left2right: bool, mesh_type: MeshType
    ) -> None:
        _add_neuromechfly_mesh_assets(
            self,
            ALL_SEGMENT_NAMES_SEPARATE_TROCHANTERFEMUR,
            mesh_basedir,
            mirror_left2right,
            mesh_type,
        )

colorize(visuals_config_path=DEFAULT_VISUALS_CONFIG_PATH)

Same as NeuroMechFly.colorize.

Source code in src/flygym/compose/fly/neuromechfly.py
def colorize(
    self, visuals_config_path: PathLike = DEFAULT_VISUALS_CONFIG_PATH
) -> None:
    """Same as `NeuroMechFly.colorize`."""
    super().colorize(visuals_config_path)

get_pose_lookup(neutral_pose)

Same as BaseFly.get_pose_lookup, but DoF names of the fused trochanterfemur are renamed to their equivalents in this model.

Source code in src/flygym/compose/fly/neuromechfly.py
def get_pose_lookup(
    self, neutral_pose: KinematicPose | KinematicPosePreset | None
) -> dict[str, float]:
    """Same as `BaseFly.get_pose_lookup`, but DoF names of the fused
    trochanterfemur are renamed to their equivalents in this model."""
    lookup = super().get_pose_lookup(neutral_pose)
    return {separate_trochanterfemur_in_dof_name(k): v for k, v in lookup.items()}

build_musculoskeletal_gpu_simulation(n_worlds, *, xml_path=DEFAULT_MUSCULOSKELETAL_XML, name='nmf', add_vision=False, **gpu_kwargs)

Build a GPUSimulation of the muscle model with n_worlds parallel copies for vectorized RL on a CUDA machine.

Call check_mjwarp_compatibility first to verify that the installed mujoco_warp version supports the muscle model's Hill-type actuators, spatial tendons, and joint-equality constraints.

Parameters:

Name Type Description Default
n_worlds int

Number of parallel simulation worlds.

required
xml_path PathLike

Path to the FlyMimic MJCF. Defaults to DEFAULT_MUSCULOSKELETAL_XML.

DEFAULT_MUSCULOSKELETAL_XML
name str

Logical fly name. Defaults to "nmf".

'nmf'
add_vision bool

Attach eye cameras (approximate; see build_musculoskeletal_simulation).

False
**gpu_kwargs Any

Forwarded to GPUSimulation.

{}

Returns:

Type Description
GPUSimulation

(gpu_simulation, fly) — a GPUSimulation and the

MusculoskeletalFly

MusculoskeletalFly.

Raises:

Type Description
ImportError

If the [warp] extra or an NVIDIA CUDA GPU is not available.

Source code in src/flygym/compose/fly/musculoskeletal.py
def build_musculoskeletal_gpu_simulation(
    n_worlds: int,
    *,
    xml_path: PathLike = DEFAULT_MUSCULOSKELETAL_XML,
    name: str = "nmf",
    add_vision: bool = False,
    **gpu_kwargs: Any,
) -> "tuple[GPUSimulation, MusculoskeletalFly]":
    """Build a `GPUSimulation` of the muscle model with *n_worlds* parallel
    copies for vectorized RL on a CUDA machine.

    Call `check_mjwarp_compatibility` first to verify that the installed
    ``mujoco_warp`` version supports the muscle model's Hill-type actuators,
    spatial tendons, and joint-equality constraints.

    Args:
        n_worlds: Number of parallel simulation worlds.
        xml_path: Path to the FlyMimic MJCF. Defaults to
            `DEFAULT_MUSCULOSKELETAL_XML`.
        name: Logical fly name. Defaults to ``"nmf"``.
        add_vision: Attach eye cameras (approximate; see
            `build_musculoskeletal_simulation`).
        **gpu_kwargs: Forwarded to `GPUSimulation`.

    Returns:
        ``(gpu_simulation, fly)`` — a `GPUSimulation` and the
        `MusculoskeletalFly`.

    Raises:
        ImportError: If the ``[warp]`` extra or an NVIDIA CUDA GPU is not
            available.
    """
    try:
        from flygym.warp.simulation import GPUSimulation
    except ImportError as e:
        raise ImportError(
            "GPU simulation requires the '[warp]' extra (warp-lang + "
            "mujoco_warp) and an NVIDIA CUDA GPU. On such a machine, install "
            "with `pip install 'flygym[warp]'`."
        ) from e
    from flygym.compose.world.musculoskeletal import MusculoskeletalWorld

    fly = MusculoskeletalFly(xml_path, name=name)
    if add_vision:
        fly.add_vision()
    world = MusculoskeletalWorld(fly)
    sim = GPUSimulation(world, n_worlds=n_worlds, **gpu_kwargs)
    return sim, fly

build_musculoskeletal_simulation(*, xml_path=DEFAULT_MUSCULOSKELETAL_XML, name='nmf', add_vision=False)

Build a MusculoskeletalFly + MusculoskeletalWorld + Simulation.

Equivalent to the standard composition flow::

fly = MusculoskeletalFly(xml_path, name=name)
world = MusculoskeletalWorld(fly)
sim = Simulation(world)

Parameters:

Name Type Description Default
xml_path PathLike

Path to the FlyMimic MJCF. Defaults to DEFAULT_MUSCULOSKELETAL_XML.

DEFAULT_MUSCULOSKELETAL_XML
name str

Logical fly name. Defaults to "nmf".

'nmf'
add_vision bool

If True, attach left/right eye cameras so Simulation.get_raw_vision / get_ommatidia_readouts work. Note that ommatidia readouts are approximate because FlyGym's Retina is calibrated for its own eye placement.

False

Plain flygym — not GPU-accelerated

Returns a plain flygym.Simulation (CPU, single world). For the GPU path use build_musculoskeletal_gpu_simulation with the [warp] extra.

Returns:

Type Description
Simulation

(simulation, fly) where simulation is a flygym.Simulation

MusculoskeletalFly

and fly is the MusculoskeletalFly instance (useful for

tuple[Simulation, MusculoskeletalFly]

inspecting fly.muscle_names or calling fly.add_vision).

Source code in src/flygym/compose/fly/musculoskeletal.py
def build_musculoskeletal_simulation(
    *,
    xml_path: PathLike = DEFAULT_MUSCULOSKELETAL_XML,
    name: str = "nmf",
    add_vision: bool = False,
) -> "tuple[Simulation, MusculoskeletalFly]":
    """Build a `MusculoskeletalFly` + `MusculoskeletalWorld` + `Simulation`.

    Equivalent to the standard composition flow::

        fly = MusculoskeletalFly(xml_path, name=name)
        world = MusculoskeletalWorld(fly)
        sim = Simulation(world)

    Args:
        xml_path: Path to the FlyMimic MJCF. Defaults to
            `DEFAULT_MUSCULOSKELETAL_XML`.
        name: Logical fly name. Defaults to ``"nmf"``.
        add_vision: If True, attach left/right eye cameras so
            `Simulation.get_raw_vision` / `get_ommatidia_readouts` work.
            Note that ommatidia readouts are approximate because FlyGym's
            `Retina` is calibrated for its own eye placement.

    !!! info "Plain flygym — not GPU-accelerated"

        Returns a plain `flygym.Simulation` (CPU, single world). For the
        GPU path use `build_musculoskeletal_gpu_simulation` with the
        `[warp]` extra.

    Returns:
        ``(simulation, fly)`` where *simulation* is a `flygym.Simulation`
        and *fly* is the `MusculoskeletalFly` instance (useful for
        inspecting ``fly.muscle_names`` or calling ``fly.add_vision``).
    """
    from flygym.simulation import Simulation
    from flygym.compose.world.musculoskeletal import MusculoskeletalWorld

    fly = MusculoskeletalFly(xml_path, name=name)
    if add_vision:
        fly.add_vision()
    world = MusculoskeletalWorld(fly)
    sim = Simulation(world)
    return sim, fly

check_mjwarp_compatibility(fly=None, *, xml_path=DEFAULT_MUSCULOSKELETAL_XML)

Probe whether MuJoCo-Warp can ingest the muscle model.

Safe to call anywhere: if mujoco_warp is not installed (the usual case off a CUDA machine), it returns a report with mjwarp_available=False rather than raising.

Parameters:

Name Type Description Default
fly MusculoskeletalFly | None

A MusculoskeletalFly to probe. If None, a default one is built.

None
xml_path PathLike

XML to use when fly is None.

DEFAULT_MUSCULOSKELETAL_XML

Returns:

Type Description
MjWarpCompatibilityReport

A MjWarpCompatibilityReport.

Source code in src/flygym/compose/fly/musculoskeletal.py
def check_mjwarp_compatibility(
    fly: "MusculoskeletalFly | None" = None,
    *,
    xml_path: PathLike = DEFAULT_MUSCULOSKELETAL_XML,
) -> MjWarpCompatibilityReport:
    """Probe whether MuJoCo-Warp can ingest the muscle model.

    Safe to call anywhere: if ``mujoco_warp`` is not installed (the usual case
    off a CUDA machine), it returns a report with ``mjwarp_available=False``
    rather than raising.

    Args:
        fly: A `MusculoskeletalFly` to probe. If None, a default one is built.
        xml_path: XML to use when ``fly`` is None.

    Returns:
        A `MjWarpCompatibilityReport`.
    """
    try:
        import mujoco_warp as mjw
    except ImportError:
        return MjWarpCompatibilityReport(
            mjwarp_available=False,
            put_model_ok=None,
            error=None,
            message=(
                "mujoco_warp is not installed; cannot probe GPU compatibility. "
                "This is expected without an NVIDIA CUDA GPU. Install the "
                "'[warp]' extra on a Linux+CUDA machine to enable the GPU path."
            ),
        )

    if fly is None:
        fly = MusculoskeletalFly(xml_path)
    mj_model, _ = fly.compile()
    try:
        mjw.put_model(mj_model)
    except Exception as e:  # noqa: BLE001 - we want to report any failure mode
        return MjWarpCompatibilityReport(
            mjwarp_available=True,
            put_model_ok=False,
            error=f"{type(e).__name__}: {e}",
            message=(
                "mujoco_warp is installed but rejected the muscle model. The "
                "likely culprits are muscle actuators, spatial tendons, or "
                "joint-equality constraints not yet supported by this mjwarp "
                "version. See the error field."
            ),
        )
    return MjWarpCompatibilityReport(
        mjwarp_available=True,
        put_model_ok=True,
        error=None,
        message="mujoco_warp accepted the muscle model (put_model succeeded).",
    )