Skip to content

Examples

Each example below is generated directly from the repository's examples/ directory during the documentation build. Use the copy button on a code block or download the original file.

01_differential_drive_rover.py

Download file

"""
Example 01: Differential Drive Rover
=====================================
Demonstrates basic wheel velocity control using the Husky robot from pybullet_data.

What this shows:
- Loading a robot by name using pybullet_data
- Setting joint velocities by name
- Smooth camera that follows the robot (CameraFollow)
- Joint/link highlighting on hover (RobotHighlighter)
- Monitoring base telemetry (position, speed, roll)
- Logging data to CSV
- Autopilot toggle with manual joystick override
- BulletLabUI joystick widget for gamepad-style control

Run::

    python examples/01_differential_drive_rover.py
"""

import math
import time
import sys
from pathlib import Path

# Ensure bulletlab is importable from the repo root
sys.path.insert(0, str(Path(__file__).parent.parent))

from bulletlab import Simulation, Robot, CameraFollow, RobotHighlighter
from bulletlab.core.world import World
from bulletlab.telemetry import TelemetryManager
from bulletlab.logging import DataLogger
from bulletlab.utils.urdf_utils import find_urdf


def main() -> None:
    print("=== BulletLab Example 01: Differential Drive Rover ===\n")

    # ──────────────────────────────────────────────
    # 1. Create simulation
    # ──────────────────────────────────────────────
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81), timestep=1.0 / 240.0)
    sim.start()
    # Initial camera — CameraFollow will take over once the robot is loaded
    sim.set_camera(distance=4.0, yaw=50.0, pitch=-30.0, target=(0, 0, 0.5))

    # ──────────────────────────────────────────────
    # 2. Build world
    # ──────────────────────────────────────────────
    world = World(sim)
    world.load_plane()

    # ──────────────────────────────────────────────
    # 3. Load robot
    # ──────────────────────────────────────────────
    # husky.urdf is bundled with pybullet_data
    try:
        urdf_path = find_urdf("husky/husky.urdf")
    except FileNotFoundError:
        # Fallback: use racecar
        try:
            urdf_path = find_urdf("racecar/racecar.urdf")
        except FileNotFoundError:
            print("Could not find husky or racecar URDF. Trying r2d2...")
            urdf_path = find_urdf("r2d2.urdf")

    robot = Robot.load(str(urdf_path), sim=sim, position=(0, 0, 0.3), name="Rover")
    print(f"Loaded robot: {robot}")
    print(f"  Joints:  {list(robot.joints.keys())[:6]}{'...' if len(robot.joints) > 6 else ''}")
    print(f"  Links:   {list(robot.links.keys())[:6]}{'...' if len(robot.links) > 6 else ''}")
    print(f"  State dim: {len(robot.get_state())}\n")

    # ──────────────────────────────────────────────
    # 4. Smooth camera follow
    # ──────────────────────────────────────────────
    cam = CameraFollow(
        robot, sim,
        mode="smooth",
        distance=4.0,
        pitch=-30.0,
        yaw=50.0,
        lerp=0.06,        # gentle glide — lower = slower/smoother
        height_offset=0.3,
    )

    # ──────────────────────────────────────────────
    # 5. Joint / link hover highlighting
    # ──────────────────────────────────────────────
    hl = RobotHighlighter(robot, sim)

    # ──────────────────────────────────────────────
    # 6. Set up telemetry
    # ──────────────────────────────────────────────
    telemetry = TelemetryManager()
    telemetry.watch("x",       lambda: robot.base_position[0], unit="m")
    telemetry.watch("y",       lambda: robot.base_position[1], unit="m")
    telemetry.watch("z",       lambda: robot.base_position[2], unit="m")
    telemetry.watch("speed",   lambda: robot.speed,            unit="m/s")
    telemetry.watch("roll",    lambda: math.degrees(robot.roll),  unit="°")
    telemetry.watch("pitch",   lambda: math.degrees(robot.pitch), unit="°")

    # ──────────────────────────────────────────────
    # 7. Set up data logger
    # ──────────────────────────────────────────────
    logger = DataLogger()
    logger.watch("speed",   lambda: robot.speed)
    logger.watch("x",       lambda: robot.base_position[0])
    logger.watch("y",       lambda: robot.base_position[1])
    logger.watch("roll",    lambda: math.degrees(robot.roll))
    logger.start("rover_run.csv")
    print("Logging to: rover_run.csv\n")

    # ──────────────────────────────────────────────
    # 8. Try to launch UI (optional)
    # ──────────────────────────────────────────────
    ui = None
    try:
        from bulletlab.ui import BulletLabUI, widgets as ui_widgets, imgui

        ui = BulletLabUI(sim=sim, robots=[robot], telemetry=telemetry, camera=cam, highlighter=hl)
        ui.start()

        # ── Shared state (mutable containers so lambdas can write to them) ──
        autopilot_on   = [True]   # Autopilot active by default
        joystick_snap  = [True]   # Snap-to-center on release

        # Store current manual velocities so the joystick callbacks can
        # write them from inside the UI frame without a direct joint reference.
        manual_vel     = [0.0, 0.0]   # [left_side_vel, right_side_vel]

        def on_joy_y(v):
            """Y axis: +1 = forward, -1 = backward."""
            spd = v * 10.0
            manual_vel[0] += spd   # additive so X-axis turn is preserved
            manual_vel[1] += spd

        def on_joy_x(v):
            """X axis: +1 = turn right, -1 = turn left."""
            turn = v * 5.0
            manual_vel[0] += turn   # left wheels speed up → turns right
            manual_vel[1] -= turn   # right wheels slow down → turns right

        @ui.custom_panel("Drive")
        def drive_panel() -> None:
            # ── Autopilot toggle switch ───────────────────────────────────────
            prev = autopilot_on[0]
            ui_widgets.toggle_switch(
                "Autopilot",
                getter=lambda: autopilot_on[0],
                setter=lambda v: autopilot_on.__setitem__(0, v),
                color_on=(0.2, 0.75, 1.0, 1.0),
            )
            # When switching back ON, zero manual velocity so robot stops
            if not prev and autopilot_on[0]:
                manual_vel[0] = 0.0
                manual_vel[1] = 0.0

            imgui.separator()

            # ── Manual controls (grayed out when autopilot is ON) ────────────
            if autopilot_on[0]:
                imgui.push_style_var(imgui.StyleVar_.alpha, 0.35)

            # Snap toggle switch
            ui_widgets.toggle_switch(
                "Snap joystick to zero",
                getter=lambda: joystick_snap[0],
                setter=lambda v: (None if autopilot_on[0]
                                  else joystick_snap.__setitem__(0, v)),
                color_on=(0.2, 0.9, 0.4, 1.0),
            )

            # Reset manual_vel to zero every frame so joystick callbacks
            # accumulate cleanly from the base speed on each frame
            if not autopilot_on[0]:
                manual_vel[0] = 0.0
                manual_vel[1] = 0.0

            # Joystick (callbacks only do work when autopilot is off)
            ui_widgets.joystick(
                "Rover Drive",
                on_y=on_joy_y if not autopilot_on[0] else None,
                on_x=on_joy_x if not autopilot_on[0] else None,
                snap=joystick_snap[0],
                size=75,
                handle_color=(0.2, 0.75, 1.0, 1.0),
            )

            if autopilot_on[0]:
                imgui.pop_style_var()

        print("BulletLab control window opened.\n")
    except Exception as exc:
        autopilot_on  = [True]
        joystick_snap = [True]
        manual_vel    = [0.0, 0.0]
        print(f"UI not available ({exc}). Running headless.\n")

    # ──────────────────────────────────────────────
    # 9. Simulation loop
    # ──────────────────────────────────────────────
    print("Running simulation. Press Ctrl+C to stop.\n")

    # Identify wheel joints (try common naming patterns)
    wheel_joints = []
    for name, joint in robot.joints.items():
        if any(kw in name.lower() for kw in ("wheel", "drive", "motor", "rear", "front")):
            if not joint.is_fixed:
                wheel_joints.append(joint)

    if not wheel_joints:
        # Fall back to all controllable joints
        wheel_joints = robot.controllable_joints[:4]

    print(f"Controlling joints: {[j.name for j in wheel_joints]}\n")

    # Set max force for all wheel joints
    for joint in wheel_joints:
        joint.max_force = 100.0

    phase = 0
    step = 0
    try:
        while sim.is_connected:
            if autopilot_on[0]:
                # ── Autopilot phase-based motion ──────────────────────────────
                phase_steps = 480  # 2 seconds at 240Hz
                t = step % (phase_steps * 4)

                if t < phase_steps:
                    left_vel, right_vel = 8.0, 8.0    # forward
                elif t < phase_steps * 2:
                    left_vel, right_vel = 8.0, -2.0   # turn right
                elif t < phase_steps * 3:
                    left_vel, right_vel = 8.0, 8.0    # forward again
                else:
                    left_vel, right_vel = -2.0, 8.0   # turn left

                for i, joint in enumerate(wheel_joints):
                    if i % 2 == 0:
                        joint.velocity = left_vel
                    else:
                        joint.velocity = right_vel
            else:
                # ── Manual / joystick control ─────────────────────────────────
                # manual_vel is written by the joystick callbacks inside ui.step()
                for i, joint in enumerate(wheel_joints):
                    if i % 2 == 0:
                        joint.velocity = manual_vel[0]
                    else:
                        joint.velocity = manual_vel[1]

            sim.step()
            cam.update()                        # smooth camera follows the rover
            telemetry.update(t=sim.elapsed_time)
            logger.step(t=sim.elapsed_time)

            if ui is not None:
                ui.step()
                if ui.should_close:
                    break

            step += 1

            # Print telemetry every 2 seconds
            if step % 480 == 0:
                snap = telemetry.snapshot()
                print(
                    f"  t={sim.elapsed_time:.1f}s | "
                    f"speed={snap.get('speed', 0):.2f} m/s | "
                    f"pos=({snap.get('x', 0):.2f}, {snap.get('y', 0):.2f}) | "
                    f"roll={snap.get('roll', 0):.1f}°"
                )

    except KeyboardInterrupt:
        print("\nSimulation stopped by user.")

    except Exception as e:
        # Catches pybullet.error when the 3D window is closed externally
        if "isConnected" in str(e) or "Joint index" in str(e) or "connect" in str(e).lower():
            print("\nSimulation window closed.")
        else:
            raise

    finally:
        logger.stop()
        print(f"\nLogged {logger.step_count} steps to rover_run.csv")
        if ui is not None:
            ui.stop()
        sim.stop()
        print("Done.")


