From 97e1bbd1bed4ec4084924695777d81b7628c1dcb Mon Sep 17 00:00:00 2001 From: brentyi Date: Mon, 16 Dec 2024 13:21:45 -0800 Subject: [PATCH 1/9] Fix embed bugs caused by loopStartIndex --- src/viser/_viser.py | 10 +++++++ src/viser/client/src/FilePlayback.tsx | 36 ++++++++++++++++++------- src/viser/client/src/MessageHandler.tsx | 11 ++++++-- src/viser/infra/_infra.py | 5 +--- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/viser/_viser.py b/src/viser/_viser.py index 7025b7eeb..6ea11eff7 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -942,6 +942,16 @@ def get_event_loop(self) -> asyncio.AbstractEventLoop: can be useful for safe concurrent operations.""" return self._event_loop + def sleep_forever(self) -> None: + """Equivalent to: + ``` + while True: + time.sleep(3600) + ``` + """ + while True: + time.sleep(3600) + def _start_scene_recording(self) -> RecordHandle: """Start recording outgoing messages for playback or embedding. Includes only the scene. diff --git a/src/viser/client/src/FilePlayback.tsx b/src/viser/client/src/FilePlayback.tsx index b1cb3f490..d3efac383 100644 --- a/src/viser/client/src/FilePlayback.tsx +++ b/src/viser/client/src/FilePlayback.tsx @@ -69,9 +69,8 @@ async function deserializeGzippedMsgpackFile( } interface SerializedMessages { - loopStartIndex: number | null; durationSeconds: number; - messages: [number, Message][]; + messages: [number, Message][]; // (time in seconds, message). } export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { @@ -84,6 +83,24 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { const [paused, setPaused] = useState(false); const [recording, setRecording] = useState(null); + // Instead of removing all of the existing scene nodes, we're just going to hide them. + // This will prevent unnecessary remounting when messages are looped. + function resetScene() { + const attrs = viewer.nodeAttributesFromName.current; + Object.keys(attrs).forEach((key) => { + if (key === "") return; + console.log("reset", attrs[key]!.poseUpdateState); + attrs[key]!.visibility = false; + + // We'll also reset poses. This is to prevent edge cases when looping: + // - We first add /parent/child. + // - We then add /parent. + // - We then modify the pose of /parent. + attrs[key]!.wxyz = [1, 0, 0, 0]; + attrs[key]!.position = [0, 0, 0]; + }); + } + const [currentTime, setCurrentTime] = useState(0.0); const theme = useMantineTheme(); @@ -113,12 +130,10 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { messageQueueRef.current.push(message); } - if ( - mutable.currentTime >= recording.durationSeconds && - recording.loopStartIndex !== null - ) { - mutable.currentIndex = recording.loopStartIndex!; - mutable.currentTime = recording.messages[recording.loopStartIndex!][0]; + if (mutable.currentTime >= recording.durationSeconds) { + resetScene(); + mutable.currentIndex = 0; + mutable.currentTime = recording.messages[0][0]; } setCurrentTime(mutable.currentTime); }, [recording]); @@ -136,7 +151,7 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { updatePlayback(); if ( playbackMutable.current.currentIndex === recording.messages.length && - recording.loopStartIndex === null + recording.durationSeconds === 0.0 ) { clearInterval(interval); } @@ -169,7 +184,8 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { (value: number) => { if (value < playbackMutable.current.currentTime) { // Going backwards is more expensive... - playbackMutable.current.currentIndex = recording!.loopStartIndex!; + resetScene(); + playbackMutable.current.currentIndex = 0; } playbackMutable.current.currentTime = value; setCurrentTime(value); diff --git a/src/viser/client/src/MessageHandler.tsx b/src/viser/client/src/MessageHandler.tsx index 3fb04104f..8abd61871 100644 --- a/src/viser/client/src/MessageHandler.tsx +++ b/src/viser/client/src/MessageHandler.tsx @@ -49,8 +49,15 @@ function useMessageHandler() { overrideVisibility: attrs[message.name]?.overrideVisibility, }; - // Don't update the pose of the object until we've made a new one! - attrs[message.name]!.poseUpdateState = "waitForMakeObject"; + // If the object is new or changed, we need to wait until it's created + // before updating its pose. Updating the pose too early can cause + // flickering when we replace objects (old object will take the pose of the new + // object while it's being loaded/mounted) + const oldMessage = + viewer.useSceneTree.getState().nodeFromName[message.name]?.message; + if (oldMessage === undefined || message !== oldMessage) { + attrs[message.name]!.poseUpdateState = "waitForMakeObject"; + } // Make sure parents exists. const nodeFromName = viewer.useSceneTree.getState().nodeFromName; diff --git a/src/viser/infra/_infra.py b/src/viser/infra/_infra.py index 9d6dbd31d..10c7735bb 100644 --- a/src/viser/infra/_infra.py +++ b/src/viser/infra/_infra.py @@ -51,7 +51,6 @@ def __init__( ): self._handler = handler self._filter = filter - self._loop_start_index: int | None = None self._time: float = 0.0 self._messages: list[tuple[float, dict[str, Any]]] = [] @@ -70,15 +69,13 @@ def insert_sleep(self, duration: float) -> None: def set_loop_start(self) -> None: """Mark the start of the loop. Messages sent after this point will be looped. Should only be called once.""" - assert self._loop_start_index is None, "Loop start already set." - self._loop_start_index = len(self._messages) + pass def end_and_serialize(self) -> bytes: """End the recording and serialize contents. Returns the recording as bytes, which should generally be written to a file.""" packed_bytes = msgspec.msgpack.encode( { - "loopStartIndex": self._loop_start_index, "durationSeconds": self._time, "messages": self._messages, } From 68c312844df5985db396b705981d81168ea11e41 Mon Sep 17 00:00:00 2001 From: brentyi Date: Fri, 3 Jan 2025 18:49:37 -0800 Subject: [PATCH 2/9] Add search param for initial up direction --- src/viser/client/src/CameraControls.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/viser/client/src/CameraControls.tsx b/src/viser/client/src/CameraControls.tsx index 4ead77608..f8e6503c1 100644 --- a/src/viser/client/src/CameraControls.tsx +++ b/src/viser/client/src/CameraControls.tsx @@ -104,7 +104,11 @@ export function SynchronizedCameraControls() { // Log camera. if (logCamera != undefined) { - console.log("Sending camera", t_world_camera.toArray(), lookAt); + console.log( + `&initialCameraPosition=${t_world_camera.x.toFixed(3)},${t_world_camera.y.toFixed(3)},${t_world_camera.z.toFixed(3)}` + + `&initialCameraLookAt=${lookAt.x.toFixed(3)},${lookAt.y.toFixed(3)},${lookAt.z.toFixed(3)}` + + `&initialCameraUp=${up.x.toFixed(3)},${up.y.toFixed(3)},${up.z.toFixed(3)}`, + ); } }, [camera, sendCameraThrottled]); @@ -114,6 +118,7 @@ export function SynchronizedCameraControls() { const searchParams = new URLSearchParams(window.location.search); const initialCameraPosString = searchParams.get("initialCameraPosition"); const initialCameraLookAtString = searchParams.get("initialCameraLookAt"); + const initialCameraUpString = searchParams.get("initialCameraUp"); const logCamera = searchParams.get("logCamera"); // Send camera for new connections. @@ -142,6 +147,24 @@ export function SynchronizedCameraControls() { : [0, 0, 0]) as [number, number, number]), ); initialCameraLookAt.applyMatrix4(computeT_threeworld_world(viewer)); + const initialCameraUp = new THREE.Vector3( + ...((initialCameraUpString + ? (initialCameraUpString.split(",").map(Number) as [ + number, + number, + number, + ]) + : [0, 0, 1]) as [number, number, number]), + ); + initialCameraUp.applyMatrix4(computeT_threeworld_world(viewer)); + initialCameraUp.normalize(); + + viewer.cameraRef.current!.up.set( + initialCameraUp.x, + initialCameraUp.y, + initialCameraUp.z, + ); + viewer.cameraControlRef.current!.updateCameraUp(); viewer.cameraControlRef.current!.setLookAt( initialCameraPos.x, From 6429191da3b663540c294be1a8a6645abf71dbad Mon Sep 17 00:00:00 2001 From: brentyi Date: Fri, 3 Jan 2025 19:20:24 -0800 Subject: [PATCH 3/9] Fix visibility / reset edge cases --- src/viser/client/src/FilePlayback.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/viser/client/src/FilePlayback.tsx b/src/viser/client/src/FilePlayback.tsx index d3efac383..55524a723 100644 --- a/src/viser/client/src/FilePlayback.tsx +++ b/src/viser/client/src/FilePlayback.tsx @@ -89,8 +89,17 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { const attrs = viewer.nodeAttributesFromName.current; Object.keys(attrs).forEach((key) => { if (key === "") return; - console.log("reset", attrs[key]!.poseUpdateState); - attrs[key]!.visibility = false; + const nodeMessage = + viewer.useSceneTree.getState().nodeFromName[key]?.message; + if ( + nodeMessage !== undefined && + (nodeMessage.type !== "FrameMessage" || nodeMessage.props.show_axes) + ) { + // ^ We don't hide intermediate frames. These can be created + // automatically by addSceneNodeMakerParents(), in which case there + // will be no message to un-hide them. + attrs[key]!.visibility = false; + } // We'll also reset poses. This is to prevent edge cases when looping: // - We first add /parent/child. @@ -120,6 +129,10 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { // We have messages with times: [0.0, 0.01, 0.01, 0.02, 0.03] // We have our current time: 0.02 // We want to get of a slice of all message _until_ the current time. + if (mutable.currentIndex == 0) { + // Reset the scene if sending the first message. + resetScene(); + } for ( ; mutable.currentIndex < recording.messages.length && @@ -131,7 +144,6 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { } if (mutable.currentTime >= recording.durationSeconds) { - resetScene(); mutable.currentIndex = 0; mutable.currentTime = recording.messages[0][0]; } From 5802a20ef4bb1e021daf110d84ab658581d5f81c Mon Sep 17 00:00:00 2001 From: brentyi Date: Fri, 3 Jan 2025 20:11:37 -0800 Subject: [PATCH 4/9] API renaming --- src/viser/_viser.py | 20 ++++++++++++-------- src/viser/infra/_infra.py | 29 +++++++++++++---------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/viser/_viser.py b/src/viser/_viser.py index 67cbc4d4f..2d34abe6c 100644 --- a/src/viser/_viser.py +++ b/src/viser/_viser.py @@ -27,7 +27,7 @@ from ._notification_handle import NotificationHandle, _NotificationHandleState from ._scene_api import SceneApi, cast_vector from ._tunnel import ViserTunnel -from .infra._infra import RecordHandle +from .infra._infra import StateSerializer class _BackwardsCompatibilityShim: @@ -992,17 +992,21 @@ def sleep_forever(self) -> None: while True: time.sleep(3600) - def _start_scene_recording(self) -> RecordHandle: - """Start recording outgoing messages for playback or embedding. - Includes only the scene. + def _start_scene_recording(self) -> None: + """**Old API.**""" + assert False, "_start_scene_recording() has been removed. See notes in https://github.com/nerfstudio-project/viser/pull/357 for the new API." - **Work-in-progress.** This API may be changed or removed. + def get_scene_serializer(self) -> StateSerializer: + """Get handle for serializing the scene state. + + This can be used for saving .viser files, which are used for offline + visualization. """ - recorder = self._websock_server.start_recording( + serializer = self._websock_server.get_message_serializer( # Don't record GUI messages. This feels brittle. filter=lambda message: "Gui" not in type(message).__name__ ) # Insert current scene state. for message in self._websock_server._broadcast_buffer.message_from_id.values(): - recorder._insert_message(message) - return recorder + serializer._insert_message(message) + return serializer diff --git a/src/viser/infra/_infra.py b/src/viser/infra/_infra.py index 10c7735bb..f86539daa 100644 --- a/src/viser/infra/_infra.py +++ b/src/viser/infra/_infra.py @@ -41,10 +41,9 @@ class _ClientHandleState: TMessage = TypeVar("TMessage", bound=Message) -class RecordHandle: - """**Experimental.** - - Handle for recording outgoing messages. Useful for logging + debugging.""" +class StateSerializer: + """Handle for serializing messages. In Viser, this is used to save the + scene state.""" def __init__( self, handler: WebsockMessageHandler, filter: Callable[[Message], bool] @@ -57,7 +56,8 @@ def __init__( def _insert_message(self, message: Message) -> None: """Insert a message into the recorded file.""" - # Exclude GUI messages. This is hacky. + # Exclude messages that are filtered out. In Viser, this is typically + # GUI messages. if not self._filter(message): return self._messages.append((self._time, message.as_serializable_dict())) @@ -66,14 +66,9 @@ def insert_sleep(self, duration: float) -> None: """Insert a sleep into the recorded file.""" self._time += duration - def set_loop_start(self) -> None: - """Mark the start of the loop. Messages sent after this point will be - looped. Should only be called once.""" - pass - - def end_and_serialize(self) -> bytes: - """End the recording and serialize contents. Returns the recording as - bytes, which should generally be written to a file.""" + def serialize(self) -> bytes: + """Serialize saved messages. Returns the recording as bytes, which + should be written to a file.""" packed_bytes = msgspec.msgpack.encode( { "durationSeconds": self._time, @@ -96,13 +91,15 @@ def __init__(self) -> None: self._locked_thread_id = -1 # Set to None if not recording. - self._record_handle: RecordHandle | None = None + self._record_handle: StateSerializer | None = None - def start_recording(self, filter: Callable[[Message], bool]) -> RecordHandle: + def get_message_serializer( + self, filter: Callable[[Message], bool] + ) -> StateSerializer: """Start recording messages that are sent. Sent messages will be serialized and can be used for playback.""" assert self._record_handle is None, "Already recording." - self._record_handle = RecordHandle(self, filter) + self._record_handle = StateSerializer(self, filter) return self._record_handle def register_handler( From f927d6625af2bf000832150f8fd2eea734187d92 Mon Sep 17 00:00:00 2001 From: brentyi Date: Sat, 4 Jan 2025 00:39:21 -0800 Subject: [PATCH 5/9] Save version --- pyproject.toml | 5 ++++- src/viser/__init__.py | 2 ++ src/viser/client/src/FilePlayback.tsx | 9 ++++++++- src/viser/infra/_infra.py | 3 +++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3155030a..2436cb194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,9 @@ exclude = ["src/viser/client/.nodeenv", "src/viser/client/node_modules", "**/__p # Client build is in the gitignore, but we still want it in the distribution. ignore-vcs = true +[tool.hatch.version] +path = "src/viser/__init__.py" + [tool.hatch.build.targets.sdist] only-include = ["src/viser"] @@ -15,7 +18,7 @@ packages = ["src/viser"] [project] name = "viser" -version = "0.2.19" +dynamic = ["version"] description = "3D visualization + Python" readme = "README.md" license = { text="MIT" } diff --git a/src/viser/__init__.py b/src/viser/__init__.py index 0b63ef1c4..6943c30dd 100644 --- a/src/viser/__init__.py +++ b/src/viser/__init__.py @@ -53,3 +53,5 @@ from ._viser import CameraHandle as CameraHandle from ._viser import ClientHandle as ClientHandle from ._viser import ViserServer as ViserServer + +__version__ = "0.2.20" diff --git a/src/viser/client/src/FilePlayback.tsx b/src/viser/client/src/FilePlayback.tsx index 55524a723..c70969a39 100644 --- a/src/viser/client/src/FilePlayback.tsx +++ b/src/viser/client/src/FilePlayback.tsx @@ -71,6 +71,7 @@ async function deserializeGzippedMsgpackFile( interface SerializedMessages { durationSeconds: number; messages: [number, Message][]; // (time in seconds, message). + viserVersion: string; } export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { @@ -116,7 +117,13 @@ export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) { useEffect(() => { deserializeGzippedMsgpackFile(fileUrl, setStatus).then( - setRecording, + (data) => { + console.log( + "File loaded! Saved with Viser version:", + data.viserVersion, + ); + setRecording(data); + }, ); }, []); diff --git a/src/viser/infra/_infra.py b/src/viser/infra/_infra.py index f86539daa..7edc19e06 100644 --- a/src/viser/infra/_infra.py +++ b/src/viser/infra/_infra.py @@ -69,10 +69,13 @@ def insert_sleep(self, duration: float) -> None: def serialize(self) -> bytes: """Serialize saved messages. Returns the recording as bytes, which should be written to a file.""" + import viser + packed_bytes = msgspec.msgpack.encode( { "durationSeconds": self._time, "messages": self._messages, + "viserVersion": viser.__version__, } ) assert isinstance(packed_bytes, bytes) From b4c238443872628593980b417d9dd0985859824c Mon Sep 17 00:00:00 2001 From: brentyi Date: Sat, 4 Jan 2025 01:36:27 -0800 Subject: [PATCH 6/9] Add `viser-build-client`, remove `viser-dev-checks` --- .github/workflows/publish.yml | 4 +- docs/source/development.md | 11 +++-- pyproject.toml | 3 +- src/viser/_client_autobuild.py | 73 ++++++++++++++++++---------- src/viser/scripts/__init__.py | 0 src/viser/scripts/dev_checks.py | 85 --------------------------------- 6 files changed, 61 insertions(+), 115 deletions(-) delete mode 100644 src/viser/scripts/__init__.py delete mode 100644 src/viser/scripts/dev_checks.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 36148780b..b1939c7f4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,9 +30,11 @@ jobs: - name: Only bundle client build for PyPI release run: | # This should delete everything in src/viser/client except for the - # build folder. + # build folder + original source files. We don't want to package + # .nodeenv, node_modules, etc in the release. mv src/viser/client/build ./__built_client rm -rf src/viser/client/* + git checkout src/viser/client mv ./__built_client src/viser/client/build - name: Build and publish env: diff --git a/docs/source/development.md b/docs/source/development.md index 0b22c0eca..55df4843d 100644 --- a/docs/source/development.md +++ b/docs/source/development.md @@ -31,12 +31,15 @@ pip install -e .[dev] pre-commit install ``` -It would be hard to write unit tests for `viser`. We rely on static typing for -robustness. To check your code, you can run the following: +For code quality, rely primarily on `pyright` and `ruff`: ```bash -# runs linting, formatting, and type-checking -viser-dev-checks +# Check static types. +pyright + +# Lint and format. +ruff check --fix . +ruff format . ``` ## Message updates diff --git a/pyproject.toml b/pyproject.toml index 2436cb194..8ecf1f0ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ examples = [ "GitHub" = "https://github.com/nerfstudio-project/viser" [project.scripts] -viser-dev-checks = "viser.scripts.dev_checks:entrypoint" +viser-build-client = "viser._client_autobuild:build_client_entrypoint" [tool.pyright] exclude = ["./docs/**/*", "./examples/assets/**/*", "./src/viser/client/.nodeenv", "./build"] @@ -89,6 +89,7 @@ lint.select = [ "I", # Import sorting. ] lint.ignore = [ + "E731", # Do not assign a lambda expression, use a def. "E741", # Ambiguous variable name. (l, O, or I) "E501", # Line too long. "E721", # Do not compare types, use `isinstance()`. diff --git a/src/viser/_client_autobuild.py b/src/viser/_client_autobuild.py index fa4029781..569430cdf 100644 --- a/src/viser/_client_autobuild.py +++ b/src/viser/_client_autobuild.py @@ -4,6 +4,7 @@ from pathlib import Path import rich +import tyro client_dir = Path(__file__).absolute().parent / "client" build_dir = client_dir / "build" @@ -62,30 +63,54 @@ def ensure_client_is_built() -> None: # Install nodejs and build if necessary. We assume bash is installed. if build: - node_bin_dir = _install_sandboxed_node() - npx_path = node_bin_dir / "npx" - - subprocess_env = os.environ.copy() - subprocess_env["NODE_VIRTUAL_ENV"] = str(node_bin_dir.parent) - subprocess_env["PATH"] = ( - str(node_bin_dir) - + (";" if sys.platform == "win32" else ":") - + subprocess_env["PATH"] - ) - subprocess.run( - args=f"{npx_path} --yes yarn install", - env=subprocess_env, - cwd=client_dir, - shell=True, - check=False, - ) - subprocess.run( - args=f"{npx_path} --yes yarn run build", - env=subprocess_env, - cwd=client_dir, - shell=True, - check=False, - ) + _build_viser_client(base_path="/", out_dir=build_dir) + + +def _build_viser_client(base_path: str, out_dir: Path) -> None: + """Create a build of the Viser client. + + Args: + base_path: The base path of the hosted client, relative to the server root. + If the client will be hosted locally at `http://127.0.0.1:8000/`, + this should be "/". If it is hosted at + `http://user.github.io/project/`, this should be "/project/". + out_dir: The directory to write the built client to. + """ + + node_bin_dir = _install_sandboxed_node() + npx_path = node_bin_dir / "npx" + + subprocess_env = os.environ.copy() + subprocess_env["NODE_VIRTUAL_ENV"] = str(node_bin_dir.parent) + subprocess_env["PATH"] = ( + str(node_bin_dir) + + (";" if sys.platform == "win32" else ":") + + subprocess_env["PATH"] + ) + subprocess.run( + args=[str(npx_path), "--yes", "yarn", "install"], + env=subprocess_env, + cwd=client_dir, + check=False, + ) + subprocess.run( + args=[ + str(npx_path), + "vite", + "build", + "--base", + str(base_path), + "--outDir", + # Relative path needs to be made absolute, since we change the CWD. + str(out_dir.absolute()), + ], + env=subprocess_env, + cwd=client_dir, + check=False, + ) + + +build_client_entrypoint = lambda: tyro.cli(_build_viser_client) def _install_sandboxed_node() -> Path: diff --git a/src/viser/scripts/__init__.py b/src/viser/scripts/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/viser/scripts/dev_checks.py b/src/viser/scripts/dev_checks.py deleted file mode 100644 index 02f4f3a4b..000000000 --- a/src/viser/scripts/dev_checks.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python -"""Runs formatting, linting, and type checking tests.""" - -import subprocess -import sys - -import tyro -from rich import console -from rich.style import Style - -CONSOLE = console.Console() - -TYPE_TESTS = ["pyright .", "mypy ."] -FORMAT_TESTS = ["ruff check --fix .", "ruff format ."] - - -def run_command(command: str, continue_on_fail: bool = False) -> bool: - """Run a command kill actions if it fails - - Args: - command: Command to run. - continue_on_fail: Whether to continue running commands if the current one fails.. - """ - ret_code = subprocess.call(command, shell=True) - if ret_code != 0: - CONSOLE.print(f"[bold red]Error: `{command}` failed.") - if not continue_on_fail: - sys.exit(1) - return ret_code == 0 - - -def run_code_checks( - continue_on_fail: bool = False, - skip_format_checks: bool = False, - skip_type_checks: bool = False, -): - """Runs formatting, linting, and type checking tests. - - Args: - continue_on_fail: Whether or not to continue running actions commands if the current one fails. - skip_format_checks: Whether or not to skip format tests. - skip_type_checks: Whether or not to skip type tests. - """ - - success = True - - assert ( - not skip_format_checks or not skip_type_checks - ), "Cannot skip format and type tests at the same time." - tests = [] - if not skip_format_checks: - tests += FORMAT_TESTS - if not skip_type_checks: - tests += TYPE_TESTS - - for test in tests: - CONSOLE.line() - CONSOLE.rule(f"[bold green]Running: {test}") - success = success and run_command(test, continue_on_fail=continue_on_fail) - - if success: - CONSOLE.line() - CONSOLE.rule(characters="=") - CONSOLE.print( - "[bold green]:TADA: :TADA: :TADA: ALL CHECKS PASSED :TADA: :TADA: :TADA:", - justify="center", - ) - CONSOLE.rule(characters="=") - else: - CONSOLE.line() - CONSOLE.rule(characters="=", style=Style(color="red")) - CONSOLE.print( - "[bold red]:skull: :skull: :skull: ERRORS FOUND :skull: :skull: :skull:", - justify="center", - ) - CONSOLE.rule(characters="=", style=Style(color="red")) - - -def entrypoint(): - """Entrypoint for use with pyproject scripts.""" - tyro.cli(run_code_checks) - - -if __name__ == "__main__": - entrypoint() From a1fbff4d8cc063a1ba012cf1908a47a5944a2c17 Mon Sep 17 00:00:00 2001 From: brentyi Date: Sat, 4 Jan 2025 02:33:41 -0800 Subject: [PATCH 7/9] polish, docs --- .github/workflows/docs.yml | 6 +- docs/source/conf.py | 10 +- docs/source/embedded_visualizations.rst | 184 ++++++++++++++++++++++++ docs/source/index.md | 1 + src/viser/_client_autobuild.py | 20 ++- src/viser/infra/_infra.py | 13 +- 6 files changed, 216 insertions(+), 18 deletions(-) create mode 100644 docs/source/embedded_visualizations.rst diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9c3299677..6dbfe0ef0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -32,14 +32,14 @@ jobs: # Get version from pyproject.toml. - name: Get version + subdirectory run: | - VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") + VERSION=$(python -c "import viser; print(viser.__version__)") echo "VERSION=$VERSION" >> $GITHUB_ENV echo "DOCS_SUBDIR=versions/$VERSION" >> $GITHUB_ENV # Hack to overwrite version. - - name: Set version to 'latest' for pushes (this will appear in the doc banner) + - name: Set version to 'main' for pushes (this will appear in the doc banner) run: | - python -c "import toml; conf = toml.load('pyproject.toml'); conf['project']['version'] = 'latest'; toml.dump(conf, open('pyproject.toml', 'w'))" + echo "VISER_VERSION_STR_OVERRIDE=main" >> $GITHUB_ENV if: github.event_name == 'push' # Build documentation. diff --git a/docs/source/conf.py b/docs/source/conf.py index c251c2595..fa544f648 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,11 +6,12 @@ # full list see the documentation: # http://www.sphinx-doc.org/en/stable/config -from pathlib import Path +import os from typing import Dict, List import m2r2 -import toml + +import viser # -- Path setup -------------------------------------------------------------- @@ -25,10 +26,7 @@ copyright = "2024" author = "brentyi" -# The short X.Y version -version: str = toml.load( - Path(__file__).absolute().parent.parent.parent / "pyproject.toml" -)["project"]["version"] +version: str = os.environ.get("VISER_VERSION_STR_OVERRIDE", viser.__version__) # Formatting! # 0.1.30 => v0.1.30 diff --git a/docs/source/embedded_visualizations.rst b/docs/source/embedded_visualizations.rst new file mode 100644 index 000000000..07e85739f --- /dev/null +++ b/docs/source/embedded_visualizations.rst @@ -0,0 +1,184 @@ +Embedding Visualizations +=============================================== + +This guide describes how to export 3D visualizations from Viser and embed them into static webpages. The process involves three main steps: exporting scene state, creating a client build, and hosting the visualization. + +.. warning:: + + This workflow is experimental and not yet polished. We're documenting it + nonetheless, since we think it's quite useful! If you have suggestions or + improvements, issues and PRs are welcome. + + +Step 1: Exporting Scene State +---------------------------- + +You can export static or dynamic 3D data from a Viser scene using the scene +serializer. :func:`ViserServer.get_scene_serializer` returns a serializer +object that can serialize the current scene state to a binary format. + +Static Scene Export +~~~~~~~~~~~~~~~~~~~ + +For static 3D visualizations, use the following code to save the scene state: + +.. code-block:: python + + import viser + from pathlib import Path + + server = viser.ViserServer() + + # Add objects to the scene via server.scene + # For example: + # server.scene.add_mesh(...) + # server.scene.add_point_cloud(...) + + # Serialize and save the scene state + data = server.get_scene_serializer().serialize() # Returns bytes + Path("recording.viser").write_bytes(data) + + +As a suggestion, you can also add a button for exporting the scene state: + +.. code-block:: python + + server = viser.ViserServer() + + save_button.server.gui.add_button("Save Scene") + + @save_button.on_click + def _(event: viser.GuiEvent) -> None: + assert event.client is not None + event.client.send_file_download("recording.viser", server.get_scene_serializer().serialize()) + +Dynamic Scene Export +~~~~~~~~~~~~~~~~~~~~ + +For dynamic visualizations with animation, you can create a "3D video" by inserting sleep commands between frames: + +.. code-block:: python + + import viser + from pathlib import Path + + server = server.ViserServer() + + # Add objects to the scene via server.scene + # For example: + # server.scene.add_mesh(...) + # server.scene.add_point_cloud(...) + + serializer = server.get_scene_serializer() + + for t in range(num_frames): + # Update existing scene objects or add new ones + # server.scene.add_point_cloud(...) + + # Add a frame delay (e.g., for 30 FPS animation) + serializer.insert_sleep(1 / 30) + + # Save the complete animation + data = serializer.serialize() # Returns bytes + Path("recording.viser").write_bytes(data) + +.. note:: + Always add scene elements using :attr:`ViserServer.scene`, not :attr:`ClientHandle.scene`. + +.. note:: + The ``.viser`` file is a binary format containing scene state data and is not meant to be human-readable. + +Step 2: Creating a Viser Client Build +----------------------------------- + +To serve the 3D visualization, you'll need two things: + +1. The ``.viser`` file containing your scene data +2. A build of the Viser client (static HTML/JS/CSS files) + +With Viser installed, create the Viser client build using the command-line tool: + +.. code-block:: bash + + # View available options + viser-build-client --help + + # Build to a specific directory + viser-build-client --output-dir viser-client/ + + +Step 3: Hosting +--------------- + +Directory Structure +~~~~~~~~~~~~~~~~~~~ + +For our hosting instructions, we're going to assume the following directory structure: + +.. code-block:: + + . + ├── recordings/ + │ └── recording.viser # Your exported scene data + └── viser-client/ + ├── index.html # Generated client files + ├── assets/ + └── ... + +This is just a suggestion; you can structure your files however you like. + +Local Development Server +~~~~~~~~~~~~~~~~~~~~~~~~ + +For testing locally, you can use Python's built-in HTTP server: + +.. code-block:: bash + + # Navigate to the parent directory containing both folders + cd /path/to/parent/dir + + # Start the server (default port 8000) + python -m http.server 8000 + +Then open your browser and navigate to: + +* ``http://localhost:8000/viser-client/`` (default port) + +This should show the a standard Viser client. To visualize the exported scene, you'll need to specify a URL via the ``?playbackPath=`` parameter: + +* ``http://localhost:8000/viser-client/?playbackPath=http://localhost:8000/recordings/recording.viser`` + + +GitHub Pages Deployment +~~~~~~~~~~~~~~~~~~~~~~~ + +To host your visualization on GitHub Pages: + +1. Create a new repository or use an existing one +2. Create a ``gh-pages`` branch or enable GitHub Pages on your main branch +3. Push your directory structure to the repository: + + .. code-block:: bash + + git add recordings/ viser-client/ + git commit -m "Add Viser visualization" + git push origin main # or gh-pages + +Your visualization will be available at: ``https://user.github.io/repo/viser-client/?playbackPath=https://user.github.io/repo/recordings/recording.viser`` + +You can embed this into other webpages using an HTML ``