vcad.
Back to Cookbook
Advanced30 min

Robot Arm Assembly

Multi-joint robot arm with revolute joints

A robot arm assembly brings together vcad's part definitions, instance placement, revolute joints, forward kinematics, and physics simulation. This recipe builds a 3-DOF articulated arm with a base, upper arm, and forearm, each connected by revolute joints with configurable limits.

Assembly Overview

The arm consists of three parts connected by two revolute joints:

Base (fixed) ──[Joint 1: shoulder]──> Upper Arm ──[Joint 2: elbow]──> Forearm

Joint 1 (shoulder) rotates around the Z axis at the top of the base. Joint 2 (elbow) rotates around the X axis at the end of the upper arm. A third joint at the wrist adds a final degree of freedom for the gripper.

Part Definitions

Base

The base is a heavy, squat cylinder that anchors the arm to a surface:

use vcad::{centered_cube, centered_cylinder, Part, Document};

fn base_part() -> Part {
    let body = centered_cylinder("base", 40.0, 30.0, 64);
    let top_boss = centered_cylinder("boss", 15.0, 10.0, 32)
        .translate(0.0, 0.0, 15.0);
    let bore = centered_cylinder("bore", 5.0, 50.0, 24);

    // Mounting holes in base flange
    let mount = centered_cylinder("mount", 3.0, 35.0, 16);
    let mounts = mount
        .translate(30.0, 0.0, 0.0)
        .circular_pattern(0.0, 4);

    body + top_boss - bore - mounts
}

The base is 80mm in diameter and 30mm tall, with a raised boss on top that serves as the pivot point for the shoulder joint. Four mounting holes on a 60mm bolt circle diameter secure it to a table or chassis.

Upper arm

The upper arm is a rectangular link with pivot bores at each end:

fn upper_arm_part() -> Part {
    let arm = centered_cube("arm", 20.0, 20.0, 120.0);

    // Shoulder pivot bore (bottom)
    let shoulder_bore = centered_cylinder("sh_bore", 5.0, 25.0, 24)
        .translate(0.0, 0.0, -55.0);

    // Elbow pivot bore (top)
    let elbow_bore = centered_cylinder("el_bore", 4.0, 25.0, 24)
        .rotate(90.0, 0.0, 0.0)
        .translate(0.0, 0.0, 55.0);

    // Lightening pockets
    let pocket = centered_cube("pocket", 12.0, 25.0, 40.0)
        .translate(0.0, 0.0, 0.0);

    arm - shoulder_bore - elbow_bore - pocket
}

The arm is 120mm long (along Z), with a bore at the bottom for the shoulder pin and a bore at the top for the elbow pin. The shoulder bore is vertical (Z axis) for rotation in the horizontal plane, while the elbow bore is horizontal (Y axis, after the 90-degree rotation) for rotation in the vertical plane. A lightening pocket removes material from the center to reduce weight.

Forearm and gripper

The forearm is shorter and ends with a simple parallel-jaw gripper:

fn forearm_part() -> Part {
    let arm = centered_cube("forearm", 16.0, 16.0, 80.0);

    // Elbow pivot bore (bottom)
    let bore = centered_cylinder("bore", 4.0, 20.0, 24)
        .rotate(90.0, 0.0, 0.0)
        .translate(0.0, 0.0, -35.0);

    // Gripper jaws (top)
    let jaw_l = centered_cube("jaw_l", 3.0, 20.0, 25.0)
        .translate(-8.0, 0.0, 52.0);
    let jaw_r = centered_cube("jaw_r", 3.0, 20.0, 25.0)
        .translate(8.0, 0.0, 52.0);

    // Wrist bore
    let wrist_bore = centered_cylinder("wrist", 3.0, 20.0, 16)
        .translate(0.0, 0.0, 35.0);

    arm + jaw_l + jaw_r - bore - wrist_bore
}

Building the Assembly

With the parts defined, create a document and place instances at their assembled positions:

fn robot_arm() -> Document {
    let mut doc = Document::new("robot_arm");

    // Register parts
    let base_id = doc.add_part("base", base_part());
    let upper_id = doc.add_part("upper_arm", upper_arm_part());
    let forearm_id = doc.add_part("forearm", forearm_part());

    // Place instances
    let base_inst = doc.add_instance(base_id, "base_inst")
        .at(0.0, 0.0, 0.0);

    let upper_inst = doc.add_instance(upper_id, "upper_inst")
        .at(0.0, 0.0, 40.0);   // Sitting on top of base boss

    let forearm_inst = doc.add_instance(forearm_id, "forearm_inst")
        .at(0.0, 0.0, 160.0);  // At top of upper arm

    doc
}