if __name__ == "__main__":
    main()

02_robotic_arm.py

Download file

"""
Example 02: Robotic Arm (Kuka iiwa)
=====================================
Demonstrates joint position control on the Kuka iiwa arm with an ImGui
slider control panel for each joint.

What this shows:
- Loading the Kuka iiwa arm from pybullet_data
- Individual joint position control
- Reading joint limits and building dynamic UI sliders
- Joint/link highlighting on hover (RobotHighlighter)
- Monitoring joint state in real-time

Run::

    python examples/02_robotic_arm.py
"""

import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from bulletlab import Simulation, Robot, RobotHighlighter
from bulletlab.core.world import World
from bulletlab.telemetry import TelemetryManager
from bulletlab.utils.urdf_utils import find_urdf


def main() -> None:
    print("=== BulletLab Example 02: Robotic Arm (Kuka iiwa) ===\n")

    # ──────────────────────────────────────────────
    # Simulation setup
    # ──────────────────────────────────────────────
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81))
    sim.start()
    sim.set_camera(distance=2.0, yaw=45.0, pitch=-20.0, target=(0, 0, 0.5))

    world = World(sim)
    world.load_plane()

    # ──────────────────────────────────────────────
    # Load Kuka arm
    # ──────────────────────────────────────────────
    try:
        urdf_path = find_urdf("kuka_iiwa/model.urdf")
    except FileNotFoundError:
        try:
            urdf_path = find_urdf("kuka_lwr/kuka.urdf")
        except FileNotFoundError:
            urdf_path = find_urdf("franka_panda/panda.urdf")

    robot = Robot.load(
        str(urdf_path),
        sim=sim,
        position=(0, 0, 0),
        fixed_base=True,
        name="KukaArm",
    )
    print(f"Loaded: {robot}")

    # ──────────────────────────────────────────────
    # Hover highlighting
    # ──────────────────────────────────────────────
    hl = RobotHighlighter(robot, sim)

    controllable = robot.controllable_joints
    print(f"Controllable joints ({len(controllable)}):")
    for j in controllable:
        lo, hi = j.limits
        print(f"  {j.name}: [{lo:.2f}, {hi:.2f}] rad")
    print()

    # ──────────────────────────────────────────────
    # Initial pose (all zeros)
    # ──────────────────────────────────────────────
    for joint in controllable:
        joint.set_position(0.0)

    # ──────────────────────────────────────────────
    # Telemetry
    # ──────────────────────────────────────────────
    telemetry = TelemetryManager()
    for joint in controllable:
        telemetry.watch(
            f"{joint.name}_pos",
            (lambda j: lambda: j.position)(joint),
            unit="rad",
        )
        telemetry.watch(
            f"{joint.name}_vel",
            (lambda j: lambda: j.velocity)(joint),
            unit="rad/s",
        )

    # ──────────────────────────────────────────────
    # UI setup
    # ──────────────────────────────────────────────
    ui = None
    try:
        from bulletlab.ui import BulletLabUI, imgui

        ui = BulletLabUI(sim=sim, robots=[robot], telemetry=telemetry, highlighter=hl)
        ui.start()

        # Custom panel: arm joint sliders
        target_positions = {j.name: 0.0 for j in controllable}

        @ui.custom_panel("Arm Control")
        def arm_control_panel() -> None:
            for joint in controllable:
                lo, hi = joint.limits
                lo2 = lo if lo != 0 or hi != 0 else -math.pi
                hi2 = hi if lo != 0 or hi != 0 else math.pi

                changed, new_val = imgui.slider_float(
                    f"{joint.name}##arm_{joint.index}",
                    joint.position,
                    lo2, hi2,
                )
                # Highlight this joint's 3D link when its slider is hovered
                if imgui.is_item_hovered():
                    hl.set_hover(joint)
                if changed:
                    joint.set_position(float(new_val))
                    target_positions[joint.name] = new_val

        print("BulletLab arm control window opened.\n")
    except Exception as exc:
        print(f"UI not available ({exc}). Running automated demo.\n")

    # ──────────────────────────────────────────────
    # Simulation loop
    # ──────────────────────────────────────────────
    print("Running simulation. Press Ctrl+C to stop.\n")

    step = 0
    period = 480  # 2 seconds per motion cycle

    try:
        while sim.is_connected:
            if ui is None:
                # Automated sinusoidal joint motion for headless mode
                t = step / 240.0
                for i, joint in enumerate(controllable):
                    lo, hi = joint.limits
                    lo2 = lo if lo != 0 or hi != 0 else -math.pi
                    hi2 = hi if lo != 0 or hi != 0 else math.pi
                    mid = (lo2 + hi2) / 2.0
                    amp = (hi2 - lo2) * 0.3
                    target = mid + amp * math.sin(t * 0.5 + i * 0.8)
                    joint.set_position(target)

            sim.step()
            telemetry.update(t=sim.elapsed_time)

            if ui is not None:
                ui.step()
                if ui.should_close:
                    break

            step += 1
            if step % 240 == 0:
                print(f"  t={sim.elapsed_time:.1f}s | joints: " +
                      ", ".join(f"{j.name}={j.position:.2f}" for j in controllable[:3]))

    except KeyboardInterrupt:
        print("\nStopped.")
    finally:
        if ui is not None:
            ui.stop()
        sim.stop()
        print("Done.")


if __name__ == "__main__":
    main()

03_self_balancing_robot.py

Download file

"""
Example 03: Self-Balancing Robot
=================================
Demonstrates a PD controller keeping a two-wheeled robot upright.
Uses R2D2 as a stand-in for demonstration (roll/pitch balancing).

What this shows:
- Reading roll and pitch from base_orientation
- Implementing a simple PD control loop manually
- Monitoring control effort in real-time via telemetry
- Logging controller data

Run::

    python examples/03_self_balancing_robot.py
"""

import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from bulletlab import Simulation, Robot
from bulletlab.core.world import World
from bulletlab.telemetry import TelemetryManager
from bulletlab.logging import DataLogger
from bulletlab.utils.urdf_utils import find_urdf
from bulletlab.utils.math_utils import clamp


