Part I: Cat Modeling

The first task is to develop models whose actuation we can then optimize for the flip maneuver. We walk through the code in modeling.cpp, which becomes an executable that creates two cat models. The first model is simply two segments connected by a 2-degree-of-freedom (no twist) joint. It exhibits the counter-rotation mechanism of flipping. The second builds on top of the first with actuate-able legs, and a twist degree of freedom between the model's two halves. This model exhibits both the counter-rotation and variable-inertia flipping modes. Here's a skeleton of modeling.cpp:

#include <OpenSim/OpenSim.h>

int main(int argc, char *argv[])
{
    // First model: exhibiting the counter-rotation mechanism of flipping
    // ========================================================================
    // ********** PART A   **********	

    // Define anterior and posterior halves of the cat
    // ------------------------------------------------------------------------
    // ********** PART A.1 **********

    // Define joints between bodies (ground and two halves)
    // ------------------------------------------------------------------------
    // ********** PART A.2 **********

    // Display geometry
    // ------------------------------------------------------------------------
    // ********** PART A.3 **********

    // Actuation
    // ------------------------------------------------------------------------
    // ********** PART A.4 **********    
    
    
    // Second model: adding legs for the variable-inertia mechanism of flipping
    // ========================================================================
    // ********** PART B   **********    

    return EXIT_SUCCESS;
};

A: First model: exhibiting the counter-rotation mechanism of flipping

This first model has 2 degrees of freedom between its halves, hunch and wag, as show in the image on the main Flippin' Felines page. These degrees of freedom are sufficient for allowing the cat to execute a flip using counter-rotation. Mass properties come from Kane and Scher [2] and extensive inspection of cats.

// Properties
// ------------------------------------------------------------------------
double segmentalLength = 0.175;                           // m
double segmentalDiam = 0.15;                              // m
double segmentalMass = 1;                                 // kg
double segmentalTransverseMomentOfInertia = 1;            // kg-m^2

// Ratio of transverse to axial moment of inertia (Kane and Scher, 1969):
double JIratio = 0.25;

// For actuators:
double maxTorque = 40.0;                                  // N-m

// Basics
// ------------------------------------------------------------------------
// Create the cat model.
OpenSim::Model cat;

// Name the cat after the founder of Stanford University.
// This model will be able to hunch and wag (see the joints below).
cat.setName("Leland_hunch_wag");

// 'Turn off' gravity so it's easier to watch an animation of the flip.
using SimTK::Vec3;
cat.setGravity(Vec3(0, 0, 0));

A.1: Define anterior and posterior halves of the cat

Both halves of the cat have the same inertial properties (mass, mass center, moments of inertia). We model the cat's halves as axially symmetric. By the variable names below, we note that the X-axis is the long axis of the bodies.

// Prepare inertia properties for the 2 primary segments of the cat.
double segmentalAxialMomentOfInertia = JIratio * segmentalTransverseMomentOfInertia;
double Ixx = segmentalAxialMomentOfInertia;
double Iyy = segmentalTransverseMomentOfInertia;
double Izz = segmentalTransverseMomentOfInertia;
double Ixy = 0;
double Ixz = 0;
double Iyz = 0;
using SimTK::Inertia;
Inertia segmentalInertia = Inertia(Ixx, Iyy, Izz, Ixz, Ixz, Iyz);

using OpenSim::Body;
Body * anteriorBody = new Body();
Body * posteriorBody = new Body();

anteriorBody->setName("anteriorBody");
posteriorBody->setName("posteriorBody");

anteriorBody->setMass(segmentalMass);
posteriorBody->setMass(segmentalMass);

anteriorBody->setInertia(segmentalInertia);
posteriorBody->setInertia(segmentalInertia);

Nonetheless, we pass different values to setMassCenter() below. For this reason, we hope to clarify what this property means in OpenSim. Each body comes with a reference frame in which the body is fixed. This reference frame has an orientation and a position, or equivalently, an origin. As modelers, we know where the mass center of each body is, independent of any reference frames. However, the setMassCenter() function requires that, when we communicate the mass center to OpenSim, we express the mass center location from the body's origin, and in the basis (orientation) of the reference frame. In doing so, we actually define the reference frame (that is, its origin and orientation in the body). The mass center is fixed regardless of how OpenSim internally represents it; it is the definition of the reference frame that is at our discretion when we use setMassCenter().  We could specify the mass center to be (0, 0, 0): this would just mean that the origin of the body's frame is at its mass center; it would not affect the inertial properties on its own. What it does affect is how we subsequently express the position and orientation of joints and display geometry in the body's frame (which, again, we define at our discretion).