Each instance is placed at a position that aligns the pivot bores. The base instance sits at the origin. The upper arm instance sits at Z=40 (top of the base boss), and the forearm at Z=160 (top of the upper arm).

Adding Joints

Joints constrain how instances can move relative to each other. Revolute joints allow rotation around a single axis:

use vcad::{Joint, JointType, JointLimits};

fn robot_arm_with_joints() -> Document {
    let mut doc = robot_arm();

    // Shoulder joint: rotates upper arm around Z axis
    doc.add_joint(Joint {
        name: "shoulder".into(),
        joint_type: JointType::Revolute,
        parent: "base_inst".into(),
        child: "upper_inst".into(),
        anchor: [0.0, 0.0, 40.0],    // At top of base boss
        axis: [0.0, 0.0, 1.0],       // Z axis (horizontal rotation)
        limits: Some(JointLimits {
            lower: -180.0_f64.to_radians(),
            upper: 180.0_f64.to_radians(),
        }),
    });

    // Elbow joint: rotates forearm around X axis
    doc.add_joint(Joint {
        name: "elbow".into(),
        joint_type: JointType::Revolute,
        parent: "upper_inst".into(),
        child: "forearm_inst".into(),
        anchor: [0.0, 0.0, 160.0],   // At top of upper arm
        axis: [1.0, 0.0, 0.0],       // X axis (vertical rotation)
        limits: Some(JointLimits {
            lower: 0.0_f64.to_radians(),
            upper: 135.0_f64.to_radians(),
        }),
    });

    // Wrist joint: rotates forearm tip around local Z
    doc.add_joint(Joint {
        name: "wrist".into(),
        joint_type: JointType::Revolute,
        parent: "forearm_inst".into(),
        child: "forearm_inst".into(),  // Self-joint for wrist
        anchor: [0.0, 0.0, 195.0],
        axis: [0.0, 0.0, 1.0],
        limits: Some(JointLimits {
            lower: -90.0_f64.to_radians(),
            upper: 90.0_f64.to_radians(),
        }),
    });

    doc
}

The shoulder joint's axis is [0, 0, 1] (Z-up), so the upper arm swings in the horizontal plane. The elbow joint's axis is [1, 0, 0] (X), so the forearm swings in the vertical plane. Joint limits prevent the arm from reaching mechanically impossible configurations -- the shoulder can rotate a full 360 degrees, while the elbow is limited to 0-135 degrees.

Joint conventions

Joint angles are in radians. The anchor point is specified in the global coordinate frame at the assembly's home position (all joints at zero angle). The axis vector does not need to be unit length -- vcad normalizes it internally.

Forward Kinematics

Set joint angles to pose the arm:

fn pose_arm(doc: &mut Document) {
    // Reach forward and down
    doc.set_joint_angle("shoulder", 45.0_f64.to_radians());
    doc.set_joint_angle("elbow", 60.0_f64.to_radians());
    doc.set_joint_angle("wrist", 0.0);

    // Recompute instance transforms
    doc.solve_kinematics();

    // Query end-effector position
    let tip = doc.instance_transform("forearm_inst");
    println!("Gripper position: ({:.1}, {:.1}, {:.1})",
        tip.translation.x, tip.translation.y, tip.translation.z);
}

solve_kinematics() propagates joint angles through the kinematic chain, updating each instance's transform. After solving, querying the forearm instance's transform gives you the gripper position in world coordinates.

Physics Simulation

For dynamic control (gravity, inertia, motor torques), convert the assembly to a physics simulation:

use vcad::{PhysicsEnv, ActionType};

fn simulate_arm(doc: &Document) {
    let mut env = PhysicsEnv::from_document(doc);
    env.set_gravity([0.0, 0.0, -9810.0]);  // mm/s² (Z-down)

    // Reset to home position
    let obs = env.reset();

    // Apply torques to joints
    for step in 0..1000 {
        let actions = vec![
            5000.0,    // Shoulder torque (N·mm)
            -3000.0,   // Elbow torque (N·mm)
            0.0,       // Wrist torque
        ];

        let obs = env.step(&actions, ActionType::Torque);

        if step % 100 == 0 {
            println!("Step {}: shoulder={:.1}° elbow={:.1}°",
                step,
                obs.joint_angles[0].to_degrees(),
                obs.joint_angles[1].to_degrees(),
            );
        }
    }
}

The physics engine (Rapier3D) simulates rigid body dynamics with gravity and joint motors. Each step() call advances the simulation by one timestep (default 1/240s). Actions can be torques (direct force), position targets (PD controller), or velocity targets.

MCP for AI control