def main() -> None:
    print("=== BulletLab Example 03: Self-Balancing Robot ===\n")

    # ──────────────────────────────────────────────
    # Simulation
    # ──────────────────────────────────────────────
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81))
    sim.start()
    sim.set_camera(distance=2.5, yaw=45.0, pitch=-15.0, target=(0, 0, 0.5))

    world = World(sim)
    world.load_plane()

    # ──────────────────────────────────────────────
    # Load robot – try r2d2 or cartpole-like URDF
    # ──────────────────────────────────────────────
    try:
        urdf_path = find_urdf("cartpole.urdf")
        robot = Robot.load(str(urdf_path), sim=sim, position=(0, 0, 0.1), name="CartPole")
        is_cartpole = True
    except FileNotFoundError:
        urdf_path = find_urdf("r2d2.urdf")
        robot = Robot.load(str(urdf_path), sim=sim, position=(0, 0, 0.3), name="R2D2")
        is_cartpole = False

    print(f"Loaded: {robot}")
    print(f"Controllable joints: {[j.name for j in robot.controllable_joints]}\n")

    # ──────────────────────────────────────────────
    # PD Controller parameters
    # ──────────────────────────────────────────────
    Kp = 50.0   # proportional gain (roll error → wheel torque)
    Kd = 5.0    # derivative gain (angular velocity damping)
    target_angle = 0.0  # desired pitch/roll angle (upright)

    prev_error = 0.0
    dt = sim.timestep

    print(f"PD Controller: Kp={Kp}, Kd={Kd}")
    print(f"Timestep: {dt:.4f}s\n")

    # ──────────────────────────────────────────────
    # Telemetry
    # ──────────────────────────────────────────────
    control_effort = [0.0]

    telemetry = TelemetryManager()
    telemetry.watch("pitch",    lambda: math.degrees(robot.pitch), unit="°")
    telemetry.watch("roll",     lambda: math.degrees(robot.roll),  unit="°")
    telemetry.watch("control",  lambda: control_effort[0],        unit="N·m")
    telemetry.watch("speed",    lambda: robot.speed,               unit="m/s")

    # ──────────────────────────────────────────────
    # Logger
    # ──────────────────────────────────────────────
    logger = DataLogger()
    logger.watch("pitch",   lambda: math.degrees(robot.pitch))
    logger.watch("roll",    lambda: math.degrees(robot.roll))
    logger.watch("control", lambda: control_effort[0])
    logger.start("balancing_run.csv")

    # ──────────────────────────────────────────────
    # UI (optional)
    # ──────────────────────────────────────────────
    ui = None
    try:
        from bulletlab.ui import BulletLabUI
        import bulletlab.ui.widgets as ui_widgets

        ui = BulletLabUI(sim=sim, robots=[robot], telemetry=telemetry)
        ui.start()

        gains = [Kp, Kd]

        @ui.custom_panel("PD Controller")
        def controller_panel() -> None:
            ui_widgets.text("Controller", "PD Balance")
            gains[0] = ui_widgets.drag_float(
                "Kp (Proportional)", lambda: gains[0], setter=lambda v: gains.__setitem__(0, v),
                speed=0.5, min_val=0.0, max_val=500.0,
            )
            gains[1] = ui_widgets.drag_float(
                "Kd (Derivative)", lambda: gains[1], setter=lambda v: gains.__setitem__(1, v),
                speed=0.1, min_val=0.0, max_val=50.0,
            )
            ui_widgets.separator()
            ui_widgets.text("Control effort", f"{control_effort[0]:.2f} N·m")
            ui_widgets.text("Pitch", f"{math.degrees(robot.pitch):.2f}°")
            ui_widgets.text("Roll",  f"{math.degrees(robot.roll):.2f}°")
            ui_widgets.separator()
            if ui_widgets.button("Reset Robot"):
                robot.reset()

        print("BulletLab control window opened.\n")
    except Exception as exc:
        print(f"UI not available ({exc}). Running headless.\n")
        gains = [Kp, Kd]

    # ──────────────────────────────────────────────
    # Simulation loop
    # ──────────────────────────────────────────────
    print("Running. Press Ctrl+C to stop.\n")
    step = 0

    try:
        while True:
            # Read current angle
            if is_cartpole:
                # Cartpole: use first joint angle as pole angle
                controllable = robot.controllable_joints
                if controllable:
                    pole_angle = controllable[-1].position
                    error = target_angle - pole_angle
                    d_error = -controllable[-1].velocity
                else:
                    error, d_error = 0.0, 0.0
            else:
                # General: use pitch for balance
                error = target_angle - robot.pitch
                d_error = (error - prev_error) / dt

            # PD control
            u = clamp(gains[0] * error + gains[1] * d_error, -100.0, 100.0)
            control_effort[0] = u
            prev_error = error

            # Apply control to wheel/drive joints
            controllable_joints = robot.controllable_joints
            if is_cartpole and controllable_joints:
                # Apply force to cart (prismatic joint)
                controllable_joints[0].torque = u
            else:
                # Apply to drive wheels
                for joint in controllable_joints[:4]:
                    joint.velocity = clamp(u * 0.5, -20.0, 20.0)

            sim.step()
            telemetry.update(t=sim.elapsed_time)
            logger.step(t=sim.elapsed_time)

            if ui is not None:
                ui.step()
                if ui.should_close:
                    break

            step += 1
            if step % 480 == 0:
                print(
                    f"  t={sim.elapsed_time:.1f}s | "
                    f"pitch={math.degrees(robot.pitch):.2f}° | "
                    f"roll={math.degrees(robot.roll):.2f}° | "
                    f"u={control_effort[0]:.2f}"
                )

    except KeyboardInterrupt:
        print("\nStopped.")
    finally:
        logger.stop()
        print(f"\nLogged {logger.step_count} steps to balancing_run.csv")
        if ui is not None:
            ui.stop()
        sim.stop()
        print("Done.")


if __name__ == "__main__":
    main()

04_drone_parameter_tuning.py

Download file

"""
Example 04: Drone Parameter Tuning
=====================================
Demonstrates live physics parameter tuning on a quadrotor.
Uses the drone URDF from pybullet_data if available, otherwise uses a
simple box as a stand-in.

What this shows:
- Modifying link mass at runtime via robot.links["body"].mass
- Modifying friction at runtime
- Applying upward forces to simulate thrust (via joint torques)
- Exposing parameter sliders in BulletLabUI
- Real-time observation of how parameter changes affect flight

Run::

    python examples/04_drone_parameter_tuning.py
"""

import math
import sys
from pathlib import Path
from bulletlab import Simulation, Robot
from bulletlab.core.world import World
from bulletlab.telemetry import TelemetryManager
from bulletlab.logging import DataLogger
from bulletlab.utils.urdf_utils import find_urdf


def create_drone_urdf(output_path: Path) -> None:
    """Generate a minimal quadrotor URDF on disk if none exists."""
    urdf_content = """<?xml version="1.0"?>
<robot name="quadrotor">
  <link name="base_link">
    <visual>
      <geometry><box size="0.4 0.4 0.1"/></geometry>
    </visual>
    <collision>
      <geometry><box size="0.4 0.4 0.1"/></geometry>
    </collision>
    <inertial>
      <mass value="1.0"/>
      <inertia ixx="0.01" iyy="0.01" izz="0.02" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>

  <link name="rotor_fl">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_fl_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_fl"/>
    <origin xyz="0.15 0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>

  <link name="rotor_fr">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_fr_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_fr"/>
    <origin xyz="0.15 -0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>

  <link name="rotor_rl">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_rl_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_rl"/>
    <origin xyz="-0.15 0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>

  <link name="rotor_rr">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_rr_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_rr"/>
    <origin xyz="-0.15 -0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>
</robot>
"""
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(urdf_content)


