Skip to content

Export and replay USD

Walks through examples/export/export_animation.py and examples/export/play_record.py — getting animation out of botrail, and back in.

A bake is only useful if it leaves the building. botrail's animation format is USD: the exported layer references the robot from its original stage at full visual fidelity, includes every obstacle, and plays in usdview, Omniverse, or Blender with no botrail installed. The same pipeline runs in reverse — a recording plays back into the studio, whether botrail baked it or Isaac Sim did.

Two exporters, one format

You have met both already:

scene.export_usd("motion.usda", traj, fps=60)   # one planned trajectory
tl.export_usd("cycle.usda", fps=60)             # a whole baked cycle

Scene.export_usd bakes a single Trajectory; the timeline version bakes everything the cycle did — every robot, every obstacle, grasped objects riding, releasing, resting exactly as simulated. Called without a trajectory, scene.export_usd("cell.usda") writes the static cell instead — robots at their current pose, every visible obstacle — the layer a layout is handed around as.

A carry motion, exported

export_animation.py is the single-trajectory case with the one wrinkle worth a tutorial: the box has to ride the gripper, so the grasp happens before the plan.

    pick = scene.frame("/World/Conveyor/PickFrame")
    place = scene.frame("/World/Pallet/PlaceFrame")
    home_q = list(scene.joint_positions)
    grip = teach_grasp(scene, pick)
    grip[7:] = [CLOSED] * len(grip[7:])
    scene.set_joint_positions(grip)
    scene.attach(BOX, link="/panda/panda_hand", touch_links=TOUCH)
    lifted_q = teach_grasp(scene, pick, standoff=0.15)

    # Teach the drop-off pose above the pallet (the taught place pose, held
    # high so the box clears the crates), snapshot the joints, return to the
    # start, and plan the carry. The pallet is a 150 deg base swing from the
    # conveyor, so that solve restarts from the ready pose — warm-starting it
    # from the pick side walks the solver into a local minimum.
    scene.set_joint_positions(home_q)
    goal_q = teach_grasp(scene, place, standoff=0.20)
    goal_q[7:] = lifted_q[7:]  # the grip does not change
    scene.set_joint_positions(lifted_q)
    traj = scene.plan(goal_q)

The order matters. Close the fingers into the box, attach it, lift so the held box clears the belt — and only then plan. While attached, the box is part of the robot: it follows the hand in the plan, collides as the robot, and rides along in the export.

python examples/export/export_animation.py
exported a 6.04s carry motion to cell_anim.usda
view it with: usdview cell_anim.usda

Open it in usdview and press play: the Franka at full Isaac fidelity, the box leaving the belt and landing over the pallet, the whole cell around it.

Playing a recording back

The studio plays USD recordings through Scene.play_usd_animation:

scene = build_scene()                     # the cell the recording was baked from
scene.play_usd_animation("cell_seq.usda")
bt.studio(scene)

play_record.py wraps this with one important idea: a recording is joint tracks addressed to robot instances, not to "the robot". botrail exports each robot under /World/<instance name>, and playback looks the scene's robots up by that path. Play the two-arm recording onto the single-arm cell and you get an error, not a degraded picture:

recording import failed: cannot locate robot `near` in the recording
(no `/World/near`); pass robot_roots with its prim path

That error message is also the escape hatch: robot_roots maps instance names to prim paths, which is how recordings from outside botrail — an Isaac Sim capture, say — play through the same pipeline:

scene.play_usd_animation("isaac_capture.usda", robot_roots={"panda": "/World/Franka"})

The demo script picks the right cell automatically by sniffing which instance prims the recording animates:

DEFAULT = Path("cell_seq.usda")


