(null);
18 | const [expanded, { toggle: toggleExpanded }] = useDisclosure(true);
19 | return (
20 |
27 | ({
30 | borderTopWidth: "1px",
31 | borderTopStyle: "solid",
32 | borderColor:
33 | useMantineColorScheme().colorScheme == "dark"
34 | ? theme.colors.dark[4]
35 | : theme.colors.gray[3],
36 | boxSizing: "border-box",
37 | width: "100%",
38 | zIndex: 10,
39 | position: "fixed",
40 | bottom: 0,
41 | left: 0,
42 | margin: 0,
43 | overflow: "scroll",
44 | minHeight: "3.5em",
45 | maxHeight: "60%",
46 | transition: "height 0.3s linear",
47 | })}
48 | ref={panelWrapperRef}
49 | >
50 | {children}
51 |
52 |
53 | );
54 | }
55 | BottomPanel.Handle = function BottomPanelHandle({
56 | children,
57 | }: {
58 | children: string | React.ReactNode;
59 | }) {
60 | const panelContext = React.useContext(BottomPanelContext)!;
61 | return (
62 | ({
65 | borderBottomWidth: panelContext.expanded ? "1px" : undefined,
66 | borderBottomStyle: "solid",
67 | borderColor:
68 | useMantineColorScheme().colorScheme == "dark"
69 | ? theme.colors.dark[4]
70 | : theme.colors.gray[3],
71 | cursor: "pointer",
72 | position: "relative",
73 | fontWeight: 400,
74 | userSelect: "none",
75 | display: "flex",
76 | alignItems: "center",
77 | padding: "0 0.8em",
78 | height: "3.5em",
79 | })}
80 | onClick={() => {
81 | panelContext.toggleExpanded();
82 | }}
83 | >
84 | {children}
85 |
86 | );
87 | };
88 |
89 | /** Contents of a panel. */
90 | BottomPanel.Contents = function BottomPanelContents({
91 | children,
92 | }: {
93 | children: string | React.ReactNode;
94 | }) {
95 | const panelContext = React.useContext(BottomPanelContext)!;
96 | return {children};
97 | };
98 |
99 | /** Hides contents when panel is collapsed. */
100 | BottomPanel.HideWhenCollapsed = function BottomPanelHideWhenCollapsed({
101 | children,
102 | }: {
103 | children: React.ReactNode;
104 | }) {
105 | const expanded = React.useContext(BottomPanelContext)?.expanded ?? true;
106 | return expanded ? children : null;
107 | };
108 |
--------------------------------------------------------------------------------
/examples/13_theming.py:
--------------------------------------------------------------------------------
1 | """Theming
2 |
3 | Viser includes support for light theming.
4 | """
5 |
6 | import time
7 |
8 | import viser
9 | from viser.theme import TitlebarButton, TitlebarConfig, TitlebarImage
10 |
11 |
12 | def main():
13 | server = viser.ViserServer(label="Viser Theming")
14 |
15 | buttons = (
16 | TitlebarButton(
17 | text="Getting Started",
18 | icon=None,
19 | href="https://nerf.studio",
20 | ),
21 | TitlebarButton(
22 | text="Github",
23 | icon="GitHub",
24 | href="https://github.com/nerfstudio-project/nerfstudio",
25 | ),
26 | TitlebarButton(
27 | text="Documentation",
28 | icon="Description",
29 | href="https://docs.nerf.studio",
30 | ),
31 | )
32 | image = TitlebarImage(
33 | image_url_light="https://docs.nerf.studio/_static/imgs/logo.png",
34 | image_url_dark="https://docs.nerf.studio/_static/imgs/logo-dark.png",
35 | image_alt="NerfStudio Logo",
36 | href="https://docs.nerf.studio/",
37 | )
38 | titlebar_theme = TitlebarConfig(buttons=buttons, image=image)
39 |
40 | server.gui.add_markdown(
41 | "Viser includes support for light theming via the `.configure_theme()` method."
42 | )
43 |
44 | gui_theme_code = server.gui.add_markdown("no theme applied yet")
45 |
46 | # GUI elements for controllable values.
47 | titlebar = server.gui.add_checkbox("Titlebar", initial_value=True)
48 | dark_mode = server.gui.add_checkbox("Dark mode", initial_value=True)
49 | show_logo = server.gui.add_checkbox("Show logo", initial_value=True)
50 | show_share_button = server.gui.add_checkbox("Show share button", initial_value=True)
51 | brand_color = server.gui.add_rgb("Brand color", (230, 180, 30))
52 | control_layout = server.gui.add_dropdown(
53 | "Control layout", ("floating", "fixed", "collapsible")
54 | )
55 | control_width = server.gui.add_dropdown(
56 | "Control width", ("small", "medium", "large"), initial_value="medium"
57 | )
58 | synchronize = server.gui.add_button("Apply theme", icon=viser.Icon.CHECK)
59 |
60 | def synchronize_theme() -> None:
61 | server.gui.configure_theme(
62 | titlebar_content=titlebar_theme if titlebar.value else None,
63 | control_layout=control_layout.value,
64 | control_width=control_width.value,
65 | dark_mode=dark_mode.value,
66 | show_logo=show_logo.value,
67 | show_share_button=show_share_button.value,
68 | brand_color=brand_color.value,
69 | )
70 | gui_theme_code.content = f"""
71 | ### Current applied theme
72 | ```
73 | server.gui.configure_theme(
74 | titlebar_content={"titlebar_content" if titlebar.value else None},
75 | control_layout="{control_layout.value}",
76 | control_width="{control_width.value}",
77 | dark_mode={dark_mode.value},
78 | show_logo={show_logo.value},
79 | show_share_button={show_share_button.value},
80 | brand_color={brand_color.value},
81 | )
82 | ```
83 | """
84 |
85 | synchronize.on_click(lambda _: synchronize_theme())
86 | synchronize_theme()
87 |
88 | while True:
89 | time.sleep(10.0)
90 |
91 |
92 | # main()
93 | if __name__ == "__main__":
94 | main()
95 |
--------------------------------------------------------------------------------
/examples/09_urdf_visualizer.py:
--------------------------------------------------------------------------------
1 | """Robot URDF visualizer
2 |
3 | Requires yourdfpy and robot_descriptions. Any URDF supported by yourdfpy should work.
4 | - https://github.com/robot-descriptions/robot_descriptions.py
5 | - https://github.com/clemense/yourdfpy
6 |
7 | The :class:`viser.extras.ViserUrdf` is a lightweight interface between yourdfpy
8 | and viser. It can also take a path to a local URDF file as input.
9 | """
10 |
11 | from __future__ import annotations
12 |
13 | import time
14 | from typing import Literal
15 |
16 | import numpy as onp
17 | import tyro
18 | from robot_descriptions.loaders.yourdfpy import load_robot_description
19 |
20 | import viser
21 | from viser.extras import ViserUrdf
22 |
23 |
24 | def create_robot_control_sliders(
25 | server: viser.ViserServer, viser_urdf: ViserUrdf
26 | ) -> tuple[list[viser.GuiInputHandle[float]], list[float]]:
27 | """Create slider for each joint of the robot. We also update robot model
28 | when slider moves."""
29 | slider_handles: list[viser.GuiInputHandle[float]] = []
30 | initial_config: list[float] = []
31 | for joint_name, (
32 | lower,
33 | upper,
34 | ) in viser_urdf.get_actuated_joint_limits().items():
35 | lower = lower if lower is not None else -onp.pi
36 | upper = upper if upper is not None else onp.pi
37 | initial_pos = 0.0 if lower < 0 and upper > 0 else (lower + upper) / 2.0
38 | slider = server.gui.add_slider(
39 | label=joint_name,
40 | min=lower,
41 | max=upper,
42 | step=1e-3,
43 | initial_value=initial_pos,
44 | )
45 | slider.on_update( # When sliders move, we update the URDF configuration.
46 | lambda _: viser_urdf.update_cfg(
47 | onp.array([slider.value for slider in slider_handles])
48 | )
49 | )
50 | slider_handles.append(slider)
51 | initial_config.append(initial_pos)
52 | return slider_handles, initial_config
53 |
54 |
55 | def main(
56 | robot_type: Literal[
57 | "panda",
58 | "ur10",
59 | "cassie",
60 | "allegro_hand",
61 | "barrett_hand",
62 | "robotiq_2f85",
63 | "atlas_drc",
64 | "g1",
65 | "h1",
66 | "anymal_c",
67 | "go2",
68 | ] = "panda",
69 | ) -> None:
70 | # Start viser server.
71 | server = viser.ViserServer()
72 |
73 | # Load URDF.
74 | #
75 | # This takes either a yourdfpy.URDF object or a path to a .urdf file.
76 | viser_urdf = ViserUrdf(
77 | server,
78 | urdf_or_path=load_robot_description(robot_type + "_description"),
79 | )
80 |
81 | # Create sliders in GUI that help us move the robot joints.
82 | with server.gui.add_folder("Joint position control"):
83 | (slider_handles, initial_config) = create_robot_control_sliders(
84 | server, viser_urdf
85 | )
86 |
87 | # Set initial robot configuration.
88 | viser_urdf.update_cfg(onp.array(initial_config))
89 |
90 | # Create joint reset button.
91 | reset_button = server.gui.add_button("Reset")
92 |
93 | @reset_button.on_click
94 | def _(_):
95 | for s, init_q in zip(slider_handles, initial_config):
96 | s.value = init_q
97 |
98 | # Sleep forever.
99 | while True:
100 | time.sleep(10.0)
101 |
102 |
103 | if __name__ == "__main__":
104 | tyro.cli(main)
105 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | viser
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | ### This repo is a customized version of https://github.com/nerfstudio-project/viser for project MonST3R (https://monst3r-project.github.io/)
17 |
18 | `viser` is a library for interactive 3D visualization in Python.
19 |
20 | Features include:
21 |
22 | - API for visualizing 3D primitives
23 | - GUI building blocks: buttons, checkboxes, text inputs, sliders, etc.
24 | - Scene interaction tools (clicks, selection, transform gizmos)
25 | - Programmatic camera control and rendering
26 | - An entirely web-based client, for easy use over SSH!
27 |
28 | For usage and API reference, see our documentation.
29 |
30 | ## Installation
31 |
32 | You can install `viser` with `pip`:
33 |
34 | ```bash
35 | pip install viser
36 | ```
37 |
38 | To include example dependencies:
39 |
40 | ```bash
41 | pip install viser[examples]
42 | ```
43 |
44 | After an example script is running, you can connect by navigating to the printed
45 | URL (default: `http://localhost:8080`).
46 |
47 | See also: our [development docs](https://viser.studio/latest/development/).
48 |
49 | ## Examples
50 |
51 | **Point cloud visualization**
52 |
53 | https://github.com/nerfstudio-project/viser/assets/6992947/df35c6ee-78a3-43ad-a2c7-1dddf83f7458
54 |
55 | Source: `./examples/07_record3d_visualizer.py`
56 |
57 | **Gaussian splatting visualization**
58 |
59 | https://github.com/nerfstudio-project/viser/assets/6992947/c51b4871-6cc8-4987-8751-2bf186bcb1ae
60 |
61 | Source:
62 | [WangFeng18/3d-gaussian-splatting](https://github.com/WangFeng18/3d-gaussian-splatting)
63 | and
64 | [heheyas/gaussian_splatting_3d](https://github.com/heheyas/gaussian_splatting_3d).
65 |
66 | **SMPLX visualizer**
67 |
68 | https://github.com/nerfstudio-project/viser/assets/6992947/78ba0e09-612d-4678-abf3-beaeeffddb01
69 |
70 | Source: `./example/08_smpl_visualizer.py`
71 |
72 | ## Acknowledgements
73 |
74 | `viser` is heavily inspired by packages like
75 | [Pangolin](https://github.com/stevenlovegrove/Pangolin),
76 | [rviz](https://wiki.ros.org/rviz/),
77 | [meshcat](https://github.com/rdeits/meshcat), and
78 | [Gradio](https://github.com/gradio-app/gradio).
79 | It's made possible by several open-source projects.
80 |
81 | The web client is implemented using [React](https://react.dev/), with:
82 |
83 | - [Vite](https://vitejs.dev/) / [Rollup](https://rollupjs.org/) for bundling
84 | - [three.js](https://threejs.org/) via [react-three-fiber](https://github.com/pmndrs/react-three-fiber) and [drei](https://github.com/pmndrs/drei)
85 | - [Mantine](https://mantine.dev/) for UI components
86 | - [zustand](https://github.com/pmndrs/zustand) for state management
87 | - [vanilla-extract](https://vanilla-extract.style/) for stylesheets
88 |
89 | The Python API communicates via [msgpack](https://msgpack.org/index.html) and [websockets](https://websockets.readthedocs.io/en/stable/index.html).
90 |
--------------------------------------------------------------------------------
/src/viser/client/src/components/Slider.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { GuiAddSliderMessage } from "../WebsocketMessages";
3 | import {
4 | Slider,
5 | Flex,
6 | NumberInput,
7 | useMantineColorScheme,
8 | } from "@mantine/core";
9 | import { GuiComponentContext } from "../ControlPanel/GuiComponentContext";
10 | import { ViserInputComponent } from "./common";
11 | import { sliderDefaultMarks } from "./ComponentStyles.css";
12 |
13 | export default function SliderComponent({
14 | id,
15 | label,
16 | hint,
17 | visible,
18 | disabled,
19 | value,
20 | ...otherProps
21 | }: GuiAddSliderMessage) {
22 | const { setValue } = React.useContext(GuiComponentContext)!;
23 | if (!visible) return <>>;
24 | const updateValue = (value: number) => setValue(id, value);
25 | const { min, max, precision, step, marks } = otherProps;
26 | const colorScheme = useMantineColorScheme().colorScheme;
27 | const input = (
28 |
29 | ({
37 | thumb: {
38 | height: "0.75rem",
39 | width: "0.5rem",
40 | },
41 | trackContainer: {
42 | zIndex: 3,
43 | position: "relative",
44 | },
45 | markLabel: {
46 | transform: "translate(-50%, 0.03rem)",
47 | fontSize: "0.6rem",
48 | textAlign: "center",
49 | },
50 | mark: {
51 | transform: "scale(1.95)",
52 | },
53 | markFilled: {
54 | background: disabled
55 | ? colorScheme === "dark"
56 | ? theme.colors.dark[3]
57 | : theme.colors.gray[4]
58 | : theme.primaryColor,
59 | },
60 | })}
61 | pt="0.3em"
62 | pb="0.2em"
63 | showLabelOnHover={false}
64 | min={min}
65 | max={max}
66 | step={step ?? undefined}
67 | precision={precision}
68 | value={value}
69 | onChange={updateValue}
70 | marks={
71 | marks === null
72 | ? [
73 | {
74 | value: min,
75 | label: `${parseInt(min.toFixed(6))}`,
76 | },
77 | {
78 | value: max,
79 | label: `${parseInt(max.toFixed(6))}`,
80 | },
81 | ]
82 | : marks
83 | }
84 | disabled={disabled}
85 | />
86 | {
89 | // Ignore empty values.
90 | newValue !== "" && updateValue(Number(newValue));
91 | }}
92 | size="xs"
93 | min={min}
94 | max={max}
95 | hideControls
96 | step={step ?? undefined}
97 | // precision={precision}
98 | style={{ width: "3rem" }}
99 | styles={{
100 | input: {
101 | padding: "0.375em",
102 | letterSpacing: "-0.5px",
103 | minHeight: "1.875em",
104 | height: "1.875em",
105 | },
106 | }}
107 | ml="xs"
108 | />
109 |
110 | );
111 |
112 | return (
113 | {input}
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/viser/client/src/components/MultiSliderPrimitive/Thumb/Thumb.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef, useState } from "react";
2 | import { Box } from "@mantine/core";
3 | import { Transition, TransitionOverride } from "@mantine/core";
4 | import { useSliderContext } from "../Slider.context";
5 |
6 | export interface ThumbProps {
7 | max: number;
8 | min: number;
9 | value: number;
10 | position: number;
11 | dragging: boolean;
12 | draggingThisThumb: boolean;
13 | label: React.ReactNode;
14 | onKeyDownCapture?: (event: React.KeyboardEvent) => void;
15 | onMouseDown?: (
16 | event: React.MouseEvent | React.TouchEvent,
17 | ) => void;
18 | labelTransitionProps: TransitionOverride | undefined;
19 | labelAlwaysOn: boolean | undefined;
20 | thumbLabel: string | undefined;
21 | onFocus?: () => void;
22 | onBlur?: () => void;
23 | showLabelOnHover: boolean | undefined;
24 | isHovered?: boolean;
25 | children?: React.ReactNode;
26 | disabled: boolean | undefined;
27 | className?: string;
28 | style?: React.CSSProperties;
29 | }
30 |
31 | export const Thumb = forwardRef(
32 | (
33 | {
34 | max,
35 | min,
36 | value,
37 | position,
38 | label,
39 | dragging,
40 | draggingThisThumb,
41 | onMouseDown,
42 | onKeyDownCapture,
43 | labelTransitionProps,
44 | labelAlwaysOn,
45 | thumbLabel,
46 | onFocus,
47 | onBlur,
48 | showLabelOnHover,
49 | isHovered,
50 | children = null,
51 | disabled,
52 | }: ThumbProps,
53 | ref,
54 | ) => {
55 | const { getStyles } = useSliderContext();
56 |
57 | const [focused, setFocused] = useState(false);
58 |
59 | const isVisible =
60 | labelAlwaysOn || dragging || focused || (showLabelOnHover && isHovered);
61 |
62 | return (
63 |
64 | tabIndex={0}
65 | role="slider"
66 | aria-label={thumbLabel}
67 | aria-valuemax={max}
68 | aria-valuemin={min}
69 | aria-valuenow={value}
70 | ref={ref}
71 | __vars={{ "--slider-thumb-offset": `${position}%` }}
72 | {...getStyles("thumb", {
73 | focusable: true,
74 | style: {
75 | /* Put active thumb + its label in front of others. */
76 | ...(draggingThisThumb ? { zIndex: 1000 } : {}),
77 | },
78 | })}
79 | mod={{ dragging, disabled }}
80 | onFocus={() => {
81 | setFocused(true);
82 | typeof onFocus === "function" && onFocus();
83 | }}
84 | onBlur={() => {
85 | setFocused(false);
86 | typeof onBlur === "function" && onBlur();
87 | }}
88 | onTouchStart={onMouseDown}
89 | onMouseDown={onMouseDown}
90 | onKeyDownCapture={onKeyDownCapture}
91 | onClick={(event) => event.stopPropagation()}
92 | >
93 | {children}
94 |
100 | {(transitionStyles) => (
101 |
108 | {label}
109 |
110 | )}
111 |
112 |
113 | );
114 | },
115 | );
116 |
117 | Thumb.displayName = "@mantine/core/SliderThumb";
118 |
--------------------------------------------------------------------------------
/docs/source/development.md:
--------------------------------------------------------------------------------
1 | # Development
2 |
3 | In this note, we outline current practices, tools, and workflows for `viser`
4 | development. We assume that the repository is cloned to `~/viser`.
5 |
6 | ## Python install
7 |
8 | We recommend an editable install for Python development, ideally in a virtual
9 | environment (eg via conda).
10 |
11 | ```bash
12 | # Install package.
13 | cd ~/viser
14 | pip install -e .
15 |
16 | # Install example dependencies.
17 | pip install -e .[examples]
18 | ```
19 |
20 | After installation, any of the example scripts (`~/viser/examples`) should be
21 | runnable. A few of them require downloading assets, which can be done via the
22 | scripts in `~/viser/examples/assets`.
23 |
24 | **Linting, formatting, type-checking.**
25 |
26 | First, install developer tools:
27 |
28 | ```bash
29 | # Using pip.
30 | pip install -e .[dev]
31 | pre-commit install
32 | ```
33 |
34 | It would be hard to write unit tests for `viser`. We rely on static typing for
35 | robustness. To check your code, you can run the following:
36 |
37 | ```bash
38 | # runs linting, formatting, and type-checking
39 | viser-dev-checks
40 | ```
41 |
42 | ## Message updates
43 |
44 | The `viser` frontend and backend communicate via a shared set of message
45 | definitions:
46 |
47 | - On the server, these are defined as Python dataclasses in
48 | `~/viser/src/viser/_messages.py`.
49 | - On the client, these are defined as TypeScript interfaces in
50 | `~/viser/src/viser/client/src/WebsocketMessages.tsx`.
51 |
52 | Note that there is a 1:1 correspondence between the dataclasses message types
53 | and the TypeScript ones.
54 |
55 | The TypeScript definitions should not be manually modified. Instead, changes
56 | should be made in Python and synchronized via the `sync_message_defs.py` script:
57 |
58 | ```
59 | cd ~/viser
60 | python sync_message_defs.py
61 | ```
62 |
63 | ## Client development
64 |
65 | For client development, we can start by launching a relevant Python script. The
66 | examples are a good place to start:
67 |
68 | ```
69 | cd ~/viser/examples
70 | python 05_camera_commands.py
71 | ```
72 |
73 | When a `viser` script is launched, two URLs will be printed:
74 |
75 | - An HTTP URL, like `http://localhost:8080`, which can be used to open a
76 | _pre-built_ version of the React frontend.
77 | - A websocket URL, like `ws://localhost:8080`, which client applications can
78 | connect to.
79 |
80 | If changes to the client source files are detected on startup, `viser` will
81 | re-build the client automatically. This is okay for quick changes, but for
82 | faster iteration we can also launch a development version of the frontend, which
83 | will reflect changes we make to the client source files
84 | (`~/viser/src/viser/client/src`) without a full build. This requires a few more
85 | steps.
86 |
87 | **Installing dependencies.**
88 |
89 | 1. [Install nodejs.](https://nodejs.dev/en/download/package-manager)
90 | 2. [Install yarn.](https://yarnpkg.com/getting-started/install)
91 | 3. Install dependencies.
92 | ```
93 | cd ~/viser/src/viser/client
94 | yarn install
95 | ```
96 |
97 | **Launching client.**
98 |
99 | To launch the client, we can run:
100 |
101 | ```
102 | cd ~/viser/src/viser/client
103 | yarn start
104 | ```
105 |
106 | from the `viser/src/viser/client` directory. After opening the client in a web
107 | browser, the websocket server address typically needs to be updated in the
108 | "Server" tab.
109 |
110 | **Formatting.**
111 |
112 | We use [prettier](https://prettier.io/docs/en/install.html). This can be run via
113 | one of:
114 |
115 | - `prettier -w .`
116 | - `npx prettier -w .`
117 |
118 | from `~/viser/src/viser/client`.
119 |
--------------------------------------------------------------------------------
/src/viser/client/src/WebsocketServerWorker.ts:
--------------------------------------------------------------------------------
1 | import { encode, decode } from "@msgpack/msgpack";
2 | import { Message } from "./WebsocketMessages";
3 | import AwaitLock from "await-lock";
4 |
5 | export type WsWorkerIncoming =
6 | | { type: "send"; message: Message }
7 | | { type: "set_server"; server: string }
8 | | { type: "close" };
9 |
10 | export type WsWorkerOutgoing =
11 | | { type: "connected" }
12 | | { type: "closed" }
13 | | { type: "message_batch"; messages: Message[] };
14 |
15 | // Helper function to collect all ArrayBuffer objects. This is used for postMessage() move semantics.
16 | function collectArrayBuffers(obj: any, buffers: Set) {
17 | if (obj instanceof ArrayBuffer) {
18 | buffers.add(obj);
19 | } else if (obj instanceof Uint8Array) {
20 | buffers.add(obj.buffer);
21 | } else if (obj && typeof obj === "object") {
22 | for (const key in obj) {
23 | if (Object.prototype.hasOwnProperty.call(obj, key)) {
24 | collectArrayBuffers(obj[key], buffers);
25 | }
26 | }
27 | }
28 | return buffers;
29 | }
30 | {
31 | let server: string | null = null;
32 | let ws: WebSocket | null = null;
33 | const orderLock = new AwaitLock();
34 |
35 | const postOutgoing = (
36 | data: WsWorkerOutgoing,
37 | transferable?: Transferable[],
38 | ) => {
39 | // @ts-ignore
40 | self.postMessage(data, transferable);
41 | };
42 |
43 | const tryConnect = () => {
44 | if (ws !== null) ws.close();
45 | ws = new WebSocket(server!);
46 |
47 | // Timeout is necessary when we're connecting to an SSH/tunneled port.
48 | const retryTimeout = setTimeout(() => {
49 | ws?.close();
50 | }, 5000);
51 |
52 | ws.onopen = () => {
53 | postOutgoing({ type: "connected" });
54 | clearTimeout(retryTimeout);
55 | console.log(`Connected! ${server}`);
56 | };
57 |
58 | ws.onclose = (event) => {
59 | postOutgoing({ type: "closed" });
60 | console.log(`Disconnected! ${server} code=${event.code}`);
61 | clearTimeout(retryTimeout);
62 |
63 | // Try to reconnect.
64 | if (server !== null) setTimeout(tryConnect, 1000);
65 | };
66 |
67 | ws.onmessage = async (event) => {
68 | // Reduce websocket backpressure.
69 | const messagePromise = new Promise((resolve) => {
70 | (event.data.arrayBuffer() as Promise).then((buffer) => {
71 | resolve(decode(new Uint8Array(buffer)) as Message[]);
72 | });
73 | });
74 |
75 | // Try our best to handle messages in order. If this takes more than 1 second, we give up. :)
76 | await orderLock.acquireAsync({ timeout: 1000 }).catch(() => {
77 | console.log("Order lock timed out.");
78 | orderLock.release();
79 | });
80 | try {
81 | const messages = await messagePromise;
82 | const arrayBuffers = collectArrayBuffers(messages, new Set());
83 | postOutgoing(
84 | { type: "message_batch", messages: messages },
85 | Array.from(arrayBuffers),
86 | );
87 | } finally {
88 | orderLock.acquired && orderLock.release();
89 | }
90 | };
91 | };
92 |
93 | self.onmessage = (e) => {
94 | const data: WsWorkerIncoming = e.data;
95 |
96 | if (data.type === "send") {
97 | ws!.send(encode(data.message));
98 | } else if (data.type === "set_server") {
99 | server = data.server;
100 | tryConnect();
101 | } else if (data.type == "close") {
102 | server = null;
103 | ws !== null && ws.close();
104 | self.close();
105 | } else {
106 | console.log(
107 | `WebSocket worker: got ${data}, not sure what to do with it!`,
108 | );
109 | }
110 | };
111 | }
112 |
--------------------------------------------------------------------------------
/src/viser/_icons_generate_enum.py:
--------------------------------------------------------------------------------
1 | """Helper script for dumping Tabler icon names into a Literal type."""
2 |
3 | import tarfile
4 | from pathlib import Path
5 |
6 | HERE_DIR = Path(__file__).absolute().parent
7 | ICON_DIR = HERE_DIR / "_icons"
8 |
9 |
10 | def enum_name_from_icon(name: str) -> str:
11 | """Capitalize an icon name for use as an enum name."""
12 | name = name.upper()
13 | name = name.replace("-", "_")
14 | if name[0].isdigit():
15 | name = "ICON_" + name
16 | return name
17 |
18 |
19 | if __name__ == "__main__":
20 | with tarfile.open(ICON_DIR / "tabler-icons.tar") as tar:
21 | icon_names = sorted([name.partition(".svg")[0] for name in tar.getnames()])
22 |
23 | # Generate stub file. This is used by type checkers.
24 | (HERE_DIR / "_icons_enum.pyi").write_text(
25 | "\n".join(
26 | [
27 | "# Automatically generated by `_icons_generate_enum.py`",
28 | "# See https://tabler-icons.io/",
29 | "import enum",
30 | "from typing import NewType",
31 | "",
32 | "IconName = NewType('IconName', str)",
33 | '"""Name of an icon. Should be generated via `viser.Icon.*`."""',
34 | "",
35 | "class Icon:",
36 | ' """\'Enum\' class for referencing Tabler icons.',
37 | "",
38 | " We don't subclass enum.Enum for performance reasons -- importing an enum with",
39 | " thousands of names can result in import times in the hundreds of milliseconds.",
40 | ' """',
41 | "",
42 | ]
43 | + [
44 | # Prefix all icon names with ICON_, since some of them start with
45 | # numbers and can't directly be used as Python names.
46 | f" {enum_name_from_icon(icon)}: IconName = IconName('{icon}')"
47 | for icon in icon_names
48 | ]
49 | )
50 | )
51 |
52 | # Generate source. This is used at runtime + by Sphinx for documentation.
53 | (HERE_DIR / "_icons_enum.py").write_text(
54 | "\n".join(
55 | [
56 | "# Automatically generated by `_icons_generate_enum.py`",
57 | "# See https://tabler-icons.io/",
58 | "from typing import NewType",
59 | "",
60 | "IconName = NewType('IconName', str)",
61 | '"""Name of an icon. Should be generated via `viser.Icon.*`."""',
62 | "",
63 | "",
64 | "class _IconStringConverter(type):",
65 | " def __getattr__(self, __name: str) -> IconName:",
66 | ' if not __name.startswith("_"):',
67 | ' return IconName(__name.lower().replace("_", "-"))',
68 | " else:",
69 | " raise AttributeError()",
70 | "",
71 | "",
72 | "class Icon(metaclass=_IconStringConverter):",
73 | ' """\'Enum\' class for referencing Tabler icons.',
74 | "",
75 | " We don't subclass enum.Enum for performance reasons -- importing an enum with",
76 | " thousands of names can result in import times in the hundreds of milliseconds.",
77 | "",
78 | " Attributes:",
79 | ]
80 | + [
81 | # Prefix all icon names with ICON_, since some of them start with
82 | # numbers and can't directly be used as Python names.
83 | f" {enum_name_from_icon(icon)} (IconName): The :code:`{icon}` icon."
84 | for icon in icon_names
85 | ]
86 | + [' """']
87 | )
88 | )
89 |
--------------------------------------------------------------------------------
/examples/03_gui_callbacks.py:
--------------------------------------------------------------------------------
1 | """GUI callbacks
2 |
3 | Asynchronous usage of GUI elements: we can attach callbacks that are called as soon as
4 | we get updates."""
5 |
6 | import time
7 |
8 | import numpy as onp
9 | from typing_extensions import assert_never
10 |
11 | import viser
12 |
13 |
14 | def main() -> None:
15 | server = viser.ViserServer()
16 |
17 | gui_reset_scene = server.gui.add_button("Reset Scene")
18 |
19 | gui_plane = server.gui.add_dropdown(
20 | "Grid plane", ("xz", "xy", "yx", "yz", "zx", "zy")
21 | )
22 |
23 | def update_plane() -> None:
24 | server.scene.add_grid(
25 | "/grid",
26 | width=10.0,
27 | height=20.0,
28 | width_segments=10,
29 | height_segments=20,
30 | plane=gui_plane.value,
31 | )
32 |
33 | gui_plane.on_update(lambda _: update_plane())
34 |
35 | with server.gui.add_folder("Control"):
36 | gui_show_frame = server.gui.add_checkbox("Show Frame", initial_value=True)
37 | gui_show_everything = server.gui.add_checkbox(
38 | "Show Everything", initial_value=True
39 | )
40 | gui_axis = server.gui.add_dropdown("Axis", ("x", "y", "z"))
41 | gui_include_z = server.gui.add_checkbox("Z in dropdown", initial_value=True)
42 |
43 | @gui_include_z.on_update
44 | def _(_) -> None:
45 | gui_axis.options = ("x", "y", "z") if gui_include_z.value else ("x", "y")
46 |
47 | with server.gui.add_folder("Sliders"):
48 | gui_location = server.gui.add_slider(
49 | "Location", min=-5.0, max=5.0, step=0.05, initial_value=0.0
50 | )
51 | gui_num_points = server.gui.add_slider(
52 | "# Points", min=1000, max=200_000, step=1000, initial_value=10_000
53 | )
54 |
55 | def draw_frame() -> None:
56 | axis = gui_axis.value
57 | if axis == "x":
58 | pos = (gui_location.value, 0.0, 0.0)
59 | elif axis == "y":
60 | pos = (0.0, gui_location.value, 0.0)
61 | elif axis == "z":
62 | pos = (0.0, 0.0, gui_location.value)
63 | else:
64 | assert_never(axis)
65 |
66 | server.scene.add_frame(
67 | "/frame",
68 | wxyz=(1.0, 0.0, 0.0, 0.0),
69 | position=pos,
70 | show_axes=gui_show_frame.value,
71 | axes_length=5.0,
72 | )
73 |
74 | def draw_points() -> None:
75 | num_points = gui_num_points.value
76 | server.scene.add_point_cloud(
77 | "/frame/point_cloud",
78 | points=onp.random.normal(size=(num_points, 3)),
79 | colors=onp.random.randint(0, 256, size=(num_points, 3)),
80 | )
81 |
82 | # We can (optionally) also attach callbacks!
83 | # Here, we update the point clouds + frames whenever any of the GUI items are updated.
84 | gui_show_frame.on_update(lambda _: draw_frame())
85 | gui_show_everything.on_update(
86 | lambda _: server.scene.set_global_visibility(gui_show_everything.value)
87 | )
88 | gui_axis.on_update(lambda _: draw_frame())
89 | gui_location.on_update(lambda _: draw_frame())
90 | gui_num_points.on_update(lambda _: draw_points())
91 |
92 | @gui_reset_scene.on_click
93 | def _(_) -> None:
94 | """Reset the scene when the reset button is clicked."""
95 | gui_show_frame.value = True
96 | gui_location.value = 0.0
97 | gui_axis.value = "x"
98 | gui_num_points.value = 10_000
99 |
100 | draw_frame()
101 | draw_points()
102 |
103 | # Finally, let's add the initial frame + point cloud and just loop infinitely. :)
104 | update_plane()
105 | draw_frame()
106 | draw_points()
107 | while True:
108 | time.sleep(1.0)
109 |
110 |
111 | if __name__ == "__main__":
112 | main()
113 |
--------------------------------------------------------------------------------
/examples/15_gui_in_scene.py:
--------------------------------------------------------------------------------
1 | """3D GUI elements
2 |
3 | `add_3d_gui_container()` allows standard GUI elements to be incorporated directly into a
4 | 3D scene. In this example, we click on coordinate frames to show actions that can be
5 | performed on them.
6 | """
7 |
8 | import time
9 | from typing import Optional
10 |
11 | import numpy as onp
12 |
13 | import viser
14 | import viser.transforms as tf
15 |
16 | server = viser.ViserServer()
17 | server.gui.configure_theme(dark_mode=True)
18 | num_frames = 20
19 |
20 |
21 | @server.on_client_connect
22 | def _(client: viser.ClientHandle) -> None:
23 | """For each client that connects, we create a set of random frames + a click handler for each frame.
24 |
25 | When a frame is clicked, we display a 3D gui node.
26 | """
27 |
28 | rng = onp.random.default_rng(0)
29 |
30 | displayed_3d_container: Optional[viser.Gui3dContainerHandle] = None
31 |
32 | def make_frame(i: int) -> None:
33 | # Sample a random orientation + position.
34 | wxyz = rng.normal(size=4)
35 | wxyz /= onp.linalg.norm(wxyz)
36 | position = rng.uniform(-3.0, 3.0, size=(3,))
37 |
38 | # Create a coordinate frame and label.
39 | frame = client.scene.add_frame(f"/frame_{i}", wxyz=wxyz, position=position)
40 |
41 | # Move the camera when we click a frame.
42 | @frame.on_click
43 | def _(_):
44 | nonlocal displayed_3d_container
45 |
46 | # Close previously opened GUI.
47 | if displayed_3d_container is not None:
48 | displayed_3d_container.remove()
49 |
50 | displayed_3d_container = client.scene.add_3d_gui_container(
51 | f"/frame_{i}/gui"
52 | )
53 | with displayed_3d_container:
54 | go_to = client.gui.add_button("Go to")
55 | randomize_orientation = client.gui.add_button("Randomize orientation")
56 | close = client.gui.add_button("Close GUI")
57 |
58 | @go_to.on_click
59 | def _(_) -> None:
60 | T_world_current = tf.SE3.from_rotation_and_translation(
61 | tf.SO3(client.camera.wxyz), client.camera.position
62 | )
63 | T_world_target = tf.SE3.from_rotation_and_translation(
64 | tf.SO3(frame.wxyz), frame.position
65 | ) @ tf.SE3.from_translation(onp.array([0.0, 0.0, -0.5]))
66 |
67 | T_current_target = T_world_current.inverse() @ T_world_target
68 |
69 | for j in range(20):
70 | T_world_set = T_world_current @ tf.SE3.exp(
71 | T_current_target.log() * j / 19.0
72 | )
73 |
74 | # Important bit: we atomically set both the orientation and the position
75 | # of the camera.
76 | with client.atomic():
77 | client.camera.wxyz = T_world_set.rotation().wxyz
78 | client.camera.position = T_world_set.translation()
79 | time.sleep(1.0 / 60.0)
80 |
81 | # Mouse interactions should orbit around the frame origin.
82 | client.camera.look_at = frame.position
83 |
84 | @randomize_orientation.on_click
85 | def _(_) -> None:
86 | wxyz = rng.normal(size=4)
87 | wxyz /= onp.linalg.norm(wxyz)
88 | frame.wxyz = wxyz
89 |
90 | @close.on_click
91 | def _(_) -> None:
92 | nonlocal displayed_3d_container
93 | if displayed_3d_container is None:
94 | return
95 | displayed_3d_container.remove()
96 | displayed_3d_container = None
97 |
98 | for i in range(num_frames):
99 | make_frame(i)
100 |
101 |
102 | while True:
103 | time.sleep(1.0)
104 |
--------------------------------------------------------------------------------
/src/viser/_notification_handle.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import dataclasses
4 | from typing import Literal
5 |
6 | from ._gui_api import Color
7 | from ._messages import NotificationMessage, RemoveNotificationMessage
8 | from .infra._infra import WebsockClientConnection
9 |
10 |
11 | @dataclasses.dataclass
12 | class _NotificationHandleState:
13 | websock_interface: WebsockClientConnection
14 | id: str
15 | title: str
16 | body: str
17 | loading: bool
18 | with_close_button: bool
19 | auto_close: int | Literal[False]
20 | color: Color | None
21 |
22 |
23 | @dataclasses.dataclass
24 | class NotificationHandle:
25 | """Handle for a notification in our visualizer."""
26 |
27 | _impl: _NotificationHandleState
28 |
29 | def _sync_with_client(self, first: bool = False) -> None:
30 | m = NotificationMessage(
31 | "show" if first else "update",
32 | self._impl.id,
33 | self._impl.title,
34 | self._impl.body,
35 | self._impl.loading,
36 | self._impl.with_close_button,
37 | self._impl.auto_close,
38 | self._impl.color,
39 | )
40 | self._impl.websock_interface.queue_message(m)
41 |
42 | @property
43 | def title(self) -> str:
44 | """Title to display on the notification."""
45 | return self._impl.title
46 |
47 | @title.setter
48 | def title(self, title: str) -> None:
49 | if title == self._impl.title:
50 | return
51 |
52 | self._impl.title = title
53 | self._sync_with_client()
54 |
55 | @property
56 | def body(self) -> str:
57 | """Message to display on the notification body."""
58 | return self._impl.body
59 |
60 | @body.setter
61 | def body(self, body: str) -> None:
62 | if body == self._impl.body:
63 | return
64 |
65 | self._impl.body = body
66 | self._sync_with_client()
67 |
68 | @property
69 | def loading(self) -> bool:
70 | """Whether the notification shows loading icon."""
71 | return self._impl.loading
72 |
73 | @loading.setter
74 | def loading(self, loading: bool) -> None:
75 | if loading == self._impl.loading:
76 | return
77 |
78 | self._impl.loading = loading
79 | self._sync_with_client()
80 |
81 | @property
82 | def with_close_button(self) -> bool:
83 | """Whether the notification can be manually closed."""
84 | return self._impl.with_close_button
85 |
86 | @with_close_button.setter
87 | def with_close_button(self, with_close_button: bool) -> None:
88 | if with_close_button == self._impl.with_close_button:
89 | return
90 |
91 | self._impl.with_close_button = with_close_button
92 | self._sync_with_client()
93 |
94 | @property
95 | def auto_close(self) -> int | Literal[False]:
96 | """Time in ms before the notification automatically closes;
97 | otherwise False such that the notification never closes on its own."""
98 | return self._impl.auto_close
99 |
100 | @auto_close.setter
101 | def auto_close(self, auto_close: int | Literal[False]) -> None:
102 | if auto_close == self._impl.auto_close:
103 | return
104 |
105 | self._impl.auto_close = auto_close
106 | self._sync_with_client()
107 |
108 | @property
109 | def color(self) -> Color | None:
110 | """Color of the notification."""
111 | return self._impl.color
112 |
113 | @color.setter
114 | def color(self, color: Color | None) -> None:
115 | if color == self._impl.color:
116 | return
117 |
118 | self._impl.color = color
119 | self._sync_with_client()
120 |
121 | def remove(self) -> None:
122 | self._impl.websock_interface.queue_message(
123 | RemoveNotificationMessage(self._impl.id)
124 | )
125 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=61.0"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "viser"
7 | version = "0.2.7"
8 | description = "3D visualization + Python"
9 | readme = "README.md"
10 | license = { text="MIT" }
11 | requires-python = ">=3.8"
12 | classifiers = [
13 | "Programming Language :: Python :: 3",
14 | "Programming Language :: Python :: 3.8",
15 | "Programming Language :: Python :: 3.9",
16 | "Programming Language :: Python :: 3.10",
17 | "Programming Language :: Python :: 3.11",
18 | "Programming Language :: Python :: 3.12",
19 | "License :: OSI Approved :: MIT License",
20 | "Operating System :: OS Independent"
21 | ]
22 | dependencies = [
23 | "websockets>=10.4",
24 | "numpy>=1.0.0",
25 | "msgspec>=0.18.6",
26 | "imageio>=2.0.0",
27 | "pyliblzfse>=0.4.1; platform_system!='Windows'",
28 | "scikit-image>=0.18.0",
29 | "scipy>=1.7.3",
30 | "tqdm>=4.0.0",
31 | "tyro>=0.2.0",
32 | "rich>=13.3.3",
33 | "trimesh>=3.21.7",
34 | "nodeenv>=1.8.0",
35 | "psutil>=5.9.5",
36 | "yourdfpy>=0.0.53",
37 | "plyfile>=1.0.2"
38 | ]
39 |
40 | [project.optional-dependencies]
41 | dev = [
42 | "pyright>=1.1.308",
43 | "ruff==0.6.2",
44 | "pre-commit==3.3.2",
45 | ]
46 | examples = [
47 | "torch>=1.13.1",
48 | "matplotlib>=3.7.1",
49 | "plotly>=5.21.0",
50 | "robot_descriptions>=1.10.0",
51 | "gdown>=4.6.6",
52 | "plyfile",
53 | ]
54 |
55 | [project.urls]
56 | "GitHub" = "https://github.com/nerfstudio-project/viser"
57 |
58 | # <>
59 | # Important: in the ./.github/workflows/publish.yml action, we have sed
60 | # commands that assume the `viser = ...` line below directly follows
61 | # `[tool.setuptools.package-data]`. We use this to remove the client source
62 | # from PyPI builds.
63 | #
64 | # We should make sure that any modifications to the package-data list remain
65 | # compatible with the sed commands!
66 | #
67 | # We keep the client source in by default to support things like pip
68 | # installation via the Git URL, because build artifacts aren't
69 | # version-controlled.
70 | [tool.setuptools.package-data]
71 | viser = ["py.typed", "*.pyi", "_icons/tabler-icons.tar", "client/**/*", "client/**/.*"]
72 | # >
73 |
74 | [tool.setuptools.exclude-package-data]
75 | # We exclude node_modules to prevent long build times for wheels when
76 | # installing from source, eg via `pip install .`.
77 | #
78 | # https://github.com/nerfstudio-project/viser/issues/271
79 | viser = ["**/node_modules/**"]
80 |
81 | [project.scripts]
82 | viser-dev-checks = "viser.scripts.dev_checks:entrypoint"
83 |
84 | [tool.pyright]
85 | exclude = ["./docs/**/*", "./examples/assets/**/*", "./src/viser/client/.nodeenv", "./build"]
86 |
87 | [tool.ruff]
88 | lint.select = [
89 | "E", # pycodestyle errors.
90 | "F", # Pyflakes rules.
91 | "PLC", # Pylint convention warnings.
92 | "PLE", # Pylint errors.
93 | "PLR", # Pylint refactor recommendations.
94 | "PLW", # Pylint warnings.
95 | "I", # Import sorting.
96 | ]
97 | lint.ignore = [
98 | "E741", # Ambiguous variable name. (l, O, or I)
99 | "E501", # Line too long.
100 | "E721", # Do not compare types, use `isinstance()`.
101 | "F722", # Forward annotation false positive from jaxtyping. Should be caught by pyright.
102 | "F821", # Forward annotation false positive from jaxtyping. Should be caught by pyright.
103 | "PLR2004", # Magic value used in comparison.
104 | "PLR0915", # Too many statements.
105 | "PLR0913", # Too many arguments.
106 | "PLC0414", # Import alias does not rename variable. (this is used for exporting names)
107 | "PLC1901", # Use falsey strings.
108 | "PLR5501", # Use `elif` instead of `else if`.
109 | "PLR0911", # Too many return statements.
110 | "PLR0912", # Too many branches.
111 | "PLW0603", # Globa statement updates are discouraged.
112 | "PLW2901", # For loop variable overwritten.
113 | "PLW0642", # Reassigned self in instance method.
114 | ]
115 | exclude = [ ".nodeenv" ]
116 |
--------------------------------------------------------------------------------
/src/viser/client/src/components/PlotlyComponent.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { GuiAddPlotlyMessage } from "../WebsocketMessages";
3 | import { useDisclosure } from "@mantine/hooks";
4 | import { Modal, Box, Paper, Tooltip } from "@mantine/core";
5 | import { useElementSize } from "@mantine/hooks";
6 |
7 | // When drawing border around the plot, it should be aligned with the folder's.
8 | import { folderWrapper } from "./Folder.css";
9 |
10 | const PlotWithAspect = React.memo(function PlotWithAspect({
11 | jsonStr,
12 | aspectRatio,
13 | staticPlot,
14 | }: {
15 | jsonStr: string;
16 | aspectRatio: number;
17 | staticPlot: boolean;
18 | }) {
19 | // Catch if the jsonStr is empty; if so, render an empty div.
20 | if (jsonStr === "") return ;
21 |
22 | // Parse json string, to construct plotly object.
23 | // Note that only the JSON string is kept as state, not the json object.
24 | const plotJson = JSON.parse(jsonStr);
25 |
26 | // This keeps the zoom-in state, etc, see https://plotly.com/javascript/uirevision/.
27 | plotJson.layout.uirevision = "true";
28 |
29 | // Box size change -> width value change -> plot rerender trigger.
30 | const { ref, width } = useElementSize();
31 | plotJson.layout.width = width;
32 | plotJson.layout.height = width * aspectRatio;
33 |
34 | // Make the plot non-interactable, if specified.
35 | // Ideally, we would use `staticplot`, but this has a known bug with 3D plots:
36 | // - https://github.com/plotly/plotly.js/issues/457
37 | // In the meantime, we choose to disable all interactions.
38 | if (staticPlot) {
39 | if (plotJson.config === undefined) plotJson.config = {};
40 | plotJson.config.displayModeBar = false;
41 | plotJson.layout.dragmode = false;
42 | plotJson.layout.hovermode = false;
43 | plotJson.layout.clickmode = "none";
44 | }
45 |
46 | // Use React hooks to update the plotly object, when the plot data changes.
47 | // based on https://github.com/plotly/react-plotly.js/issues/242.
48 | const plotRef = React.useRef(null);
49 | React.useEffect(() => {
50 | // @ts-ignore - Plotly.js is dynamically imported with an eval() call.
51 | Plotly.react(
52 | plotRef.current!,
53 | plotJson.data,
54 | plotJson.layout,
55 | plotJson.config,
56 | );
57 | }, [plotJson]);
58 |
59 | return (
60 |
66 |
67 | {/* Add a div on top of the plot, to prevent interaction + cursor changes. */}
68 | {staticPlot ? (
69 |
79 | ) : null}
80 |
81 | );
82 | });
83 |
84 | export default function PlotlyComponent({
85 | visible,
86 | plotly_json_str,
87 | aspect,
88 | }: GuiAddPlotlyMessage) {
89 | if (!visible) return <>>;
90 |
91 | // Create a modal with the plot, and a button to open it.
92 | const [opened, { open, close }] = useDisclosure(false);
93 | return (
94 |
95 | {/* Draw static plot in the controlpanel, which can be clicked. */}
96 |
97 |
105 |
110 |
111 |
112 |
113 | {/* Modal contents. keepMounted makes state changes (eg zoom) to the plot
114 | persistent. */}
115 |
116 |
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/examples/10_realsense.py:
--------------------------------------------------------------------------------
1 | """RealSense visualizer
2 |
3 | Connect to a RealSense camera, then visualize RGB-D readings as a point clouds. Requires
4 | pyrealsense2.
5 | """
6 |
7 | from __future__ import annotations
8 |
9 | import contextlib
10 |
11 | import numpy as np
12 | import numpy.typing as npt
13 | import pyrealsense2 as rs # type: ignore
14 | from tqdm.auto import tqdm
15 |
16 | import viser
17 |
18 |
19 | @contextlib.contextmanager
20 | def realsense_pipeline(fps: int = 30):
21 | """Context manager that yields a RealSense pipeline."""
22 |
23 | # Configure depth and color streams.
24 | pipeline = rs.pipeline() # type: ignore
25 | config = rs.config() # type: ignore
26 |
27 | pipeline_wrapper = rs.pipeline_wrapper(pipeline) # type: ignore
28 | config.resolve(pipeline_wrapper)
29 |
30 | config.enable_stream(rs.stream.depth, rs.format.z16, fps) # type: ignore
31 | config.enable_stream(rs.stream.color, rs.format.rgb8, fps) # type: ignore
32 |
33 | # Start streaming.
34 | pipeline.start(config)
35 |
36 | yield pipeline
37 |
38 | # Close pipeline when done.
39 | pipeline.close()
40 |
41 |
42 | def point_cloud_arrays_from_frames(
43 | depth_frame, color_frame
44 | ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint8]]:
45 | """Maps realsense frames to two arrays.
46 |
47 | Returns:
48 | - A point position array: (N, 3) float32.
49 | - A point color array: (N, 3) uint8.
50 | """
51 | # Processing blocks. Could be tuned.
52 | point_cloud = rs.pointcloud() # type: ignore
53 | decimate = rs.decimation_filter() # type: ignore
54 | decimate.set_option(rs.option.filter_magnitude, 3) # type: ignore
55 |
56 | # Downsample depth frame.
57 | depth_frame = decimate.process(depth_frame)
58 |
59 | # Map texture and calculate points from frames. Uses frame intrinsics.
60 | point_cloud.map_to(color_frame)
61 | points = point_cloud.calculate(depth_frame)
62 |
63 | # Get color coordinates.
64 | texture_uv = (
65 | np.asanyarray(points.get_texture_coordinates())
66 | .view(np.float32)
67 | .reshape((-1, 2))
68 | )
69 | color_image = np.asanyarray(color_frame.get_data())
70 | color_h, color_w, _ = color_image.shape
71 |
72 | # Note: for points that aren't in the view of our RGB camera, we currently clamp to
73 | # the closes available RGB pixel. We could also just remove these points.
74 | texture_uv = texture_uv.clip(0.0, 1.0)
75 |
76 | # Get positions and colors.
77 | positions = np.asanyarray(points.get_vertices()).view(np.float32)
78 | positions = positions.reshape((-1, 3))
79 | colors = color_image[
80 | (texture_uv[:, 1] * (color_h - 1.0)).astype(np.int32),
81 | (texture_uv[:, 0] * (color_w - 1.0)).astype(np.int32),
82 | :,
83 | ]
84 | N = positions.shape[0]
85 |
86 | assert positions.shape == (N, 3)
87 | assert positions.dtype == np.float32
88 | assert colors.shape == (N, 3)
89 | assert colors.dtype == np.uint8
90 |
91 | return positions, colors
92 |
93 |
94 | def main():
95 | # Start visualization server.
96 | server = viser.ViserServer()
97 |
98 | with realsense_pipeline() as pipeline:
99 | for i in tqdm(range(10000000)):
100 | # Wait for a coherent pair of frames: depth and color
101 | frames = pipeline.wait_for_frames()
102 | depth_frame = frames.get_depth_frame()
103 | color_frame = frames.get_color_frame()
104 |
105 | # Compute point cloud from frames.
106 | positions, colors = point_cloud_arrays_from_frames(depth_frame, color_frame)
107 |
108 | R = np.array(
109 | [
110 | [1.0, 0.0, 0.0],
111 | [0.0, 0.0, 1.0],
112 | [0.0, -1.0, 0.0],
113 | ],
114 | dtype=np.float32,
115 | )
116 | positions = positions @ R.T
117 |
118 | # Visualize.
119 | server.scene.add_point_cloud(
120 | "/realsense",
121 | points=positions * 10.0,
122 | colors=colors,
123 | point_size=0.1,
124 | )
125 |
126 |
127 | if __name__ == "__main__":
128 | main()
129 |
--------------------------------------------------------------------------------
/docs/source/examples/09_urdf_visualizer.rst:
--------------------------------------------------------------------------------
1 | .. Comment: this file is automatically generated by `update_example_docs.py`.
2 | It should not be modified manually.
3 |
4 | Robot URDF visualizer
5 | ==========================================
6 |
7 |
8 | Requires yourdfpy and robot_descriptions. Any URDF supported by yourdfpy should work.
9 |
10 |
11 | * https://github.com/robot-descriptions/robot_descriptions.py
12 | * https://github.com/clemense/yourdfpy
13 |
14 | The :class:`viser.extras.ViserUrdf` is a lightweight interface between yourdfpy
15 | and viser. It can also take a path to a local URDF file as input.
16 |
17 |
18 |
19 | .. code-block:: python
20 | :linenos:
21 |
22 |
23 | from __future__ import annotations
24 |
25 | import time
26 | from typing import Literal
27 |
28 | import numpy as onp
29 | import tyro
30 | import viser
31 | from robot_descriptions.loaders.yourdfpy import load_robot_description
32 | from viser.extras import ViserUrdf
33 |
34 |
35 | def create_robot_control_sliders(
36 | server: viser.ViserServer, viser_urdf: ViserUrdf
37 | ) -> tuple[list[viser.GuiInputHandle[float]], list[float]]:
38 | """Create slider for each joint of the robot. We also update robot model
39 | when slider moves."""
40 | slider_handles: list[viser.GuiInputHandle[float]] = []
41 | initial_config: list[float] = []
42 | for joint_name, (
43 | lower,
44 | upper,
45 | ) in viser_urdf.get_actuated_joint_limits().items():
46 | lower = lower if lower is not None else -onp.pi
47 | upper = upper if upper is not None else onp.pi
48 | initial_pos = 0.0 if lower < 0 and upper > 0 else (lower + upper) / 2.0
49 | slider = server.gui.add_slider(
50 | label=joint_name,
51 | min=lower,
52 | max=upper,
53 | step=1e-3,
54 | initial_value=initial_pos,
55 | )
56 | slider.on_update( # When sliders move, we update the URDF configuration.
57 | lambda _: viser_urdf.update_cfg(
58 | onp.array([slider.value for slider in slider_handles])
59 | )
60 | )
61 | slider_handles.append(slider)
62 | initial_config.append(initial_pos)
63 | return slider_handles, initial_config
64 |
65 |
66 | def main(
67 | robot_type: Literal[
68 | "panda",
69 | "ur10",
70 | "cassie",
71 | "allegro_hand",
72 | "barrett_hand",
73 | "robotiq_2f85",
74 | "atlas_drc",
75 | "g1",
76 | "h1",
77 | "anymal_c",
78 | "go2",
79 | ] = "panda",
80 | ) -> None:
81 | # Start viser server.
82 | server = viser.ViserServer()
83 |
84 | # Load URDF.
85 | #
86 | # This takes either a yourdfpy.URDF object or a path to a .urdf file.
87 | viser_urdf = ViserUrdf(
88 | server,
89 | urdf_or_path=load_robot_description(robot_type + "_description"),
90 | )
91 |
92 | # Create sliders in GUI that help us move the robot joints.
93 | with server.gui.add_folder("Joint position control"):
94 | (slider_handles, initial_config) = create_robot_control_sliders(
95 | server, viser_urdf
96 | )
97 |
98 | # Set initial robot configuration.
99 | viser_urdf.update_cfg(onp.array(initial_config))
100 |
101 | # Create joint reset button.
102 | reset_button = server.gui.add_button("Reset")
103 |
104 | @reset_button.on_click
105 | def _(_):
106 | for s, init_q in zip(slider_handles, initial_config):
107 | s.value = init_q
108 |
109 | # Sleep forever.
110 | while True:
111 | time.sleep(10.0)
112 |
113 |
114 | if __name__ == "__main__":
115 | tyro.cli(main)
116 |
--------------------------------------------------------------------------------
/docs/source/examples/24_notification.rst:
--------------------------------------------------------------------------------
1 | .. Comment: this file is automatically generated by `update_example_docs.py`.
2 | It should not be modified manually.
3 |
4 | Notifications
5 | ==========================================
6 |
7 |
8 | Examples of adding notifications per client in Viser.
9 |
10 |
11 |
12 | .. code-block:: python
13 | :linenos:
14 |
15 |
16 | import time
17 |
18 | import viser
19 |
20 |
21 | def main() -> None:
22 | server = viser.ViserServer()
23 |
24 | persistent_notif_button = server.gui.add_button(
25 | "Show persistent notification (default)"
26 | )
27 | timed_notif_button = server.gui.add_button("Show timed notification")
28 | controlled_notif_button = server.gui.add_button("Show controlled notification")
29 | loading_notif_button = server.gui.add_button("Show loading notification")
30 |
31 | remove_controlled_notif = server.gui.add_button("Remove controlled notification")
32 |
33 | @persistent_notif_button.on_click
34 | def _(event: viser.GuiEvent) -> None:
35 | """Show persistent notification when the button is clicked."""
36 | client = event.client
37 | assert client is not None
38 |
39 | client.add_notification(
40 | title="Persistent notification",
41 | body="This can be closed manually and does not disappear on its own!",
42 | loading=False,
43 | with_close_button=True,
44 | auto_close=False,
45 | )
46 |
47 | @timed_notif_button.on_click
48 | def _(event: viser.GuiEvent) -> None:
49 | """Show timed notification when the button is clicked."""
50 | client = event.client
51 | assert client is not None
52 |
53 | client.add_notification(
54 | title="Timed notification",
55 | body="This disappears automatically after 5 seconds!",
56 | loading=False,
57 | with_close_button=True,
58 | auto_close=5000,
59 | )
60 |
61 | @controlled_notif_button.on_click
62 | def _(event: viser.GuiEvent) -> None:
63 | """Show controlled notification when the button is clicked."""
64 | client = event.client
65 | assert client is not None
66 |
67 | controlled_notif = client.add_notification(
68 | title="Controlled notification",
69 | body="This cannot be closed by the user and is controlled in code only!",
70 | loading=False,
71 | with_close_button=False,
72 | auto_close=False,
73 | )
74 |
75 | @remove_controlled_notif.on_click
76 | def _(_) -> None:
77 | """Remove controlled notification."""
78 | controlled_notif.remove()
79 |
80 | @loading_notif_button.on_click
81 | def _(event: viser.GuiEvent) -> None:
82 | """Show loading notification when the button is clicked."""
83 | client = event.client
84 | assert client is not None
85 |
86 | loading_notif = client.add_notification(
87 | title="Loading notification",
88 | body="This indicates that some action is in progress! It will be updated in 3 seconds.",
89 | loading=True,
90 | with_close_button=False,
91 | auto_close=False,
92 | )
93 |
94 | time.sleep(3.0)
95 |
96 | loading_notif.title = "Updated notification"
97 | loading_notif.body = "This notification has been updated!"
98 | loading_notif.loading = False
99 | loading_notif.with_close_button = True
100 | loading_notif.auto_close = 5000
101 | loading_notif.color = "green"
102 |
103 | while True:
104 | time.sleep(1.0)
105 |
106 |
107 | if __name__ == "__main__":
108 | main()
109 |
--------------------------------------------------------------------------------