Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/source/examples/gui/notifications.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Code
title="Controlled notification",
body="This cannot be closed by the user and is controlled in code only!",
with_close_button=False,
auto_close_seconds=False,
auto_close_seconds=None,
)

@remove_controlled_notif.on_click
Expand Down
2 changes: 1 addition & 1 deletion examples/02_gui/06_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def _(event: viser.GuiEvent) -> None:
title="Controlled notification",
body="This cannot be closed by the user and is controlled in code only!",
with_close_button=False,
auto_close_seconds=False,
auto_close_seconds=None,
)

@remove_controlled_notif.on_click
Expand Down
7 changes: 7 additions & 0 deletions src/viser/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ class NotificationMessage(Message):
uuid: str
props: NotificationProps

@override
def redundancy_key(self) -> str:
# Include mode in the key so "show" and "update" messages are kept
# separately. Without this, an "update" message would cull the "show"
# message, preventing the notification from being created.
return f"{type(self).__name__}_{self.uuid}_{self.mode}"


@dataclasses.dataclass
class NotificationProps:
Expand Down
70 changes: 16 additions & 54 deletions src/viser/_viser.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@ class InitialCameraConfig:
1. The starting camera pose for new client connections
2. The pose that "Reset View" returns to in the client

Default values:
- ``position``: ``(3.0, 3.0, 3.0)``
- ``look_at``: ``(0.0, 0.0, 0.0)``
- ``up``: ``(0.0, 0.0, 1.0)``
- ``fov``: 50 degrees (~0.873 radians, Three.js default)
- ``near``: ``0.01``
- ``far``: ``1000.0``
Default behavior (when properties are not explicitly set):
The client uses a built-in default camera position that provides a
reasonable view regardless of the scene's up direction. This default
is specified in three.js coordinates and does not require world
coordinate transformation.

When properties are explicitly set, they are interpreted as viser world
coordinates and transformed appropriately based on the scene's up direction.

