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/.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/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/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/docs/source/embedded_visualizations.rst b/docs/source/embedded_visualizations.rst
new file mode 100644
index 000000000..0cfda5aa9
--- /dev/null
+++ b/docs/source/embedded_visualizations.rst
@@ -0,0 +1,198 @@
+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(...)
+ server.scene.add_box("/box", color=(255, 0, 0), dimensions=(1, 1, 1))
+
+ # 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
+
+ import viser
+ server = viser.ViserServer()
+
+ # Add objects to the scene via server.scene.
+ # For example:
+ # server.scene.add_mesh(...)
+ # server.scene.add_point_cloud(...)
+ server.scene.add_box("/box", color=(255, 0, 0), dimensions=(1, 1, 1))
+
+ 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())
+
+ server.sleep_forever()
+
+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
+ import numpy as np
+ 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(...)
+ box = server.scene.add_box("/box", color=(255, 0, 0), dimensions=(1, 1, 1))
+
+ # Create serializer.
+ serializer = server.get_scene_serializer()
+
+ num_frames = 100
+ for t in range(num_frames):
+ # Update existing scene objects or add new ones.
+ box.position = (0.0, 0.0, np.sin(t / num_frames * 2 * np.pi))
+
+ # Add a frame delay.
+ serializer.insert_sleep(1.0 / 30.0)
+
+ # 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 ```` tag.
+
+
+Step 4: Setting the initial camera pose
+-----------------------------------------------
+
+To set the initial camera pose, you can add a ``&logCamera`` parameter to the URL:
+
+* ``http://localhost:8000/viser-client/?playbackPath=http://localhost:8000/recordings/recording.viser&logCamera``
+
+Then, open your Javascript console. You should see the camera pose printed
+whenever you move the camera. It should look something like this:
+
+* ``&initialCameraPosition=2.216,-4.233,-0.947&initialCameraLookAt=-0.115,0.346,-0.192&initialCameraUp=0.329,-0.904,0.272``
+
+You can then add this string to the URL to set the initial camera pose.
diff --git a/docs/source/index.md b/docs/source/index.md
index c55da51a3..67546cd1c 100644
--- a/docs/source/index.md
+++ b/docs/source/index.md
@@ -39,6 +39,7 @@ URL (default: `http://localhost:8080`).
./conventions.md
./development.md
+ ./embedded_visualizations.rst
.. toctree::
:caption: API (Basics)
diff --git a/pyproject.toml b/pyproject.toml
index c3155030a..8ecf1f0ce 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" }
@@ -70,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"]
@@ -86,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/__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_autobuild.py b/src/viser/_client_autobuild.py
index fa4029781..6b3b0fd21 100644
--- a/src/viser/_client_autobuild.py
+++ b/src/viser/_client_autobuild.py
@@ -1,9 +1,11 @@
import os
+import shutil
import subprocess
import sys
from pathlib import Path
import rich
+import tyro
client_dir = Path(__file__).absolute().parent / "client"
build_dir = client_dir / "build"
@@ -62,30 +64,59 @@ 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(out_dir=build_dir, cached=False)
+
+
+def _build_viser_client(out_dir: Path, cached: bool = True) -> None:
+ """Create a build of the Viser client.
+
+ Args:
+ out_dir: The directory to write the built client to.
+ cached: If True, skip the build if the client is already built.
+ Instead, we'll simply copy the previous build to the new location.
+ """
+
+ if cached and build_dir.exists() and (build_dir / "index.html").exists():
+ rich.print(
+ f"[bold](viser)[/bold] Copying client build from {build_dir} to {out_dir}."
)
+ shutil.copytree(build_dir, out_dir)
+ return
+
+ 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",
+ "./",
+ "--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/_viser.py b/src/viser/_viser.py
index e746c2fd4..4a6130ede 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:
@@ -982,17 +982,60 @@ def get_event_loop(self) -> asyncio.AbstractEventLoop:
can be useful for safe concurrent operations."""
return self._event_loop
- def _start_scene_recording(self) -> RecordHandle:
- """Start recording outgoing messages for playback or embedding.
- Includes only the scene.
+ def sleep_forever(self) -> None:
+ """Equivalent to:
+ ```
+ while True:
+ time.sleep(3600)
+ ```
+ """
+ while True:
+ time.sleep(3600)
+
+ def _start_scene_recording(self) -> Any:
+ """**Old API.**"""
+ warnings.warn(
+ "_start_scene_recording() has been renamed. See notes in https://github.com/nerfstudio-project/viser/pull/357 for the new API.",
+ stacklevel=2,
+ )
+
+ serializer = self.get_scene_serializer()
+
+ # We'll add a shim for the old API for now. We can remove this later.
+ class _SceneRecordCompatibilityShim:
+ def set_loop_start(self):
+ warnings.warn(
+ "_start_scene_recording() has been renamed. See notes in https://github.com/nerfstudio-project/viser/pull/357 for the new API.",
+ stacklevel=2,
+ )
+
+ def insert_sleep(self, duration: float):
+ warnings.warn(
+ "_start_scene_recording() has been renamed. See notes in https://github.com/nerfstudio-project/viser/pull/357 for the new API.",
+ stacklevel=2,
+ )
+ serializer.insert_sleep(duration)
+
+ def end_and_serialize(self) -> bytes:
+ warnings.warn(
+ "_start_scene_recording() has been renamed. See notes in https://github.com/nerfstudio-project/viser/pull/357 for the new API.",
+ stacklevel=2,
+ )
+ return serializer.serialize()
+
+ return _SceneRecordCompatibilityShim()
+
+ def get_scene_serializer(self) -> StateSerializer:
+ """Get handle for serializing the scene state.
- **Work-in-progress.** This API may be changed or removed.
+ 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/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,
diff --git a/src/viser/client/src/FilePlayback.tsx b/src/viser/client/src/FilePlayback.tsx
index b1cb3f490..c70969a39 100644
--- a/src/viser/client/src/FilePlayback.tsx
+++ b/src/viser/client/src/FilePlayback.tsx
@@ -69,9 +69,9 @@ async function deserializeGzippedMsgpackFile(
}
interface SerializedMessages {
- loopStartIndex: number | null;
durationSeconds: number;
- messages: [number, Message][];
+ messages: [number, Message][]; // (time in seconds, message).
+ viserVersion: string;
}
export function PlaybackFromFile({ fileUrl }: { fileUrl: string }) {
@@ -84,13 +84,46 @@ 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;
+ 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.
+ // - 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();
useEffect(() => {
deserializeGzippedMsgpackFile(fileUrl, setStatus).then(
- setRecording,
+ (data) => {
+ console.log(
+ "File loaded! Saved with Viser version:",
+ data.viserVersion,
+ );
+ setRecording(data);
+ },
);
}, []);
@@ -103,6 +136,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 &&
@@ -113,12 +150,9 @@ 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) {
+ mutable.currentIndex = 0;
+ mutable.currentTime = recording.messages[0][0];
}
setCurrentTime(mutable.currentTime);
}, [recording]);
@@ -136,7 +170,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 +203,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 285b76ca0..fbb6a3c85 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..6738adff5 100644
--- a/src/viser/infra/_infra.py
+++ b/src/viser/infra/_infra.py
@@ -41,46 +41,50 @@ 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]
):
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]]] = []
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()))
def insert_sleep(self, duration: float) -> None:
"""Insert a sleep into the recorded file."""
+ assert (
+ self._handler._record_handle is not None
+ ), "serialize() was already called!"
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."""
- assert self._loop_start_index is None, "Loop start already set."
- self._loop_start_index = len(self._messages)
+ def serialize(self) -> bytes:
+ """Serialize saved messages. Should only be called once.
+
+ Returns:
+ The recording as bytes.
+ """
+ assert (
+ self._handler._record_handle is not None
+ ), "serialize() was already called!"
+ import viser
- 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,
+ "viserVersion": viser.__version__,
}
)
assert isinstance(packed_bytes, bytes)
@@ -99,13 +103,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(
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()