Lightweight Three.js viewer controlled from Python via WebSocket.
A Python client runs a WebSocket server that a browser-based Three.js viewer connects to. Designed for robotics visualization, scientific computing, and interactive 3D exploration.
- Simple API: Add primitives, load models, update transforms
- PBR materials: Roughness, metalness, transparency and environment reflections on all objects
- GLB/PBR support: Load GLB models with PBR materials, embedded skeletal/morph animations
- Animation support: Pre-compute animations, scrub timeline, adjust playback speed
- Binary channels: Efficient transfer of large animations (100k+ objects × frames)
- Toolpath visualization: Extruded bead tubes with per-point colors, draw_range animation, and automatic LOD — handles 1M+ point toolpaths at 60 fps with smooth camera-distance-adaptive simplification via Web Worker
- 5-axis swept tool body:
add_swept_toollofts an oriented shank/holder profile about the per-station tool axis (decoupled from the path tangent) into a swept surface — visualizes 5-axis reorientation rate and tool-body collisions, with per-station heatmap colours and draw_range reveal - GPU point clouds:
add_pointsrenders millions of unconnected points (voxel fields, metrology/deviation clouds, LiDAR scans) in a singleTHREE.Pointsdraw call, with per-point colormap colours, distance size-attenuation, and draw_range reveal - Per-point time windows: give each point a
[birth, removal)lifetime and scrub a global time — points appear/disappear out of buffer order in the vertex shader (material-removal animations that a prefix reveal can't express), driven live or from the animation slider - Octree LOD streaming:
add_points(lod=True)builds a Potree-style sampled octree in Python and streams node payloads on demand — draw a ~1.5M-point budget of the biggest-on-screen nodes out of clouds far larger than one draw call, refining as you zoom; composes with the time windows at every LOD - Auto-reconnect: Browser reconnects automatically, animations persist
- Z-up coordinates: Robotics convention (matches ROS, URDF)
- No build step: Self-contained HTML viewer, just open in browser
pip install threejs-viewerfrom threejs_viewer import viewer
# Start server and wait for browser to connect
v = viewer()
# Add objects
v.add_sphere("ball", radius=0.3, color=0xFF0000, position=[0, 0, 0.5])
v.add_box("ground", width=5, height=5, depth=0.1, color=0x444444)
# Keep running
input("Press Enter to exit")The viewer opens automatically in your default browser. To open it manually:
threejs-viewer open
# Or: threejs-viewer path (prints path to viewer.html)from threejs_viewer import Toolpath, viewer
# Primitives with PBR materials
v.add_box("box1", width=1, height=2, depth=0.5, color=0x4A90D9, roughness=0.5, metalness=0.1)
v.add_sphere("ball", radius=0.5, position=[2, 0, 0], roughness=0.3, metalness=0.7)
v.add_cylinder("cyl1", radius_top=0.3, radius_bottom=0.5, height=1)
# 3D models (binary transfer)
v.add_model_binary("robot", "robot.stl", format="stl")
# Polylines with colormaps
v.add_polyline("path", points, colors=z_values, colormap="viridis", line_width=3)
# Bead toolpath (parametric tube, built client-side)
tp = Toolpath.from_points(points, bead_width=0.3, bead_height=0.08)
tp.colorize(per_point_rgb)
v.add_toolpath("bead", tp)
# Transparency
v.set_opacity("box1", 0.5)
v.set_color("ball", 0xFF0000, opacity=0.3)# Single object
v.set_matrix("box1", matrix_4x4.flatten().tolist())
# Batch update (efficient for 60fps)
v.batch_update({
"link1": {"position": [1, 2, 0.5]},
"link2": {"position": [3, 0, 1], "rotation": [0, 0, 1.57]},
})# Click a point anywhere along any polyline or parametric tube; the pick
# travels back to Python. Hovering glides a marker to the closest point.
def on_pick(pick):
print(f"{pick['kind']} {pick['id']}: {pick['fraction']:.1%} along at {pick['point']}")
v.on_polyline_pick(on_pick) # also enables pickingEach click sends {id, kind, fraction, point, local_point, segment, t} where
kind is "line" or "tube". Movement is continuous — the marker never
snaps to vertices. Picking is opt-out per object — pass pickable=False to
add_polyline / add_parametric_tube to exclude one from picking (it stays
rendered); adding objects costs nothing when picking is never enabled. For a tube, segment indexes the full-resolution spine 1:1
with the per-point arrays you built it from, so it doubles as a lookup key for
other per-point data at the picked point. A browser embedder can subscribe in
JS instead — viewer.onPolylinePick(cb) (click) / viewer.onPolylineHover(cb)
(every hover move) — for a live readout with no Python round-trip. See
examples/22_polyline_picking.py.
# Per-point lifetimes: a point is visible while birth_time <= t < removal_time.
# NaN = unbounded (never born-late / never removed).
v.add_points("stock", positions, colors=depth, colormap="turbo",
birth_times=birth, removal_times=removal)
v.set_points_time("stock", 3.0) # scrub the time directly...
# ...or map the animation slider onto it (drag to scrub points in/out):
anim = Animation(loop=True)
anim.set_frame_times(times)
anim.set_point_time_data(["stock"], times.reshape(-1, 1))
v.load_animation(anim)
# Octree LOD for clouds beyond one draw call (10M+ points): nodes stream
# on demand from Python under a point budget as the camera moves. Keep the
# Python process running — it serves node payloads.
v.add_points("big", positions, colors=scalars, lod=True)
# tunable: lod={"node_capacity": 15000, "point_budget": 1_500_000,
# "refine_pixels": 12, "seed": 0}Both compose: octree node samples are stratified over the time values, so
scrubbing thins every LOD level uniformly. On LOD clouds use the time window
instead of set_draw_range (buffer order is spatial there). See
examples/27_points_octree_lod.py — point
count tunable from the command line.
from threejs_viewer import Animation
animation = Animation(loop=True)
for t in times:
animation.add_frame(
time=t,
transforms=compute_transforms(t),
colors={"robot": 0xFF0000 if collision else 0x00FF00},
clip_times={"glb_model": t}, # drive embedded GLTF animations
)
animation.add_marker(3.5, "Collision detected")
client.load_animation(animation)Viewer controls: Space (play/pause), Arrow keys (step frames), 1-5 (speed), L (loop)
For animations with many objects or frames, use binary channels instead of Frame dicts for much faster serialization and transfer:
import numpy as np
from threejs_viewer import Animation
animation = Animation(loop=True)
animation.set_frame_times(np.arange(n_frames) / 60.0)
# Transforms: (n_frames, n_objects, 16) float32
animation.set_transform_data(object_ids, transform_array)
# Draw ranges: (n_frames, n_objects) float32
animation.set_draw_range_data(object_ids, draw_range_array)
# Clip times for embedded GLTF animations: (n_frames, n_objects) float32
animation.set_clip_time_data(object_ids, clip_time_array)
# Colors with indexed colormap: (n_frames, n_objects) uint8
animation.add_channel("colors", object_ids, color_indices, dtype="uint8",
metadata={"colormap": [0x44AA44, 0xFF3333]})
# Visibility: (n_frames, n_objects) uint8 (0=hidden, 1=visible)
animation.add_channel("visibility", object_ids, vis_data, dtype="uint8")
client.load_animation(animation)Binary channels and Frame-based JSON can be mixed. A binary channel supersedes the same-named Frame field.
# Load a GLB with embedded animations (skeletal, morph targets)
client.add_model_binary("fox", "fox.glb", format="glb")
# Seek embedded animation to a specific time (seconds)
client.set_clip_time("fox", 1.5)threejs-viewer path # Print path to viewer.html
threejs-viewer open # Open in default browser
threejs-viewer code # Open in VS Code (use "Show Preview" for docked view)MIT