def main() -> None:
    print("=== BulletLab Example 04: Drone Parameter Tuning ===\n")

    # ──────────────────────────────────────────────
    # Simulation
    # ──────────────────────────────────────────────
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81))
    sim.start()
    sim.set_camera(distance=3.0, yaw=45.0, pitch=-20.0, target=(0, 0, 1.0))

    world = World(sim)
    world.load_plane()

    # ──────────────────────────────────────────────
    # Load drone URDF
    # ──────────────────────────────────────────────
    drone_urdf = Path(__file__).parent / "assets" / "quadrotor.urdf"
    if not drone_urdf.exists():
        print("Generating quadrotor URDF...")
        create_drone_urdf(drone_urdf)

    robot = Robot.load(str(drone_urdf), sim=sim, position=(0, 0, 0.5), name="Drone")
    print(f"Loaded: {robot}")
    print(f"Joints: {list(robot.joints.keys())}")
    print(f"Links:  {list(robot.links.keys())}\n")

    # ──────────────────────────────────────────────
    # Parameters (mutable via UI)
    # ──────────────────────────────────────────────
    params = {
        "thrust": 12.0,    # total thrust force (N)
        "mass": 1.0,       # body mass (kg)
        "drag": 0.1,       # artificial drag coefficient
        "rotor_speed": 100.0,  # rotor spin speed (rad/s)
    }

    # ──────────────────────────────────────────────
    # Telemetry
    # ──────────────────────────────────────────────
    telemetry = TelemetryManager()
    telemetry.watch("height",  lambda: robot.base_position[2], unit="m")
    telemetry.watch("vz",      lambda: robot.base_velocity[2], unit="m/s")
    telemetry.watch("roll",    lambda: math.degrees(robot.roll), unit="°")
    telemetry.watch("pitch",   lambda: math.degrees(robot.pitch), unit="°")

    # ──────────────────────────────────────────────
    # Logger
    # ──────────────────────────────────────────────
    logger = DataLogger()
    logger.watch("height", lambda: robot.base_position[2])
    logger.watch("thrust", lambda: params["thrust"])
    logger.watch("mass",   lambda: params["mass"])
    logger.start("drone_tuning.csv")

    # ──────────────────────────────────────────────
    # UI
    # ──────────────────────────────────────────────
    ui = None
    try:
        from bulletlab.ui import BulletLabUI
        import bulletlab.ui.widgets as ui_widgets

        ui = BulletLabUI(sim=sim, robots=[robot], telemetry=telemetry)
        ui.start()

        @ui.custom_panel("Drone Parameters")
        def drone_panel() -> None:
            ui_widgets.text("Drone", "Quadrotor Tuner")
            ui_widgets.separator()

            params["thrust"] = ui_widgets.slider(
                "Total Thrust (N)", lambda: params["thrust"], 0.0, 50.0,
                setter=lambda v: params.update({"thrust": v}),
            )
            params["mass"] = ui_widgets.slider(
                "Body Mass (kg)", lambda: params["mass"], 0.1, 10.0,
                setter=lambda v: (params.update({"mass": v}),
                                  robot.set_dynamics("base_link", mass=v)),
            )
            params["drag"] = ui_widgets.slider(
                "Drag Coefficient", lambda: params["drag"], 0.0, 2.0,
                setter=lambda v: params.update({"drag": v}),
            )
            params["rotor_speed"] = ui_widgets.slider(
                "Rotor Speed (rad/s)", lambda: params["rotor_speed"], 0.0, 500.0,
                setter=lambda v: params.update({"rotor_speed": v}),
            )

            ui_widgets.separator()
            ui_widgets.text("Height",   f"{robot.base_position[2]:.3f} m")
            ui_widgets.text("Vertical Vel", f"{robot.base_velocity[2]:.3f} m/s")

            if ui_widgets.button("Reset Drone"):
                robot.reset(position=(0, 0, 0.5))

        print("BulletLab drone tuning window opened.\n")
    except Exception as exc:
        print(f"UI not available ({exc}). Running headless.\n")

    # ──────────────────────────────────────────────
    # Simulation loop
    # ──────────────────────────────────────────────
    print("Running. Press Ctrl+C to stop.\n")
    step = 0
    rotor_joints = list(robot.joints.values())

    try:
        while True:
            # Apply upward thrust force (world frame, at the robot's centre)
            robot.apply_force((0, 0, params["thrust"]))

            # Apply simple drag (opposite to velocity, world frame)
            vel = robot.base_velocity
            drag_force = tuple(-params["drag"] * v for v in vel)
            robot.apply_force(drag_force)

            # Spin rotors
            for joint in rotor_joints:
                if not joint.is_fixed:
                    joint.velocity = params["rotor_speed"]

            sim.step()
            telemetry.update(t=sim.elapsed_time)
            logger.step(t=sim.elapsed_time)

            if ui is not None:
                ui.step()
                if ui.should_close:
                    break

            step += 1
            if step % 240 == 0:
                h = robot.base_position[2]
                vz = robot.base_velocity[2]
                print(f"  t={sim.elapsed_time:.1f}s | height={h:.2f}m | vz={vz:.2f}m/s | thrust={params['thrust']:.1f}N")

    except KeyboardInterrupt:
        print("\nStopped.")
    finally:
        logger.stop()
        print(f"\nLogged {logger.step_count} steps to drone_tuning.csv")
        if ui is not None:
            ui.stop()
        sim.stop()
        print("Done.")


if __name__ == "__main__":
    main()

05_generic_robot_inspector.py

Download file

"""
Example 05: Generic Robot Inspector
=====================================
Load any URDF by name or path and automatically inspect all joints,
links, and telemetry in the BulletLab UI.

This is the most versatile example — it works with any robot.

Usage::

    python examples/05_generic_robot_inspector.py
    python examples/05_generic_robot_inspector.py kuka_iiwa/model.urdf
    python examples/05_generic_robot_inspector.py r2d2.urdf
    python examples/05_generic_robot_inspector.py /absolute/path/to/robot.urdf

What this shows:
- Auto-discovery of all joints and links
- Dynamically building a full inspector UI
- RL-compatible state inspection
- Generic telemetry without knowing the robot type in advance
"""

import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from bulletlab import Simulation, Robot
from bulletlab.core.world import World
from bulletlab.telemetry import TelemetryManager
from bulletlab.logging import DataLogger
from bulletlab.utils.urdf_utils import find_urdf, list_available_urdfs


def pick_robot(arg: str | None) -> str:
    """Determine which URDF to load based on CLI argument."""
    if arg is not None:
        try:
            return str(find_urdf(arg))
        except FileNotFoundError:
            print(f"Could not find: {arg}")
            print("Available URDFs in pybullet_data:")
            for u in list_available_urdfs(30):
                print(f"  {u}")
            sys.exit(1)

    # Default priority list
    defaults = [
        "kuka_iiwa/model.urdf",
        "husky/husky.urdf",
        "franka_panda/panda.urdf",
        "r2d2.urdf",
        "cartpole.urdf",
    ]
    for d in defaults:
        try:
            return str(find_urdf(d))
        except FileNotFoundError:
            continue

    print("No default URDF found. Available URDFs:")
    for u in list_available_urdfs(20):
        print(f"  {u}")
    sys.exit(1)