So, below, we provide different values to setMassCenter() for the 2 bodies, despite the fact that they have same mass center (that is, the same distribution of mass). What does differ between the 2 bodies is the location of their origin. (NOTE: We've presented one perspective of the situation. An alternative perspective is that we, the modelers, are given a frame, and must place our body in this frame. We can choose where we place the body in this frame, and we do so via the values we give to setMassCenter() and setInertia().)

// By choosing the following as the mass center, we choose the origin of
// the anteriorBody frame to be at the body's positive-X extent. That is,
// the anterior body sits to the -X direction from its origin.
anteriorBody->setMassCenter(Vec3(-0.5 * segmentalLength, 0, 0));
// Posterior body sits to the +X direction from its origin.
posteriorBody->setMassCenter(Vec3(0.5 * segmentalLength, 0, 0));

A.2: Define joints between bodies (ground and two halves)

We now connect the bodies, and thus define how they can move with respect to one another. The cat is free to move about in space, and we represent this using a 6-degree-of-freedom joint between the ground (the inertial frame) and the cat's anterior body. We could use a FreeJoint, which is simpler to define, but we instead employ a CustomJoint. In our case, we do so because we want to specify the type of Euler angles that describe the orientation of the cat's anterior segment. Although the FreeJoint also uses Euler angles, it does not allow the modeler to choose which type of Euler angles are used.

We choose YZX Euler angles, named yaw, pitch, and roll respectively, to avoid representation singularities. We specify this via a SpatialTransform object, which we'll use to define our CustomJoint. A spatial transform has 6 transform axes. The first 3 are rotations, defined about the axes of our choosing. The remaining 3 are translations, for which we can also set the axes. We choose the translation axes to be the X, Y, and Z directions of the ground's frame, which are also the default (XYZ, not YZX, Euler angles are the default).

Body & ground = cat.getGroundBody();

using OpenSim::CustomJoint;
using OpenSim::Array;
OpenSim::SpatialTransform groundAnteriorST;

groundAnteriorST.updTransformAxis(0).setCoordinateNames(
        Array<std::string>("yaw", 1));
groundAnteriorST.updTransformAxis(0).setAxis(Vec3(0, 1, 0));
groundAnteriorST.updTransformAxis(1).setCoordinateNames(
        Array<std::string>("pitch", 1));
groundAnteriorST.updTransformAxis(1).setAxis(Vec3(0, 0, 1));
groundAnteriorST.updTransformAxis(2).setCoordinateNames(
        Array<std::string>("roll", 1));
groundAnteriorST.updTransformAxis(2).setAxis(Vec3(1, 0, 0));
groundAnteriorST.updTransformAxis(3).setCoordinateNames(
        Array<std::string>("tx", 1));
groundAnteriorST.updTransformAxis(3).setAxis(Vec3(1, 0, 0));
groundAnteriorST.updTransformAxis(4).setCoordinateNames(
        Array<std::string>("ty", 1));
groundAnteriorST.updTransformAxis(4).setAxis(Vec3(0, 1, 0));
groundAnteriorST.updTransformAxis(5).setCoordinateNames(
        Array<std::string>("tz", 1));
groundAnteriorST.updTransformAxis(5).setAxis(Vec3(0, 0, 1));

The joint is located at the origin of both bodies.

Vec3 locGAInGround(0);
Vec3 orientGAInGround(0);
Vec3 locGAInAnterior(0);
Vec3 orientGAInAnterior(0);

CustomJoint * groundAnterior = new CustomJoint("ground_anterior",
        ground, locGAInGround, orientGAInGround,
        *anteriorBody, locGAInAnterior, orientGAInAnterior,
        groundAnteriorST);

The CustomJoint creates the appropriate coordinates in the model, with the names we specified in the SpatialTransform. Now, we modify the properties of these 6 coordinates.

// Edit the Coordinate's created by the CustomJoint. The 6 coordinates
// correspond to the TransformAxis's we set above.
using OpenSim::CoordinateSet;
using SimTK::convertDegreesToRadians;
using SimTK::Pi;
CoordinateSet & groundAnteriorCS = groundAnterior->upd_CoordinateSet();
// yaw
// As is, the range only affects how one can vary this coordinate in the
// GUI. The range is not a joint limit, and does not affect dynamics.
double groundAnteriorCS0range[2] = {-Pi, Pi};
groundAnteriorCS[0].setRange(groundAnteriorCS0range);
groundAnteriorCS[0].setDefaultValue(0);
groundAnteriorCS[0].setDefaultLocked(false);
// pitch
double groundAnteriorCS1range[2] = {-Pi, Pi};
groundAnteriorCS[1].setRange(groundAnteriorCS1range);
groundAnteriorCS[1].setDefaultValue(convertDegreesToRadians(-15));
groundAnteriorCS[1].setDefaultLocked(false);
// roll
double groundAnteriorCS2range[2] = {-Pi, Pi};
groundAnteriorCS[2].setRange(groundAnteriorCS2range);
groundAnteriorCS[2].setDefaultValue(0);
groundAnteriorCS[2].setDefaultLocked(false);
// tx
double groundAnteriorCS3range[2] = {-1, 1};
groundAnteriorCS[3].setRange(groundAnteriorCS3range);
groundAnteriorCS[3].setDefaultValue(0);
groundAnteriorCS[3].setDefaultLocked(false);
// ty
double groundAnteriorCS4range[2] = {-1, 5};
groundAnteriorCS[4].setRange(groundAnteriorCS4range);
groundAnteriorCS[4].setDefaultValue(0);
groundAnteriorCS[4].setDefaultLocked(false);
// tz
double groundAnteriorCS5range[2] = {-1, 1};
groundAnteriorCS[5].setRange(groundAnteriorCS5range);
groundAnteriorCS[5].setDefaultValue(0);
groundAnteriorCS[5].setDefaultLocked(false); 

We use a CustomJoint again to define the orientation of the posterior body with respect to the anterior body. We could use a BallJoint, but we would like to specify the degrees of freedom to be ZYX Euler angles.

// Anterior to posterior body via a CustomJoint
// ````````````````````````````````````````````
// Rotation is defined via ZYX Euler angles.

OpenSim::SpatialTransform anteriorPosteriorST;
anteriorPosteriorST.updTransformAxis(0).setCoordinateNames(
        Array<std::string>("hunch", 1));
anteriorPosteriorST.updTransformAxis(0).setAxis(Vec3(0, 0, 1));
anteriorPosteriorST.updTransformAxis(1).setCoordinateNames(
        Array<std::string>("wag", 1));
anteriorPosteriorST.updTransformAxis(1).setAxis(Vec3(0, 1, 0));
anteriorPosteriorST.updTransformAxis(2).setCoordinateNames(
        Array<std::string>("twist", 1));
anteriorPosteriorST.updTransformAxis(2).setAxis(Vec3(1, 0, 0));
// There is no translation between the segments, and so we do not name the
// remaining 3 TransformAxis's in the SpatialTransform.

Vec3 locAPInAnterior(0);
Vec3 orientAPInAnterior(0);
Vec3 locAPInPosterior(0);
Vec3 orientAPInPosterior(0);

CustomJoint * anteriorPosterior = new CustomJoint("anterior_posterior",
        *anteriorBody, locAPInAnterior, orientAPInAnterior,
        *posteriorBody, locAPInPosterior, orientAPInPosterior,
        anteriorPosteriorST);

// Set coordinate limits and default values from empirical data (i.e.,
// photos & video).
CoordinateSet & anteriorPosteriorCS = anteriorPosterior->upd_CoordinateSet();
// hunch: [-20, +90] degrees
double anteriorPosteriorCS0range[2] = {convertDegreesToRadians(-20),
                                       convertDegreesToRadians(90)};
anteriorPosteriorCS[0].setRange(anteriorPosteriorCS0range);
anteriorPosteriorCS[0].setDefaultValue(convertDegreesToRadians(30));
anteriorPosteriorCS[0].setDefaultLocked(false);
// wag: [-45, 45] degrees
double anteriorPosteriorCS1range[2] = {-0.25 * Pi, 0.25 * Pi};
anteriorPosteriorCS[1].setRange(anteriorPosteriorCS1range);
anteriorPosteriorCS[1].setDefaultValue(0);
anteriorPosteriorCS[1].setDefaultLocked(false);
// twist: [-80, 80] degrees
double anteriorPosteriorCS2range[2] = {convertDegreesToRadians(-80),
                                       convertDegreesToRadians(80)};
anteriorPosteriorCS[2].setRange(anteriorPosteriorCS2range);
anteriorPosteriorCS[2].setDefaultValue(0);
// This first model can't twist; we'll unlock this for the next model.
anteriorPosteriorCS[2].setDefaultLocked(true);

Now that we've connected the bodies, we can tell OpenSim about the bodies. Bodies cannot be added to a model until they are connected by joints. We need not tell OpenSim explicitly about the joints. OpenSim can take care of this for us, since we tell the joints about the bodies that they connect.

cat.addBody(anteriorBody);
cat.addBody(posteriorBody);

A.3: Display geometry

As is, if we load the model into the GUI we won't see anything in the View (display window). That's because we haven't told OpenSim how to display our model. We do so by adding DisplayGeometry to the bodies. The display geometry has nothing to do with the actual geometry, and thus mass properties, of the bodies. DisplayGeometry is loaded from geometry files, such as cylinder.vtp, located in the Geometry folder of an OpenSim installation. The geometry defined in a geometry file, which is sometimes simply a list of coordinates, implicitly contains a reference frame. For the cylinder.vtp file, the origin of this frame is at the cylinder's centroid. Equivalently, the cylinder's centroid is at the frame's origin. Also, the symmetry axis of the cylinder is its Y-axis. The diameter and height of the cylinder are both 1 meter.

using OpenSim::DisplayGeometry;
// So that we can see what the cat's up to.

// Anterior body
// `````````````
// 'cylinder.vtp' is in the Geometry folder of an OpenSim installation.
DisplayGeometry * anteriorDisplay = new DisplayGeometry("cylinder.vtp");
anteriorDisplay->setOpacity(0.5);
anteriorDisplay->setColor(Vec3(0.5, 0.5, 0.5));

For the display geometry to provide a useful visualization, we must transform the geometry so that it aligns with how we defined the inertial properties of the bodies. Namely, we want the centroid of the cylinder to be located at the body's mass center (+/-0.5 * segmentalLength, 0, 0), and for the symmetry axis of the cylinder to be parallel to the X-axis of the body's frame.

SimTK::Rotation rot;
// Rotate the cylinder's symmetry (Y) axis to align with the body's X axis:
rot.setRotationFromAngleAboutZ(0.5 * Pi);
// Tranform combines rotation and translation:
anteriorDisplay->setTransform(
        SimTK::Transform(rot, Vec3(-0.5 * segmentalLength, 0, 0)));
anteriorDisplay->setScaleFactors(
        Vec3(segmentalDiam, segmentalLength, segmentalDiam));
anteriorBody->updDisplayer()->updGeometrySet().adoptAndAppend(anteriorDisplay);
anteriorBody->updDisplayer()->setShowAxes(true);

// Posterior body
// ``````````````
DisplayGeometry * posteriorDisplay = new DisplayGeometry("cylinder.vtp");
posteriorDisplay->setOpacity(0.5);
posteriorDisplay->setColor(Vec3(0.7, 0.7, 0.7));

posteriorDisplay->setTransform(
        SimTK::Transform(rot, Vec3(0.5 * segmentalLength, 0, 0)));
posteriorDisplay->setScaleFactors(
        Vec3(segmentalDiam, segmentalLength, segmentalDiam));
posteriorBody->updDisplayer()->updGeometrySet().adoptAndAppend(posteriorDisplay);
posteriorBody->updDisplayer()->setShowAxes(true);

A.4: Actuation

Since the coordinates between the segments are angles, the actuators are effectively torque actuators. The reason to use a CoordinateActuator instead of a TorqueActuator is that for a CoordinateActuator we needn't specify the axis of the actuation, or the bodies on which it acts.

using OpenSim::CoordinateActuator;
// hunch
CoordinateActuator * hunchAct = new CoordinateActuator("hunch");
hunchAct->setName("hunch_actuator");
hunchAct->setMinControl(-maxTorque);
hunchAct->setMaxControl(maxTorque);
cat.addForce(hunchAct);
// wag
CoordinateActuator * wagAct = new CoordinateActuator("wag");
wagAct->setName("wag_actuator");
wagAct->setMinControl(-maxTorque);
wagAct->setMaxControl(maxTorque);
cat.addForce(wagAct);

We're done making the first model! Print it (serialize) to a file.

cat.print("flippinfelines_hunch_wag.osim");

B: Second model: adding legs for the variable-inertia mechanism of flipping

This model will be able to twist, and also has legs. Most of the set-up for this model has already been done. Many of the objects and variables defined above are simply updated or renamed.

// First model: exhibiting the counter-rotation mechanism of flipping
// ========================================================================
// ********** PART A   ********** 	

// Second model: adding legs for the variable-inertia mechanism of flipping
// ========================================================================
// ********** PART B ********** 	        	            	                
    
// Define leg bodies
// ------------------------------------------------------------------------
// ********** PART B.1 **********

// Define leg joints (i.e., between legs and two halves of cat)
// ------------------------------------------------------------------------
// ********** PART B.2 ********** 	                    
    
// Display geometry
// ------------------------------------------------------------------------
// ********** PART B.3 ********** 	            
    
// Actuation
// ------------------------------------------------------------------------
// ********** PART B.4 ********** 	            
    
// Enforce joint limits on the legs
// ------------------------------------------------------------------------
// ********** PART B.5 ********** 	            

The twist degree of freedom was defined already. Now, we just need to unlock it and add a corresponding actuator.

cat.setName("Leland_hunch_wag_twist_legs");

// Allow twist.
anteriorPosteriorCS[2].setDefaultLocked(false);
CoordinateActuator * twistAct = new CoordinateActuator("twist");
twistAct->setName("twist_actuator");
twistAct->setMinControl(-maxTorque);
twistAct->setMaxControl(maxTorque);
cat.addForce(twistAct);

// Leg properties
// ------------------------------------------------------------------------
double legsLength = 0.125;                             // m
double legsDiam = 0.1 * legsLength;                    // m
// Sum of both legs (60% distance across the belly):
double legsWidth = 0.6 * segmentalDiam;                // m
double legsMass = 0.2;                                 // kg

B.1: Define leg bodies

We model the legs as having the same shape as the anterior and posterior halves; just scaled down. We model the cat's 4 legs using just 2 bodies: one for both front legs and another for the back legs.

// Scale the segmental inertia.
Inertia legsInertia = (legsMass/segmentalMass) * segmentalInertia;

// Anterior and posterior legs.
Body * anteriorLegs = new Body();
anteriorLegs->setName("anteriorLegs");
anteriorLegs->setMass(legsMass);
anteriorLegs->setMassCenter(Vec3(0.5 * legsLength, 0, 0));
anteriorLegs->setInertia(legsInertia);

Body * posteriorLegs = new Body();
posteriorLegs->setName("posteriorLegs");
posteriorLegs->setMass(legsMass);
posteriorLegs->setMassCenter(Vec3(0.5 * legsLength, 0, 0));
posteriorLegs->setInertia(legsInertia); 

B.2: Define leg joints (i.e., between legs and two halves of cat)

Note the definition of orientALegsInLegs. We rotate the leg about what will become the pin-joint axis (the leg's Z-axis) so that it points straight out from the belly when leg angle is 0. In other words, we position the leg's long (X) axis normal to the half of the cat.

using OpenSim::PinJoint;
// Anterior leg
// ````````````
Vec3 locALegsInAnterior(-0.75 * segmentalLength, 0.5 * segmentalDiam, 0);
Vec3 orientALegsInAnterior(0);
Vec3 locALegsInLegs(0);
Vec3 orientALegsInLegs(0, 0, -0.5 * Pi);

PinJoint * anteriorToLegs = new PinJoint("anterior_legs",
        *anteriorBody, locALegsInAnterior, orientALegsInAnterior,
        *anteriorLegs, locALegsInLegs, orientALegsInLegs);
CoordinateSet & anteriorToLegsCS = anteriorToLegs->upd_CoordinateSet();
anteriorToLegsCS[0].setName("frontLegs");
double anteriorToLegsCS0range[2] = {-0.5 * Pi, 0.5 * Pi};
anteriorToLegsCS[0].setRange(anteriorToLegsCS0range);
// So that the legs are directed strictly upwards initially:
double pitch = groundAnteriorCS[1].getDefaultValue();
anteriorToLegsCS[0].setDefaultValue(-pitch);
anteriorToLegsCS[0].setDefaultLocked(false);

The posterior leg joint is defined similarly.

// Posterior leg
// `````````````
Vec3 locPLegsInPosterior(0.75 * segmentalLength, 0.5 * segmentalDiam, 0);
Vec3 orientPLegsInPosterior(0, Pi, 0);
Vec3 locPLegsInLegs(0);
Vec3 orientPLegsInLegs(0, 0, -0.5 * Pi);

PinJoint * posteriorToLegs = new PinJoint("posterior_legs",
        *posteriorBody, locPLegsInPosterior, orientPLegsInPosterior,
        *posteriorLegs, locPLegsInLegs, orientPLegsInLegs);
CoordinateSet & posteriorToLegsCS = posteriorToLegs->upd_CoordinateSet();
posteriorToLegsCS[0].setName("backLegs");
double posteriorToLegsCS0range[2] = {-0.5 * Pi, 0.5 * Pi};
posteriorToLegsCS[0].setRange(posteriorToLegsCS0range);
posteriorToLegsCS[0].setDefaultValue(-pitch);
posteriorToLegsCS[0].setDefaultLocked(false);

Now that we've defined the joints, we can add the bodies.

cat.addBody(anteriorLegs);
cat.addBody(posteriorLegs);

B.3: Display geometry

We give both legs the same display geometry. We needn't create two DisplayGeometry objects.

// 'box.vtp' is in the Geometry folder of an OpenSim installation.
DisplayGeometry legsDisplay = DisplayGeometry("box.vtp");
legsDisplay.setOpacity(0.5);
legsDisplay.setColor(Vec3(0.7, 0.7, 0.7));
legsDisplay.setTransform(Transform(Vec3(0.3 * legsLength, 0, 0)));
legsDisplay.setScaleFactors(Vec3(legsLength, legsDiam, legsWidth));

anteriorLegs->updDisplayer()->updGeometrySet().cloneAndAppend(legsDisplay);
anteriorLegs->updDisplayer()->setShowAxes(true);

posteriorLegs->updDisplayer()->updGeometrySet().cloneAndAppend(legsDisplay);
posteriorLegs->updDisplayer()->setShowAxes(true); 

B.4: Actuation

// front legs.
CoordinateActuator * frontLegsAct = new CoordinateActuator("frontLegs");
frontLegsAct->setName("frontLegs_actuator");
frontLegsAct->setMinControl(-maxTorque);
frontLegsAct->setMaxControl(maxTorque);
cat.addForce(frontLegsAct);

// back legs.
CoordinateActuator * backLegsAct = new CoordinateActuator("backLegs");
backLegsAct->setName("backLegs_actuator");
backLegsAct->setMinControl(-maxTorque);
backLegsAct->setMaxControl(maxTorque);
cat.addForce(backLegsAct);

B.5: Enforce joint limits on the legs

In playing around with the models, it became evident that enforcing reasonable joint limits on the legs would be helpful. These objects apply a restorative force about the corresponding coordinate when the coordinate goes outside its range. The range for both of these coordinates is [-90 , 90] degrees.

using OpenSim::CoordinateLimitForce;

CoordinateLimitForce * frontLegsLimitForce = new CoordinateLimitForce(
            "frontLegs", 90, 1.0E2, -90, 1.0E2, 1.0E1, 2.0, false);
cat.addForce(frontLegsLimitForce);

CoordinateLimitForce * backLegsLimitForce = new CoordinateLimitForce(
            "backLegs", 90, 1.0E2, -90, 1.0E2, 1.0E1, 2.0, false);
cat.addForce(backLegsLimitForce);
cat.print("flippinfelines_hunch_wag_twist_legs.osim");

Let's put these cats to use!

 

OpenSim is supported by the Mobilize Center , an NIH Biomedical Technology Resource Center (grant P41 EB027060); the Restore Center , an NIH-funded Medical Rehabilitation Research Resource Network Center (grant P2C HD101913); and the Wu Tsai Human Performance Alliance through the Joe and Clara Tsai Foundation. See the People page for a list of the many people who have contributed to the OpenSim project over the years. ©2010-2024 OpenSim. All rights reserved.