From 419fac679c956cab067e1b5252a442747dd99cc4 Mon Sep 17 00:00:00 2001 From: Brent Yi Date: Thu, 20 Apr 2023 03:06:15 -0700 Subject: [PATCH] API cleanup, icon tweaks --- .github/workflows/docs.yml | 1 - .github/workflows/publish.yml | 31 +++--- README.md | 28 ++++-- docs/source/_static/css/custom.css | 7 ++ docs/source/_static/viser.svg | 33 +++++++ docs/source/conf.py | 6 +- docs/source/index.md | 19 +++- examples/0_basic.py | 5 +- examples/2_camera_poses.py | 7 +- examples/3_client_targeted.py | 4 +- examples/4_gui.py | 44 +++++---- examples/5_gui_callbacks.py | 24 +++-- examples/7_record3d_visualizer.py | 40 ++++---- examples/8_smplx_visualizer.py | 39 ++++---- examples/9_urdf_visualizer.py | 6 +- pyproject.toml | 2 +- viser/_gui.py | 85 ++++++++++++---- viser/_message_api.py | 88 ++++++++--------- viser/_messages.py | 17 +++- viser/_scene_handle.py | 97 ++++++++++--------- viser/_viser.py | 31 +++++- viser/client/build/asset-manifest.json | 6 +- viser/client/build/favicon.svg | 52 +++++----- viser/client/build/index.html | 2 +- .../js/{main.99842d9f.js => main.f23cd3b8.js} | 6 +- ...CENSE.txt => main.f23cd3b8.js.LICENSE.txt} | 0 ...n.99842d9f.js.map => main.f23cd3b8.js.map} | 2 +- viser/client/public/favicon.svg | 52 +++++----- viser/client/src/ControlPanel/Generated.tsx | 6 +- viser/client/src/ControlPanel/GuiState.tsx | 2 +- viser/client/src/SceneTree.tsx | 32 ++++-- viser/client/src/WebsocketInterface.tsx | 53 ++++++---- viser/client/src/WebsocketMessages.tsx | 19 ++-- viser/infra/_messages.py | 2 +- 34 files changed, 502 insertions(+), 346 deletions(-) create mode 100644 docs/source/_static/css/custom.css create mode 100644 docs/source/_static/viser.svg rename viser/client/build/static/js/{main.99842d9f.js => main.f23cd3b8.js} (76%) rename viser/client/build/static/js/{main.99842d9f.js.LICENSE.txt => main.f23cd3b8.js.LICENSE.txt} (100%) rename viser/client/build/static/js/{main.99842d9f.js.map => main.f23cd3b8.js.map} (51%) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fa567cc3b..e965eb0cf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,7 +8,6 @@ jobs: docs: runs-on: ubuntu-latest steps: - # Check out source - uses: actions/checkout@v2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 66317b076..33d86e91a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,22 +9,21 @@ on: jobs: deploy: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.8' - - name: Install dependencies - run: | - curl -sSL https://install.python-poetry.org | python3 - - poetry install - - name: Build and publish - env: - PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} - PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - poetry publish --build --username $PYPI_USERNAME --password $PYPI_PASSWORD + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.8" + - name: Install dependencies + run: | + curl -sSL https://install.python-poetry.org | python3 - + poetry install + - name: Build and publish + env: + PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} + PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + poetry publish --build --username $PYPI_USERNAME --password $PYPI_PASSWORD diff --git a/README.md b/README.md index 04b54eb15..b30a49491 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# viser +

+ + viser +