The physics simulation is exposed through vcad's MCP server, letting AI agents control the robot arm via gym_step, gym_reset, and gym_observe tools. This enables reinforcement learning workflows where an LLM or RL policy learns to control the arm to reach target positions.

Workspace Visualization

To verify the arm's reachable workspace, sweep through joint angles and plot the gripper positions:

fn compute_workspace(doc: &mut Document) -> Vec<[f64; 3]> {
    let mut points = Vec::new();

    for shoulder_deg in (-180..=180).step_by(15) {
        for elbow_deg in (0..=135).step_by(10) {
            doc.set_joint_angle("shoulder", (shoulder_deg as f64).to_radians());
            doc.set_joint_angle("elbow", (elbow_deg as f64).to_radians());
            doc.solve_kinematics();

            let tip = doc.instance_transform("forearm_inst");
            points.push([tip.translation.x, tip.translation.y, tip.translation.z]);
        }
    }

    points
}

Complete Code

use vcad::{
    centered_cube, centered_cylinder, Part, Document,
    Joint, JointType, JointLimits,
};

fn base_part() -> Part {
    let body = centered_cylinder("base", 40.0, 30.0, 64);
    let boss = centered_cylinder("boss", 15.0, 10.0, 32)
        .translate(0.0, 0.0, 15.0);
    let bore = centered_cylinder("bore", 5.0, 50.0, 24);
    let mount = centered_cylinder("mount", 3.0, 35.0, 16)
        .translate(30.0, 0.0, 0.0)
        .circular_pattern(0.0, 4);

    body + boss - bore - mount
}

fn upper_arm_part() -> Part {
    let arm = centered_cube("arm", 20.0, 20.0, 120.0);
    let sh = centered_cylinder("sh", 5.0, 25.0, 24)
        .translate(0.0, 0.0, -55.0);
    let el = centered_cylinder("el", 4.0, 25.0, 24)
        .rotate(90.0, 0.0, 0.0)
        .translate(0.0, 0.0, 55.0);
    let pocket = centered_cube("pocket", 12.0, 25.0, 40.0);

    arm - sh - el - pocket
}

fn forearm_part() -> Part {
    let arm = centered_cube("forearm", 16.0, 16.0, 80.0);
    let bore = centered_cylinder("bore", 4.0, 20.0, 24)
        .rotate(90.0, 0.0, 0.0)
        .translate(0.0, 0.0, -35.0);
    let jaw_l = centered_cube("jl", 3.0, 20.0, 25.0)
        .translate(-8.0, 0.0, 52.0);
    let jaw_r = centered_cube("jr", 3.0, 20.0, 25.0)
        .translate(8.0, 0.0, 52.0);

    arm + jaw_l + jaw_r - bore
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut doc = Document::new("robot_arm");

    // Parts
    let base_id = doc.add_part("base", base_part());
    let upper_id = doc.add_part("upper_arm", upper_arm_part());
    let forearm_id = doc.add_part("forearm", forearm_part());

    // Instances
    doc.add_instance(base_id, "base_inst").at(0.0, 0.0, 0.0);
    doc.add_instance(upper_id, "upper_inst").at(0.0, 0.0, 40.0);
    doc.add_instance(forearm_id, "forearm_inst").at(0.0, 0.0, 160.0);

    // Joints
    doc.add_joint(Joint {
        name: "shoulder".into(),
        joint_type: JointType::Revolute,
        parent: "base_inst".into(),
        child: "upper_inst".into(),
        anchor: [0.0, 0.0, 40.0],
        axis: [0.0, 0.0, 1.0],
        limits: Some(JointLimits {
            lower: -std::f64::consts::PI,
            upper: std::f64::consts::PI,
        }),
    });

    doc.add_joint(Joint {
        name: "elbow".into(),
        joint_type: JointType::Revolute,
        parent: "upper_inst".into(),
        child: "forearm_inst".into(),
        anchor: [0.0, 0.0, 160.0],
        axis: [1.0, 0.0, 0.0],
        limits: Some(JointLimits {
            lower: 0.0,
            upper: 135.0_f64.to_radians(),
        }),
    });

    // Pose the arm
    doc.set_joint_angle("shoulder", 30.0_f64.to_radians());
    doc.set_joint_angle("elbow", 45.0_f64.to_radians());
    doc.solve_kinematics();

    // Export
    doc.write_vcad("robot_arm.vcad")?;
    doc.export_glb("robot_arm.glb")?;

    println!("Robot arm assembly saved.");
    Ok(())
}
URDF import

If you have an existing robot description in URDF format, vcad can import it directly with vcad import-urdf robot.urdf out.vcad. This creates the part definitions, instances, and joints automatically from the URDF kinematic tree.