When properties are changed after clients are connected, only the "Reset
View" target is updated. Clients' current camera positions are not moved,
Expand All @@ -56,20 +57,14 @@ class InitialCameraConfig:
per-client camera control.
"""

# Default FOV matches Three.js PerspectiveCamera default of 50 degrees.
DEFAULT_FOV: float = 50.0 * np.pi / 180.0

def __init__(self, broadcast: Callable[[_messages.Message], None]) -> None:
self._broadcast = broadcast
# Defaults match the TypeScript client defaults in InitialCameraState.ts.
self._position: npt.NDArray[np.float64] = np.array(
[3.0, 3.0, 3.0], dtype=np.float64
)
self._look_at: npt.NDArray[np.float64] = np.array(
[0.0, 0.0, 0.0], dtype=np.float64
)
self._up: npt.NDArray[np.float64] = np.array([0.0, 0.0, 1.0], dtype=np.float64)
self._fov: float = InitialCameraConfig.DEFAULT_FOV
self._position: npt.NDArray[np.float64] = np.array([3.0, 3.0, 3.0])
self._look_at: npt.NDArray[np.float64] = np.array([0.0, 0.0, 0.0])
# None means "same as the scene up direction".
self._up: npt.NDArray[np.float64] | None = None
# 50 degrees in radians; matches three.js PerspectiveCamera default.
self._fov: float = 50.0 * np.pi / 180.0
self._near: float = 0.01
self._far: float = 1000.0

Expand Down Expand Up @@ -102,8 +97,8 @@ def look_at(
)

@property
def up(self) -> npt.NDArray[np.float64]:
"""Camera up direction."""
def up(self) -> npt.NDArray[np.float64] | None:
"""Camera up direction, or None for scene up direction."""
return self._up

@up.setter
Expand Down Expand Up @@ -143,23 +138,6 @@ def far(self, value: float) -> None:
self._far = float(value)
self._broadcast(_messages.SetCameraFarMessage(self._far, initial=True))

def _get_messages(self) -> list[_messages.Message]:
"""Get camera messages for current configuration."""
return [
_messages.SetCameraPositionMessage(
cast_vector(self._position, 3), initial=True
),
_messages.SetCameraLookAtMessage(
cast_vector(self._look_at, 3), initial=True
),
_messages.SetCameraUpDirectionMessage(
cast_vector(self._up, 3), initial=True
),
_messages.SetCameraFovMessage(self._fov, initial=True),
_messages.SetCameraNearMessage(self._near, initial=True),
_messages.SetCameraFarMessage(self._far, initial=True),
]


@dataclasses.dataclass
class _CameraHandleState:
Expand Down Expand Up @@ -840,12 +818,6 @@ async def handle_camera_message(

conn.register_handler(_messages.ViewerCameraMessage, handle_camera_message)

# Send initial camera messages.
# initial=True sets the "Reset View" target, and on first load also
# moves the camera.
for msg in self._initial_camera._get_messages():
conn.queue_message(msg)

# Remove clients when they disconnect.
@server.on_client_disconnect
async def _(conn: infra.WebsockClientConnection) -> None:
Expand Down Expand Up @@ -1259,14 +1231,4 @@ def get_scene_serializer(self) -> StateSerializer:
# Insert current scene state.
for message in self._websock_server._broadcast_buffer.message_from_id.values():
serializer._insert_message(message)

# Prepend initial camera messages.
# initial=True sets the "Reset View" target, and on first load also
# moves the camera.
camera_messages = [
(0.0, msg.as_serializable_dict())
for msg in self._initial_camera._get_messages()
]
serializer._messages = camera_messages + serializer._messages

return serializer
7 changes: 2 additions & 5 deletions src/viser/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ function ViewerRoot() {
)
: () => null,
sendCamera: null,
resetCameraView: null,
resetCameraPose: null,

// DOM/Three.js references.
canvas: null,
Expand Down Expand Up @@ -274,9 +274,7 @@ function ViewerRoot() {
// Parse URL params once during initialization.
React.useMemo(() => {
// Helper to parse and validate a vector URL param.
const parseVec3 = (
param: string,
): [number, number, number] | null => {
const parseVec3 = (param: string): [number, number, number] | null => {
const str = searchParams.get(param);
if (str === null) return null;
const parts = str.split(",").map(Number);
Expand Down Expand Up @@ -590,7 +588,6 @@ function ViewerCanvas({ children }: { children: React.ReactNode }) {
style={{ position: "relative", zIndex: 0, width: "100%", height: "100%" }}
>
<Canvas
camera={{ position: [-3.0, 3.0, -3.0], near: 0.01, far: 1000.0 }}
gl={{ preserveDrawingBuffer: true }}
style={{ width: "100%", height: "100%" }}
ref={(el) => (viewer.mutable.current.canvas = el)}
Expand Down
41 changes: 10 additions & 31 deletions src/viser/client/src/CameraControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,14 +292,18 @@ export function SynchronizedCameraControls() {
pivotRef.current.updateMatrixWorld(true);
};

viewerMutable.resetCameraView = () => {
viewerMutable.resetCameraPose = () => {
// Read initial camera state from the Zustand store.
const initialCameraState = viewer.useInitialCamera.getState();
const T_threeworld_world = computeT_threeworld_world(viewer);

// Transform from world coordinates to threeworld coordinates.
// Skip the up direction transform for the default up direction. This makes
// it so the initial camera up always matches the initial scene up, except
// in the case where the up direction was explicitly set.
const initialUp = new THREE.Vector3(...initialCameraState.up.value);
initialUp.applyMatrix4(T_threeworld_world);
if (initialCameraState.position.source !== "default") {
initialUp.applyMatrix4(T_threeworld_world);
}
initialUp.normalize();

const initialPos = new THREE.Vector3(...initialCameraState.position.value);
Expand Down Expand Up @@ -413,37 +417,12 @@ export function SynchronizedCameraControls() {
const initialCameraPositionSet = React.useRef(false);
React.useEffect(() => {
if (!initialCameraPositionSet.current) {
// Reset position, orientation, and up direction.
viewerMutable.resetCameraPose!();

// Read initial camera state from the Zustand store.
// This contains defaults, URL params, or will be updated by server messages.
const initialCameraState = viewer.useInitialCamera.getState();
const T_threeworld_world = computeT_threeworld_world(viewer);

const initialCameraPos = new THREE.Vector3(
...initialCameraState.position.value,
);
initialCameraPos.applyMatrix4(T_threeworld_world);
const initialCameraLookAt = new THREE.Vector3(
...initialCameraState.lookAt.value,
);
initialCameraLookAt.applyMatrix4(T_threeworld_world);
const initialCameraUp = new THREE.Vector3(
...initialCameraState.up.value,
);
initialCameraUp.applyMatrix4(T_threeworld_world);
initialCameraUp.normalize();

camera.up.set(initialCameraUp.x, initialCameraUp.y, initialCameraUp.z);
viewerMutable.cameraControl!.updateCameraUp();

viewerMutable.cameraControl!.setLookAt(
initialCameraPos.x,
initialCameraPos.y,
initialCameraPos.z,
initialCameraLookAt.x,
initialCameraLookAt.y,
initialCameraLookAt.z,
false,
);

// Apply fov/near/far from the store.
// tan(fov / 2.0) = 0.5 * film height / focal length
Expand Down
2 changes: 1 addition & 1 deletion src/viser/client/src/ControlPanel/ServerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export default function ServerControls() {
</Button>
<Button
onClick={() => {
viewerMutable.resetCameraView!();
viewerMutable.resetCameraPose!();
}}
flex={1}
leftSection={
Expand Down
19 changes: 13 additions & 6 deletions src/viser/client/src/InitialCameraState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@
* handle priority between different configuration methods.
*
* ## Source Priority (lowest to highest)
* - "default": Built-in defaults matching Three.js/Python server defaults
* - "message": Server's initial_camera configuration sent via websocket
* - "url": URL parameters (highest priority, always wins)
* - "default": Built-in defaults in three.js coordinates (no world transform applied)
* - "message": Server's initial_camera configuration sent via websocket (viser world coords)
* - "url": URL parameters (highest priority, viser world coords)
*
* ## Default Values
* ## Default Values (in three.js coordinates, Y-up)
* - position: [3, 3, 3]
* - lookAt: [0, 0, 0]
* - up: [0, 0, 1]
* - up: [0, 1, 0]
* - fov: 50 degrees (≈0.873 radians, Three.js PerspectiveCamera default)
* - near: 0.01
* - far: 1000
*
* Default values are in three.js coordinates and work regardless of the scene's
* up direction (set via set_up_direction()). Values from "message" or "url"
* sources are in viser world coordinates and are transformed appropriately.
*
* When server.initial_camera properties are changed after clients connect,
* "Reset View" targets are updated without disrupting users' current camera
* positions.
Expand Down Expand Up @@ -105,9 +109,12 @@ export function useInitialCameraState(urlParams: InitialCameraConfig) {
lookAt: urlParams.lookAt
? { value: urlParams.lookAt, source: "url" as const }
: { value: [0, 0, 0], source: "default" as const },
// Default up is Y-up in three.js coordinates. When source is "default",
// the world transform is not applied, so this gives correct behavior
// regardless of set_up_direction().
up: urlParams.up
? { value: urlParams.up, source: "url" as const }
: { value: [0, 0, 1], source: "default" as const },
: { value: [0, 1, 0], source: "default" as const },
// Default FOV matches Three.js PerspectiveCamera default of 50 degrees.
fov: urlParams.fov
? { value: urlParams.fov, source: "url" as const }
Expand Down
37 changes: 34 additions & 3 deletions src/viser/client/src/MessageHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,17 @@ function useMessageHandler() {

// Add a notification.
case "NotificationMessage": {
console.log(message.uuid, message.props.loading);
(message.mode === "show" ? notifications.show : notifications.update)({
id: message.uuid,
title: message.props.title,
message: message.props.body,
withCloseButton: message.props.with_close_button,
loading: message.props.loading,
autoClose:
message.props.auto_close_seconds === null
// Handle both null and falsy values (e.g., if False is accidentally
// passed from Python) as "no auto-close".
message.props.auto_close_seconds === null ||
!message.props.auto_close_seconds
? false
: message.props.auto_close_seconds * 1000,
color: toMantineColor(message.props.color),
Expand Down Expand Up @@ -306,14 +308,17 @@ function useMessageHandler() {
return;
}
case "SetCameraPositionMessage": {
console.log("set camera position");
// Setting initial camera parameters.
const wasDefault = initialCamera.getState().position.source === "default";
const wasDefault =
initialCamera.getState().position.source === "default";
if (message.initial) {
// URL params take priority, ignore server's initial value.
initialCamera.getState().setPosition(message.position, "message");

// If this is the first initial camera: we'll also move the actual
// camera. If not, we return immediately.
console.log(message.initial, wasDefault);
if (!wasDefault) return;
}

Expand Down Expand Up @@ -663,6 +668,7 @@ export function FrameSynchronizedMessageHandler() {
const messageQueue = viewerMutable.messageQueue;
const splatContext = React.useContext(GaussianSplatsContext)!;
const gl = useThree((state) => state.gl);
const isFirstBatchRef = React.useRef(true);

useFrame(
() => {
Expand Down Expand Up @@ -787,6 +793,31 @@ export function FrameSynchronizedMessageHandler() {
: messageQueue.length;
const processBatch = messageQueue.splice(0, numMessages);

// Hack: On the very first batch, handle any root node SetOrientationMessage
// (from set_up_direction()) before all other messages. This ensures
// T_threeworld_world is up-to-date when initial camera messages are processed.
if (isFirstBatchRef.current) {
isFirstBatchRef.current = false;
const rootOrientationIndex = processBatch.findIndex(
(msg) => msg.type === "SetOrientationMessage" && msg.name === "",
);
if (rootOrientationIndex !== -1) {
const rootNodeUpdate = handleMessage(
processBatch[rootOrientationIndex],
)!;
const rootNode = viewer.useSceneTree.getState()[""]!;
viewer.useSceneTree.setState({
"": {
...rootNode,
wxyz: rootNodeUpdate.updates.wxyz!,
},
});

// Remove the message from the batch.
processBatch.splice(rootOrientationIndex, 1);
}
}

// Handle messages and accumulate updates.
const updates = processBatch.map(handleMessage).reduce(
(acc, cur) => {
Expand Down
1 change: 1 addition & 0 deletions src/viser/client/src/SceneTreeState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const rootNodeTemplate: SceneNode = {
);
return [quat.w, quat.x, quat.y, quat.z] as [number, number, number, number];
})(),
position: [0.0, 0.0, 0.0],
};
const worldAxesNodeTemplate: SceneNode = {
message: {
Expand Down
2 changes: 1 addition & 1 deletion src/viser/client/src/ViewerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type ViewerMutable = {
// Function references.
sendMessage: (message: Message) => void;
sendCamera: (() => void) | null;
resetCameraView: (() => void) | null;
resetCameraPose: (() => void) | null;

// DOM/Three.js references.
canvas: HTMLCanvasElement | null;
Expand Down
4 changes: 2 additions & 2 deletions src/viser/client/src/WorldTransformUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import * as THREE from "three";
* between +Y and +Z up directions for the world frame. */
export function computeT_threeworld_world(viewer: ViewerContextContents) {
const rootNode = viewer.useSceneTree.getState()[""];
const wxyz = rootNode?.wxyz ?? [1, 0, 0, 0];
const position = rootNode?.position ?? [0, 0, 0];
const wxyz = rootNode!.wxyz!;
const position = rootNode!.position!;
return new THREE.Matrix4()
.makeRotationFromQuaternion(
new THREE.Quaternion(wxyz[1], wxyz[2], wxyz[3], wxyz[0]),
Expand Down
Loading
Loading