def robot_instances(recording: Path) -> set:
    """The instance names a recording animates, read off the prim names
    botrail exported them under. Empty for a binary `.usd`/`.usdc`, which
    just means the cell has to be chosen by hand below."""
    try:
        text = recording.read_text()
    except (UnicodeDecodeError, OSError):
        return set()
    # Direct children of /World; `Env` is the static scenery, not a robot.
    return {

A recording baked before a layout change still plays — the new scenery just stays static, and the import says so once per prim rather than flooding the console. When the warnings pile up, re-bake.

How the robot plays depends on where it came from. A USD-sourced robot shares a stage with its recording, so playback recovers q(t) from the recorded joint states (joint_state mode). A robot built from URDF or by attach_tool — the welding cell's arm-plus-gun, say — has no single stage behind it, so its export bakes per-link world poses instead, and playback follows those directly (transforms mode). Both kinds of recording also stand alone: open one in usdview and it plays with no botrail installed. What playing it into the live cell adds is the studio around it — the timeline dock, scrubbing, and the scene's own obstacles following their recorded tracks.

python examples/basics/sequence_demo.py          # bake cell_seq.usda
python examples/export/play_record.py            # …and watch it in the studio

The complete scripts

examples/export/export_animation.py
"""Bake a carry motion to USD: the Franka grasps a box on the conveyor and
carries it to the pallet, box riding the gripper and every obstacle in the
scene included. The result plays directly in usdview / Omniverse / Blender
(the robot is referenced from the original Isaac stage at full fidelity).

Run with:  python examples/export/export_animation.py [out.usda]
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "basics"))

from demo import build_scene, teach_grasp  # noqa: E402  (path setup first)

BOX = "/World/Conveyor/Box_A"
CLOSED = 0.029  # a millimetre a side into the 60 mm box
TOUCH = ["/panda/panda_leftfinger", "/panda/panda_rightfinger"]


def main() -> None:
    out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("cell_anim.usda")
    scene = build_scene()

    # Put the gripper on the cell's taught pick pose (tool pointing down,
    # pads around the box), close on the box and grasp it, then lift a
    # little so the held box clears the conveyor belt before planning.
    pick = scene.frame("/World/Conveyor/PickFrame")
    place = scene.frame("/World/Pallet/PlaceFrame")
    home_q = list(scene.joint_positions)
    grip = teach_grasp(scene, pick)
    grip[7:] = [CLOSED] * len(grip[7:])
    scene.set_joint_positions(grip)
    scene.attach(BOX, link="/panda/panda_hand", touch_links=TOUCH)
    lifted_q = teach_grasp(scene, pick, standoff=0.15)

    # Teach the drop-off pose above the pallet (the taught place pose, held
    # high so the box clears the crates), snapshot the joints, return to the
    # start, and plan the carry. The pallet is a 150 deg base swing from the
    # conveyor, so that solve restarts from the ready pose — warm-starting it
    # from the pick side walks the solver into a local minimum.
    scene.set_joint_positions(home_q)
    goal_q = teach_grasp(scene, place, standoff=0.20)
    goal_q[7:] = lifted_q[7:]  # the grip does not change
    scene.set_joint_positions(lifted_q)
    traj = scene.plan(goal_q)

    warnings = scene.export_usd(out, traj, fps=60.0)
    for w in warnings:
        print(f"warning: {w}")
    print(f"exported a {traj.duration:.2f}s carry motion to {out}")
    print(f"view it with: usdview {out}")


if __name__ == "__main__":
    main()
examples/export/play_record.py
"""Play a baked USD recording back into the studio.

A recording is joint tracks addressed to robot *instances*, not to "the
robot": botrail exports each one under `/World/<instance name>`, and
playback looks the scene's robots up by that path. So a recording has to be
played onto the cell it was baked from — and putting the two-arm recording
on the single-arm cell is an error, not a degraded picture:

    recording import failed: cannot locate robot `near` in the recording
    (no `/World/near`); pass robot_roots with its prim path

which is also the escape hatch when a recording came from somewhere else
(Isaac Sim, say) and its prims are named differently: pass `robot_roots`.

How the robot plays depends on where it came from. USD-sourced robots
carry joint tracks (`joint_state` mode); URDF and `attach_tool` composite
robots have no stage behind them, so their bake is per-link world poses
and playback follows those (`transforms` mode). Either way the cell has to
be rebuilt first — the recording stores motion, not geometry.

Run with:  python examples/export/play_record.py [recording.usda] [--cell NAME]

A binary `.usdc` keeps its prim names out of reach of the text sniffing
below, so name its cell explicitly: `--cell line` / `line4` / `weld` / …
"""

import re
import sys
from pathlib import Path

sys.path[:0] = [str(Path(__file__).resolve().parents[1] / d)
                for d in ("basics", "machining", "multi_robot", "vehicles", "welding")]

import agv_cell_demo  # noqa: E402
import amr_demo  # noqa: E402
import botrail as bt  # noqa: E402
import demo  # noqa: E402
import dual_cell_demo  # noqa: E402
import machining_demo  # noqa: E402
import weld_station_demo  # noqa: E402

DEFAULT = Path("cell_seq.usda")


def robot_instances(recording: Path) -> set:
    """The instance names a recording animates, read off the prim names
    botrail exported them under. Empty for a binary `.usd`/`.usdc`, which
    just means the cell has to be chosen by hand below."""
    try:
        text = recording.read_text()
    except (UnicodeDecodeError, OSError):
        return set()
    # Direct children of /World; `Env` is the static scenery, not a robot.
    return {
        name
        for name in re.findall(r'^    def Xform "([^"]+)"', text, re.M)
        if name != "Env"
    }


def marks(recording: Path, needle: str) -> bool:
    """Does the recording mention `needle`? Cells are told apart by a prim
    only one of them has."""
    try:
        return needle in recording.read_text()
    except (UnicodeDecodeError, OSError):
        return False


def has_vehicle(recording: Path) -> bool:
    """Does the recording animate an AGV? Its body is scenery, so it lands
    under `/World/Env`, not beside the robots — the cell still has to be
    rebuilt with the vehicle in it, or the body prims resolve to nothing
    and the AGV sits frozen at the warehouse while its cycle plays."""
    try:
        return '"agv"' in recording.read_text()
    except (UnicodeDecodeError, OSError):
        return False


def cell_for(recording: Path) -> bt.Scene:
    """Rebuilds the cell the recording was baked from."""
    names = robot_instances(recording)
    if {"near", "far"} <= names:
        print(f"{recording}: two-arm cell ({', '.join(sorted(names))})")
        return dual_cell_demo.build_cell()
    if {"lh_up", "lh_dn", "rh_up", "rh_dn"} <= names:
        # The weld cell's arms are composites (`attach_tool`: catalog arm
        # + catalog gun) — no USD stage behind them, so their bake is
        # per-link transforms and playback follows those directly
        # (`transforms` mode) instead of joint tracks.
        print(f"{recording}: weld station ({', '.join(sorted(names))})")
        return weld_station_demo.build_cell()[0]
    if {"st1_lh", "st1_rh", "st2_lh", "st2_rh"} <= names:
        import weld_line_demo

        print(f"{recording}: weld line ({', '.join(sorted(names))})")
        return weld_line_demo.build_line()[0]
    # The AMR carries its own arm, so check it before the AGV: both bake a
    # vehicle body under `/World/Env`, and only the AMR calls its `amr`.
    if marks(recording, 'def Xform "amr"'):
        print(f"{recording}: AMR (arm riding the vehicle)")
        return amr_demo.build_scene()
    if has_vehicle(recording):
        print(f"{recording}: single-arm cell + AGV")
        return agv_cell_demo.build_scene()
    # Only machining bakes carry toolpath overlays (`/World/Toolpaths`).
    # Like the weld arms, the machining robot is an `attach_tool` composite
    # (arm + spindle), so its bake is per-link transforms. The replay cell
    # re-carves the stock so the recording's `plate_cut/NNN` visibility
    # prims find their obstacles — that is the material melting away.
    if marks(recording, 'def Xform "Toolpaths"'):
        print(f"{recording}: machining cell (plate trim + pocket)")
        return machining_demo.build_replay_cell()
    # Everything left falls through to the single-arm cell, which is safe
    # for *one* robot however it is named: playback structurally searches
    # the stage when there is only one to find, so an older bake whose
    # instance is `Robot` still lands. Two or more unrecognised instances
    # have no such escape — answering those with a Franka would show a
    # factory that has nothing to do with the recording, and the mismatch
    # would surface as `cannot locate robot ...` rather than as "I do not
    # know this cell". (A binary recording reads as no names at all;
    # nothing to check, so it still falls through.)
    if len(names) > 1:
        raise SystemExit(
            f"{recording} animates {', '.join(sorted(names))}, which is not a "
            "cell this script knows how to rebuild. Build that cell yourself "
            "and call scene.play_usd_animation() on it."
        )
    if not names:
        # Binary: nothing to sniff. The demos all write `cell_<name>.usd*`,
        # so the filename is the next best evidence — better than assuming
        # a cell the recording has nothing to do with.
        stem = STEM_CELLS.get(recording.stem)
        if stem is not None:
            print(f"{recording}: binary recording, rebuilt from its name ({stem})")
            return CELLS[stem]()
        print(
            f"{recording}: no robot prims readable (a binary .usdc keeps its "
            f"names out of reach) — assuming the single-arm cell; pass "
            f"--cell {'/'.join(sorted(CELLS))} to say otherwise"
        )
    else:
        print(f"{recording}: single-arm cell")
    return demo.build_scene()


# Cells this script can rebuild, for `--cell`. A *binary* recording
# (`.usdc`) carries the same prims as a text one, but the sniffing below
# reads prim names out of the text — so a binary recording has to be told
# which cell it belongs to rather than guessed at.
CELLS = {
    "single": lambda: demo.build_scene(),
    "dual": lambda: dual_cell_demo.build_cell(),
    "weld": lambda: weld_station_demo.build_cell()[0],
    "line": lambda: _line_cell(2),
    "line4": lambda: _line_cell(4),
    "agv": lambda: agv_cell_demo.build_scene(),
    "amr": lambda: amr_demo.build_scene(),
    "machining": lambda: machining_demo.build_replay_cell(),
}

# What each demo names its bake, for recordings whose prims cannot be read
# (binary). Only the unambiguous ones: `cell_line.usda` could be either
# line length, so it stays a `--cell` decision.
STEM_CELLS = {
    "cell_seq": "single",
    "cell_dual": "dual",
    "cell_weld": "weld",
    "cell_agv": "agv",
    "cell_amr": "amr",
    "cell_machining": "machining",
}


def _line_cell(stations: int):
    import weld_line_demo

    weld_line_demo.set_stations(stations)
    return weld_line_demo.build_line()[0]


def main() -> None:
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    cell = None
    if "--cell" in sys.argv:
        cell = sys.argv[sys.argv.index("--cell") + 1]
        if cell not in CELLS:
            raise SystemExit(
                f"--cell takes one of: {', '.join(sorted(CELLS))}"
            )
    recording = Path(args[0]) if args else DEFAULT
    if not recording.exists():
        raise SystemExit(
            f"{recording} not found — bake one first:\n"
            "  python examples/basics/sequence_demo.py      # -> cell_seq.usda\n"
            "  python examples/multi_robot/dual_cell_demo.py     # -> cell_dual.usda\n"
            "  python examples/welding/weld_station_demo.py  # -> cell_weld.usda\n"
            "  python examples/welding/weld_line_demo.py     # -> cell_line.usda\n"
            "  python examples/machining/machining_demo.py     # -> cell_machining.usdc"
        )

    scene = CELLS[cell]() if cell else cell_for(recording)
    server = bt.studio(scene, block=False)  # ブラウザが開く

    result = scene.play_usd_animation(recording)
    print(f"{result['mode']} {result['duration']:.2f}s")
    print(f"  robots:  {', '.join(scene.robots)}")
    print(f"  objects: {', '.join(result['object_tracks']) or '—'}")
    # A recording baked before a layout change leaves the new scenery
    # static and says so, once per prim — summarise rather than flood.
    warnings = result["warnings"]
    for warning in warnings[:3]:
        print(f"  warning: {warning}")
    if len(warnings) > 3:
        print(f"  warning: … and {len(warnings) - 3} more (re-bake the recording)")

    input("Enterで終了")
    server.stop()


if __name__ == "__main__":
    main()

Where this leaves you

Everything in this series composes: a cell taught by IK (Pose and plan), sequenced like a PLC (Pick from a moving belt), verified in CI (Verify the cell), studied as a function (Parameter sweeps), scaled to two arms (Two arms, one belt) — and shipped as a USD file anyone can open.