def main() -> None:
    print("=== BulletLab Example 05: Generic Robot Inspector ===\n")

    urdf_path = pick_robot(sys.argv[1] if len(sys.argv) > 1 else None)
    print(f"Loading: {urdf_path}\n")

    # ──────────────────────────────────────────────
    # Simulation
    # ──────────────────────────────────────────────
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81))
    sim.start()
    sim.set_camera(distance=3.0, yaw=45.0, pitch=-20.0, target=(0, 0, 0.3))

    world = World(sim)
    world.load_plane()

    robot = Robot.load(urdf_path, sim=sim, position=(0, 0, 0.2), name="InspectedRobot")

    print(f"Robot: {robot}")
    print(f"  Joints ({len(robot.joints)}):")
    for name, j in robot.joints.items():
        lo, hi = j.limits
        jtype = j.joint_type.name if hasattr(j.joint_type, "name") else str(j.joint_type)
        print(f"    [{name}] type={jtype} limits=[{lo:.2f}, {hi:.2f}]")
    print(f"  Links ({len(robot.links)}):")
    for name, l in robot.links.items():
        print(f"    [{name}] mass={l.mass:.3f}kg")
    print(f"  State dim: {len(robot.get_state())}\n")

    # ──────────────────────────────────────────────
    # Telemetry – auto-discover
    # ──────────────────────────────────────────────
    telemetry = TelemetryManager()
    telemetry.watch("x",     lambda: robot.base_position[0], unit="m")
    telemetry.watch("y",     lambda: robot.base_position[1], unit="m")
    telemetry.watch("z",     lambda: robot.base_position[2], unit="m")
    telemetry.watch("speed", lambda: robot.speed,             unit="m/s")
    telemetry.watch("roll",  lambda: math.degrees(robot.roll),  unit="°")
    telemetry.watch("pitch", lambda: math.degrees(robot.pitch), unit="°")
    telemetry.watch("yaw",   lambda: math.degrees(robot.yaw),   unit="°")

    # Add joint positions for first 4 controllable joints
    for joint in robot.controllable_joints[:4]:
        telemetry.watch(
            f"j_{joint.name[:12]}",
            (lambda j: lambda: j.position)(joint),
            unit="rad",
        )

    # ──────────────────────────────────────────────
    # Logger
    # ──────────────────────────────────────────────
    logger = DataLogger()
    logger.watch("x",     lambda: robot.base_position[0])
    logger.watch("y",     lambda: robot.base_position[1])
    logger.watch("z",     lambda: robot.base_position[2])
    logger.watch("speed", lambda: robot.speed)
    logger.start("inspector_run.csv")

    # ──────────────────────────────────────────────
    # UI
    # ──────────────────────────────────────────────
    ui = None
    try:
        from bulletlab.ui import BulletLabUI
        import bulletlab.ui.widgets as ui_widgets

        ui = BulletLabUI(sim=sim, robots=[robot], telemetry=telemetry)
        ui.start()

        @ui.custom_panel("Joint Inspector")
        def joint_inspector() -> None:
            ui_widgets.text("Robot", robot.name)
            ui_widgets.text("State dim", str(len(robot.get_state())))
            ui_widgets.separator("Controllable Joints")
            for joint in robot.controllable_joints[:8]:
                lo, hi = joint.limits
                lo2 = lo if lo != 0 or hi != 0 else -math.pi
                hi2 = hi if lo != 0 or hi != 0 else math.pi
                ui_widgets.slider(
                    joint.name[:20],
                    lambda j=joint: j.position,
                    lo2, hi2,
                    setter=lambda v, j=joint: j.set_position(v),
                )

        @ui.custom_panel("Link Inspector")
        def link_inspector() -> None:
            ui_widgets.text("Link count", str(len(robot.links)))
            ui_widgets.separator("Editable Properties")
            for link in list(robot.links.values())[:6]:
                if ui_widgets.collapsing_header(link.name[:20], default_open=False):
                    ui_widgets.drag_float(
                        f"mass##{link.name}", lambda l=link: l.mass,
                        setter=lambda v, l=link: setattr(l, "mass", v),
                        speed=0.01, min_val=0.0001, max_val=100.0,
                    )
                    ui_widgets.drag_float(
                        f"friction##{link.name}", lambda l=link: l.friction,
                        setter=lambda v, l=link: setattr(l, "friction", v),
                        speed=0.01, min_val=0.0, max_val=10.0,
                    )

        print("BulletLab inspector window opened.\n")
    except Exception as exc:
        print(f"UI not available ({exc}). Running headless.\n")

    # ──────────────────────────────────────────────
    # Simulation loop
    # ──────────────────────────────────────────────
    print("Running inspector. Press Ctrl+C to stop.\n")
    step = 0

    try:
        while sim.is_connected:
            sim.step()
            telemetry.update(t=sim.elapsed_time)
            logger.step(t=sim.elapsed_time)

            if ui is not None:
                ui.step()
                if ui.should_close:
                    break

            step += 1
            if step % 480 == 0:
                snap = telemetry.snapshot()
                pos = (snap.get("x", 0), snap.get("y", 0), snap.get("z", 0))
                print(
                    f"  t={sim.elapsed_time:.1f}s | "
                    f"pos=({pos[0]:.2f},{pos[1]:.2f},{pos[2]:.2f}) | "
                    f"speed={snap.get('speed', 0):.2f}m/s"
                )

    except KeyboardInterrupt:
        print("\nStopped.")
    finally:
        logger.stop()
        print(f"\nLogged {logger.step_count} steps to inspector_run.csv")
        if ui is not None:
            ui.stop()
        sim.stop()
        print("Done.")


if __name__ == "__main__":
    main()

06_irregular_terrain.py

Download file

"""
Example 06: Husky Rover on Irregular Terrain
=============================================
Demonstrates rover navigation over procedurally generated terrain with
real-time throttle and steering control via an ImGui control panel.

What this shows:
- Procedural heightfield terrain using multi-octave sine noise
- Scattering static rock/box obstacles across the environment
- Loading the Husky rover and driving it with differential steering
- Real-time throttle, steering, and max-speed control via UI sliders
- Live telemetry (speed, roll, pitch, position)

Run::

    python examples/06_irregular_terrain.py
"""

import math
import numpy as np

from bulletlab import Simulation, Robot
from bulletlab.core.world import World
from bulletlab.telemetry import TelemetryManager
from bulletlab.ui import BulletLabUI
from bulletlab.ui import widgets as ui

# ─────────────────────────────────────────────────────────────────────────────
# 1. Simulation
# ─────────────────────────────────────────────────────────────────────────────
sim = Simulation(mode="gui", gravity=(0, 0, -9.81), timestep=1/240).start()

# ─────────────────────────────────────────────────────────────────────────────
# 2. Irregular terrain via World.load_heightfield()
# ─────────────────────────────────────────────────────────────────────────────
world = World(sim)

TERRAIN_SIZE = 256
np.random.seed(42)

# Multi-octave noise for natural-looking hills
def make_heightfield(n):
    h = np.zeros((n, n))
    for octave, amp, freq in [(1, 0.6, 0.05), (2, 0.3, 0.12), (4, 0.1, 0.3)]:
        for i in range(n):
            for j in range(n):
                h[i, j] += amp * math.sin(freq * i) * math.cos(freq * j)
    h += np.random.uniform(-0.15, 0.15, (n, n))  # fine grain noise
    # Flatten a spawn-safe zone at the origin
    cx, cy = n // 2, n // 2
    h[cx-8:cx+8, cy-8:cy+8] = 0.0
    return h

heights = make_heightfield(TERRAIN_SIZE)

world.load_heightfield(
    heights,
    xy_scale=0.1,
    z_scale=0.25,
    color=(0.55, 0.45, 0.35, 1.0),
)

# ─────────────────────────────────────────────────────────────────────────────
# 3. Scatter rock/box obstacles via World.scatter_obstacles()
# ─────────────────────────────────────────────────────────────────────────────
world.scatter_obstacles(
    count=30,
    kind="box",
    size_range=(0.2, 0.6),
    region=(-10.0, -10.0, 8.0, 8.0),
    color=(0.4, 0.4, 0.4, 1.0),
    seed=7,
)

# ─────────────────────────────────────────────────────────────────────────────
# 4. Load Husky rover
# ─────────────────────────────────────────────────────────────────────────────
robot = Robot.load(
    "husky/husky.urdf",
    sim      = sim,
    position = (0, 0, 0.4),
    name     = "Husky",
)

sim.set_camera(distance=5.0, yaw=50, pitch=-30, target=(0, 0, 0.2))

# Wheel joint names in Husky URDF
WHEEL_JOINTS = [
    "front_left_wheel",
    "front_right_wheel",
    "rear_left_wheel",
    "rear_right_wheel",
]

def get_wheel(name):
    """Return joint by name, gracefully."""
    return robot.joints.get(name)

wheels = {n: get_wheel(n) for n in WHEEL_JOINTS}
wheels = {k: v for k, v in wheels.items() if v is not None}
print("Wheel joints found:", list(wheels.keys()))

# ─────────────────────────────────────────────────────────────────────────────
# 5. Drive state (controlled by UI sliders)
# ─────────────────────────────────────────────────────────────────────────────
drive = {
    "throttle": 0.0,   # -1 → 1  (backward → forward)
    "steer":    0.0,   # -1 → 1  (left → right)
    "max_vel":  15.0,  # rad/s cap
    "stopped":  False,
}

def apply_drive():
    if drive["stopped"]:
        for w in wheels.values():
            w.velocity = 0.0
        return
    cap   = drive["max_vel"]
    fwd   = drive["throttle"] * cap
    turn  = drive["steer"]    * cap * 0.5
    left_vel  = fwd - turn
    right_vel = fwd + turn
    for name, joint in wheels.items():
        if "left" in name:
            joint.velocity = left_vel
        else:
            joint.velocity = right_vel