-**[ [API Reference](https://brentyi.github.io/viser) ]**   •   `pip install viser` +**`pip install viser`**   •   **[ +[API Reference](https://brentyi.github.io/viser) ]** ![pyright](https://github.com/brentyi/viser/workflows/pyright/badge.svg) ![mypy](https://github.com/brentyi/viser/workflows/mypy/badge.svg) @@ -10,20 +14,29 @@ --- `viser` is a library for interactive 3D visualization + Python, inspired by -our favorite bits of the -[Nerfstudio viewer](https://github.com/nerfstudio-project/nerfstudio), -[Pangolin](https://github.com/stevenlovegrove/Pangolin), +tools like [Pangolin](https://github.com/stevenlovegrove/Pangolin), [rviz](https://wiki.ros.org/rviz/), and [meshcat](https://github.com/rdeits/meshcat). -Core features: +As a standalone visualization tool, `viser` features include: - Web interface for easy use on remote machines. -- Pure-Python API for sending 3D primitives to the browser. +- Python API for sending 3D primitives to the browser. - Python-configurable inputs: buttons, checkboxes, text inputs, sliders, dropdowns, gizmos. - Support for multiple panels and view-synchronized connections. +The `viser.infra` backend can also be used to build custom web applications +(example: +[the Nerfstudio viewer](https://github.com/nerfstudio-project/nerfstudio)). It +supports: + +- Websocket / HTTP server management, on a shared port. +- Asynchronous server/client communication infrastructure. +- Client state persistence logic. +- Typed serialization; synchronization between Python dataclass and TypeScript + interfaces. + ## Running examples ```bash @@ -60,7 +73,6 @@ yarn start https://user-images.githubusercontent.com/6992947/228734499-87d8a12a-df1a-4511-a4e0-0a46bd8532fd.mov - ### Interactive NeRF rendering (code not released) diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css new file mode 100644 index 000000000..a517e9273 --- /dev/null +++ b/docs/source/_static/css/custom.css @@ -0,0 +1,7 @@ +img.sidebar-logo { + width: 3em; + margin: 1em 0 0 0; +} +.sidebar-brand-text { + display: none; +} diff --git a/docs/source/_static/viser.svg b/docs/source/_static/viser.svg new file mode 100644 index 000000000..4e0aaf680 --- /dev/null +++ b/docs/source/_static/viser.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/conf.py b/docs/source/conf.py index 10e288a73..181a651fc 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -75,8 +75,8 @@ "class": "", }, ], - # "light_logo": "logo-light.svg", - # "dark_logo": "logo-dark.svg", + "light_logo": "viser.svg", + "dark_logo": "viser.svg", } # Pull documentation types from hints @@ -246,7 +246,7 @@ def docstring(app, what, name, obj, options, lines): def setup(app): app.connect("autodoc-process-docstring", docstring) - app.add_css_file("css/compact_table_header.css") + app.add_css_file("css/custom.css") # Generate name aliases diff --git a/docs/source/index.md b/docs/source/index.md index f5194da16..e4a7cbffa 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -3,20 +3,29 @@ |mypy| |nbsp| |pyright| |nbsp| |typescript| |nbsp| |versions| `viser` is a library for interactive 3D visualization + Python, inspired by -our favorite bits of the -[Nerfstudio viewer](https://github.com/nerfstudio-project/nerfstudio), -[Pangolin](https://github.com/stevenlovegrove/Pangolin), +tools like [Pangolin](https://github.com/stevenlovegrove/Pangolin), [rviz](https://wiki.ros.org/rviz/), and [meshcat](https://github.com/rdeits/meshcat). -Core features: +As a standalone visualization tool, `viser` features include: - Web interface for easy use on remote machines. -- Pure-Python API for sending 3D primitives to the browser. +- Python API for sending 3D primitives to the browser. - Python-configurable inputs: buttons, checkboxes, text inputs, sliders, dropdowns, gizmos. - Support for multiple panels and view-synchronized connections. +The `viser.infra` backend can also be used to build custom web applications +(example: +[the Nerfstudio viewer](https://github.com/nerfstudio-project/nerfstudio)). It +supports: + +- Websocket / HTTP server management, on a shared port. +- Asynchronous server/client communication infrastructure. +- Client state persistence logic. +- Typed serialization; synchronization between Python dataclass and TypeScript + interfaces. + ## Running examples ```bash diff --git a/examples/0_basic.py b/examples/0_basic.py index 0d37d7fee..0ee3a8172 100644 --- a/examples/0_basic.py +++ b/examples/0_basic.py @@ -18,12 +18,13 @@ wxyz=(1.0, 0.0, 0.0, 0.0), position=(random.random() * 2.0, 2.0, 0.2), ) - server.add_frame( + leaf = server.add_frame( "/tree/branch/leaf", wxyz=(1.0, 0.0, 0.0, 0.0), position=(random.random() * 2.0, 2.0, 0.2), ) time.sleep(5.0) - server.remove_scene_node("/tree/branch/leaf") + # Remove the leaf node from the scene. + leaf.remove() time.sleep(0.5) diff --git a/examples/2_camera_poses.py b/examples/2_camera_poses.py index 5eb2d779c..d333fe97e 100644 --- a/examples/2_camera_poses.py +++ b/examples/2_camera_poses.py @@ -20,12 +20,13 @@ # This will run whenever we get a new camera! @client.on_camera_update def camera_update(client: viser.ClientHandle) -> None: - print("New camera", client.get_camera()) + print("New camera", client.camera) # Show the client ID in the GUI. - client.add_gui_text("Info", initial_value=f"Client {id}").set_disabled(True) + gui_info = client.add_gui_text("Info", initial_value=f"Client {id}") + gui_info.disabled = True - camera = client.get_camera() + camera = client.camera print(f"Camera pose for client {id}") print(f"\twxyz: {camera.wxyz}") print(f"\tposition: {camera.position}") diff --git a/examples/3_client_targeted.py b/examples/3_client_targeted.py index e243f28a1..07c18b5dc 100644 --- a/examples/3_client_targeted.py +++ b/examples/3_client_targeted.py @@ -32,7 +32,7 @@ for id, client in clients.items(): # Match the image rotation of this particular client to face its camera. - camera = client.get_camera() + camera = client.camera client.add_frame("/main", wxyz=camera.wxyz, position=(0, 0, 0), show_axes=False) # Kind of fun: send our own camera to all of the other clients. This lets each @@ -40,7 +40,7 @@ for other in clients.values(): if client.client_id == other.client_id: continue - camera = client.get_camera() + camera = client.camera other.add_frame( f"/client_{client.client_id}", wxyz=camera.wxyz, diff --git a/examples/4_gui.py b/examples/4_gui.py index 4611ff4bc..03a36366d 100644 --- a/examples/4_gui.py +++ b/examples/4_gui.py @@ -14,12 +14,13 @@ def main(): counter = 0 with server.gui_folder("Read-only"): - gui_counter = server.add_gui_number( - "Counter", initial_value=counter - ).set_disabled(True) + gui_counter = server.add_gui_number("Counter", initial_value=counter) + gui_counter.disabled = True + gui_slider = server.add_gui_slider( "Slider", min=0, max=100, step=1, initial_value=counter - ).set_disabled(True) + ) + gui_slider.disabled = True with server.gui_folder("Editable"): gui_vector2 = server.add_gui_vector2( @@ -56,30 +57,31 @@ def main(): point_positions = onp.random.uniform(low=-1.0, high=1.0, size=(500, 3)) point_colors = onp.random.randint(0, 256, size=(500, 3)) + frame_node = server.add_frame( + "/controlled_frame", wxyz=(1.0, 0.0, 0.0, 0.0), position=(0.0, 0.0, 0.0) + ) + while True: - # We can call `set_value()` to set an input to a particular value. - gui_counter.set_value(counter) - gui_slider.set_value(counter % 100) - - # We can call `value()` to read the current value of an input. - xy = gui_vector2.get_value() - server.add_frame( - "/controlled_frame", - wxyz=(1, 0, 0, 0), - position=xy + (0,), - ) + # We can set the value of an input to a particular value. Changes are + # automatically reflected in connected clients. + gui_counter.value = counter + gui_slider.value = counter % 100 + + # We can set the position of a scene node with `.position`, and read the value + # of a gui element with `.value`. Changes are automatically reflected in + # connected clients. + frame_node.position = gui_vector2.value + (0,) - size = gui_vector3.get_value() server.add_point_cloud( "/controlled_frame/point_cloud", - position=point_positions * onp.array(size, dtype=onp.float32), + position=point_positions * onp.array(gui_vector3.value, dtype=onp.float32), color=point_colors, ) - # We can use `set_disabled()` to enable/disable GUI elements. - gui_text.set_hidden(gui_checkbox_hide.get_value()) - gui_button.set_hidden(gui_checkbox_hide.get_value()) - gui_rgba.set_disabled(gui_checkbox_disable.get_value()) + # We can use `.visible` and `.disabled` to toggle GUI elements. + gui_text.visible = not gui_checkbox_hide.value + gui_button.visible = not gui_checkbox_hide.value + gui_rgba.disabled = gui_checkbox_disable.value counter += 1 time.sleep(1e-2) diff --git a/examples/5_gui_callbacks.py b/examples/5_gui_callbacks.py index d2812c9ba..4d26c6608 100644 --- a/examples/5_gui_callbacks.py +++ b/examples/5_gui_callbacks.py @@ -26,9 +26,7 @@ def main(): @gui_include_z.on_update def _(_) -> None: - gui_axis.set_options( - ["x", "y", "z"] if gui_include_z.get_value() else ["x", "y"] - ) + gui_axis.options = ["x", "y", "z"] if gui_include_z.value else ["x", "y"] with server.gui_folder("Sliders"): gui_location = server.add_gui_slider( @@ -42,13 +40,13 @@ def _(_) -> None: gui_button = server.add_gui_button("Reset") def draw_frame() -> None: - axis = gui_axis.get_value() + axis = gui_axis.value if axis == "x": - pos = (gui_location.get_value(), 0.0, 0.0) + pos = (gui_location.value, 0.0, 0.0) elif axis == "y": - pos = (0.0, gui_location.get_value(), 0.0) + pos = (0.0, gui_location.value, 0.0) elif axis == "z": - pos = (0.0, 0.0, gui_location.get_value()) + pos = (0.0, 0.0, gui_location.value) else: assert_never(axis) @@ -56,12 +54,12 @@ def draw_frame() -> None: "/frame", wxyz=(1.0, 0.0, 0.0, 0.0), position=pos, - show_axes=gui_show.get_value(), + show_axes=gui_show.value, axes_length=5.0, ) def draw_points() -> None: - num_points = gui_num_points.get_value() + num_points = gui_num_points.value server.add_point_cloud( "/frame/point_cloud", position=onp.random.normal(size=(num_points, 3)), @@ -78,10 +76,10 @@ def draw_points() -> None: @gui_button.on_update def _(_: viser.GuiHandle[bool]) -> None: """Reset the scene when the reset button is clicked.""" - gui_show.set_value(True) - gui_location.set_value(0.0) - gui_axis.set_value("x") - gui_num_points.set_value(10_000) + gui_show.value = True + gui_location.value = 0.0 + gui_axis.value = "x" + gui_num_points.value = 10_000 draw_frame() draw_points() diff --git a/examples/7_record3d_visualizer.py b/examples/7_record3d_visualizer.py index ecbf60802..2cd56221e 100644 --- a/examples/7_record3d_visualizer.py +++ b/examples/7_record3d_visualizer.py @@ -2,7 +2,7 @@ import time from pathlib import Path -from typing import Tuple +from typing import List, Tuple import numpy as onp import numpy.typing as onpt @@ -36,9 +36,6 @@ def main( loader = viser.extras.Record3dLoader(data_path) num_frames = min(max_frames, loader.num_frames()) - # Hide world axes. - server.set_scene_node_visibility("/WorldAxes", False) - # Add playback UI. with server.gui_folder("Playback"): gui_timestep = server.add_gui_slider( @@ -54,28 +51,28 @@ def main( # Frame step buttons. @gui_next_frame.on_update def _(_) -> None: - gui_timestep.set_value((gui_timestep.get_value() + 1) % num_frames) + gui_timestep.value = (gui_timestep.value + 1) % num_frames @gui_prev_frame.on_update def _(_) -> None: - gui_timestep.set_value((gui_timestep.get_value() - 1) % num_frames) + gui_timestep.value = (gui_timestep.value - 1) % num_frames # Disable frame controls when we're playing. @gui_playing.on_update def _(_) -> None: - gui_timestep.set_disabled(gui_playing.get_value()) - gui_next_frame.set_disabled(gui_playing.get_value()) - gui_prev_frame.set_disabled(gui_playing.get_value()) + gui_timestep.disabled = gui_playing.value + gui_next_frame.disabled = gui_playing.value + gui_prev_frame.disabled = gui_playing.value - prev_timestep = gui_timestep.get_value() + prev_timestep = gui_timestep.value # Toggle frame visibility when the timestep slider changes. @gui_timestep.on_update def _(_) -> None: nonlocal prev_timestep - current_timestep = gui_timestep.get_value() - server.set_scene_node_visibility(f"/frames/t{current_timestep}", True) - server.set_scene_node_visibility(f"/frames/t{prev_timestep}", False) + current_timestep = gui_timestep.value + frame_nodes[current_timestep].visible = True + frame_nodes[prev_timestep].visible = False prev_timestep = current_timestep # Load in frames. @@ -85,9 +82,11 @@ def _(_) -> None: position=(0, 0, 0), show_axes=False, ) + frame_nodes: List[viser.SceneNodeHandle] = [] for i in tqdm(range(num_frames)): frame = loader.get_frame(i) position, color = frame.get_point_cloud(downsample_factor) + frame_nodes.append(server.add_frame(f"/frames/t{i}", show_axes=False)) server.add_point_cloud( name=f"/frames/t{i}/pcd", position=position, color=color, point_size=0.01 ) @@ -105,20 +104,17 @@ def _(_) -> None: scale=0.15, ) - # Remove loading progress indicator. - server.set_scene_node_visibility("/axes", False) - # Hide all but the current frame. - for i in range(num_frames): - server.set_scene_node_visibility(f"/frames/t{i}", i == gui_timestep.get_value()) + for i, frame_node in enumerate(frame_nodes): + frame_node.visible = i == gui_timestep.value # Playback update loop. - prev_timestep = gui_timestep.get_value() + prev_timestep = gui_timestep.value while True: - if gui_playing.get_value(): - gui_timestep.set_value((gui_timestep.get_value() + 1) % num_frames) + if gui_playing.value: + gui_timestep.value = (gui_timestep.value + 1) % num_frames - time.sleep(1.0 / gui_framerate.get_value()) + time.sleep(1.0 / gui_framerate.value) if __name__ == "__main__": diff --git a/examples/8_smplx_visualizer.py b/examples/8_smplx_visualizer.py index 6955a8061..f3c2fc83e 100644 --- a/examples/8_smplx_visualizer.py +++ b/examples/8_smplx_visualizer.py @@ -36,15 +36,19 @@ def quat_from_mat3(mat3: onp.ndarray) -> onp.ndarray: return onp.roll(Rotation.from_matrix(mat3).as_quat(), 1) -def quat_from_so3(*omegas: Tuple[float, float, float]) -> onp.ndarray: +def quat_from_so3( + *omegas: Tuple[float, float, float] +) -> Tuple[float, float, float, float]: # xyzw => wxyz - return onp.roll( + wxyz = onp.roll( functools.reduce( Rotation.__mul__, [Rotation.from_rotvec(onp.array(omega)) for omega in omegas], ).as_quat(), 1, ) + assert wxyz.shape == (4,) + return (wxyz[0], wxyz[1], wxyz[2], wxyz[3]) def main( @@ -75,7 +79,6 @@ def main( position=onp.zeros(3), show_axes=False, ) - server.set_scene_node_visibility("/WorldAxes", False) # Main loop. We'll just keep read from the joints, deform the mesh, then sending the # updated mesh in a loop. This could be made a lot more efficient. @@ -92,16 +95,16 @@ def main( # Get deformed mesh. output = model.forward( betas=torch.from_numpy( # type: ignore - onp.array( - [b.get_value() for b in gui_elements.gui_betas], dtype=onp.float32 - )[None, ...] + onp.array([b.value for b in gui_elements.gui_betas], dtype=onp.float32)[ + None, ... + ] ), expression=None, return_verts=True, body_pose=torch.from_numpy( - onp.array([j.get_value() for j in gui_elements.gui_joints[1:]], dtype=onp.float32)[None, ...] # type: ignore + onp.array([j.value for j in gui_elements.gui_joints[1:]], dtype=onp.float32)[None, ...] # type: ignore ), - global_orient=torch.from_numpy(onp.array(gui_elements.gui_joints[0].get_value(), dtype=onp.float32)[None, ...]), # type: ignore + global_orient=torch.from_numpy(onp.array(gui_elements.gui_joints[0].value, dtype=onp.float32)[None, ...]), # type: ignore return_full_pose=True, ) joint_positions = output.joints.squeeze(axis=0).detach().cpu().numpy() # type: ignore @@ -114,8 +117,8 @@ def main( "/reoriented/smpl", vertices=output.vertices.squeeze(axis=0).detach().cpu().numpy(), # type: ignore faces=model.faces, - wireframe=gui_elements.gui_wireframe.get_value(), - color=gui_elements.gui_rgb.get_value(), + wireframe=gui_elements.gui_wireframe.value, + color=gui_elements.gui_rgb.value, ) # Update per-joint frames, which are used for transform controls. @@ -168,7 +171,7 @@ def _(_): @gui_show_controls.on_update def _(_): - add_transform_controls(enabled=gui_show_controls.get_value()) + add_transform_controls(enabled=gui_show_controls.value) # GUI elements: shape parameters. with server.gui_folder("Shape"): @@ -178,12 +181,12 @@ def _(_): @gui_reset_shape.on_update def _(_): for beta in gui_betas: - beta.set_value(0.0) + beta.value = 0.0 @gui_random_shape.on_update def _(_): for beta in gui_betas: - beta.set_value(onp.random.normal(loc=0.0, scale=1.0)) + beta.value = onp.random.normal(loc=0.0, scale=1.0) gui_betas = [] for i in range(num_betas): @@ -205,7 +208,7 @@ def _(_): @gui_reset_joints.on_update def _(_): for joint in gui_joints: - joint.set_value((0.0, 0.0, 0.0)) + joint.value = (0.0, 0.0, 0.0) sync_transform_controls() @gui_random_joints.on_update @@ -215,7 +218,7 @@ def _(_): # first sample on S^3 and then convert. quat = onp.random.normal(loc=0.0, scale=1.0, size=(4,)) quat /= onp.linalg.norm(quat) - joint.set_value(so3_from_quat(quat)) + joint.value = so3_from_quat(quat) sync_transform_controls() gui_joints: List[viser.GuiHandle[Tuple[float, float, float]]] = [] @@ -251,8 +254,8 @@ def add_transform_controls(enabled: bool) -> List[viser.TransformControlsHandle] def curry_callback(i: int) -> None: @controls.on_update def _(controls: viser.TransformControlsHandle) -> None: - axisangle = so3_from_quat(controls.get_state().wxyz) - gui_joints[i].set_value((axisangle[0], axisangle[1], axisangle[2])) + axisangle = so3_from_quat(controls.wxyz) + gui_joints[i].value = (axisangle[0], axisangle[1], axisangle[2]) curry_callback(i) @@ -261,7 +264,7 @@ def _(controls: viser.TransformControlsHandle) -> None: def sync_transform_controls() -> None: """Sync transform controls when a joint angle changes.""" for t, j in zip(transform_controls, gui_joints): - t.set_transform(quat_from_so3(j.get_value()), t.get_state().position) + t.wxyz = quat_from_so3(j.value) add_transform_controls(enabled=False) diff --git a/examples/9_urdf_visualizer.py b/examples/9_urdf_visualizer.py index cd48fbada..1b9537f10 100644 --- a/examples/9_urdf_visualizer.py +++ b/examples/9_urdf_visualizer.py @@ -54,10 +54,10 @@ def frame_name_with_parents(frame_name: str): @button.on_update def _(_): for g in gui_joints: - g.set_value(0.0) + g.value = 0.0 def update_frames(): - urdf.update_cfg(onp.array([gui.get_value() for gui in gui_joints])) + urdf.update_cfg(onp.array([gui.value for gui in gui_joints])) for joint in urdf.joint_map.values(): assert isinstance(joint, yourdfpy.Joint) T_parent_child = urdf.get_transform(joint.child, joint.parent) @@ -86,7 +86,7 @@ def update_frames(): initial_value=0.0, ) if joint.limit is None: - slider.set_hidden(True) + slider.visible = False @slider.on_update def _(_): diff --git a/pyproject.toml b/pyproject.toml index 8e11fda83..658c2b642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "viser" -version = "0.0.5" +version = "0.0.6" description = "3D visualization helper" authors = ["brentyi "] readme = "README.md" diff --git a/viser/_gui.py b/viser/_gui.py index a19fc838b..bb4ce9b6a 100644 --- a/viser/_gui.py +++ b/viser/_gui.py @@ -19,9 +19,9 @@ from ._messages import ( GuiRemoveMessage, - GuiSetHiddenMessage, GuiSetLevaConfMessage, GuiSetValueMessage, + GuiSetVisibleMessage, ) from .infra import ClientId @@ -70,8 +70,11 @@ class _GuiHandleState(Generic[T]): encoder: Callable[[T], Any] = lambda x: x # noqa decoder: Callable[[Any], T] = lambda x: x # noqa + disabled: bool = False + visible: bool = False -@dataclasses.dataclass(frozen=True) + +@dataclasses.dataclass class GuiHandle(Generic[T]): """Handle for a particular GUI input in our visualizer. @@ -90,16 +93,30 @@ def on_update( self._impl.update_cb.append(func) return func - def get_value(self) -> T: - """Get the value of the GUI input.""" - return self._impl.value + # Should we use @property for get_value / set_value, set_hidden, etc? + # + # Benefits: + # @property is syntactically very nice. + # `gui.value = ...` is really tempting! + # Feels a bit more magical. + # + # Downsides: + # Consistency: not everything that can be written can be read, and not everything + # that can be read can be written. `get_`/`set_` makes this really clear. + # Clarity: some things that we read (like client mappings) are copied before + # they're returned. An attribute access obfuscates the overhead here. + # Flexibility: getter/setter types should match. https://github.com/python/mypy/issues/3004 + # Feels a bit more magical. + # + # Is this worth the tradeoff? - def get_update_timestamp(self) -> float: - """Get the last time that this input was updated.""" - return self._impl.last_updated + @property + def value(self) -> T: + """Value of the GUI input. Synchronized automatically when assigned.""" + return self._impl.value - def set_value(self, value: Union[T, onp.ndarray]) -> GuiHandle[T]: - """Set the value of the GUI input.""" + @value.setter + def value(self, value: Union[T, onp.ndarray]) -> GuiHandle[T]: if isinstance(value, onp.ndarray): assert len(value.shape) <= 1, f"{value.shape} should be at most 1D!" value = tuple(map(float, value)) # type: ignore @@ -121,8 +138,19 @@ def set_value(self, value: Union[T, onp.ndarray]) -> GuiHandle[T]: return self - def set_disabled(self, disabled: bool) -> GuiHandle[T]: - """Allow/disallow user interaction with the input.""" + @property + def update_timestamp(self) -> float: + """Get the last time that this input was updated.""" + return self._impl.last_updated + + @property + def disabled(self) -> bool: + """Allow/disallow user interaction with the input. Synchronized automatically + when assigned.""" + return self._impl.disabled + + @disabled.setter + def disabled(self, disabled: bool) -> GuiHandle[T]: if self._impl.is_button: self._impl.leva_conf["settings"]["disabled"] = disabled self._impl.api._queue( @@ -133,12 +161,19 @@ def set_disabled(self, disabled: bool) -> GuiHandle[T]: self._impl.api._queue( GuiSetLevaConfMessage(self._impl.name, self._impl.leva_conf), ) - + self._impl.disabled = disabled return self - def set_hidden(self, hidden: bool) -> GuiHandle[T]: - """Temporarily hide this GUI element from the visualizer.""" - self._impl.api._queue(GuiSetHiddenMessage(self._impl.name, hidden=hidden)) + @property + def visible(self) -> bool: + """Temporarily show or hide this GUI element from the visualizer. Synchronized + automatically when assigned.""" + return self._impl.visible + + @visible.setter + def visible(self, visible: bool) -> GuiHandle[T]: + self._impl.api._queue(GuiSetVisibleMessage(self._impl.name, visible=visible)) + self._impl.visible = visible return self def remove(self) -> None: @@ -151,16 +186,24 @@ def remove(self) -> None: StringType = TypeVar("StringType", bound=str) -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass class GuiSelectHandle(GuiHandle[StringType], Generic[StringType]): - def set_options(self, options: List[StringType]) -> None: - """Assign a new set of options for the dropdown menu. + _impl_options: List[StringType] + + @property + def options(self) -> List[StringType]: + """Options for our dropdown. Synchronized automatically when assigned. For projects that care about typing: the static type of `options` should be consistent with the `StringType` associated with a handle. Literal types will be inferred where possible when handles are instantiated; for the most flexibility, we can declare handles as `GuiHandle[str]`. """ + return self._impl_options + + @options.setter + def options(self, options: List[StringType]) -> None: + self._impl_options = options # Make sure initial value is in options. self._impl.leva_conf["options"] = options @@ -173,5 +216,5 @@ def set_options(self, options: List[StringType]) -> None: ) # Make sure current value is in options. - if self.get_value() not in options: - self.set_value(options[0]) + if self.value not in options: + self.value = options[0] diff --git a/viser/_message_api.py b/viser/_message_api.py index a4d8676f1..39889af18 100644 --- a/viser/_message_api.py +++ b/viser/_message_api.py @@ -125,8 +125,8 @@ class MessageApi(abc.ABC): def __init__(self, handler: infra.MessageHandler) -> None: self._handle_state_from_gui_name: Dict[str, _GuiHandleState[Any]] = {} - self._handle_state_from_transform_controls_name: Dict[ - str, _TransformControlsState + self._handle_from_transform_controls_name: Dict[ + str, TransformControlsHandle ] = {} handler.register_handler(_messages.GuiUpdateMessage, self._handle_gui_updates) @@ -260,7 +260,8 @@ def add_gui_select( "label": name, "options": options, }, - )._impl + )._impl, + options, # type: ignore ) def add_gui_slider( @@ -363,23 +364,27 @@ def add_camera_frustum( def add_frame( self, name: str, - wxyz: Tuple[float, float, float, float] | onp.ndarray, - position: Tuple[float, float, float] | onp.ndarray, + wxyz: Tuple[float, float, float, float] | onp.ndarray = (1.0, 0.0, 0.0, 0.0), + position: Tuple[float, float, float] | onp.ndarray = (0.0, 0.0, 0.0), show_axes: bool = True, axes_length: float = 0.5, axes_radius: float = 0.025, ) -> SceneNodeHandle: + wxyz_tup = _cast_vector(wxyz, length=4) + position_tup = _cast_vector(position, length=3) self._queue( _messages.FrameMessage( name=name, - wxyz=_cast_vector(wxyz, length=4), - position=_cast_vector(position, length=3), + wxyz=wxyz_tup, + position=position_tup, show_axes=show_axes, axes_length=axes_length, axes_radius=axes_radius, ) ) - return SceneNodeHandle(_SceneNodeHandleState(name, self)) + return SceneNodeHandle( + _SceneNodeHandleState(name, self, wxyz=wxyz_tup, position=position_tup) + ) def add_point_cloud( self, @@ -498,46 +503,33 @@ def add_transform_controls( ) ) - def sync_cb(client_id: ClientId, state: _TransformControlsState) -> None: - message = _messages.SetTransformMessage( - name=name, wxyz=state.wxyz, position=state.position + def sync_cb(client_id: ClientId, state: TransformControlsHandle) -> None: + message_orientation = _messages.SetOrientationMessage( + name=name, wxyz=state._impl.wxyz ) - message.excluded_self_client = client_id - self._queue(message) + message_orientation.excluded_self_client = client_id + self._queue(message_orientation) - state = _TransformControlsState( + message_position = _messages.SetPositionMessage( + name=name, position=state._impl.position + ) + message_position.excluded_self_client = client_id + self._queue(message_position) + + state = _SceneNodeHandleState( name=name, api=self, wxyz=(1.0, 0.0, 0.0, 0.0), position=(0.0, 0.0, 0.0), + ) + state_aux = _TransformControlsState( last_updated=time.time(), update_cb=[], sync_cb=sync_cb, ) - self._handle_state_from_transform_controls_name[name] = state - return TransformControlsHandle(state) - - def remove_scene_node(self, name: str) -> None: - """Remove a node from the scene by name.""" - self._queue(_messages.RemoveSceneNodeMessage(name=name)) - - if name in self._handle_state_from_transform_controls_name: - self._handle_state_from_transform_controls_name.pop(name) - - def set_scene_node_visibility(self, name: str, visible: bool) -> None: - """Set the visibility of a node in the scene by name.""" - self._queue(_messages.SetSceneNodeVisibilityMessage(name=name, visible=visible)) - - def set_scene_node_transform( - self, - name: str, - wxyz: Tuple[float, float, float, float] | onp.ndarray, - position: Tuple[float, float, float] | onp.ndarray, - ) -> None: - """Set the transformation of a node in the scene by name.""" - wxyz = _cast_vector(wxyz, 4) - position = _cast_vector(position, 3) - self._queue(_messages.SetTransformMessage(name, wxyz, position)) + handle = TransformControlsHandle(state, state_aux) + self._handle_from_transform_controls_name[name] = handle + return handle def reset_scene(self): """Reset the scene.""" @@ -576,22 +568,20 @@ def _handle_transform_controls_updates( self, client_id: ClientId, message: _messages.TransformControlsUpdateMessage ) -> None: """Callback for handling transform gizmo messages.""" - handle_state = self._handle_state_from_transform_controls_name.get( - message.name, None - ) - if handle_state is None: + handle = self._handle_from_transform_controls_name.get(message.name, None) + if handle is None: return # Update state. - handle_state.wxyz = message.wxyz - handle_state.position = message.position - handle_state.last_updated = time.time() + handle._impl.wxyz = message.wxyz + handle._impl.position = message.position + handle._impl_aux.last_updated = time.time() # Trigger callbacks. - for cb in handle_state.update_cb: - cb(TransformControlsHandle(handle_state)) - if handle_state.sync_cb is not None: - handle_state.sync_cb(client_id, handle_state) + for cb in handle._impl_aux.update_cb: + cb(handle) + if handle._impl_aux.sync_cb is not None: + handle._impl_aux.sync_cb(client_id, handle) def _add_gui_impl( self, diff --git a/viser/_messages.py b/viser/_messages.py index 97e783195..d960d0f80 100644 --- a/viser/_messages.py +++ b/viser/_messages.py @@ -137,13 +137,22 @@ class TransformControlsMessage(Message): @dataclasses.dataclass -class SetTransformMessage(Message): - """Server -> client message to set a scene node's pose. +class SetOrientationMessage(Message): + """Server -> client message to set a scene node's orientation. As with all other messages, transforms take the `T_parent_local` convention.""" name: str wxyz: Tuple[float, float, float, float] + + +@dataclasses.dataclass +class SetPositionMessage(Message): + """Server -> client message to set a scene node's position. + + As with all other messages, transforms take the `T_parent_local` convention.""" + + name: str position: Tuple[float, float, float] @@ -222,11 +231,11 @@ class GuiUpdateMessage(Message): @dataclasses.dataclass -class GuiSetHiddenMessage(Message): +class GuiSetVisibleMessage(Message): """Sent client->server when a GUI input is changed.""" name: str - hidden: bool + visible: bool @dataclasses.dataclass diff --git a/viser/_scene_handle.py b/viser/_scene_handle.py index a242e9c62..cd13dbfcb 100644 --- a/viser/_scene_handle.py +++ b/viser/_scene_handle.py @@ -5,6 +5,8 @@ import numpy as onp +from . import _messages + if TYPE_CHECKING: from ._message_api import ClientId, MessageApi @@ -24,43 +26,67 @@ def _cast_vector(vector: TVector | onp.ndarray, length: int) -> TVector: class _SceneNodeHandleState: name: str api: MessageApi + wxyz: Tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0) + position: Tuple[float, float, float] = (0.0, 0.0, 0.0) + visible: bool = True -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass class SceneNodeHandle: """Handle for interacting with scene nodes.""" _impl: _SceneNodeHandleState - def set_transform( - self, - wxyz: Tuple[float, float, float, float] | onp.ndarray, - position: Tuple[float, float, float] | onp.ndarray, - ) -> SceneNodeHandle: - """Set the 6D pose of the scene node.""" - self._impl.api.set_scene_node_transform(self._impl.name, wxyz, position) - return self + @property + def wxyz(self) -> Tuple[float, float, float, float]: + """Orientation of the scene node. This is the quaternion representation of the R + in `p_parent = [R | t] p_local`. Synchronized to clients automatically when assigned. + """ + return self._impl.wxyz + + @wxyz.setter + def wxyz(self, wxyz: Tuple[float, float, float, float]) -> None: + self._impl.wxyz = wxyz + self._impl.api._queue( + _messages.SetOrientationMessage(self._impl.name, self._impl.wxyz) + ) + + @property + def position(self) -> Tuple[float, float, float]: + """Position of the scene node. This is equivalent to the t in + `p_parent = [R | t] p_local`. Synchronized to clients automatically when assigned. + """ + return self._impl.position + + @position.setter + def position(self, position: Tuple[float, float, float]) -> None: + self._impl.position = position + self._impl.api._queue( + _messages.SetPositionMessage(self._impl.name, self._impl.position) + ) + + @property + def visible(self) -> bool: + """Whether the scene node is visible or not. Synchronized to clients automatically when assigned.""" + return self._impl.visible - def set_visibility(self, visible: bool) -> SceneNodeHandle: - """Set the visibility of the scene node.""" - self._impl.api.set_scene_node_visibility(self._impl.name, visible) - return self + @visible.setter + def visible(self, visible: bool) -> None: + self._impl.api._queue( + _messages.SetSceneNodeVisibilityMessage(self._impl.name, visible) + ) + self._impl.visible = visible def remove(self) -> None: """Remove the node from the scene.""" - self._impl.api.remove_scene_node(self._impl.name) + self._impl.api._queue(_messages.RemoveSceneNodeMessage(self._impl.name)) @dataclasses.dataclass class _TransformControlsState: - name: str - api: MessageApi - wxyz: Tuple[float, float, float, float] - position: Tuple[float, float, float] last_updated: float - update_cb: List[Callable[[TransformControlsHandle], None]] - sync_cb: Optional[Callable[[ClientId, _TransformControlsState], None]] = None + sync_cb: Optional[Callable[[ClientId, TransformControlsHandle], None]] = None @dataclasses.dataclass(frozen=True) @@ -70,36 +96,19 @@ class TransformControlsState: last_updated: float -@dataclasses.dataclass(frozen=True) -class TransformControlsHandle: +@dataclasses.dataclass +class TransformControlsHandle(SceneNodeHandle): """Handle for interacting with transform control gizmos.""" - _impl: _TransformControlsState + _impl_aux: _TransformControlsState - def get_state(self) -> TransformControlsState: - """Get the current state of the gizmo.""" - return TransformControlsState( - self._impl.wxyz, self._impl.position, self._impl.last_updated - ) + @property + def update_timestamp(self) -> float: + return self._impl_aux.last_updated def on_update( self, func: Callable[[TransformControlsHandle], None] ) -> Callable[[TransformControlsHandle], None]: """Attach a callback for when the gizmo is moved.""" - self._impl.update_cb.append(func) + self._impl_aux.update_cb.append(func) return func - - def set_transform( - self, - wxyz: Tuple[float, float, float, float] | onp.ndarray, - position: Tuple[float, float, float] | onp.ndarray, - ) -> TransformControlsHandle: - """Set the 6D pose of the gizmo.""" - self._impl.api.set_scene_node_transform(self._impl.name, wxyz, position) - self._impl.wxyz = _cast_vector(wxyz, 4) - self._impl.position = _cast_vector(position, 3) - return self - - def remove(self) -> None: - """Remove the node from the scene.""" - self._impl.api.remove_scene_node(self._impl.name) diff --git a/viser/_viser.py b/viser/_viser.py index 218613ba4..ab3e1abdb 100644 --- a/viser/_viser.py +++ b/viser/_viser.py @@ -4,7 +4,7 @@ import threading import time from pathlib import Path -from typing import Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from typing_extensions import override @@ -47,13 +47,19 @@ def _queue(self, message: infra.Message) -> None: """Define how the message API should send messages.""" self._state.connection.send(message) - def get_camera(self) -> CameraState: + @property + def camera(self) -> CameraState: """Get the view camera from a particular client. Blocks if not available yet.""" # TODO: there's a risk of getting stuck in an infinite loop here. while self._state.camera_info is None: time.sleep(0.01) return self._state.camera_info + @camera.setter + def camera(self, camera: CameraState) -> None: + # TODO + raise NotImplementedError() + def on_camera_update( self, callback: Callable[[ClientHandle], None] ) -> Callable[[ClientHandle], None]: @@ -82,12 +88,17 @@ def __init__(self, host: str = "127.0.0.1", port: int = 8080): state = _ViserServerState(server, {}, threading.Lock()) self._state = state + self._client_connect_cb: List[Callable[[ClientHandle], None]] = [] + self._client_disconnect_cb: List[Callable[[ClientHandle], None]] = [] # For new clients, register and add a handler for camera messages. @server.on_client_connect def _(conn: infra.ClientConnection) -> None: client = ClientHandle(conn.client_id, _ClientHandleState(conn, None, [])) + for cb in self._client_connect_cb: + cb(client) + def handle_camera_message( client_id: infra.ClientId, message: ViewerCameraMessage ) -> None: @@ -110,17 +121,29 @@ def handle_camera_message( @server.on_client_disconnect def _(conn: infra.ClientConnection) -> None: with self._state.client_lock: - state.connected_clients.pop(conn.client_id) + handle = state.connected_clients.pop(conn.client_id) + + for cb in self._client_disconnect_cb: + cb(handle) # Start the server. server.start() self.reset_scene() def get_clients(self) -> Dict[int, ClientHandle]: - """Get mapping from connected client IDs to handles.""" + """Creates and returns a copy of the mapping from connected client IDs to + handles.""" with self._state.client_lock: return self._state.connected_clients.copy() + def on_client_connect(self, cb: Callable[[ClientHandle], Any]) -> None: + """Attach a callback to run for newly connected clients.""" + self._client_connect_cb.append(cb) + + def on_client_disconnect(self, cb: Callable[[ClientHandle], Any]) -> None: + """Attach a callback to run when clients disconnect.""" + self._client_disconnect_cb.append(cb) + @override def _queue(self, message: infra.Message) -> None: """Define how the message API should send messages.""" diff --git a/viser/client/build/asset-manifest.json b/viser/client/build/asset-manifest.json index 4d7286227..183552ccb 100644 --- a/viser/client/build/asset-manifest.json +++ b/viser/client/build/asset-manifest.json @@ -1,13 +1,13 @@ { "files": { "main.css": "/static/css/main.992126cd.css", - "main.js": "/static/js/main.99842d9f.js", + "main.js": "/static/js/main.f23cd3b8.js", "index.html": "/index.html", "main.992126cd.css.map": "/static/css/main.992126cd.css.map", - "main.99842d9f.js.map": "/static/js/main.99842d9f.js.map" + "main.f23cd3b8.js.map": "/static/js/main.f23cd3b8.js.map" }, "entrypoints": [ "static/css/main.992126cd.css", - "static/js/main.99842d9f.js" + "static/js/main.f23cd3b8.js" ] } \ No newline at end of file diff --git a/viser/client/build/favicon.svg b/viser/client/build/favicon.svg index 6bc7f2c26..4e0aaf680 100644 --- a/viser/client/build/favicon.svg +++ b/viser/client/build/favicon.svg @@ -1,37 +1,33 @@ - - - + + + + - - - - - - + + - - - - - - - + + + + + diff --git a/viser/client/build/index.html b/viser/client/build/index.html index 8e01fe2ae..16db471f1 100644 --- a/viser/client/build/index.html +++ b/viser/client/build/index.html @@ -1 +1 @@ -Viser
\ No newline at end of file +Viser
\ No newline at end of file diff --git a/viser/client/build/static/js/main.99842d9f.js b/viser/client/build/static/js/main.f23cd3b8.js similarity index 76% rename from viser/client/build/static/js/main.99842d9f.js rename to viser/client/build/static/js/main.f23cd3b8.js index f35868963..2d567920b 100644 --- a/viser/client/build/static/js/main.99842d9f.js +++ b/viser/client/build/static/js/main.f23cd3b8.js @@ -1,3 +1,3 @@ -/*! For license information please see main.99842d9f.js.LICENSE.txt */ -!function(){var e={7823:function(e){"use strict";e.exports=function(e,t){if(null===e||"undefined"===typeof e)throw new TypeError("expected first argument to be an object.");if("undefined"===typeof t||"undefined"===typeof Symbol)return e;if("function"!==typeof Object.getOwnPropertySymbols)return e;for(var n=Object.prototype.propertyIsEnumerable,r=Object(e),i=arguments.length,a=0;++a0&&void 0!==arguments[0]?arguments[0]:{}).timeout;return l(this,r,"f")?null==a?new Promise((function(e){l(n,i,"f").add(e)})):Promise.race([new Promise((function(r){e=function(){clearTimeout(t),r()},l(n,i,"f").add(e)})),new Promise((function(r,o){t=setTimeout((function(){l(n,i,"f").delete(e),o(new Error("Timed out waiting for lock"))}),a)}))]):(u(this,r,!0,"f"),Promise.resolve())}},{key:"tryAcquire",value:function(){return!l(this,r,"f")&&(u(this,r,!0,"f"),!0)}},{key:"release",value:function(){if(!l(this,r,"f"))throw new Error("Cannot release an unacquired lock");if(l(this,i,"f").size>0){var e=l(this,i,"f"),t=a(e,1)[0];l(this,i,"f").delete(t),t()}else u(this,r,!1,"f")}}]),e}();t.default=c,r=new WeakMap,i=new WeakMap},7494:function(e){function t(e,t,n){var r,i,a,o,s;function l(){var u=Date.now()-o;u=0?r=setTimeout(l,t-u):(r=null,n||(s=e.apply(a,i),a=i=null))}null==t&&(t=100);var u=function(){a=this,i=arguments,o=Date.now();var u=n&&!r;return r||(r=setTimeout(l,t)),u&&(s=e.apply(a,i),a=i=null),s};return u.clear=function(){r&&(clearTimeout(r),r=null)},u.flush=function(){r&&(s=e.apply(a,i),a=i=null,clearTimeout(r),r=null)},u}t.debounce=t,e.exports=t},5456:function(e,t,n){"use strict";var r=n(9583);function i(e,t){for(var n in t)a(t,n)&&(e[n]=t[n])}function a(e,t){return Object.prototype.hasOwnProperty.call(e,t)}e.exports=function(e){r(e)||(e={});for(var t=arguments.length,n=1;n=t?e:t)),e}(c(e),t,n)}},8089:function(e,t,n){"use strict";var r=n(9105),i=n(7046),a=n(9944),o=n(7809);e.exports=function(e,t,n){if(!r(e))throw new TypeError("expected an object");if("string"!==typeof t||null==n)return i.apply(null,arguments);if("string"===typeof n)return o(e,t,n),e;var s=a(e,t);return r(n)&&r(s)&&(n=i({},s,n)),o(e,t,n),e}},7046:function(e,t,n){"use strict";var r=n(9105),i=n(8845);function a(e,t){for(var n=arguments.length,r=0;++r