Skip to content

Commit 4c4618e

Browse files
authored
Initial camera API, improve orbit crosshair sizing (#636)
* Add initial camera API, more consistent orbit crosshair size * Fix possible race condition, make FOV and near/far planes configurable from URL * Remove recommended note * Cleanup pass
1 parent 4844633 commit 4c4618e

File tree

11 files changed

+290
-33
lines changed

11 files changed

+290
-33
lines changed

docs/source/api/handles/camera_handles.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,10 @@ Camera Handles
55
:members:
66
:undoc-members:
77
:inherited-members:
8+
9+
Initial Camera Configuration
10+
----------------------------
11+
12+
.. autoclass:: viser.InitialCameraConfig
13+
:members:
14+
:undoc-members:

docs/source/embedded_visualizations.rst

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,54 @@ You can embed this into other webpages using an HTML ``<iframe />`` tag.
189189
Step 4: Setting the initial camera pose
190190
-----------------------------------------------
191191

192-
To set the initial camera pose, you can add a ``&logCamera`` parameter to the URL:
192+
Using Python
193+
~~~~~~~~~~~~
194+
195+
The easiest way to set the initial camera pose is using :attr:`viser.ViserServer.initial_camera`
196+
before serializing or calling :meth:`~viser.SceneApi.show`:
197+
198+
.. code-block:: python
199+
200+
import viser
201+
202+
server = viser.ViserServer()
203+
server.scene.add_box("/box", color=(255, 0, 0), dimensions=(1, 1, 1))
204+
205+
# Set the initial camera pose.
206+
server.initial_camera.position = (2.0, -4.0, 1.0)
207+
server.initial_camera.look_at = (0.0, 0.0, 0.0)
208+
server.initial_camera.up = (0.0, 0.0, 1.0)
209+
210+
# The initial camera is included when serializing.
211+
data = server.get_scene_serializer().serialize()
212+
213+
This sets the camera pose that will be used when the visualization first loads.
214+
See :class:`viser.InitialCameraConfig` for all available options including
215+
``fov``, ``near``, and ``far``.
216+
217+
Using URL Parameters
218+
~~~~~~~~~~~~~~~~~~~~
219+
220+
You can also override the initial camera pose using URL parameters. This is
221+
useful for fine-tuning the camera position after export.
222+
223+
To find the camera parameters, add a ``&logCamera`` parameter to the URL:
193224

194225
* ``http://localhost:8000/viser-client/?playbackPath=http://localhost:8000/recordings/recording.viser&logCamera``
195226

196227
Then, open your Javascript console. You should see the camera pose printed
197228
whenever you move the camera. It should look something like this:
198229

199-
* ``&initialCameraPosition=2.216,-4.233,-0.947&initialCameraLookAt=-0.115,0.346,-0.192&initialCameraUp=0.329,-0.904,0.272``
230+
* ``&initialCameraPosition=2.216,-4.233,-0.947&initialCameraLookAt=-0.115,0.346,-0.192&initialCameraUp=0.329,-0.904,0.272&initialCameraFov=0.7854&initialCameraNear=0.01&initialCameraFar=1000``
231+
232+
You can then add this string to the URL to set the initial camera pose. URL
233+
parameters take priority over the Python-configured initial camera.
234+
235+
Available URL parameters:
200236

201-
You can then add this string to the URL to set the initial camera pose.
237+
* ``initialCameraPosition`` - Camera position as ``x,y,z``
238+
* ``initialCameraLookAt`` - Look-at target as ``x,y,z``
239+
* ``initialCameraUp`` - Up direction as ``x,y,z``
240+
* ``initialCameraFov`` - Field of view in radians
241+
* ``initialCameraNear`` - Near clipping plane distance
242+
* ``initialCameraFar`` - Far clipping plane distance

docs/source/interactive_notebooks.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
{
3939
"cell_type": "markdown",
4040
"metadata": {},
41-
"source": "The visualization is interactive with orbit controls and works offline once loaded.\n\nOptional parameters:\n- `height`: Height of the embedded viewer in pixels (default: 400)\n- `dark_mode`: Use dark color scheme (default: False)"
41+
"source": "The visualization is interactive with orbit controls and works offline once loaded.\n\nOptional parameters:\n- `height`: Height of the embedded viewer in pixels (default: 400)\n- `dark_mode`: Use dark color scheme (default: False)\n\n## Setting the Initial Camera\n\nUse {attr}`viser.ViserServer.initial_camera` to set the camera pose when the\nvisualization first loads:\n\n```python\nimport viser\n\nserver = viser.ViserServer()\nserver.scene.add_box(\"/box\", color=(255, 0, 0), dimensions=(1, 1, 1))\n\n# Set the initial camera pose.\nserver.initial_camera.position = (3.0, 3.0, 2.0)\nserver.initial_camera.look_at = (0.0, 0.0, 0.0)\n\nserver.scene.show()\n```\n\nAvailable properties: `position`, `look_at`, `up`, `fov`, `near`, `far`. See\n{class}`viser.InitialCameraConfig` for details."
4242
},
4343
{
4444
"cell_type": "markdown",

src/viser/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from ._scene_handles import TransformControlsHandle as TransformControlsHandle
6262
from ._viser import CameraHandle as CameraHandle
6363
from ._viser import ClientHandle as ClientHandle
64+
from ._viser import InitialCameraConfig as InitialCameraConfig
6465
from ._viser import ViserServer as ViserServer
6566

6667
__version__ = "1.0.17"

src/viser/_messages.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,41 +967,53 @@ class SetCameraPositionMessage(Message):
967967
"""Server -> client message to set the camera's position."""
968968

969969
position: Tuple[float, float, float]
970+
initial: bool = False
971+
"""If True, this is an initial camera setup that can be overridden by URL params."""
970972

971973

972974
@dataclasses.dataclass
973975
class SetCameraUpDirectionMessage(Message):
974976
"""Server -> client message to set the camera's up direction."""
975977

976978
position: Tuple[float, float, float]
979+
initial: bool = False
980+
"""If True, this is an initial camera setup that can be overridden by URL params."""
977981

978982

979983
@dataclasses.dataclass
980984
class SetCameraLookAtMessage(Message):
981985
"""Server -> client message to set the camera's look-at point."""
982986

983987
look_at: Tuple[float, float, float]
988+
initial: bool = False
989+
"""If True, this is an initial camera setup that can be overridden by URL params."""
984990

985991

986992
@dataclasses.dataclass
987993
class SetCameraNearMessage(Message):
988994
"""Server -> client message to set the camera's near clipping plane."""
989995

990996
near: float
997+
initial: bool = False
998+
"""If True, this is an initial camera setup that can be overridden by URL params."""
991999

9921000

9931001
@dataclasses.dataclass
9941002
class SetCameraFarMessage(Message):
9951003
"""Server -> client message to set the camera's far clipping plane."""
9961004

9971005
far: float
1006+
initial: bool = False
1007+
"""If True, this is an initial camera setup that can be overridden by URL params."""
9981008

9991009

10001010
@dataclasses.dataclass
10011011
class SetCameraFovMessage(Message):
10021012
"""Server -> client message to set the camera's field of view."""
10031013

10041014
fov: float
1015+
initial: bool = False
1016+
"""If True, this is an initial camera setup that can be overridden by URL params."""
10051017

10061018

10071019
@dataclasses.dataclass

src/viser/_viser.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,78 @@
2929
from .infra._infra import StateSerializer
3030

3131

32+
@dataclasses.dataclass
33+
class InitialCameraConfig:
34+
"""Configuration for the initial camera pose.
35+
36+
Accessed via :attr:`ViserServer.initial_camera`. Values set here are
37+
applied to new client connections and serialized scenes.
38+
39+
The API is designed to match :class:`CameraHandle`, which is used for
40+
per-client camera control.
41+
"""
42+
43+
position: tuple[float, float, float] | npt.NDArray[np.floating] | None = None
44+
"""Camera position in world coordinates."""
45+
46+
look_at: tuple[float, float, float] | npt.NDArray[np.floating] | None = None
47+
"""Point the camera looks at in world coordinates."""
48+
49+
up: tuple[float, float, float] | npt.NDArray[np.floating] | None = None
50+
"""Camera up direction."""
51+
52+
fov: float | None = None
53+
"""Vertical field of view in radians."""
54+
55+
near: float | None = None
56+
"""Near clipping plane distance."""
57+
58+
far: float | None = None
59+
"""Far clipping plane distance."""
60+
61+
def _get_messages(self) -> list[_messages.Message]:
62+
"""Get camera messages for all non-None fields.
63+
64+
Messages are marked with initial=True so the client can skip them
65+
if URL parameters were provided.
66+
"""
67+
messages: list[_messages.Message] = []
68+
if self.position is not None:
69+
messages.append(
70+
_messages.SetCameraPositionMessage(
71+
cast_vector(self.position, 3),
72+
initial=True,
73+
)
74+
)
75+
if self.look_at is not None:
76+
messages.append(
77+
_messages.SetCameraLookAtMessage(
78+
cast_vector(self.look_at, 3),
79+
initial=True,
80+
)
81+
)
82+
if self.up is not None:
83+
messages.append(
84+
_messages.SetCameraUpDirectionMessage(
85+
cast_vector(self.up, 3),
86+
initial=True,
87+
)
88+
)
89+
if self.fov is not None:
90+
messages.append(
91+
_messages.SetCameraFovMessage(float(self.fov), initial=True)
92+
)
93+
if self.near is not None:
94+
messages.append(
95+
_messages.SetCameraNearMessage(float(self.near), initial=True)
96+
)
97+
if self.far is not None:
98+
messages.append(
99+
_messages.SetCameraFarMessage(float(self.far), initial=True)
100+
)
101+
return messages
102+
103+
32104
@dataclasses.dataclass
33105
class _CameraHandleState:
34106
"""Information about a client's camera state."""
@@ -639,6 +711,7 @@ def __init__(
639711

640712
_client_autobuild.ensure_client_is_built()
641713

714+
self._initial_camera = InitialCameraConfig()
642715
self._connection = server
643716
self._connected_clients: dict[int, ClientHandle] = {}
644717
self._client_lock = threading.Lock()
@@ -707,6 +780,10 @@ async def handle_camera_message(
707780

708781
conn.register_handler(_messages.ViewerCameraMessage, handle_camera_message)
709782

783+
# Send initial camera messages.
784+
for msg in self._initial_camera._get_messages():
785+
conn.queue_message(msg)
786+
710787
# Remove clients when they disconnect.
711788
@server.on_client_disconnect
712789
async def _(conn: infra.WebsockClientConnection) -> None:
@@ -797,6 +874,21 @@ def request_share_url_no_return() -> None: # To suppress type error.
797874
self.gui.reset()
798875
self.gui.set_panel_label(label)
799876

877+
@property
878+
def initial_camera(self) -> InitialCameraConfig:
879+
"""Configuration for initial camera pose.
880+
881+
Set these values to control the initial camera position for new
882+
clients and serialized/embedded scenes. The API is designed to match
883+
:class:`viser.CameraHandle`, which is used for per-client camera control.
884+
885+
Example usage::
886+
887+
server.initial_camera.position = (5.0, 5.0, 3.0)
888+
server.initial_camera.look_at = (0.0, 0.0, 0.0)
889+
"""
890+
return self._initial_camera
891+
800892
def _run_garbage_collector(self, force: bool = False) -> None:
801893
"""Clean up old messages. This is not elegant; a refactor of our
802894
message persistence logic will significantly reduce complexity."""
@@ -1105,4 +1197,12 @@ def get_scene_serializer(self) -> StateSerializer:
11051197
# Insert current scene state.
11061198
for message in self._websock_server._broadcast_buffer.message_from_id.values():
11071199
serializer._insert_message(message)
1200+
1201+
# Prepend initial camera messages.
1202+
camera_messages = [
1203+
(0.0, msg.as_serializable_dict())
1204+
for msg in self._initial_camera._get_messages()
1205+
]
1206+
serializer._messages = camera_messages + serializer._messages
1207+
11081208
return serializer

src/viser/client/src/App.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,35 @@ function ViewerRoot() {
257257

258258
// Global hover state tracking.
259259
hoveredElementsCount: 0,
260+
261+
// Initial camera from URL params (if provided).
262+
initialCameraFromUrlParams: (() => {
263+
// Helper to parse and validate a vector URL param.
264+
const parseVec3 = (
265+
param: string,
266+
): [number, number, number] | null => {
267+
const str = searchParams.get(param);
268+
if (str === null) return null;
269+
const parts = str.split(",").map(Number);
270+
if (parts.length !== 3 || !parts.every(Number.isFinite)) return null;
271+
return parts as [number, number, number];
272+
};
273+
// Helper to parse and validate a scalar URL param.
274+
const parseScalar = (param: string): number | null => {
275+
const str = searchParams.get(param);
276+
if (str === null) return null;
277+
const val = Number(str);
278+
return Number.isFinite(val) ? val : null;
279+
};
280+
return {
281+
position: parseVec3("initialCameraPosition"),
282+
lookAt: parseVec3("initialCameraLookAt"),
283+
up: parseVec3("initialCameraUp"),
284+
fov: parseScalar("initialCameraFov"),
285+
near: parseScalar("initialCameraNear"),
286+
far: parseScalar("initialCameraFar"),
287+
};
288+
})(),
260289
});
261290

262291
// Create the scene tree state and extract store and actions.

0 commit comments

Comments
 (0)