GitHub repository | Documentation (not yet published)
Swift is a light-weight browser-based animation visualizer which provides:
- visualisation of mesh objects (Collada, STL, OBJ, glTF/GLB, PLY, VRML/WRL, and PCD files) and primitive shapes;
- visualisation of multi-link robots created with the Robotics Toolbox for Python;
- interactive UI controls (sliders, buttons, and more) for driving a scene from the browser;
- recording and saving a video of the simulation;
- source code which can be read for learning and teaching;
Built using Python and Javascript, Swift is cross-platform (Linux, MacOS, and Windows) while also leveraging the ubiquity and support of these languages.
Swift provides robotics-specific functionality for rapid prototyping of algorithms, research, and education. Through the Robotics Toolbox for Python, Swift can visualise over 30 supplied robot models: well-known contemporary robots from Franka-Emika, Kinova, Universal Robotics, Rethink as well as classical robots such as the Puma 560 and the Stanford arm. Swift is under development and will support mobile robots in the future.
Swift's browser frontend has been rebuilt from scratch as modern, dependency-free ES modules (no bundler, no framework, current three.js) — see this release's changelog for the full list. For existing users:
- a bottom-left playback panel with a pause/play button and a realtime-speed selector (Max/1x/0.5x/0.25x) — see Playback controls below;
- WebRTC support has been removed (
comms="rtc", thevisioninstall extra) — it had no live-camera use case and wasn't providing anything a plain WebSocket doesn't already handle for the normal desktop/browser setup this simulator targets; env.add()is now four explicit methods —add_shape(),add_ui(),add_assembly(),add_robot()— one entry point per kind of thing, no type-checking required.env.add()still works and dispatches to these, kept for backward compatibility;add_robot()/add_assembly()return anAssemblyHandlethat owns that instance's live joint state (handle.q,handle.qd) — therobotmodel (or bare forward-kinematics function, foradd_assembly()) stays plain and shareable, driven functionally (panda.fkine(handle.q),panda.jacobe(handle.q)). Settingrobot.q/robot.qddirectly still works but is deprecated;- any shape, assembly, or robot can take a per-step callback —
callback=lambda t, values: ...— invoked byenv.step()with the current sim time and any named UI element values, returning the new pose (shape) orq(assembly/robot). Removes the need to hand-write a loop body that mutates pose/qeach step; - UI elements take an optional
name=, and their current value is kept inenv.values— available to any callback asvalues[name]without writing a per-element setter function; env.show()prints the current display list (every shape/assembly/robot/UI element, with its id and name if given) for debugging.
These build up from the simplest possible scene to a fully interactive one. All are in examples/ and runnable as-is.
The simplest possible Swift scene: one shape, no motion.
import spatialgeometry as sg
from spatialmath import SE3
from swift import Swift
env = Swift()
env.launch(realtime=True)
box = sg.Cuboid([0.2, 0.2, 0.2], pose=SE3(0, 0, 0.1), color=[0.2, 0.4, 1.0, 1.0])
env.add_shape(box)
env.hold() # keep the browser tab openNamed sliders (name=...) push their current value into env.values; a per-step callback reads it from there and returns the box's new pose — no per-slider setter function, no manual pose assignment in the loop. The Z slider controls height above the floor (the box's bottom face, not its centre), so it's never possible to clip the box through the ground:
import time
import spatialgeometry as sg
from spatialmath import SE3
from swift import Swift, Slider
env = Swift()
env.launch(realtime=True)
SIDE = 0.2
box = sg.Cuboid([SIDE, SIDE, SIDE], pose=SE3(0, 0, SIDE / 2), color=[0.2, 0.4, 1.0, 1.0])
def box_pose(t, values):
# z is height above the floor, not the box centre
return SE3(values["x"], values["y"], values["z"] + SIDE / 2)
env.add_shape(box, callback=box_pose)
env.add_ui(Slider(lambda v: None, min=-0.5, max=0.5, step=0.01, value=0.0, desc="Box X", unit="m"), name="x")
env.add_ui(Slider(lambda v: None, min=-0.5, max=0.5, step=0.01, value=0.0, desc="Box Y", unit="m"), name="y")
env.add_ui(Slider(lambda v: None, min=0.0, max=0.6, step=0.01, value=0.0, desc="Box Z", unit="m"), name="z")
while True:
env.step(0.05)
time.sleep(0.05)Before reaching for a full robot model, it's worth seeing what one actually automates. add_assembly(fk, parts) takes a pure forward-kinematics function — given the assembly's current q, return one world pose per part — plus the parts themselves, and swift builds and owns the handle for you. Here two elongated cuboids stand in for the links of a 2-revolute-joint arm:
import time
import numpy as np
import spatialgeometry as sg
from spatialmath import SE3
from swift import Swift, Slider
env = Swift()
env.launch(realtime=True)
class TwoLinkArm:
"""A pure kinematic model: two links, two revolute joints about z."""
def __init__(self, L1=0.3, L2=0.25, thickness=0.03):
self.L1 = L1
self.L2 = L2
self.link1 = sg.Cuboid([L1, thickness, thickness], color=[0.8, 0.2, 0.2, 1.0])
self.link2 = sg.Cuboid([L2, thickness, thickness], color=[0.2, 0.4, 1.0, 1.0])
def part_poses(self, q) -> list[SE3]:
"""World pose of each link, purely as a function of q. Each
cuboid's local origin sits at its own proximal (joint) end, so
Tx(length / 2) places its centre correctly."""
joint1 = SE3.Rz(q[0])
joint2 = joint1 * SE3.Tx(self.L1) * SE3.Rz(q[1])
return [joint1 * SE3.Tx(self.L1 / 2), joint2 * SE3.Tx(self.L2 / 2)]
arm = TwoLinkArm()
handle = env.add_assembly(
arm.part_poses,
[arm.link1, arm.link2],
q0=[0.0, 0.0],
callback=lambda t, values: [values["q1"], values["q2"]],
)
env.add_ui(Slider(lambda v: None, min=-np.pi, max=np.pi, step=0.01, value=0.0, desc="Joint 1", unit="rad"), name="q1")
env.add_ui(Slider(lambda v: None, min=-np.pi, max=np.pi, step=0.01, value=0.0, desc="Joint 2", unit="rad"), name="q2")
while True:
env.step(0.05)
time.sleep(0.05)TwoLinkArm is the kinematic model — shareable, and it knows nothing about its own current configuration. env.add_assembly() builds the piece that does: an AssemblyHandle owning live q, exactly what add_robot() (below) returns for a real rtb.Robot — same class either way. The leap from here to a full robot model is then just: a kinematic model with more than two links, described by roboticstoolbox instead of by hand.
import roboticstoolbox as rtb
from swift import Swift
env = Swift()
env.launch(realtime=True)
panda = rtb.models.Panda()
handle = env.add_robot(panda)
handle.q = panda.qr
env.hold() # keep the browser tab openNot every scene needs sliders — a pose can just as easily be a function of time. Here a box orbits the origin on a circle of radius 3*W (W = box width), while the plane of that circle slowly tilts about the x-axis. Swift owns t and calls the callback each step, so there's no manual pose assignment in the loop:
import time
import spatialgeometry as sg
import spatialmath as sm
from swift import Swift
env = Swift()
env.launch(realtime=True)
W = 0.1
box = sg.Cuboid([W, W, W], color=[0.2, 0.4, 1.0, 1.0])
def orbit(t, values):
return sm.SE3.Rx(t / 10) * sm.SE3.Rz(t) * sm.SE3.Tx(3 * W)
env.add_shape(box, callback=orbit)
dt = 0.02
while True:
env.step(dt)
time.sleep(dt)Tx(3*W) places the box at the orbit radius; Rz(t) spins it around the circle; Rx(t/10), applied last (so in the world frame, after the orbit is computed), tilts the whole orbital plane about x — ten times slower than the orbit itself.
Worth knowing: the ground plane is solid and opaque, so once the tilt carries the box below z=0 it's genuinely hidden underneath the ground from a normal above-ground viewpoint — the same as burying a real box and looking down at the dirt, not a rendering glitch. This particular orbit has no z-offset, so it dips underground for part of every cycle by construction; add a z-offset to Tx/wrap it in a translation if you'd rather the whole orbit stayed above the floor.
The most complete example: a target box is positioned by three named sliders, and on every step the arm runs resolved-rate motion control (rtb.p_servo) towards wherever that box currently is — move a slider, the box moves, and the arm continuously chases it. Both the box's pose and the arm's q are computed by per-step callbacks reading from env.values, so the whole thing runs off a plain env.step() loop with no pose/q mutation inside it:
import time
import numpy as np
import roboticstoolbox as rtb
import spatialgeometry as sg
import spatialmath as sm
from swift import Swift, Slider
env = Swift()
env.launch(realtime=True)
dt = 0.05
panda = rtb.models.Panda()
handle = env.add_robot(panda)
handle.q = panda.qr
SIDE = 0.05
X0, Y0, Z0 = 0.5, 0.0, 0.3 # initial target position; z is height above the floor
# The gripper points straight down at the target
target_orientation = sm.SE3.Rx(np.pi)
target = sg.Cuboid(
[SIDE, SIDE, SIDE],
pose=sm.SE3(X0, Y0, Z0 + SIDE / 2) * target_orientation,
color=[0.2, 0.4, 1.0, 1.0],
)
def target_pose(t, values):
return sm.SE3(values["x"], values["y"], values["z"] + SIDE / 2) * target_orientation
env.add_shape(target, callback=target_pose)
def track_target(t, values):
v, _ = rtb.p_servo(panda.fkine(handle.q), target.T, gain=1.0, threshold=0.01)
qd = np.linalg.pinv(panda.jacobe(handle.q)) @ v
return handle.q + qd * dt
handle.callback = track_target
env.add_ui(Slider(lambda v: None, min=0.2, max=0.7, step=0.01, value=X0, desc="Target X", unit="m"), name="x")
env.add_ui(Slider(lambda v: None, min=-0.4, max=0.4, step=0.01, value=Y0, desc="Target Y", unit="m"), name="y")
env.add_ui(Slider(lambda v: None, min=0.05, max=0.6, step=0.01, value=Z0, desc="Target Z", unit="m"), name="z")
while True:
env.step(dt)
time.sleep(dt)To embed within a Jupyter Notebook cell, use the browser="notebook" option when launching the simulator — any of the examples above work the same way, just swap the env.launch(...) line:
env.launch(realtime=True, browser="notebook")Every non-headless session shows a small panel bottom-left of the browser view:
- a pause/play button (
||/▶) — also bound to the spacebar; - a realtime-speed selector (Max/1x/0.5x/0.25x) —
1xmatchesenv.launch(realtime=True),Maxmatches the default (realtime=False, uncapped).env.launch(realtime=0.5)(a specific float, not justTrue/False) sets an initial speed directly.
Any scene can be recorded straight from Python — call env.start_recording(...) around the part you want captured, env.stop_recording() when done. The .webm file downloads automatically once encoding finishes:
env.start_recording("my_recording", framerate=20, format="webm")
# ... normal env.step() loop, or any pose changes ...
env.stop_recording()format also accepts "png"/"jpg" (frame sequences). "gif" currently captures real frames but doesn't reliably trigger a download yet — use "webm" for now if you need the file to actually save.
Swift is designed to be controlled through the Robotics Toolbox for Python. By installing the toolbox through PyPI, swift is installed as a dependency
pip install roboticstoolbox-pythonOtherwise, Swift can be install by
pip install swift-simAvailable options are:
nbprovides the ability for Swift to be embedded within a Jupyter Notebook
Put the options in a comma-separated list like
pip install swift-sim[optionlist]Swift requires Python 3.10 or later.
To install the latest version from GitHub
git clone https://github.com/jhavl/swift.git
cd swift
pip install -e .