Skip to content

base_fly

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
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())

    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())

    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())

    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.
        """

        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

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.
    """

    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_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())

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"