# ─────────────────────────────────────────────────────────────────────────────
# 6. Telemetry
# ─────────────────────────────────────────────────────────────────────────────
telemetry = TelemetryManager()
telemetry.watch("Speed",  lambda: robot.speed,                 unit="m/s")
telemetry.watch("Height", lambda: robot.base_position[2],      unit="m")
telemetry.watch("Roll",   lambda: math.degrees(robot.roll),    unit="°")
telemetry.watch("Pitch",  lambda: math.degrees(robot.pitch),   unit="°")

# ─────────────────────────────────────────────────────────────────────────────
# 7. UI
# ─────────────────────────────────────────────────────────────────────────────
app = BulletLabUI(
    sim=sim, robots=[robot], telemetry=telemetry,
    title="BulletLab — Husky on Terrain",
    width=480, height=820,
)

@app.custom_panel("Drive Controls")
def drive_panel():
    pos = robot.base_position
    ui.text("Speed",  f"{robot.speed:.2f} m/s")
    ui.text("Height", f"{pos[2]:.3f} m")
    ui.text("Roll",   f"{math.degrees(robot.roll):.1f} °")
    ui.text("Pitch",  f"{math.degrees(robot.pitch):.1f} °")

    ui.separator("Controls")

    drive["throttle"] = ui.slider(
        "Throttle ↑↓",
        lambda: drive["throttle"],
        -1.0, 1.0,
        setter=lambda v: drive.__setitem__("throttle", v),
        fmt="%.2f",
    )
    drive["steer"] = ui.slider(
        "Steer  ←→",
        lambda: drive["steer"],
        -1.0, 1.0,
        setter=lambda v: drive.__setitem__("steer", v),
        fmt="%.2f",
    )
    drive["max_vel"] = ui.slider(
        "Max Speed",
        lambda: drive["max_vel"],
        1.0, 40.0,
        setter=lambda v: drive.__setitem__("max_vel", v),
        fmt="%.1f rad/s",
    )

    ui.separator("Actions")
    if ui.button("  ⏹  STOP  "):
        drive["throttle"] = 0.0
        drive["steer"]    = 0.0
    ui.same_line()
    if ui.button("  Reset Rover  "):
        robot.reset()
        drive["throttle"] = 0.0
        drive["steer"]    = 0.0

app.start()

# ─────────────────────────────────────────────────────────────────────────────
# 8. Main loop
# ─────────────────────────────────────────────────────────────────────────────
step = 0
while sim.is_connected and not app.should_close:
    apply_drive()
    sim.step()
    step += 1
    if step % 10 == 0:
        telemetry.update(t=sim.elapsed_time)
    app.step()

app.stop()
sim.stop()

07_arsenal_loading.py

Download file

"""
Example 07: BulletLab Arsenal — Direct Loading Demo
=====================================================
Loads the BLem1 rover directly from the BulletLab Arsenal registry into a
temporary session cache.  No installation or manual download is required —
the URDF and meshes are fetched on-the-fly and cleaned up on exit.

After loading, the full BulletLabUI opens with:
  - A joystick panel to drive the rover with all four wheel pairs
  - Live telemetry (position, speed, orientation)
  - Camera follow in smooth mode
  - Joint / link hover highlighting

Internet access is required on the first run to fetch assets from the registry.

Run::

    python examples/07_arsenal_loading.py
"""

import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from bulletlab import Simulation, Robot, CameraFollow, RobotHighlighter
from bulletlab.core.world import World
from bulletlab.telemetry import TelemetryManager


def main() -> None:
    print("=" * 60)
    print("BulletLab Arsenal — BLem1 Rover")
    print("=" * 60)

    # ------------------------------------------------------------------
    # 1. Simulation
    # ------------------------------------------------------------------
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81), timestep=1.0 / 240.0)

    # ------------------------------------------------------------------
    # 2. Load BLem1 directly from Arsenal (session cache, no install)
    # ------------------------------------------------------------------
    print("\nFetching 'reference_bot/BLem1' from Arsenal registry...")
    robot = Robot.load(
        "arsenal:reference_bot/BLem1",
        sim=sim,
        position=(0, 0, 0.3),
        name="BLem1",
    )
    print(f"Loaded: {robot}")
    print(f"  Joints: {list(robot.joints.keys())}")
    print()

    # ------------------------------------------------------------------
    # 3. World and Camera setup
    # ------------------------------------------------------------------
    sim.set_camera(distance=3.5, yaw=45.0, pitch=-25.0, target=(0, 0, 0.3))
    world = World(sim)
    world.load_plane()
    # ------------------------------------------------------------------
    # 4. Camera follow
    # ------------------------------------------------------------------
    cam = CameraFollow(
        robot, sim,
        mode="smooth",
        distance=3.5,
        pitch=-25.0,
        yaw=45.0,
        lerp=0.06,
        height_offset=0.3,
    )

    # ------------------------------------------------------------------
    # 5. Hover highlighting
    # ------------------------------------------------------------------
    hl = RobotHighlighter(robot, sim)

    # ------------------------------------------------------------------
    # 6. Telemetry
    # ------------------------------------------------------------------
    telemetry = TelemetryManager()
    telemetry.watch("x",     lambda: robot.base_position[0], unit="m")
    telemetry.watch("y",     lambda: robot.base_position[1], unit="m")
    telemetry.watch("z",     lambda: robot.base_position[2], unit="m")
    telemetry.watch("speed", lambda: robot.speed,            unit="m/s")
    telemetry.watch("roll",  lambda: math.degrees(robot.roll),  unit="°")
    telemetry.watch("pitch", lambda: math.degrees(robot.pitch), unit="°")
    telemetry.watch("yaw",   lambda: math.degrees(robot.yaw),   unit="°")

    # ------------------------------------------------------------------
    # 7. Identify wheel joints
    # BLem1 wheels: rear_right_wheel, front_right_wheel,
    #               rear_left_wheel,  front_left_wheel
    # Left-side wheels spin one direction; right-side the other for steering.
    # ------------------------------------------------------------------
    left_wheels  = [j for name, j in robot.joints.items() if "left_wheel"  in name]
    right_wheels = [j for name, j in robot.joints.items() if "right_wheel" in name]

    # Fallback: use all wheel joints if naming differs
    if not left_wheels or not right_wheels:
        all_wheels = [j for name, j in robot.joints.items() if "wheel" in name]
        half = len(all_wheels) // 2
        left_wheels  = all_wheels[:half]
        right_wheels = all_wheels[half:]

    for j in left_wheels + right_wheels:
        j.max_force = 150.0

    print(f"  Left  wheels: {[j.name for j in left_wheels]}")
    print(f"  Right wheels: {[j.name for j in right_wheels]}")
    print()

    # ------------------------------------------------------------------
    # 8. UI
    # ------------------------------------------------------------------
    ui = None
    manual_vel = [0.0, 0.0]    # [left_vel, right_vel]

    try:
        from bulletlab.ui import BulletLabUI, widgets as ui_widgets, imgui

        ui = BulletLabUI(
            sim=sim,
            robots=[robot],
            telemetry=telemetry,
            camera=cam,
            highlighter=hl,
        )
        ui.start()

        snap_on = [True]

        def on_joy_y(v: float) -> None:
            """Forward (+) / backward (-)."""
            spd = v * 12.0
            manual_vel[0] += spd
            manual_vel[1] += spd

        def on_joy_x(v: float) -> None:
            """Steer right (+) / left (-)."""
            turn = v * 6.0
            manual_vel[0] += turn
            manual_vel[1] -= turn

        @ui.custom_panel("Drive — BLem1")
        def drive_panel() -> None:
            # Reset each frame so callbacks accumulate cleanly
            manual_vel[0] = 0.0
            manual_vel[1] = 0.0

            ui_widgets.toggle_switch(
                "Snap joystick to zero",
                getter=lambda: snap_on[0],
                setter=lambda v: snap_on.__setitem__(0, v),
                color_on=(0.2, 0.9, 0.4, 1.0),
            )
            imgui.separator()
            ui_widgets.joystick(
                "BLem1 Drive",
                on_y=on_joy_y,
                on_x=on_joy_x,
                snap=snap_on[0],
                size=80,
                handle_color=(0.2, 0.75, 1.0, 1.0),
            )
            imgui.separator()
            ui_widgets.text("Left  vel", f"{manual_vel[0]:+.1f} rad/s")
            ui_widgets.text("Right vel", f"{manual_vel[1]:+.1f} rad/s")

        # Leg controls — continuous revolute joints, no position limits
        leg_joints = [j for name, j in robot.joints.items() if "leg" in name]

        if leg_joints:
            leg_vel = [0.0]

            @ui.custom_panel("Legs")
            def legs_panel() -> None:
                changed, v = imgui.slider_float(
                    "Leg fold speed", leg_vel[0], -3.0, 3.0
                )
                if changed:
                    leg_vel[0] = v
                for j in leg_joints:
                    j.velocity = leg_vel[0]

        print("BulletLab UI opened.\n")
    except Exception as exc:
        print(f"UI not available ({exc}). Running headless.\n")

    # ------------------------------------------------------------------
    # 9. Simulation loop
    # ------------------------------------------------------------------
    print("Running simulation. Close the UI window or press Ctrl+C to stop.\n")

    try:
        while sim.is_connected:
            # Apply wheel velocities (updated by joystick callbacks)
            for j in left_wheels:
                j.velocity = manual_vel[0]
            for j in right_wheels:
                j.velocity = manual_vel[1]

            sim.step()
            telemetry.update(t=sim.elapsed_time)
            cam.update()

            if ui is not None:
                ui.step()
                if ui.should_close:
                    break

    except KeyboardInterrupt:
        print("\nStopped.")
    finally:
        if ui is not None:
            ui.stop()
        sim.stop()
        print("Done. Session cache cleaned up automatically.")


