diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 911dd23a4..e543e3949 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,6 +3,9 @@ name: docs on: push: branches: [main] + pull_request: + branches: [main] + workflow_dispatch: jobs: docs: @@ -14,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: "3.10" # Build documentation - name: Building documentation @@ -31,3 +34,4 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs/build cname: viser.studio + if: github.event_name != 'pull_request' diff --git a/.github/workflows/typescript-compile.yml b/.github/workflows/typescript-compile.yml index 7192ea21f..fb4beea07 100644 --- a/.github/workflows/typescript-compile.yml +++ b/.github/workflows/typescript-compile.yml @@ -14,7 +14,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v3 with: - node-version: 19 + node-version: 20 - name: Run tsc run: | cd src/viser/client diff --git a/docs/requirements.txt b/docs/requirements.txt index f913bc506..7260ab5aa 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,8 +1,7 @@ -sphinx==5.2.0 -furo==2023.03.27 -docutils==0.17.1 -sphinx-autoapi==2.1.0 -m2r2==0.3.2 +sphinx==7.2.6 +furo==2023.9.10 +docutils==0.20.1 +sphinx-autoapi==3.0.0 +m2r2==0.3.3.post2 git+https://github.com/brentyi/sphinxcontrib-programoutput.git git+https://github.com/brentyi/ansi.git -setuptools diff --git a/docs/source/conf.py b/docs/source/conf.py index 596c206a3..f116ad8fe 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -212,6 +212,7 @@ # -- Options for autoapi extension -------------------------------------------- autoapi_dirs = ["../../src/viser"] autoapi_root = "api" +autoapi_ignore = ["**/client/**/*.py"] autoapi_options = [ "members", "inherited-members", diff --git a/docs/source/examples/15_gui_in_scene.rst b/docs/source/examples/15_gui_in_scene.rst index d75e1aaa2..a8dc4a549 100644 --- a/docs/source/examples/15_gui_in_scene.rst +++ b/docs/source/examples/15_gui_in_scene.rst @@ -37,7 +37,6 @@ performed on them. rng = onp.random.default_rng(0) displayed_3d_container: Optional[viser.Gui3dContainerHandle] = None - displayed_index: Optional[int] = None def make_frame(i: int) -> None: # Sample a random orientation + position. @@ -52,18 +51,11 @@ performed on them. @frame.on_click def _(_): nonlocal displayed_3d_container - nonlocal displayed_index # Close previously opened GUI. if displayed_3d_container is not None: displayed_3d_container.remove() - # Don't re-show the same GUI element. - if displayed_index == i: - return - - displayed_index = i - displayed_3d_container = client.add_3d_gui_container(f"/frame_{i}/gui") with displayed_3d_container: go_to = client.add_gui_button("Go to") @@ -105,12 +97,10 @@ performed on them. @close.on_click def _(_) -> None: nonlocal displayed_3d_container - nonlocal displayed_index if displayed_3d_container is None: return displayed_3d_container.remove() displayed_3d_container = None - displayed_index = None for i in range(num_frames): make_frame(i) diff --git a/examples/14_markdown.py b/examples/14_markdown.py index 3579cbde5..4d85ac6cc 100644 --- a/examples/14_markdown.py +++ b/examples/14_markdown.py @@ -11,24 +11,32 @@ server = viser.ViserServer() server.world_axes.visible = True +markdown_counter = server.add_gui_markdown("Counter: 0") -@server.on_client_connect -def _(client: viser.ClientHandle) -> None: - here = Path(__file__).absolute().parent - markdown_source = (here / "./assets/mdx_example.mdx").read_text() - markdown = client.add_gui_markdown(markdown=markdown_source, image_root=here) +here = Path(__file__).absolute().parent - button = client.add_gui_button("Remove Markdown") - checkbox = client.add_gui_checkbox("Visibility", initial_value=True) +button = server.add_gui_button("Remove blurb") +checkbox = server.add_gui_checkbox("Visibility", initial_value=True) - @button.on_click - def _(_): - markdown.remove() +markdown_source = (here / "./assets/mdx_example.mdx").read_text() +markdown_blurb = server.add_gui_markdown( + content=markdown_source, + image_root=here, +) - @checkbox.on_update - def _(_): - markdown.visible = checkbox.value +@button.on_click +def _(_): + markdown_blurb.remove() + +@checkbox.on_update +def _(_): + markdown_blurb.visible = checkbox.value + + +counter = 0 while True: - time.sleep(10.0) + markdown_counter.content = f"Counter: {counter}" + counter += 1 + time.sleep(0.1) diff --git a/examples/15_gui_in_scene.py b/examples/15_gui_in_scene.py index 2ff54f4fd..d57e76db2 100644 --- a/examples/15_gui_in_scene.py +++ b/examples/15_gui_in_scene.py @@ -32,7 +32,6 @@ def _(client: viser.ClientHandle) -> None: rng = onp.random.default_rng(0) displayed_3d_container: Optional[viser.Gui3dContainerHandle] = None - displayed_index: Optional[int] = None def make_frame(i: int) -> None: # Sample a random orientation + position. @@ -47,18 +46,11 @@ def make_frame(i: int) -> None: @frame.on_click def _(_): nonlocal displayed_3d_container - nonlocal displayed_index # Close previously opened GUI. if displayed_3d_container is not None: displayed_3d_container.remove() - # Don't re-show the same GUI element. - if displayed_index == i: - return - - displayed_index = i - displayed_3d_container = client.add_3d_gui_container(f"/frame_{i}/gui") with displayed_3d_container: go_to = client.add_gui_button("Go to") @@ -100,12 +92,10 @@ def _(_) -> None: @close.on_click def _(_) -> None: nonlocal displayed_3d_container - nonlocal displayed_index if displayed_3d_container is None: return displayed_3d_container.remove() displayed_3d_container = None - displayed_index = None for i in range(num_frames): make_frame(i) diff --git a/examples/17_background_composite.py b/examples/17_background_composite.py index caa422e9c..c839937ef 100644 --- a/examples/17_background_composite.py +++ b/examples/17_background_composite.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="var-annotated" """Depth compositing In this example, we show how to use a background image with depth compositing. This can diff --git a/pyproject.toml b/pyproject.toml index ca29e0cc7..4e115d20a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "viser" -version = "0.1.5" +version = "0.1.6" description = "3D visualization + Python" readme = "README.md" license = { text="MIT" } @@ -67,6 +67,7 @@ python_version = "3.8" ignore_missing_imports = true warn_unused_configs = true exclude="viser/client/.nodeenv" +disable_error_code="var-annotated" # Common source of mypy + numpy false positives. [tool.pyright] exclude = ["./docs/**/*", "./examples/assets/**/*", "./src/viser/client/.nodeenv", "./build"] diff --git a/src/viser/_gui_api.py b/src/viser/_gui_api.py index cacc3a027..039f5d127 100644 --- a/src/viser/_gui_api.py +++ b/src/viser/_gui_api.py @@ -6,10 +6,8 @@ import abc import dataclasses -import re import threading import time -import urllib.parse import warnings from pathlib import Path from typing import ( @@ -24,7 +22,6 @@ overload, ) -import imageio.v3 as iio import numpy as onp from typing_extensions import Literal, LiteralString @@ -47,7 +44,7 @@ ) from ._icons import base64_from_icon from ._icons_enum import Icon -from ._message_api import MessageApi, _encode_image_base64, cast_vector +from ._message_api import MessageApi, cast_vector if TYPE_CHECKING: from .infra import ClientId @@ -87,38 +84,6 @@ def _compute_precision_digits(x: float) -> int: return digits -def _get_data_url(url: str, image_root: Optional[Path]) -> str: - if not url.startswith("http") and not image_root: - warnings.warn( - "No `image_root` provided. All relative paths will be scoped to viser's installation path.", - stacklevel=2, - ) - if url.startswith("http"): - return url - if image_root is None: - image_root = Path(__file__).parent - try: - image = iio.imread(image_root / url) - data_uri = _encode_image_base64(image, "png") - url = urllib.parse.quote(f"{data_uri[1]}") - return f"data:{data_uri[0]};base64,{url}" - except (IOError, FileNotFoundError): - warnings.warn( - f"Failed to read image {url}, with image_root set to {image_root}.", - stacklevel=2, - ) - return url - - -def _parse_markdown(markdown: str, image_root: Optional[Path]) -> str: - markdown = re.sub( - r"\!\[([^]]*)\]\(([^]]*)\)", - lambda match: f"![{match.group(1)}]({_get_data_url(match.group(2), image_root)})", - markdown, - ) - return markdown - - @dataclasses.dataclass class _RootGuiContainer: _children: Dict[str, SupportsRemoveProtocol] @@ -277,30 +242,25 @@ def add_gui_tab_group( def add_gui_markdown( self, - markdown: str, + content: str, image_root: Optional[Path] = None, order: Optional[float] = None, ) -> GuiMarkdownHandle: """Add markdown to the GUI.""" - markdown = _parse_markdown(markdown, image_root) - markdown_id = _make_unique_id() - order = _apply_default_order(order) - self._get_api()._queue( - _messages.GuiAddMarkdownMessage( - order=order, - id=markdown_id, - markdown=markdown, - container_id=self._get_container_id(), - ) - ) - return GuiMarkdownHandle( + handle = GuiMarkdownHandle( _gui_api=self, - _id=markdown_id, + _id=_make_unique_id(), _visible=True, _container_id=self._get_container_id(), - _order=order, + _order=_apply_default_order(order), + _image_root=image_root, + _content=None, ) + # Assigning content will send a GuiAddMarkdownMessage. + handle.content = content + return handle + def add_gui_button( self, label: str, diff --git a/src/viser/_gui_handles.py b/src/viser/_gui_handles.py index c3bad52cf..50c5556b4 100644 --- a/src/viser/_gui_handles.py +++ b/src/viser/_gui_handles.py @@ -1,9 +1,13 @@ from __future__ import annotations import dataclasses +import re import threading import time +import urllib.parse import uuid +import warnings +from pathlib import Path from typing import ( TYPE_CHECKING, Callable, @@ -18,13 +22,16 @@ Union, ) +import imageio.v3 as iio import numpy as onp from typing_extensions import Protocol from ._icons import base64_from_icon from ._icons_enum import Icon +from ._message_api import _encode_image_base64 from ._messages import ( GuiAddDropdownMessage, + GuiAddMarkdownMessage, GuiAddTabGroupMessage, GuiCloseModalMessage, GuiRemoveMessage, @@ -493,6 +500,38 @@ def remove(self) -> None: child.remove() +def _get_data_url(url: str, image_root: Optional[Path]) -> str: + if not url.startswith("http") and not image_root: + warnings.warn( + "No `image_root` provided. All relative paths will be scoped to viser's installation path.", + stacklevel=2, + ) + if url.startswith("http"): + return url + if image_root is None: + image_root = Path(__file__).parent + try: + image = iio.imread(image_root / url) + data_uri = _encode_image_base64(image, "png") + url = urllib.parse.quote(f"{data_uri[1]}") + return f"data:{data_uri[0]};base64,{url}" + except (IOError, FileNotFoundError): + warnings.warn( + f"Failed to read image {url}, with image_root set to {image_root}.", + stacklevel=2, + ) + return url + + +def _parse_markdown(markdown: str, image_root: Optional[Path]) -> str: + markdown = re.sub( + r"\!\[([^]]*)\]\(([^]]*)\)", + lambda match: f"![{match.group(1)}]({_get_data_url(match.group(2), image_root)})", + markdown, + ) + return markdown + + @dataclasses.dataclass class GuiMarkdownHandle: """Use to remove markdown.""" @@ -502,6 +541,26 @@ class GuiMarkdownHandle: _visible: bool _container_id: str # Parent. _order: float + _image_root: Optional[Path] + _content: Optional[str] + + @property + def content(self) -> str: + """Current content of this markdown element. Synchronized automatically when assigned.""" + assert self._content is not None + return self._content + + @content.setter + def content(self, content: str) -> None: + self._content = content + self._gui_api._get_api()._queue( + GuiAddMarkdownMessage( + order=self._order, + id=self._id, + markdown=_parse_markdown(content, self._image_root), + container_id=self._container_id, + ) + ) @property def order(self) -> float: diff --git a/src/viser/_message_api.py b/src/viser/_message_api.py index 192d726a7..271f860dc 100644 --- a/src/viser/_message_api.py +++ b/src/viser/_message_api.py @@ -48,7 +48,6 @@ PointCloudHandle, SceneNodeHandle, TransformControlsHandle, - _SupportsVisibility, _TransformControlsState, ) @@ -372,10 +371,11 @@ def add_label( text: str, 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), + visible: bool = True, ) -> LabelHandle: """Add a 2D label to the scene.""" self._queue(_messages.LabelMessage(name, text)) - return LabelHandle._make(self, name, wxyz, position) + return LabelHandle._make(self, name, wxyz, position, visible=visible) def add_point_cloud( self, @@ -581,7 +581,7 @@ def sync_cb(client_id: ClientId, state: TransformControlsHandle) -> None: message_position.excluded_self_client = client_id self._queue(message_position) - node_handle = _SupportsVisibility._make(self, name, wxyz, position, visible) + node_handle = SceneNodeHandle._make(self, name, wxyz, position, visible) state_aux = _TransformControlsState( last_updated=time.time(), update_cb=[], @@ -703,6 +703,7 @@ def add_3d_gui_container( name: str, 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), + visible: bool = True, ) -> Gui3dContainerHandle: """Add a 3D gui container to the scene. The returned container handle can be used as a context to place GUI elements into the 3D scene.""" @@ -728,5 +729,5 @@ def add_3d_gui_container( container_id=container_id, ) ) - node_handle = SceneNodeHandle._make(self, name, wxyz, position) + node_handle = SceneNodeHandle._make(self, name, wxyz, position, visible=visible) return Gui3dContainerHandle(node_handle._impl, gui_api, container_id) diff --git a/src/viser/_scene_handles.py b/src/viser/_scene_handles.py index beffa6c03..c900c7642 100644 --- a/src/viser/_scene_handles.py +++ b/src/viser/_scene_handles.py @@ -40,7 +40,6 @@ class ScenePointerEvent: TSceneNodeHandle = TypeVar("TSceneNodeHandle", bound="SceneNodeHandle") -TSupportsVisibility = TypeVar("TSupportsVisibility", bound="_SupportsVisibility") @dataclasses.dataclass @@ -73,12 +72,14 @@ def _make( name: str, wxyz: Tuple[float, float, float, float] | onp.ndarray, position: Tuple[float, float, float] | onp.ndarray, + visible: bool, ) -> TSceneNodeHandle: out = cls(_SceneNodeHandleState(name, api)) api._handle_from_node_name[name] = out out.wxyz = wxyz out.position = position + out.visible = visible return out @property @@ -115,6 +116,18 @@ def position(self, position: Tuple[float, float, float] | onp.ndarray) -> None: _messages.SetPositionMessage(self._impl.name, position_cast) ) + @property + def visible(self) -> bool: + """Whether the scene node is visible or not. Synchronized to clients automatically when assigned.""" + return self._impl.visible + + @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._queue(_messages.RemoveSceneNodeMessage(self._impl.name)) @@ -127,7 +140,7 @@ class SceneNodePointerEvent(Generic[TSceneNodeHandle]): @dataclasses.dataclass -class _SupportsClick(SceneNodeHandle): +class _ClickableSceneNodeHandle(SceneNodeHandle): def on_click( self: TSceneNodeHandle, func: Callable[[SceneNodePointerEvent[TSceneNodeHandle]], None], @@ -148,71 +161,38 @@ def on_click( @dataclasses.dataclass -class _SupportsVisibility(SceneNodeHandle): - @classmethod - def _make( - cls: Type[TSupportsVisibility], - api: MessageApi, - name: str, - wxyz: Tuple[float, float, float, float] | onp.ndarray, - position: Tuple[float, float, float] | onp.ndarray, - visible: bool = True, - ) -> TSupportsVisibility: - out = cls(_SceneNodeHandleState(name, api)) - api._handle_from_node_name[name] = out - - out.wxyz = wxyz - out.position = position - out.visible = visible - - return out - - @property - def visible(self) -> bool: - """Whether the scene node is visible or not. Synchronized to clients automatically when assigned.""" - return self._impl.visible - - @visible.setter - def visible(self, visible: bool) -> None: - self._impl.api._queue( - _messages.SetSceneNodeVisibilityMessage(self._impl.name, visible) - ) - self._impl.visible = visible - - -@dataclasses.dataclass -class CameraFrustumHandle(_SupportsClick, _SupportsVisibility): +class CameraFrustumHandle(_ClickableSceneNodeHandle): """Handle for camera frustums.""" @dataclasses.dataclass -class PointCloudHandle(_SupportsVisibility): +class PointCloudHandle(SceneNodeHandle): """Handle for point clouds. Does not support click events.""" @dataclasses.dataclass -class FrameHandle(_SupportsClick, _SupportsVisibility): +class FrameHandle(_ClickableSceneNodeHandle): """Handle for coordinate frames.""" @dataclasses.dataclass -class MeshHandle(_SupportsClick, _SupportsVisibility): +class MeshHandle(_ClickableSceneNodeHandle): """Handle for mesh objects.""" @dataclasses.dataclass -class GlbHandle(_SupportsClick, _SupportsVisibility): +class GlbHandle(_ClickableSceneNodeHandle): """Handle for GLB objects.""" @dataclasses.dataclass -class ImageHandle(_SupportsClick, _SupportsVisibility): +class ImageHandle(_ClickableSceneNodeHandle): """Handle for 2D images, rendered in 3D.""" @dataclasses.dataclass class LabelHandle(SceneNodeHandle): - """Handle for 2D label objects. Does not support click events or visibility toggling.""" + """Handle for 2D label objects. Does not support click events.""" @dataclasses.dataclass @@ -223,7 +203,7 @@ class _TransformControlsState: @dataclasses.dataclass -class TransformControlsHandle(_SupportsClick, _SupportsVisibility): +class TransformControlsHandle(_ClickableSceneNodeHandle): """Handle for interacting with transform control gizmos.""" _impl_aux: _TransformControlsState diff --git a/src/viser/_viser.py b/src/viser/_viser.py index 692661f29..df587330f 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -408,13 +408,15 @@ def _(conn: infra.ClientConnection) -> None: ) table.add_row("HTTP", http_url) table.add_row("Websocket", ws_url) - rich.print(Panel(table, title="[bold]viser[/bold]", expand=False)) # Create share tunnel if requested. if not share: self._share_tunnel = None + rich.print(Panel(table, title="[bold]viser[/bold]", expand=False)) else: - rich.print("[bold](viser)[/bold] Share URL requested!") + rich.print( + "[bold](viser)[/bold] Share URL requested! (expires in 24 hours)" + ) self._share_tunnel = _ViserTunnel(port) @self._share_tunnel.on_connect @@ -424,9 +426,8 @@ def _() -> None: if share_url is None: rich.print("[bold](viser)[/bold] Could not generate share URL") else: - rich.print( - f"[bold](viser)[/bold] Share URL (expires in 24 hours): {share_url}" - ) + table.add_row("Share URL", share_url) + rich.print(Panel(table, title="[bold]viser[/bold]", expand=False)) self.reset_scene() self.world_axes = FrameHandle( diff --git a/src/viser/client/package.json b/src/viser/client/package.json index 43b20fcf7..748216ecc 100644 --- a/src/viser/client/package.json +++ b/src/viser/client/package.json @@ -1,5 +1,5 @@ { - "name": "viewer", + "name": "viser", "version": "0.1.0", "private": true, "dependencies": { @@ -28,7 +28,7 @@ "isomorphic-ws": "^5.0.0", "mantine-react-table": "^1.0.0-beta.11", "msgpackr": "^1.8.5", - "prettier": "^2.8.7", + "prettier": "^3.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.10", diff --git a/src/viser/client/src/App.tsx b/src/viser/client/src/App.tsx index 942cb4db6..afcf5e87b 100644 --- a/src/viser/client/src/App.tsx +++ b/src/viser/client/src/App.tsx @@ -185,7 +185,7 @@ function ViewerCanvas({ children }: { children: React.ReactNode }) { - + void; }>(null); +/** A bottom panel is used to display the controls on mobile devices. */ export default function BottomPanel({ children, }: { @@ -77,6 +78,7 @@ BottomPanel.Handle = function BottomPanelHandle({ ); }; + /** Contents of a panel. */ BottomPanel.Contents = function BottomPanelContents({ children, diff --git a/src/viser/client/src/ControlPanel/FloatingPanel.tsx b/src/viser/client/src/ControlPanel/FloatingPanel.tsx index ce9f6796a..aa688e5d1 100644 --- a/src/viser/client/src/ControlPanel/FloatingPanel.tsx +++ b/src/viser/client/src/ControlPanel/FloatingPanel.tsx @@ -24,8 +24,7 @@ const FloatingPanelContext = React.createContext; }>(null); -/** Root component for control panel. Parents a set of control tabs. - * This could be refactored+cleaned up a lot! */ +/** A floating panel for displaying controls. */ export default function FloatingPanel({ children, }: { @@ -288,7 +287,13 @@ FloatingPanel.Contents = function FloatingPanelContents({ return ( - {children} + + {children} + ); diff --git a/src/viser/client/src/ControlPanel/SidebarPanel.tsx b/src/viser/client/src/ControlPanel/SidebarPanel.tsx index cdcb6b584..c3aebd97b 100644 --- a/src/viser/client/src/ControlPanel/SidebarPanel.tsx +++ b/src/viser/client/src/ControlPanel/SidebarPanel.tsx @@ -10,8 +10,7 @@ export const SidebarPanelContext = React.createContext void; }>(null); -/** Root component for control panel. Parents a set of control tabs. - * This could be refactored+cleaned up a lot! */ +/** A fixed or collapsible side panel for displaying controls. */ export default function SidebarPanel({ children, collapsible, @@ -73,9 +72,9 @@ export default function SidebarPanel({ }} > {children} diff --git a/src/viser/client/src/Markdown.tsx b/src/viser/client/src/Markdown.tsx index 9d3ce865b..91826438b 100644 --- a/src/viser/client/src/Markdown.tsx +++ b/src/viser/client/src/Markdown.tsx @@ -177,7 +177,7 @@ export default function Markdown(props: { children?: string }) { } catch { setChild(Error Parsing Markdown...); } - }, []); + }, [props.children]); return child; } diff --git a/src/viser/client/src/SceneTree.tsx b/src/viser/client/src/SceneTree.tsx index 9d482a904..39d37c6f0 100644 --- a/src/viser/client/src/SceneTree.tsx +++ b/src/viser/client/src/SceneTree.tsx @@ -26,6 +26,12 @@ export class SceneNode { public name: string, public makeObject: MakeObject, public cleanup?: () => void, + /** unmountWhenInvisible is used to unmount components when they + * should be hidden. + * + * https://github.com/pmndrs/drei/issues/1323 + */ + public unmountWhenInvisible?: true, ) { this.children = []; this.clickable = false; @@ -49,7 +55,13 @@ function SceneNodeThreeChildren(props: { {children && children.map((child_id) => { - return ; + return ( + + ); })} , @@ -81,7 +93,10 @@ function SceneNodeLabel(props: { name: string }) { } /** Component containing the three.js object and children for a particular scene node. */ -export function SceneNodeThreeObject(props: { name: string }) { +export function SceneNodeThreeObject(props: { + name: string; + parent: THREE.Object3D | null; +}) { const viewer = React.useContext(ViewerContext)!; const makeObject = viewer.useSceneTree( (state) => state.nodeFromName[props.name]?.makeObject, @@ -89,11 +104,21 @@ export function SceneNodeThreeObject(props: { name: string }) { const cleanup = viewer.useSceneTree( (state) => state.nodeFromName[props.name]?.cleanup, ); + const unmountWhenInvisible = viewer.useSceneTree( + (state) => state.nodeFromName[props.name]?.unmountWhenInvisible, + ); + const [unmount, setUnmount] = React.useState(false); const clickable = viewer.useSceneTree((state) => state.nodeFromName[props.name]?.clickable) ?? false; const [obj, setRef] = React.useState(null); + const dragInfo = React.useRef({ + dragging: false, + startClientX: 0, + startClientY: 0, + }); + // Create object + children. // // For not-fully-understood reasons, wrapping makeObject with useMemo() fixes @@ -108,9 +133,41 @@ export function SceneNodeThreeObject(props: { name: string }) { ); + // Helper for transient visibility checks. Checks the .visible attribute of + // both this object and ancestors. + // + // This is used for (1) suppressing click events and (2) unmounting when + // unmountWhenInvisible is true. The latter is used for components. + function isDisplayed() { + // We avoid checking obj.visible because obj may be unmounted when + // unmountWhenInvisible=true. + if (viewer.nodeAttributesFromName.current[props.name]?.visibility === false) + return false; + if (props.parent === null) return true; + + // Check visibility of parents + ancestors. + let visible = props.parent.visible; + if (visible) { + props.parent.traverseAncestors((ancestor) => { + visible = visible && ancestor.visible; + }); + } + return visible; + } + // Update attributes on a per-frame basis. Currently does redundant work, // although this shouldn't be a bottleneck. useFrame(() => { + if (unmountWhenInvisible) { + const displayed = isDisplayed(); + if (displayed && unmount) { + setUnmount(false); + } + if (!displayed && !unmount) { + setUnmount(true); + } + } + if (obj === null) return; const nodeAttributes = viewer.nodeAttributesFromName.current[props.name]; @@ -149,17 +206,10 @@ export function SceneNodeThreeObject(props: { name: string }) { 50, ); const [hovered, setHovered] = React.useState(false); - const [dragged, setDragged] = React.useState(false); useCursor(hovered); if (!clickable && hovered) setHovered(false); - // Helper for checking transient visibility checks. - function isVisible() { - const nodeAttributes = viewer.nodeAttributesFromName.current[props.name]; - return nodeAttributes?.visibility ?? false; - } - - if (objNode === undefined) { + if (objNode === undefined || unmount) { return <>{children}; } else if (clickable) { return ( @@ -172,31 +222,41 @@ export function SceneNodeThreeObject(props: { name: string }) { // - onPointerUp, if triggered, sends a click if dragged = false. // Note: It would be cool to have dragged actions too... onPointerDown={(e) => { - if (!isVisible()) return; + if (!isDisplayed()) return; e.stopPropagation(); - setDragged(false); + const state = dragInfo.current; + state.startClientX = e.clientX; + state.startClientY = e.clientY; + state.dragging = false; }} onPointerMove={(e) => { - if (!isVisible()) return; + if (!isDisplayed()) return; e.stopPropagation(); - setDragged(true); + const state = dragInfo.current; + const deltaX = e.clientX - state.startClientX; + const deltaY = e.clientY - state.startClientY; + // Minimum motion. + console.log(deltaX, deltaY); + if (Math.abs(deltaX) <= 3 && Math.abs(deltaY) <= 3) return; + state.dragging = true; }} onPointerUp={(e) => { - if (!isVisible()) return; + if (!isDisplayed()) return; e.stopPropagation(); - if (dragged) return; + const state = dragInfo.current; + if (state.dragging) return; sendClicksThrottled({ type: "SceneNodeClickedMessage", name: props.name, }); }} onPointerOver={(e) => { - if (!isVisible()) return; + if (!isDisplayed()) return; e.stopPropagation(); setHovered(true); }} onPointerOut={() => { - if (!isVisible()) return; + if (!isDisplayed()) return; setHovered(false); }} > diff --git a/src/viser/client/src/ThreeAssets.tsx b/src/viser/client/src/ThreeAssets.tsx index 8fbfa6234..69f31c339 100644 --- a/src/viser/client/src/ThreeAssets.tsx +++ b/src/viser/client/src/ThreeAssets.tsx @@ -80,7 +80,7 @@ export const GlbAsset = React.forwardRef< (error) => { console.log("Error loading GLB!"); console.log(error); - } + }, ); return () => { @@ -149,7 +149,7 @@ export const CoordinateFrame = React.forwardRef< } >(function CoordinateFrame( { show_axes = true, axes_length = 0.5, axes_radius = 0.0125 }, - ref + ref, ) { return ( @@ -162,7 +162,7 @@ export const CoordinateFrame = React.forwardRef< new THREE.Vector3( axes_radius * 2.5, axes_radius * 2.5, - axes_radius * 2.5 + axes_radius * 2.5, ) } /> @@ -304,7 +304,7 @@ function LineSegmentInstance(props: { const orientation = new THREE.Quaternion().setFromAxisAngle( rotationAxis, - rotationAngle + rotationAngle, ); return ( <> diff --git a/src/viser/client/src/WebsocketInterface.tsx b/src/viser/client/src/WebsocketInterface.tsx index 243bb4e1a..34e6ee414 100644 --- a/src/viser/client/src/WebsocketInterface.tsx +++ b/src/viser/client/src/WebsocketInterface.tsx @@ -34,7 +34,7 @@ function threeColorBufferFromUint8Buffer(colors: ArrayBuffer) { return Math.pow((value + 0.055) / 1.055, 2.4); } }), - 3 + 3, ); } @@ -66,7 +66,7 @@ function useMessageHandler() { addSceneNodeMakeParents( new SceneNode(parent_name, (ref) => ( - )) + )), ); } addSceneNode(node); @@ -110,7 +110,7 @@ function useMessageHandler() { axes_length={message.axes_length} axes_radius={message.axes_radius} /> - )) + )), ); return; } @@ -131,18 +131,18 @@ function useMessageHandler() { new Float32Array( message.points.buffer.slice( message.points.byteOffset, - message.points.byteOffset + message.points.byteLength - ) + message.points.byteOffset + message.points.byteLength, + ), ), - 3 - ) + 3, + ), ); geometry.computeBoundingSphere(); // Wrap uint8 buffer for colors. Note that we need to set normalized=true. geometry.setAttribute( "color", - threeColorBufferFromUint8Buffer(message.colors) + threeColorBufferFromUint8Buffer(message.colors), ); addSceneNodeMakeParents( @@ -161,8 +161,8 @@ function useMessageHandler() { // disposal. geometry.dispose(); pointCloudMaterial.dispose(); - } - ) + }, + ), ); return; } @@ -198,16 +198,16 @@ function useMessageHandler() { new Float32Array( message.vertices.buffer.slice( message.vertices.byteOffset, - message.vertices.byteOffset + message.vertices.byteLength - ) + message.vertices.byteOffset + message.vertices.byteLength, + ), ), - 3 - ) + 3, + ), ); if (message.vertex_colors !== null) { geometry.setAttribute( "color", - threeColorBufferFromUint8Buffer(message.vertex_colors) + threeColorBufferFromUint8Buffer(message.vertex_colors), ); } @@ -216,11 +216,11 @@ function useMessageHandler() { new Uint32Array( message.faces.buffer.slice( message.faces.byteOffset, - message.faces.byteOffset + message.faces.byteLength - ) + message.faces.byteOffset + message.faces.byteLength, + ), ), - 1 - ) + 1, + ), ); geometry.computeVertexNormals(); geometry.computeBoundingSphere(); @@ -236,8 +236,8 @@ function useMessageHandler() { // disposal. geometry.dispose(); material.dispose(); - } - ) + }, + ), ); return; } @@ -247,7 +247,7 @@ function useMessageHandler() { message.image_media_type !== null && message.image_base64_data !== null ? new TextureLoader().load( - `data:${message.image_media_type};base64,${message.image_base64_data}` + `data:${message.image_media_type};base64,${message.image_base64_data}`, ) : undefined; @@ -264,8 +264,8 @@ function useMessageHandler() { image={texture} /> ), - () => texture?.dispose() - ) + () => texture?.dispose(), + ), ); return; } @@ -273,7 +273,7 @@ function useMessageHandler() { const name = message.name; const sendDragMessage = makeThrottledMessageSender( viewer.websocketRef, - 50 + 50, ); addSceneNodeMakeParents( new SceneNode(message.name, (ref) => ( @@ -314,7 +314,7 @@ function useMessageHandler() { }} /> - )) + )), ); return; } @@ -323,12 +323,12 @@ function useMessageHandler() { const R_threeworld_world = new THREE.Quaternion(); R_threeworld_world.setFromEuler( - new THREE.Euler(-Math.PI / 2.0, 0.0, 0.0) + new THREE.Euler(-Math.PI / 2.0, 0.0, 0.0), ); const target = new THREE.Vector3( message.look_at[0], message.look_at[1], - message.look_at[2] + message.look_at[2], ); target.applyQuaternion(R_threeworld_world); cameraControls.setTarget(target.x, target.y, target.z, false); @@ -339,12 +339,12 @@ function useMessageHandler() { const cameraControls = viewer.cameraControlRef.current!; const R_threeworld_world = new THREE.Quaternion(); R_threeworld_world.setFromEuler( - new THREE.Euler(-Math.PI / 2.0, 0.0, 0.0) + new THREE.Euler(-Math.PI / 2.0, 0.0, 0.0), ); const updir = new THREE.Vector3( message.position[0], message.position[1], - message.position[2] + message.position[2], ).applyQuaternion(R_threeworld_world); camera.up.set(updir.x, updir.y, updir.z); @@ -359,7 +359,7 @@ function useMessageHandler() { prevPosition.x, prevPosition.y, prevPosition.z, - false + false, ); return; } @@ -370,18 +370,18 @@ function useMessageHandler() { const position_cmd = new THREE.Vector3( message.position[0], message.position[1], - message.position[2] + message.position[2], ); const R_worldthree_world = new THREE.Quaternion(); R_worldthree_world.setFromEuler( - new THREE.Euler(-Math.PI / 2.0, 0.0, 0.0) + new THREE.Euler(-Math.PI / 2.0, 0.0, 0.0), ); position_cmd.applyQuaternion(R_worldthree_world); cameraControls.setPosition( position_cmd.x, position_cmd.y, - position_cmd.z + position_cmd.z, ); return; } @@ -390,7 +390,7 @@ function useMessageHandler() { // tan(fov / 2.0) = 0.5 * film height / focal length // focal length = 0.5 * film height / tan(fov / 2.0) camera.setFocalLength( - (0.5 * camera.getFilmHeight()) / Math.tan(message.fov / 2.0) + (0.5 * camera.getFilmHeight()) / Math.tan(message.fov / 2.0), ); return; } @@ -426,7 +426,7 @@ function useMessageHandler() { if (isTexture(oldBackgroundTexture)) oldBackgroundTexture.dispose(); viewer.useGui.setState({ backgroundAvailable: true }); - } + }, ); viewer.backgroundMaterialRef.current!.uniforms.enabled.value = true; viewer.backgroundMaterialRef.current!.uniforms.hasDepth.value = @@ -442,7 +442,7 @@ function useMessageHandler() { viewer.backgroundMaterialRef.current!.uniforms.depthMap.value = texture; if (isTexture(oldDepthTexture)) oldDepthTexture.dispose(); - } + }, ); } return; @@ -470,61 +470,71 @@ function useMessageHandler() { } `; addSceneNodeMakeParents( - new SceneNode(message.name, (ref) => { - // We wrap with because Html doesn't implement THREE.Object3D. - return ( - - -
- -
- -
- ); - }) + new SceneNode( + message.name, + (ref) => { + // We wrap with because Html doesn't implement THREE.Object3D. + return ( + + +
+ +
+ +
+ ); + }, + undefined, + true, + ), ); return; } case "Gui3DMessage": { addSceneNodeMakeParents( - new SceneNode(message.name, (ref) => { - // We wrap with because Html doesn't implement THREE.Object3D. - return ( - - - - { - evt.stopPropagation(); - }} + new SceneNode( + message.name, + (ref) => { + // We wrap with because Html doesn't implement THREE.Object3D. + return ( + + + - - - - - - ); - }) + { + evt.stopPropagation(); + }} + > + + + + + + ); + }, + undefined, + true, + ), ); return; } @@ -559,10 +569,10 @@ function useMessageHandler() {
); }, - () => texture.dispose() - ) + () => texture.dispose(), + ), ); - } + }, ); return; } @@ -623,7 +633,7 @@ function useMessageHandler() { scale={message.scale} /> ); - }) + }), ); return; } @@ -642,7 +652,7 @@ function useMessageHandler() { >
); - }) + }), ); return; } @@ -664,7 +674,7 @@ function useMessageHandler() { ))} ); - }) + }), ); return; } @@ -729,7 +739,7 @@ export function FrameSynchronizedMessageHandler() { console.log( `Sending render; requested aspect ratio was ${targetAspect} (dimensinos: ${targetWidth}/${targetHeight}), copying from aspect ratio ${ sourceWidth / sourceHeight - } (dimensions: ${sourceWidth}/${sourceHeight}).` + } (dimensions: ${sourceWidth}/${sourceHeight}).`, ); ctx.drawImage( @@ -741,7 +751,7 @@ export function FrameSynchronizedMessageHandler() { 0, 0, targetWidth, - targetHeight + targetHeight, ); viewer.getRenderRequestState.current = "in_progress"; @@ -775,7 +785,7 @@ export function FrameSynchronizedMessageHandler() { // If a render is requested, note that we don't handle any more messages // until the render is done. const requestRenderIndex = messageQueueRef.current.findIndex( - (message) => message.type === "GetRenderRequestMessage" + (message) => message.type === "GetRenderRequestMessage", ); const numMessages = requestRenderIndex !== -1 diff --git a/src/viser/client/yarn.lock b/src/viser/client/yarn.lock index 60ba30b6f..ac9877f56 100644 --- a/src/viser/client/yarn.lock +++ b/src/viser/client/yarn.lock @@ -3767,10 +3767,10 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@^2.8.7: - version "2.8.8" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" - integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== +prettier@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.3.tgz#432a51f7ba422d1469096c0fdc28e235db8f9643" + integrity sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg== prop-types@^15.6.0, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1"