Interactive 3D visualisation and control for robots and scientific instruments. Config-driven — works with any device defined by a JSON config and GLB model. Includes forward and inverse kinematics, mesh import, collision detection, and a remote control API.
| Device | Config | Type | Description |
|---|---|---|---|
| Meca500 R3 | meca500_config.json |
6-DOF serial | Compact industrial manipulator |
| i16 Diffractometer | i16_config.json |
Branching (10 movable) | Diamond Light Source 6-circle diffractometer with merlin and crystal detectors |
| i19 Kappa Diffractometer | i19_config.json |
Branching | Diamond Light Source kappa diffractometer (2θ / θ / κ / φ chain) |
| Yaskawa GP225 | gp225_config.json |
6-DOF serial | Heavy-payload industrial robot |
| Yaskawa GP280 | gp280_config.json |
6-DOF serial | Heavy-payload industrial robot |
| Yaskawa GP180-120 | gp180_config.json |
6-DOF serial | Heavy-payload industrial robot |
| Yaskawa MotoMini | motomini_config.json |
6-DOF serial | Compact industrial robot |
| Hexapod | hexapod_config.json |
Stewart platform (6 legs) | 6-DOF parallel kinematic platform with Damped Track leg IK |
New devices can be added from Blender scenes using import_robot.py (serial robots) or import_hexapod.py (Stewart platforms). See Adding New Devices.
- Config-driven viewer — a single generic
threejs_scene.htmlviewer loads any device via JSON config - Multi-device scene — load multiple devices simultaneously from the add-device dropdown; click a device in the list or click its mesh to switch active device
- Device renaming — double-click a device name in the device list to rename it
- Device origin transform — move and rotate device origins with translate/rotate gizmo modes; World/Local space toggle for gizmo axis alignment; numeric X/Y/Z (mm) and Rx/Ry/Rz (deg) inputs for precise positioning, synced live with the gizmo
- Device parenting — parent a device to a link on another device so it follows the kinematic chain
- Auto-fit camera — camera automatically frames the loaded model on startup
- Forward Kinematics — joint angle sliders for all movable joints (fixed kinematic links are hidden)
- Inverse Kinematics — 6-DOF damped least-squares solver (position + ZYX Euler orientation)
- Branching kinematic chains — supports devices with multiple independent chains and sub-branches (e.g., i16 has gamma and mu chains with merlin/crystal sub-branches)
- Stewart platform / hexapod support — parallel kinematic platform with 6 two-bar linkage legs using Damped Track IK; platform pose control via translation (X/Y/Z mm) and rotation (Rx/Ry/Rz deg) sliders
- Hexapod FK solver — Newton-Raphson solver computes platform pose from 6 leg lengths; interactive leg length sliders drive the platform in real time with bidirectional sync to pose sliders
- Drag Platform mode — attach TransformControls gizmo directly to the hexapod platform for interactive 3D dragging with real-time leg tracking, slider sync, and limit clamping (keyboard: T for translate, R for rotate)
- Draggable IK target — move the green sphere with the gizmo or use XYZ / alpha-beta-gamma sliders
- Orientation gizmo — visual end-effector orientation indicator showing the current tool frame axes
- Double-click to type — double-click any slider value label to enter a number directly
- Mesh labels toggle — show/hide object name labels on all meshes
- Mesh import — load external STL, OBJ, PLY, and GLB/GLTF files into the scene with auto-scaling, labels, and per-object colour
- Primitive objects — add cube, sphere, and cylinder primitives directly from the toolbar
- Object duplication — duplicate any imported or primitive object with a single click
- Persistent objects — imported and primitive objects are automatically saved to IndexedDB and restored on page reload
- Object manipulation — click objects to select, then move, rotate, or scale with transform gizmos (keyboard: T/R/S, Escape to deselect); World/Local space toggle for gizmo axis alignment
- Parent-child linking — attach objects to device links so they follow the kinematic chain
- Self-collision detection — BVH-accelerated triangle-level intersection testing between device links, using kinematic adjacency to skip physically connected parts
- Collision detection — intersection testing between device links and imported scene objects, with red highlight on colliding meshes
- Screenshot — one-click PNG capture of the WebGL view composited with the control panel overlay
- Unified Euler convention — viewer, WebSocket state, and the Python
GNKinematicslibrary all report end-effector orientation as the same ZYX Euler triple (α=Rx, β=Ry, γ=Rz), with matching gimbal-lock branches - Remote control API — two-way WebSocket API for controlling any device from Python or any WebSocket client
- Session routing — each browser tab gets a unique session ID; controllers can target a specific tab or broadcast to all
- Connection info panel — click the API status indicator (top-left) to see the session ID, connection command, and download
RemoteAPI.zip(IPython client +GNKinematicslibrary + robot definitions)
Standalone (no remote control):
python3 -m http.server 8000Open http://localhost:8000/threejs_scene.html in a browser. Use the dropdown to switch devices, or specify a config: ?config=i16_config.json.
With remote control API:
pip3 install aiohttp websockets ipython
python3 server.py --config meca500_config.jsonOpen http://localhost:8080 in a browser. The status indicator in the top-left shows the WebSocket connection state and the session ID for this tab. Click it to open the connection info panel, which shows the full connection command, WebSocket URL, and a link to download RemoteAPI.zip — a self-contained bundle with robot_ipython.py, the GNKinematics Python library, and RobotDefinitions.py.
The import_robot.py script extracts bone transforms, rotation axes, mesh parenting, and joint limits from a Blender armature, then exports a config JSON and skin-free GLB.
- Armature: create an armature with one bone per joint/kinematic link. Bones should form a parent-child chain matching the kinematic chain.
- Mesh parenting: parent each mesh to its corresponding bone (select mesh, then bone in Pose Mode,
Ctrl+P→ Bone). - Rotation mode: set all pose bones to Quaternion rotation mode.
- Quaternion locks: for each bone, lock all quaternion components except the one that corresponds to the rotation axis:
- Lock W, Y, Z → free X (rotation about bone-local X)
- Lock W, X, Z → free Y (rotation about bone-local Y)
- Lock W, X, Y → free Z (rotation about bone-local Z)
- Lock all (W, X, Y, Z) → fixed joint (no rotation)
- Leave all unlocked → fixed joint (treated as structural)
- Joint limits: set IK limits on the free axis for each movable bone (
Bone Properties → Inverse Kinematics). If no IK limits are set, the importer defaults to [-180, 180]. - Render visibility: hide any helper meshes by disabling their render visibility (camera icon in outliner). These will be excluded from the GLB export.
In Blender's Script Editor (or Python console):
exec(open('/path/to/RobotVisualisation/import_robot.py').read())To override defaults, set variables before exec():
ARMATURE_NAME = 'MyArmature' # default: auto-detect first armature
DEVICE_NAME = 'My Robot' # default: armature name
CONFIG_FILE = 'my_robot_config.json' # default: derived from device name
GLB_FILE = 'my_robot_scene.glb' # default: derived from config name
exec(open('/path/to/RobotVisualisation/import_robot.py').read())The script:
- Finds the armature and extracts bone rest transforms (converted from Blender Z-up to Three.js Y-up)
- Determines rotation axes from quaternion locks
- Maps meshes to bones via parent chains
- Extracts IK joint limits
- Exports a skin-free GLB (temporarily unparents bone-parented meshes to avoid glTF skins)
- Generates a config JSON with joints, links, eeOffset, and eeAxes
- Registers the config in
js/panel.jsif not already present
Open http://localhost:8000/threejs_scene.html?config=my_robot_config.json and verify:
- All meshes load and appear correctly
- FK sliders move the correct joints
- Each joint rotates about the correct axis and in the correct direction
- Labels toggle shows correct mesh names
If rotation directions are wrong for specific joints, negate the axis array in the config JSON (e.g. [0, -1, 0] → [0, 1, 0]). If meshes are missing, check GLTFLoader name sanitization in the browser console — the importer sanitizes names (spaces → underscores, dots removed) but the GLB mesh names must match.
If the robot manufacturer's joint angle convention is opposite to the viewer's for specific joints, add "apiSign": -1 to those joints in the config. This flips the sign on the slider display and the WebSocket API without changing the physical rotation axis. For example, the Meca500 J4 has "apiSign": -1 because the manufacturer defines positive J4 in the opposite direction.
The import_hexapod.py script extracts pivot positions, mesh assignments, and platform limits from a Blender armature with Damped Track leg constraints.
- Armature: create an armature with bones for each leg segment plus a control bone for the platform.
- Lower leg bones (LL): one per leg, positioned at the base pivot. Each has a Damped Track constraint targeting its corresponding upper leg bone.
- Upper leg bones (UL): one per leg, positioned at the platform pivot. Each has a Damped Track constraint targeting its lower leg bone, plus a Child Of constraint targeting the control bone.
- Control bone: a single bone (e.g.,
ControlHandle) that the upper leg bones follow. Moving it moves the platform. - Mesh parenting: parent each lower leg mesh to its LL bone, each upper leg mesh to its UL bone, and the top plate mesh to the control bone. The base plate can be unparented.
- Platform limits (optional): add Limit Location and Limit Rotation constraints to the control bone to define the platform's travel range. The importer reads these and converts to mm/degrees. If no constraints are found, reasonable defaults are used.
exec(open('/path/to/RobotVisualisation/import_hexapod.py').read())To override defaults:
ARMATURE_NAME = 'MyHexapod'
CONTROL_BONE = 'ControlHandle'
DEVICE_NAME = 'Hexapod'
BASE_MESH = 'BasePlate'
PLATFORM_MESH = 'TopPlate'
exec(open('/path/to/RobotVisualisation/import_hexapod.py').read())The script:
- Finds the armature and auto-detects the hexapod structure from Damped Track + Child Of constraints
- Identifies lower/upper leg bone pairs and the control bone
- Extracts rest-pose pivot positions (converted from Blender Z-up to Three.js Y-up)
- Maps meshes to bones (legs, platform, base)
- Reads Limit Location / Limit Rotation constraints for platform limits
- Exports a skin-free GLB
- Generates a
type: "hexapod"config JSON - Registers the config in
js/panel.js
Open http://localhost:8000/threejs_scene.html?config=hexapod_config.json and verify:
- All meshes load and appear correctly
- Platform translation sliders move the platform
- Rotation sliders tilt the platform correctly
- Legs track correctly (no pass-through or separation)
- Demo pose button works
- FK solver (leg length sliders) converges
- Drag Platform mode works with TransformControls
If pivots are wrong, check bone head positions in Blender edit mode. If limits are wrong, add Limit Location / Limit Rotation constraints to the control bone, or edit the limits in the config JSON.
To enable IK via the Python GNKinematics library (used by robot_ipython.py), add a kinematics definition to RobotDefinitions.py:
from GNKinematics import kinematics
MyRobot_kin = kinematics.kinematics.from_home_positions(
v0=np.array([0, 0, 135.0]), # base to J2 (mm)
v1=np.array([0, 0, 270.0]), # J2 to J3
v2=np.array([60, 0, 308]), # J3 to J4
v3=np.array([120, 0, 308]), # J4 to J5
v4=np.array([190, 0, 308]), # J5 to J6 flange
motor_limits=np.array([[-175, 175], [-70, 90], [-135, 70],
[-170, 120], [-90, 115], [-360, 360]]),
centre_offset=[0, 0, 0],
tool_offset=[0, 0, 0],
strategy='minimum_movement',
weighting=[6, 5, 4, 3, 2, 1],
)The v0–v4 vectors are the joint positions at home (all joints at zero), in the robot's Z-up coordinate frame. motor_limits are per-joint angle limits in degrees.
Then add the new object to the IPython namespace in robot_ipython.py:
- Import it at the top:
from RobotDefinitions import ..., MyRobot_kin - Add it to the
user_nsdict passed toIPython.start_ipython():user_ns={ ... "MyRobot_kin": MyRobot_kin, }
{
"name": "Device Name",
"model": "device_scene.glb",
"joints": [
{
"name": "joint_name",
"bone": "blender_bone_name",
"restPos": [x, y, z],
"restQuat": [w, x, y, z],
"axis": [x, y, z],
"limits": [-180, 180],
"parent": 0,
"fixed": false,
"apiSign": 1
}
],
"links": [
{ "name": "mesh_name", "label": "Display Name", "joint": 0 }
],
"eeOffset": [0, 0, -0.05],
"eeAxes": [[0, 0, -1], [0, -1, 0], [1, 0, 0]],
"demoPose": [0, 45, -90]
}Key fields:
- joints: one entry per bone —
restPos/restQuatfrom Blender (C matrix converted),axisfrom quaternion lock analysis,parentindex (-1 for roots),fixedfor non-movable kinematic links, optionalapiSign(-1 to flip slider/API convention) - links: maps GLB mesh names (sanitized: spaces→underscores, dots removed) to joint indices
- eeOffset: displacement from last joint to end-effector point (derived from last bone length)
- eeAxes: end-effector crosshair axes — use
[[0,0,-1],[0,-1,0],[1,0,0]]for all robots - demoPose: joint angles in degrees for the demo button (one per joint, fixed joints = 0)
{
"name": "Hexapod",
"type": "hexapod",
"model": "hexapod_scene.glb",
"platform": {
"mesh": "TopPlate",
"restPosition": [0.0, 0.2, 0.0]
},
"base": {
"mesh": "BasePlate"
},
"legs": [
{
"basePivot": [0.125, 0.014, 0.029],
"platformPivotLocal": [0.058, -0.013, 0.068],
"lowerMesh": "LowerLeg_1",
"upperMesh": "UpperLeg_1"
}
],
"limits": {
"x": [-30, 30], "y": [-30, 30], "z": [-20, 20],
"rx": [-11, 11], "ry": [-11, 11], "rz": [-20, 20]
},
"demoPose": [0, 0, 5, 5, 0, 10]
}Key fields:
- type: must be
"hexapod"to trigger the parallel kinematics loader - platform.mesh: GLB mesh name for the top plate (reparented to the platform group)
- platform.restPosition: platform centre in Three.js Y-up coordinates (metres) at home pose
- base.mesh: GLB mesh name for the base plate
- legs: array of 6 leg entries —
basePivotis the lower pivot in world coordinates (metres),platformPivotLocalis the upper pivot relative to the platform rest position - limits: platform travel limits — translation in mm (
x,y,z) and rotation in degrees (rx,ry,rz) - demoPose:
[x, y, z, rx, ry, rz]for the demo button (mm and degrees)
Click Import Mesh to load files into the scene. Supported formats:
| Format | Extension | Notes |
|---|---|---|
| STL | .stl |
Binary or ASCII; auto-scaled if bounding box > 1 m |
| OBJ | .obj |
Geometry only; no MTL material files |
| PLY | .ply |
Binary and ASCII; vertex colours preserved if present |
| GLB / GLTF | .glb, .gltf |
Full scene hierarchy, materials, and textures |
Click Cube, Sphere, or Cylinder to add a primitive shape. Primitives behave identically to imported objects — they can be moved, coloured, parented, and are persisted across reloads.
| Key | Mode |
|---|---|
T |
Move (translate) |
R |
Rotate |
S |
Scale |
Esc |
Deselect |
Both device and object gizmos have a World/Local toggle button. In World mode the gizmo axes align with the scene axes; in Local mode they align with the object's own axes. The toggle resets to World when the gizmo is deactivated.
Use the Parent dropdown to attach objects to device links. Parented objects follow the kinematic chain. Local transforms are preserved when reparenting.
Toggle Collision: ON/OFF in the panel. The viewer tests for triangle-level intersections using:
- Broad phase — AABB check to eliminate distant pairs
- Narrow phase — BVH-accelerated triangle-triangle intersection via three-mesh-bvh
Self-collision between device links uses kinematic adjacency analysis — links sharing the same joint or connected through a parent-child relationship are skipped. Links on separate branches (e.g., gamma chain vs mu chain on the i16) are always checked.
By default the checks are driven from the animation loop: they run on every 6th drawn frame, so the rate is capped at about a sixth of the display refresh — and because the viewer only draws on demand, a pose change that triggers just one or two frames can be missed entirely.
Toggle Headless Collision: ON/OFF (or {"cmd": "setCollisionHeadless", "enabled": true} / robot.collision_headless(True)) to take the checks off the render loop. The next pass then starts as soon as the previous result lands, so throughput is bound by the collision computation rather than by vsync, and checks continue while the tab is in the background, where requestAnimationFrame is suspended.
- The achieved rate is shown next to the collision readout (
Collisions: none · 240 Hz). - A pass is skipped when nothing has moved — a fingerprint of every participating mesh's world matrix is compared first — so a static scene reads
idleinstead of burning a core. - Highlights and the info panel are only rewritten when the set of colliding pairs actually changes.
- It samples the current pose; if a device is driven faster than a pass completes, intermediate poses are still skipped.
The WebSocket API at ws://localhost:8080/ws allows any client to control devices, manage multi-device scenes, manipulate objects, and control the camera in real time. All commands target the active device by default; include "device": "<name>" to target a specific device.
Each browser tab that connects to the viewer is assigned a unique session ID (an 8-character hex string, e.g. ab12cd34). The ID is shown in the status bar (API: connected [ab12cd34]) and in the connection info panel.
Controllers can target a specific viewer tab or broadcast to all:
| Connection | Routes to |
|---|---|
ws://localhost:8080/ws |
All connected viewer tabs |
ws://localhost:8080/ws?session=ab12cd34 |
Only the tab with that session ID |
Active sessions can be listed via the HTTP endpoint:
GET http://localhost:8080/sessions
→ [{"id": "ab12cd34", "viewers": 1}, ...]
python3 robot_ipython.py --config meca500_config.json # broadcast to all tabs
python3 robot_ipython.py --config meca500_config.json --session ab12cd34 # target a specific tab
python3 robot_ipython.py --config i16_config.json --session ab12cd34 # i16 diffractometer
python3 robot_ipython.py --url ws://192.168.1.100:8080/ws --session ab12cd34 # remote serverThe client launches an IPython terminal with a pre-connected robot object. It supports two syntaxes — Python method calls for full programmatic control, and space-separated commands (via IPython magics) for quick interactive use. The prompt, help text, and tab completion adapt to the loaded device. The robot_ipython.py file can also be downloaded from the connection info panel in the viewer.
Both syntaxes work side by side:
meca500 [1]: home # space-separated
meca500 [2]: robot.home() # Python method
meca500 [3]: joints 0 30 60 0 45 90 # space-separated
meca500 [4]: robot.joints(0, 30, 60, 0, 45, 90) # Python method
meca500 [5]: for a in range(0, 91, 10): # full Python syntax
...: robot.joint('J1', a)
...: time.sleep(0.1)Position queries (return values for programmatic use):
| Property / Method | Example | Description |
|---|---|---|
robot.pos |
x, y, z = robot.pos |
End-effector position [x, y, z] mm |
robot.ori |
robot.ori |
End-effector orientation [a, b, g] degrees |
robot.angles |
robot.angles |
All joint angles (list) |
robot.get_joint('J1') |
a = robot.get_joint('J1') |
Single joint angle by name |
robot.mode |
robot.mode |
Current mode ('FK' or 'IK') |
robot.get_device_pos('GP225') |
d = robot.get_device_pos() |
Any device: pos, rot, joints, EE |
robot.get_obj_pos('cube_1') |
o = robot.get_obj_pos(0) |
Any object: pos, rot, scale, BB |
robot.platform_pose |
robot.platform_pose |
Hexapod platform pose [x,y,z,rx,ry,rz] |
robot.leg_lengths |
robot.leg_lengths |
Hexapod leg lengths [l1..l6] mm |
Device commands:
| Space-separated | Python | Description |
|---|---|---|
state |
robot.state() |
Request current device state |
devices |
robot.devices() |
List all loaded devices |
device i16 |
robot.device('i16') |
Switch active device by name |
sessions |
robot.sessions() |
List viewer session IDs |
home |
robot.home() |
All joints to 0 |
fk |
robot.fk() |
Switch to FK mode |
ik |
robot.ik() |
Switch to IK mode |
joints 45 -90 0 0 30 0 |
robot.joints(45, -90, 0, 0, 30, 0) |
Set all movable joint angles (degrees) |
joint gamma 45 |
robot.joint('gamma', 45) |
Set a single joint by name |
pos meca500 [0,0,0,0,0,0] |
robot.set_pos('meca500', [0,0,0,0,0,0]) |
Set joints on a named device (accepts list, numpy array, or callable) |
pos meca500 {2: 45} |
robot.set_pos('meca500', {2: 45}) |
Set individual axes only (key = joint index or name) |
pos i19 {'v:chi': 45} |
robot.set_pos('i19', {'v:chi': 45}) |
Set kappa virtual angle(s) — keys v:chi, v:theta, v:phi |
inc meca500 [0,0,0,0,0,10] |
robot.inc_pos('meca500', [0,0,0,0,0,10]) |
Increment joints on a named device relative to current |
inc meca500 {5: 10} |
robot.inc_pos('meca500', {5: 10}) |
Increment individual axes only (key = joint index or name) |
inc i19 {'v:chi': 5} |
robot.inc_pos('i19', {'v:chi': 5}) |
Increment kappa virtual angle(s) |
move 150 100 300 45 0 0 |
robot.move(150, 100, 300, 45, 0, 0) |
IK move to position (mm) + orientation (deg) |
target 190 0 308 |
robot.target(190, 0, 308) |
Set IK target without switching mode |
demo |
robot.demo() |
Run the config's demo pose |
Device transform commands:
| Python | Description |
|---|---|
robot.devpose([x,y,z], [rx,ry,rz]) |
Set device origin position (mm) and/or rotation (deg); also accepts a single 6-element list |
robot.devtranslate(dx, dy, dz, space='parent') |
Translate device origin by delta (mm) in parent/local/world frame |
robot.devrotate(rx, ry, rz, space='parent') |
Rotate device origin by delta (deg) in parent/local/world frame |
devpose sets the absolute position and/or rotation of the device origin in its parent frame (the same values shown in the numeric inputs when Move Device Origin is active). Both arguments are optional — pass only position or only rotation to change one without affecting the other.
r.devpose([100, 0, 0]) # position only
r.devpose([100, 0, 0], [0, 0, 90]) # position + rotation
r.devpose([100, 0, 0, 0, 0, 90]) # full pose as one list
r.devpose(rotation=[0, 0, 90]) # rotation only
r.devpose([0, 0, 0], device='GP225') # specific deviceHexapod (Stewart platform) commands:
| Property / Method | Description |
|---|---|
robot.platform([x,y,z,rx,ry,rz]) |
Set platform pose (mm, degrees) |
robot.platform_pose |
Current platform pose [x, y, z, rx, ry, rz] |
robot.leg_lengths |
Current leg lengths [l1..l6] in mm |
robot.hexapod_fk([pose]) |
FK: pose → leg lengths (query only). Omit pose to use current |
robot.hexapod_ik([l1..l6]) |
IK: leg lengths (mm) → platform pose (query only) |
robot.get_leg_lengths() |
Get current leg lengths with per-leg detail |
robot.set_leg_lengths(l1..l6) |
Set platform pose by specifying desired leg lengths (mm) |
hexapod [1]: robot.platform([0, 0, 10, 0, 0, 0]) # move platform up 10mm
hexapod [2]: robot.platform_pose # read back current pose
hexapod [3]: robot.leg_lengths # read current leg lengths
hexapod [4]: result = robot.hexapod_fk([5, 0, 10, 2, 0, 0]) # compute leg lengths for a pose
hexapod [5]: result = robot.hexapod_ik([150, 150, 150, 150, 150, 150]) # solve pose from leg lengths
hexapod [6]: robot.set_leg_lengths([152, 148, 150, 150, 151, 149]) # drive platform via leg lengthsCoordinate transform:
| Python | Description |
|---|---|
robot.worldToLocal([x,y,z,rx,ry,rz]) |
Transform a world-frame pose into the active device's local frame |
robot.worldToLocal([x,y,z], [rx,ry,rz]) |
Same, with separate position and orientation arguments |
robot.worldToLocal(pos, ori, device='GP225') |
Transform relative to a specific device |
worldToLocal converts a world-frame pose (position in mm, orientation as XYZ intrinsic Euler angles in degrees) into the coordinate frame of a device's origin. This is useful when a robot is mounted at an arbitrary position/rotation and you need to express a world target in the robot's own coordinate system — for example, to feed into an IK solver that expects local coordinates.
The orientation convention is R = Rx(α)·Ry(β)·Rz(γ) in a right-handed Z-up frame (X right, Y into scene, Z up). Note that Y points into the scene, which is the negation of the viewer's Y readback from dev_pose().
When called with a 6-element list, returns a 6-element list [x, y, z, rx, ry, rz]. When called with separate position and orientation arguments, returns a (position, orientation) tuple.
# Robot mounted at world position [500, 200, 0] with 45° rotation
meca500 [1]: robot.worldToLocal([600, 200, 300, 0, 0, 0])
# → [70.71, 70.71, 300.0, 0.0, 0.0, -45.0] (position and orientation in robot's local frame)
meca500 [2]: p, o = robot.worldToLocal([600, 200, 300], [0, 0, 0])
# p = [70.71, 70.71, 300.0], o = [0.0, 0.0, -45.0]
meca500 [3]: robot.worldToLocal([600, 200, 300], [0, 0, 0], device='GP225')
# Transform relative to a different deviceMotion planning commands:
| Space-separated | Python | Description |
|---|---|---|
plan --start 0 0 0 --end 45 90 0 |
robot.plan([0,0,0], [45,90,0]) |
Path plan between two poses |
plan --start mu=0 --end mu=45 |
robot.plan({'mu': 0}, {'mu': 45}) |
Named axes |
scan theta 0 90 5 |
robot.scan(('theta', 0, 90, 5)) |
1D scan |
scan theta 0 90 5 phi 0 30 2 |
robot.scan(('theta',0,90,5), ('phi',0,30,2)) |
2D grid scan |
scan theta 0 90 5 phi 0 1 |
robot.scan(('theta',0,90,5), ('phi',0,1)) |
Coupled scan |
scan DevA:J1 0 50 5 DevB:J1 0 30 5 |
robot.scan(('DevA:J1',0,50,5), ('DevB:J1',0,30,5)) |
Multi-device scan |
scan v:chi 0 90 5 |
robot.scan(('v:chi', 0, 90, 5)) |
Kappa virtual-axis scan (chi/theta/phi) |
scan v:chi 0 45 5 v:phi 0 30 5 |
robot.scan(('v:chi',0,45,5), ('v:phi',0,30,5)) |
Virtual-axis grid/coupled scan |
scan ee:x 150 250 10 |
robot.scan(('ee:x', 150, 250, 10)) |
Cartesian end-effector scan (Python IK) |
scan ee:x 150 250 5 ee:y -50 50 5 |
robot.scan(('ee:x',150,250,5), ('ee:y',-50,50,5)) |
Cartesian grid/coupled scan |
scan ee:z 200 400 10 --space world |
robot.scan(('ee:z',200,400,10), space='world') |
Cartesian scan in world frame |
scan ee:x ee:y ee:z waypoints |
robot.scan('ee:x','ee:y','ee:z', waypoints) |
Cartesian array scan (rows = poses) |
scan GP180_120:ee:z 200 400 10 |
robot.scan(('GP180_120:ee:z', 200, 400, 10)) |
Cartesian scan on a named device |
scan GP180_120:ee:z 200 400 10 Meca500:ee:x 150 250 10 |
robot.scan(('GP180_120:ee:z',200,400,10), ('Meca500:ee:x',150,250,10)) |
Multi-device Cartesian scan |
scan GP180_120:ee:y 354 400 10 I16_diff:delta 0 120 10 |
robot.scan(('GP180_120:ee:y',354,400,10), ('I16_diff:delta',0,120,10)) |
Mixed Cartesian + joint scan |
scan GP180_120 scanpoints |
robot.scan('GP180_120', scanpoints) |
Full-vector array scan (device name expands to all joints) |
scan GP180_120 Meca500 combined_pts |
robot.scan('GP180_120', 'Meca500', combined_pts) |
Multi-device vector scan (cols = joints of each device) |
scan robot1 robot2 my_func() |
robot.scan('robot1', 'robot2', my_func) |
Multi-device vector scan with callable |
| — | robot.scan(('@Cube:tx', 0, 100, 10)) |
Object translation scan |
| — | robot.scan(('@Cube:rz', 0, 360, 10)) |
Object rotation scan |
| — | robot.scan(('@Cube:tx',0,100,10), space='world') |
Object scan in world coords |
| — | robot.scan(('J1',0,90,5), ('@Cube:tz',0,50,5)) |
Mixed joint + object scan |
Object scan axes use @ObjectName:component syntax where component is tx, ty, tz, rx, ry, or rz. The space parameter ('local' or 'world') controls the coordinate frame for object transforms (default: 'local').
Kappa virtual axes use a v: prefix (v:chi, v:theta, v:phi) to disambiguate from the physical theta/phi joints. Virtual scans target the active kappa device and cannot be mixed with physical-joint axes in the same scan. The same v:<axis> keys work as dict keys in robot.set_pos / robot.inc_pos.
Cartesian end-effector axes use an ee: prefix — x, y, z (mm) and a, b, g (ZYX Euler degrees). Each target pose is solved to joint angles by the analytical Python IK (GNKinematics) and streamed as joint waypoints, so the on-screen pose matches the analytical solution. Start/end/step are absolute coordinates and unlisted axes hold their current value. --space/space= selects the frame: local (default, robot base frame) or world (converted per waypoint via worldToLocal, so the end-effector tracks world axes regardless of how the device is mounted). Supported on Meca500, GP180_120, GP225, GP280 and MotoMini; cannot be mixed with joint, virtual, or object axes in the same scan.
By default an ee: scan targets the active device. Prefix the axis with a device name — Device:ee:<axis> (e.g. GP180_120:ee:z) — to target a specific device, or list several to scan multiple arms in one command. Each device is solved with its own IK and base pose, and the per-step joint solutions are streamed together (grid axes form a product across devices, coupled axes lock-step with the primary), mirroring multi-device joint scans. Device-prefixed axes are supported in the range form only; for an array scan, switch to the device first with robot.device('Name').
Cartesian ee: axes can be combined with ordinary joint axes on other devices in the same scan (e.g. scan GP180_120:ee:y 354 400 10 I16_diff:delta 0 120 10) — the Cartesian device is IK-solved while the joint axis is set directly, and all devices step together. A single device cannot mix ee: and joint axes, and ee: cannot be combined with object (@) or virtual (v:) axes.
Object commands:
| Space-separated | Python | Description |
|---|---|---|
objects |
robot.objects() |
List all imported objects |
obj MyPart |
robot.obj('MyPart') |
Get object details |
objpos MyPart 100 50 0 |
robot.objpos('MyPart', 100, 50, 0) |
Set object position (mm) |
objrot #0 0 0 45 |
robot.objrot('#0', 0, 0, 45) |
Set object rotation (degrees) |
objscale MyPart 2 |
robot.objscale('MyPart', 2) |
Set uniform scale |
objvis MyPart on |
robot.objvis('MyPart', True) |
Show/hide object |
collision on |
robot.collision(True) |
Enable/disable collision detection |
collision headless on |
robot.collision_headless(True) |
Run checks off the render loop (uncapped rate) |
collisions |
robot.collisions() |
Get current collision pairs |
Object transforms accept a space parameter ('parent', 'local', or 'world'):
| Python | Description |
|---|---|
robot.objtranslate('Cube', dx, dy, dz, space='parent') |
Translate object by delta (mm) |
robot.objrotate('Cube', rx, ry, rz, space='parent') |
Rotate object by delta (deg) |
Visualization and camera:
| Python | Description |
|---|---|
robot.labels() / robot.labels(False) |
Show/hide joint labels |
robot.origins() / robot.origins(False) |
Show/hide joint origin axes |
robot.chain() / robot.chain(False) |
Show/hide kinematic chain |
robot.ortho() / robot.ortho(False) |
Orthographic/perspective camera |
robot.camera(position=[500,500,500]) |
Set camera position/target |
robot.snap('iso') |
Snap to preset view |
robot.virtual_angles(chi=45) |
Set kappa virtual angles (diffractometers) |
All positions are in mm, angles in degrees, using Z-up robot convention. Most commands accept an optional "device" field to target a specific device by name or ID; if omitted, the active device is used.
Device management:
{"cmd": "getState"}
{"cmd": "listDevices"}
{"cmd": "getDevice"}
{"cmd": "addDevice", "config": "i16_config.json"}
{"cmd": "removeDevice", "device": "Meca500"}
{"cmd": "renameDevice", "name": "MyRobot"}
{"cmd": "setActiveDevice", "device": "i16"}
{"cmd": "setDeviceOrigin", "position": [100, 0, 0], "rotation": [0, 0, 45]}
{"cmd": "translateDevice", "delta": [10, 0, 0], "space": "parent"}
{"cmd": "rotateDevice", "delta": [0, 0, 45], "space": "local"}
{"cmd": "setDeviceParent", "parent": "dev_0:L3"}
{"cmd": "listConfigs"}Joint control:
{"cmd": "setJoints", "angles": [0, -30, 60, 0, 45, 90]}
{"cmd": "setSingleJoint", "index": 1, "angle": -30}
{"cmd": "home"}
{"cmd": "demoPose"}Hexapod platform control:
{"cmd": "setPlatformPose", "pose": [0, 0, 5, 5, 0, 10]}
{"cmd": "hexapodFK", "pose": [0, 0, 5, 5, 0, 10]}
{"cmd": "hexapodIK", "legLengths": [150.1, 150.1, 150.1, 150.1, 150.1, 150.1]}
{"cmd": "getLegLengths"}
{"cmd": "setLegLengths", "legLengths": [150.1, 150.1, 150.1, 150.1, 150.1, 150.1]}setPlatformPose— set the platform pose[x, y, z, rx, ry, rz](mm, degrees) and update the visualisationhexapodFK— forward kinematics: compute leg lengths from a pose (query only, does not move the platform). Ifposeis omitted, uses the current platform posehexapodIK— inverse kinematics: compute the platform pose from 6 leg lengths in mm (query only)getLegLengths— return the current leg lengths for the current platform posesetLegLengths— solve IK for the given leg lengths and apply the resulting pose
The getState response for hexapod devices includes platformPose, legLengths, and platformPosition instead of joints.
Kappa virtual angles (diffractometer geometry):
{"cmd": "setVirtualAngles", "chi": 45, "theta": 10, "phi": 20}
{"cmd": "getVirtualAngles"}
{"cmd": "setKappaSign", "positive": true}IK control:
{"cmd": "setMode", "mode": "IK"}
{"cmd": "setIKTarget", "position": [190, 0, 308], "orientation": [0, 0, 0]}
{"cmd": "moveTo", "position": [150, 100, 300], "orientation": [45, 0, 0]}Object commands:
{"cmd": "listObjects"}
{"cmd": "getObject", "object": "MyPart"}
{"cmd": "setObject", "object": "MyPart", "position": [100, 50, 0], "rotation": [0, 0, 45]}
{"cmd": "setObject", "object": "MyPart", "position": [100, 50, 0], "space": "world"}
{"cmd": "setObject", "object": "MyPart", "color": "#ff0000", "parent": "dev_0:L3"}
{"cmd": "translateObject", "name": "MyPart", "delta": [10, 0, 0], "space": "parent"}
{"cmd": "rotateObject", "name": "MyPart", "delta": [0, 0, 45], "space": "local"}
{"cmd": "addPrimitive", "type": "cube"}
{"cmd": "removeObject", "object": "MyPart"}
{"cmd": "duplicateObject", "object": "MyPart"}
{"cmd": "resetObjectRotation", "object": "MyPart"}
{"cmd": "resetObjectScale", "index": 0}setObject, translateObject, and rotateObject accept "space": "parent"|"local"|"world" (default: "parent"). The getObject response includes both local (position, rotation) and world-frame (worldPosition, worldRotation) coordinates. translateDevice and rotateDevice use the same space convention.
Collision commands:
{"cmd": "setCollision", "enabled": true}
{"cmd": "setCollisionHeadless", "enabled": true}
{"cmd": "getCollisions"}setCollisionHeadless decouples the checks from the render loop so their rate is not capped by the display refresh — see Headless Mode.
Visualization toggles:
{"cmd": "setLabels", "enabled": true}
{"cmd": "setOrigins", "enabled": true}
{"cmd": "setChain", "enabled": true}
{"cmd": "setOrtho", "enabled": true}Camera control:
{"cmd": "getCamera"}
{"cmd": "setCamera", "position": [500, 500, 500], "target": [0, 0, 150]}
{"cmd": "snapCamera", "view": "front"}Snap views: +X, -X, +Y, -Y, +Z, -Z, top, bottom, front, back, left, right, iso.
Scene persistence:
{"cmd": "getSceneState"}
{"cmd": "saveScene"}
{"cmd": "help"}State response (serial robot):
{
"type": "state",
"device": "Meca500",
"joints": [0, -30, 60, 0, 45, 90],
"eePosition": [190.0, 0.0, 308.0],
"eeOrientation": [0.0, 0.0, 0.0],
"mode": "FK",
"ikError": null,
"collisionEnabled": true,
"collision": false,
"collisions": [],
"chi": 45.0
}The chi field is only present for kappa-geometry devices (diffractometers).
State response (hexapod):
{
"type": "state",
"device": "Hexapod",
"deviceType": "hexapod",
"platformPose": [0, 0, 5, 5, 0, 10],
"legLengths": [150.12, 149.88, 150.34, 149.66, 150.21, 149.79],
"platformPosition": [0.0, 0.0, 205.0],
"collisionEnabled": false,
"collision": false,
"collisions": []
}Hexapod FK response:
{
"type": "hexapodFK",
"device": "Hexapod",
"pose": [0, 0, 5, 5, 0, 10],
"legLengths": [150.12, 149.88, 150.34, 149.66, 150.21, 149.79]
}Hexapod IK response:
{
"type": "hexapodIK",
"device": "Hexapod",
"pose": [0.0, 0.0, 5.0, 5.0, 0.0, 10.0],
"legLengths": [150.12, 149.88, 150.34, 149.66, 150.21, 149.79]
}import asyncio, json, websockets
async def main():
# Target a specific viewer tab by session ID (omit ?session=... to broadcast to all)
async with websockets.connect("ws://localhost:8080/ws?session=ab12cd34") as ws:
# Set joint angles
await ws.send(json.dumps({"cmd": "setJoints", "angles": [0, -30, 60, 0, 45, 90]}))
state = json.loads(await ws.recv())
print(state["eePosition"])
# Set a single joint by index
await ws.send(json.dumps({"cmd": "setSingleJoint", "index": 1, "angle": -45}))
# Multi-device: add a second device and position it
await ws.send(json.dumps({"cmd": "addDevice", "config": "i16_config.json"}))
await ws.recv()
await ws.send(json.dumps({"cmd": "setDeviceOrigin", "device": "i16", "position": [500, 0, 0]}))
# Camera: snap to front view
await ws.send(json.dumps({"cmd": "snapCamera", "view": "front"}))
# Kappa virtual angles (diffractometer only)
await ws.send(json.dumps({"cmd": "setActiveDevice", "device": "i16"}))
await ws.recv()
await ws.send(json.dumps({"cmd": "setVirtualAngles", "chi": 45, "theta": 10}))
# Translate a device in world coordinates
await ws.send(json.dumps({"cmd": "translateDevice", "delta": [100, 0, 0], "space": "world"}))
# Move an object in world space
await ws.send(json.dumps({"cmd": "translateObject", "name": "Cube", "delta": [50, 0, 0], "space": "world"}))
# Enable collision detection
await ws.send(json.dumps({"cmd": "setCollision", "enabled": True}))
asyncio.run(main())docker build -t robot-visualisation .
docker run -p 8080:8080 robot-visualisationhelm install robot-vis ./helmSee helm/values.yaml for configuration.
threejs_scene.html HTML shell — loads viewer.css and js/main.js
viewer.css All viewer styles
js/
main.js Entry point — animate loop, event handlers, initialisation
state.js Shared mutable state (scene, cameras, controls, devices)
scene.js Three.js scene setup, cameras, lights, ground, nav gizmo
device.js Device loading, GLB import, slider/IK sync
hexapod.js Hexapod loader, Damped Track IK, FK solver, platform sync
kinematics.js FK, IK solver, kappa geometry math
panel.js Control panel UI, device list, parent dropdowns
stl.js Mesh import/export, primitives, duplication, IndexedDB persistence
collision.js BVH-accelerated collision detection (Web Worker + main-thread fallback)
collision-worker.js Background thread for collision math
websocket.js WebSocket client for remote control API
import_robot.py Blender import script — extracts serial robot armature to config JSON + GLB
import_hexapod.py Blender import script — extracts hexapod (Damped Track legs) to config JSON + GLB
server.py WebSocket + HTTP server for remote control API
robot_ipython.py IPython remote control client (any device)
GNKinematics/ Python forward/inverse kinematics library (matches viewer's ZYX Euler)
RobotDefinitions.py Robot DH / geometry parameters for GNKinematics
RemoteAPI.zip Bundled client (ipython client + GNKinematics + RobotDefinitions); served from viewer
meca500_config.json Meca500 R3 device config
i16_config.json i16 diffractometer device config
i19_config.json i19 kappa diffractometer device config
gp225_config.json Yaskawa GP225 device config
gp280_config.json Yaskawa GP280 device config
gp180_config.json Yaskawa GP180-120 device config
robot_scene.glb Meca500 GLB model
i16_scene.glb i16 diffractometer GLB model
i19_scene.glb i19 kappa diffractometer GLB model
gp225_scene.glb Yaskawa GP225 GLB model
gp280_scene.glb Yaskawa GP280 GLB model
gp180_scene.glb Yaskawa GP180-120 GLB model
hexapod_config.json Hexapod Stewart platform device config
hexapod_scene.glb Hexapod GLB model
Dockerfile Multi-stage container build
helm/ Kubernetes Helm chart
The viewer uses a 6×N geometric Jacobian with damped least-squares (DLS):
- Position error: difference between target and end-effector world position
- Orientation error: rotation vector from quaternion error (target × current⁻¹)
- Convention: ZYX Euler angles (alpha=Rz, beta=Ry, gamma=Rx)
- Orientation is weighted at 0.3× relative to position to prioritise reach accuracy
- For N < 6 joints: underdetermined for full 6-DOF; for N > 6: redundancy handled naturally by DLS
The hexapod uses two kinematics approaches:
Leg IK (Damped Track): given the platform pose [x, y, z, rx, ry, rz], each leg's lower and upper segments are oriented to track toward their respective pivot points using Quaternion.setFromUnitVectors() — the Three.js equivalent of Blender's Damped Track constraint. This runs every frame and is the primary visual update.
Platform FK (Newton-Raphson): given 6 target leg lengths, solves for the platform pose that produces those lengths. Uses a numerical 6×6 Jacobian (central finite differences) and Gaussian elimination with partial pivoting. Convergence is typically 3–5 iterations from a nearby starting pose. The solver clamps the result to the config's platform limits. This drives the leg length sliders in the UI — dragging a leg length slider triggers FK, which updates the pose and all other sliders bidirectionally.