if __name__ == "__main__":
    main()

08_loading_humanoid.py

Download file

"""
Example 08: Loading Humanoid from Arsenal
=========================================
Loads the Unitree G1 humanoid from BulletLab Arsenal and auto-generates
a UI panel with sliders for all its controllable joints.

Run::

    python examples/08_loading_humanoid.py
"""

import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from bulletlab import Simulation, Robot, CameraFollow
from bulletlab.core.world import World

def main() -> None:
    print("=== BulletLab Example 08: Loading Humanoid ===\n")

    # 1. Setup Simulation
    # Note: We do not call sim.start() immediately. This ensures the PyBullet
    # GUI window only pops up *after* the heavy model download completes.
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81), timestep=1.0 / 240.0)

    # 2. Load Humanoid from Arsenal
    print("Fetching 'unitree/g1_description/g1_29dof' from Arsenal registry...")
    try:
        robot = Robot.load(
            "arsenal:unitree/g1_description/g1_29dof",
            sim=sim,
            position=(0, 0, 0.8),  # Spawn slightly above ground
            name="UnitreeG1",
        )
    except Exception as exc:
        print(f"Failed to load robot from arsenal: {exc}")
        sys.exit(1)

    print(f"Loaded: {robot.name} with {len(robot.controllable_joints)} controllable joints.")

    # 3. World and Camera setup
    sim.set_camera(distance=2.5, yaw=45.0, pitch=-20.0, target=(0, 0, 0.5))
    world = World(sim)
    world.load_plane()

    # 4. Camera Follow
    cam = CameraFollow(
        robot, sim,
        mode="smooth",
        distance=2.5,
        pitch=-20.0,
        yaw=45.0,
        lerp=0.1,
        height_offset=0.5,
    )

    # 5. UI Setup
    ui = None
    try:
        from bulletlab.ui import BulletLabUI
        import bulletlab.ui.widgets as ui_widgets

        ui = BulletLabUI(sim=sim, robots=[robot], camera=cam)
        ui.start()

        @ui.custom_panel("Joint Controls")
        def joint_controls() -> None:
            ui_widgets.text("Robot", robot.name)
            ui_widgets.text("Total Joints", str(len(robot.controllable_joints)))
            ui_widgets.separator()

            for joint in robot.controllable_joints:
                lo, hi = joint.limits
                # If limits are not defined, provide a reasonable default range
                lo2 = lo if lo != 0 or hi != 0 else -math.pi
                hi2 = hi if lo != 0 or hi != 0 else math.pi
                ui_widgets.slider(
                    joint.name[:25],
                    lambda j=joint: j.position,
                    lo2, hi2,
                    setter=lambda v, j=joint: j.set_position(v),
                )

        print("BulletLab UI opened.\n")
    except Exception as exc:
        print(f"UI not available ({exc}). Running headless.\n")

    # 6. Simulation loop
    print("Running simulation. Close the UI window or press Ctrl+C to stop.\n")

    try:
        while sim.is_connected:
            sim.step()
            cam.update()

            if ui is not None:
                ui.step()
                if ui.should_close:
                    break

    except KeyboardInterrupt:
        print("\nStopped.")
    finally:
        if ui is not None:
            ui.stop()
        sim.stop()
        print("Done.")

if __name__ == "__main__":
    main()

09_quick_launch.py

Download file

"""
Example 09: Instant Robot Deployment with quickLaunch()
======================================================
Demonstrates BulletLab's signature one-liner deployment feature.

Pass any model path (built-in URDF, local file, or Arsenal package URI)
and quickLaunch immediately spins up:
  - Physics world with ground plane and realistic gravity
  - Auto-generated Joint Control UI with Position / Velocity / Torque modes
  - Dynamic Camera Tracking with smooth follow mode and capsule toggle switch
  - Live Telemetry tracking base pose, speed, roll/pitch/yaw, and joint angles
  - Interactive Python Console for live scripting

Run with default (R2D2):
    python examples/09_quick_launch.py

Run with an Arsenal package:
    python examples/09_quick_launch.py arsenal:reference_bot

Run with a local/built-in URDF:
    python examples/09_quick_launch.py kuka_iiwa/model.urdf
"""

import sys
from pathlib import Path

# Allow importing bulletlab from source checkout
sys.path.insert(0, str(Path(__file__).parent.parent))

import bulletlab


def main() -> None:
    # Accept model path from command-line argument, or default to Arsenal model
    model_path = sys.argv[1] if len(sys.argv) > 1 else "arsenal:reference_bot"

    print("=" * 60)
    print("  BulletLab — Signature One-Line Deployment")
    print(f"  Target Model: {model_path}")
    print("=" * 60)

    # ⚡ ONE LINE AND YOUR MODEL IS DEPLOYED!
    bulletlab.quickLaunch(model_path)


if __name__ == "__main__":
    main()

assets/quadrotor.urdf

Download file

<?xml version="1.0"?>
<robot name="quadrotor">
  <link name="base_link">
    <visual>
      <geometry><box size="0.4 0.4 0.1"/></geometry>
    </visual>
    <collision>
      <geometry><box size="0.4 0.4 0.1"/></geometry>
    </collision>
    <inertial>
      <mass value="1.0"/>
      <inertia ixx="0.01" iyy="0.01" izz="0.02" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>

  <link name="rotor_fl">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_fl_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_fl"/>
    <origin xyz="0.15 0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>

  <link name="rotor_fr">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_fr_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_fr"/>
    <origin xyz="0.15 -0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>

  <link name="rotor_rl">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_rl_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_rl"/>
    <origin xyz="-0.15 0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>

  <link name="rotor_rr">
    <visual><geometry><cylinder radius="0.08" length="0.02"/></geometry></visual>
    <collision><geometry><cylinder radius="0.08" length="0.02"/></geometry></collision>
    <inertial><mass value="0.05"/>
      <inertia ixx="0.0001" iyy="0.0001" izz="0.0001" ixy="0" ixz="0" iyz="0"/>
    </inertial>
  </link>
  <joint name="rotor_rr_joint" type="continuous">
    <parent link="base_link"/>
    <child link="rotor_rr"/>
    <origin xyz="-0.15 -0.15 0.06"/>
    <axis xyz="0 0 1"/>
  </joint>
</robot>

demo_ui_control.py

Download file

"""
BulletLab Interactive Demo
==========================

Loads a robot model and lets you control every joint via the UI window.

Two windows open:
  • PyBullet window  — 3D physics simulation
  • BulletLab window — sliders, telemetry, console, live plots

Usage:
    python examples/demo_ui_control.py              # auto-picks best model
    python examples/demo_ui_control.py r2d2         # R2D2 (velocity wheels)
    python examples/demo_ui_control.py kuka         # Kuka iiwa arm (position)
    python examples/demo_ui_control.py husky        # Husky rover (velocity)
"""

from __future__ import annotations

import math
import sys
import time

import pybullet_data

from bulletlab import Robot, Simulation
from bulletlab.telemetry import TelemetryManager
from bulletlab.ui import BulletLabUI
from bulletlab.ui import widgets as ui


# ──────────────────────────────────────────────────────────────────────────────
# 1.  Model catalogue
# ──────────────────────────────────────────────────────────────────────────────

MODELS = {
    "kuka": {
        "urdf":       "kuka_iiwa/model.urdf",
        "name":       "Kuka iiwa 14",
        "position":   (0, 0, 0),
        "fixed_base": True,
        "mode":       "position",      # joint control mode
        "description": "7-DOF robotic arm — drag sliders to pose each joint",
    },
    "r2d2": {
        "urdf":       "r2d2.urdf",
        "name":       "R2D2",
        "position":   (0, 0, 0.3),
        "fixed_base": False,
        "mode":       "velocity",
        "description": "Wheeled robot — set wheel velocities to drive around",
    },
    "husky": {
        "urdf":       "husky/husky.urdf",
        "name":       "Husky Rover",
        "position":   (0, 0, 0.2),
        "fixed_base": False,
        "mode":       "velocity",
        "description": "4-wheel-drive rover — set wheel velocities to steer",
    },
    "ant": {
        "urdf":       "ant.urdf",
        "name":       "Ant Robot",
        "position":   (0, 0, 0.5),
        "fixed_base": False,
        "mode":       "torque",
        "description": "Multi-legged ant — apply torques to each leg joint",
    },
}

# Default preference order
DEFAULT_ORDER = ["kuka", "r2d2", "husky", "ant"]


def pick_model(arg: str | None) -> dict:
    """Return model config; fall back gracefully if URDF not found."""
    import os
    data = pybullet_data.getDataPath()

    if arg and arg.lower() in MODELS:
        cfg = MODELS[arg.lower()]
        if os.path.exists(os.path.join(data, cfg["urdf"])):
            return cfg
        print(f"[demo] '{arg}' URDF not found, trying defaults…")

    for key in DEFAULT_ORDER:
        cfg = MODELS[key]
        if os.path.exists(os.path.join(data, cfg["urdf"])):
            return cfg

    raise RuntimeError("No bundled URDF found. Check pybullet_data installation.")


# ──────────────────────────────────────────────────────────────────────────────
# 2.  Per-joint slider helpers
# ──────────────────────────────────────────────────────────────────────────────

class JointState:
    """Holds the current target value for one joint (position, velocity, or torque)."""
    def __init__(self, default: float = 0.0):
        self.target: float = default

# We store targets in a dict so the UI closures can capture live references.
_joint_targets: dict[str, JointState] = {}


def build_joint_panel(robot: Robot, mode: str):
    """Return an ImGui render function that draws sliders for each joint."""

    controllable = robot.controllable_joints
    for j in controllable:
        _joint_targets[j.name] = JointState(0.0)

    def render():
        ui.text("Model",  robot.name)
        ui.text("Mode",   mode.capitalize() + " control")
        ui.text("Joints", str(len(controllable)))
        ui.separator()

        for j in controllable:
            state = _joint_targets[j.name]
            lo, hi = j.limits if j.limits != (0.0, 0.0) else (-3.14, 3.14)

            if mode == "velocity":
                lo, hi = -20.0, 20.0   # rad/s

            elif mode == "torque":
                lo, hi = -50.0, 50.0   # N·m

            # Slider — short label so it fits
            label = j.name[:22] if len(j.name) > 22 else j.name
            state.target = ui.slider(
                label,
                lambda s=state: s.target,
                lo, hi,
                setter=lambda v, s=state: setattr(s, "target", v),
            )

        ui.separator()
        if ui.button("  Zero All  "):
            for s in _joint_targets.values():
                s.target = 0.0

        if mode == "position":
            ui.same_line()
            if ui.button("  Wave  "):
                # Animate a sine wave across all joints
                t = time.monotonic()
                for i, j in enumerate(controllable):
                    _joint_targets[j.name].target = math.sin(t + i * 0.5) * 1.0

    return render


def apply_targets(robot: Robot, mode: str):
    """Send the current slider targets to PyBullet."""
    for joint in robot.controllable_joints:
        target = _joint_targets.get(joint.name)
        if target is None:
            continue

        if mode == "position":
            joint.set_position(target.target)

        elif mode == "velocity":
            joint.velocity = target.target

        elif mode == "torque":
            joint.torque = target.target


# ──────────────────────────────────────────────────────────────────────────────
# 3.  Main
# ──────────────────────────────────────────────────────────────────────────────

def main():
    arg = sys.argv[1] if len(sys.argv) > 1 else None
    model_cfg = pick_model(arg)

    print(f"\n{'─'*60}")
    print(f"  BulletLab Demo  —  {model_cfg['name']}")
    print(f"  {model_cfg['description']}")
    print(f"{'─'*60}\n")

    # ── Simulation ───────────────────────────────────────────────────────────
    sim = Simulation(mode="gui", gravity=(0, 0, -9.81), timestep=1/240).start()

    # Ground plane
    from bulletlab.core.world import World
    world = World(sim=sim)
    world.load_plane()

    # Load robot
    robot = Robot.load(
        model_cfg["urdf"],
        sim=sim,
        position=model_cfg["position"],
        fixed_base=model_cfg["fixed_base"],
        name=model_cfg["name"],
    )

    print(f"  Joints  : {list(robot.joints.keys())}")
    print(f"  Links   : {list(robot.links.keys())}")
    print(f"  Controllable joints: {robot.num_controllable_joints}\n")

    mode = model_cfg["mode"]

    # ── Telemetry ─────────────────────────────────────────────────────────────
    telemetry = TelemetryManager()
    telemetry.watch("Speed",    lambda: robot.speed,               unit="m/s")
    telemetry.watch("Height",   lambda: robot.base_position[2],    unit="m")
    telemetry.watch("Roll",     lambda: math.degrees(robot.roll),  unit="°")
    telemetry.watch("Pitch",    lambda: math.degrees(robot.pitch), unit="°")
    telemetry.watch("SimStep",  lambda: sim.step_count)

    for j in robot.controllable_joints[:4]:
        telemetry.watch(
            f"j:{j.name[:12]}",
            (lambda jj: lambda: jj.position)(j),
            unit="rad",
        )

    # ── UI ────────────────────────────────────────────────────────────────────
    app = BulletLabUI(
        sim=sim,
        robots=[robot],
        telemetry=telemetry,
        title=f"BulletLab — {model_cfg['name']}",
        width=660,
        height=900,
    )

    # Joint control panel (custom)
    joint_render_fn = build_joint_panel(robot, mode)
    app.register_panel("🎮  Joint Control", joint_render_fn)

    # Info panel
    def info_panel():
        ui.text("Model",   robot.name)
        ui.text("Mode",    mode.capitalize())
        ui.text("Joints",  str(robot.num_controllable_joints))
        ui.text("Links",   str(len(robot.links)))
        ui.separator("Base State")
        pos = robot.base_position
        ui.text("X",  f"{pos[0]:.3f} m")
        ui.text("Y",  f"{pos[1]:.3f} m")
        ui.text("Z",  f"{pos[2]:.3f} m")
        ui.text("Speed",  f"{robot.speed:.3f} m/s")
        ui.text("Roll",   f"{math.degrees(robot.roll):.1f} °")
        ui.text("Pitch",  f"{math.degrees(robot.pitch):.1f} °")
        ui.text("Yaw",    f"{math.degrees(robot.yaw):.1f} °")
        ui.separator("Actions")
        if ui.button("Reset Robot"):
            robot.reset()
            for s in _joint_targets.values():
                s.target = 0.0

    app.register_panel("📊  Info", info_panel)
    app.start()

    # ── Simulation loop ───────────────────────────────────────────────────────
    print("  Simulation running. Close the BulletLab window to exit.\n")

    step = 0
    while sim.is_connected and not app.should_close:
        apply_targets(robot, mode)
        sim.step()

        step += 1
        if step % 10 == 0:
            telemetry.update(t=sim.elapsed_time)

        app.step()

    app.stop()
    sim.stop()
    print("\n  Demo finished. Goodbye!\n")


if __name__ == "__main__":
    main()

imgui.ini

Download file

[Window][Debug##Default]
Pos=60,60
Size=400,400
Collapsed=0

[Window][##main]
Pos=0,20
Size=480,800
Collapsed=0