Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
225a5d5
Add e2e tests pinning cross-scope (server vs client handle) semantics
brentyi Aug 8, 2026
41abb42
Document the ephemeral-client contract
brentyi Aug 9, 2026
f2a492f
Enforce cross-scope scene name rules; fix world-axes per-client handle
brentyi Aug 9, 2026
15da0fa
Remove client.scene.world_axes; world axes are server-owned only
brentyi Aug 9, 2026
d197c88
Add design doc: (owner, name) scene-node identity
brentyi Aug 9, 2026
dd7d3c0
Rework identity design doc: variant slots with client-wins display
brentyi Aug 9, 2026
05d98e7
Design doc: scope-local cascade + unconditional virtual anchors
brentyi Aug 9, 2026
e284f2b
Implement (owner, name) scene-node identity with client-wins shadowing
brentyi Aug 9, 2026
956e5fb
Add pixel-level e2e tests for cross-scope shadowing
brentyi Aug 9, 2026
d318802
Simplify and clean up the cross-scope shadowing implementation
brentyi Aug 9, 2026
1867bf0
Keep the virtual-anchor marker out of add_frame's public signature
brentyi Aug 9, 2026
fc0bcc5
Cleanup batch: pointer coexistence, dead-client unification, containe…
brentyi Aug 9, 2026
7803929
GUI containers: allow client elements inside server containers
brentyi Aug 10, 2026
59cce09
Harden cross-scope GUI nesting: context-restore fix + test pyramid
brentyi Aug 10, 2026
269335c
Fix missing get_client_handle import in floating-panel e2e test
brentyi Aug 10, 2026
b8dc9d6
Fix 10 cross-scope regressions found by review + regression-hunt passes
brentyi Aug 10, 2026
0921a01
Simplify pass over the regression fixes
brentyi Aug 10, 2026
6f90c40
Address final review pass: disconnect drag drain, task-local contexts
brentyi Aug 10, 2026
a22b2ad
Fix review pass 3 findings: per-name owner routing, teardown races
brentyi Aug 10, 2026
92194a7
Fix CI: batched visibility recompute broke in the parking refactor
brentyi Aug 10, 2026
3469a93
Harden new cross-scope e2e tests against CI timing
brentyi Aug 10, 2026
45a7d03
Fix review pass 4: wire-order across mid-batch flips, warning hygiene
brentyi Aug 10, 2026
ab4e289
Observe wire pose, not the render loop, in the coexistence e2e wait
brentyi Aug 10, 2026
bb98058
Format SceneTreeState.test.ts (prettier gate)
brentyi Aug 10, 2026
54cfa75
Add cross-version recording fixture + server-registry leak test
brentyi Aug 10, 2026
10fa134
Address review pass 5 + harden drag e2e tests for CI contention
brentyi Aug 10, 2026
ed7fdbc
Drop redundant store wait in _wait_drag_ready (pass 6 cleanup)
brentyi Aug 10, 2026
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
180 changes: 180 additions & 0 deletions docs/design/scene_node_identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Scene node identity: per-name variant slots with client-wins display

Status: **implemented** (same branch, after an interim rejection-based
design; the "where we are" section below describes the state this replaced).
Code map: owner/virtual fields in `_messages.py`; owner stamping via
`SceneApi._queue_scene_message` and virtual anchors via
`_ensure_ancestors_exist` in `_scene_api.py` / `_scene_handles.py`; variant
slots, display rule, and frozen-pose inheritance in the frontend's
`SceneTreeState.ts` with owner routing in `MessageHandler.tsx`. Tests:
`tests/test_scene_scopes.py`, `SceneTreeState.test.ts`,
`tests/e2e/test_cross_scope_handles.py`. Not yet implemented: a scene-tree
panel badge for shadowing/local variants (cosmetic follow-up).

## Where this started

Scene nodes were identified by name alone, everywhere: the frontend scene
tree was a single store keyed by node name with no record of which scope
created an entry, and every name-keyed message (updates, removes, clicks,
drags) resolved against that one namespace. Because `server.scene`
(broadcast) and each `client.scene` (per-client) both feed the same tree, a
name claimed by two scopes visible to the same viewer silently corrupted
state.

An interim fix (a `SceneNameIndex`, since removed) kept name-only identity
and **rejected** overlapping-scope claims at the add site (`ValueError`),
with an audience-subset rule for cross-scope parenting and cross-scope
cascade on broadcast removals. That was sound, but it made the collision
class *forbidden* rather than *unrepresentable*, and it forced server and
client code to coordinate names.

## The model

Each scene-tree **name** becomes a slot holding up to two **variants**: a
broadcast variant and a client variant. Both variants keep independent state
(props, pose, visibility, interaction bindings), fed independently by their
scopes' messages. Exactly one variant is **effective** (rendered,
interactive) per name, chosen by a local display rule:

> Pick the variant maximizing ``(is_real, is_client)``:
> real client > real broadcast > virtual client > virtual broadcast.

"Virtual" marks auto-created intermediate ancestors (see below). The rule
gives client-over-server supersede semantics -- a client-scoped add of a name
the server owns *shadows* the broadcast node for that one viewer -- without
any of the machinery that made shadowing expensive under name-only identity:

- **No per-client filtering of broadcast sends.** Updates to a shadowed
broadcast node land in the broadcast variant's state; they're simply not
displayed while shadowed. Nothing clobbers the client variant.
- **No resurrection machinery.** Removing the client variant un-hides the
broadcast variant, which has been accumulating state all along -- the
display rule is recomputed locally from data already in the store.
- **Deterministic removal in both directions.** Server removes its `/x`:
the broadcast variant leaves the slot; a client variant is unaffected.
Client removes its `/x`: the broadcast variant (if any) shows again.
- **Late joiners are trivially correct**: broadcast replay populates only
broadcast variants.

**Hierarchy stays name-based.** One tree edge structure per name; children
attach to their parent *name*, not to a specific variant, and pose composes
through whichever variant is effective. This is what keeps the frontend
change small: no per-variant tree, no parent-edge resolution rules.

### Virtual intermediates

Ancestor auto-creation (`_ensure_ancestors_exist`) becomes **unconditional
per scope**: every add creates anchors for all missing *same-scope*
ancestors, even when another scope's variant of that name exists. These
anchors are flagged **virtual** -- a field on the create message -- and:

- Virtual variants yield to real ones in the display rule, so a client
auto-ancestor for `/a` never shadows the server's real `/a` (its axes, its
pose visuals). A later explicit add of the same name from the same scope
supersedes the virtual variant with a real one (ordinary within-scope
supersede).
- Virtual variants render nothing and are never interactive; while a real
variant of the name exists, the anchor is pure lifecycle bookkeeping.

Unconditional anchors give every node a complete same-scope ancestor chain,
which is what makes scope-local cascade (below) orphan-free: the two scopes
are two complete overlaid trees, merged per name by the display rule. The
cost is a handful of tiny anchor messages per deep add.

Virtual intermediates also dissolve the audience-subset rule: a broadcast add
of `/a/b` where `/a` exists only in some client's scope auto-creates a
*virtual broadcast* `/a`. Other clients see the child under an invisible
anchor; the owning client's real `/a` shadows the anchor. No error needed in
either direction, so **both `ValueError`s from the name index are relaxed**
(non-breaking: code that raised starts working, with defined semantics).

## Wire protocol

`owner` is an **opaque string** stamped on scene-node messages, not a
boolean: today it takes two values ("broadcast" and a per-connection
identifier), but under the audience-set endgame (elements carry an audience;
`client.scene.add_*` becomes sugar) a client may see nodes from several
owners, and an opaque id avoids a second identity migration.

**Per-message owner field, not per-batch origin tagging.** Batch tagging
(each of the two producer tasks stamping the windows it drains) is cheaper
today, but it identifies owners with *buffers* -- and the endgame is a single
persistent buffer whose per-client window generator filters messages by
audience (precedent: `excluded_self_client` is already filtered per-client in
`AsyncMessageBuffer.window_generator`). In that world one batch carries mixed
owners. Pay the schema sweep once:

- Server→client: scene-node messages (`_CreateSceneNodeMessage` subclasses,
`SceneNodeUpdateMessage`, `Set{Orientation,Position,...}`,
`SetSceneNodeVisibilityMessage`, `RemoveSceneNodeMessage`, binding
messages) gain `owner: str`, stamped by the queueing `SceneApi`. Create
messages additionally gain the `virtual` flag. Changes go through
`_messages.py` + `sync_client_server.py --sync-messages`.
- Client→server: interaction messages (`SceneNodeClickMessage`,
`SceneNodeDragMessage`, transform-controls updates and drag start/end)
echo the effective variant's owner, so dispatch resolves to exactly one
scope's registry. Only the effective variant is interactive; a shadowed
broadcast node's bindings lie dormant until it is unshadowed.
- Entity identity for redundancy keys and GC becomes (owner, name) -- a
no-op while buffers are split per owner, load-bearing once merged.

## Frontend

- Store: per-name slot with `broadcast?` / `client?` variant entries; pose
data and bindings move into the variant. Effective-variant selection is
one pure function; only the effective variant is mounted (a shadow toggle
remounts, which is acceptable churn -- same cost as today's same-name
re-add).
- `nodeRefFromName` stays name-keyed (only the effective variant mounts).
- Scene-tree panel: one row per name (the effective variant), with a badge
for local/shadowing variants.
- `.viser` serialization: unchanged (recordings already filter to the
broadcast scope, which is collision-free on its own).

## Python side

- The `SceneNameIndex` keeps its bookkeeping roles (ancestor-existence
checks, disconnect cleanup) and loses both claim-time rejections -- and,
with scope-local cascade, its cross-scope cascade lookup.
- **Cascade is scope-local**: a server remove cascades through broadcast
descendants only; a client remove cascades through that client's
descendants only. Neither scope can destroy the other's state -- both
scopes are driven by the same Python program, so when coupled teardown is
wanted (a per-client annotation that should die with the mesh it
annotates), the author removes it explicitly rather than the design doing
it behind their back. Unconditional same-scope virtual anchors (above)
guarantee no orphans: the surviving scope's subtree keeps a complete
ancestor chain. This also deletes the cross-scope handle-invalidation
machinery from the current branch -- the zombie problem is solved from
the other side, by keeping the frontend node alive so handle and frontend
agree by construction.
- **Frozen-pose inheritance**: children compose pose through the parent
name's *effective* variant; virtual anchors contribute nothing while a
real variant exists. When the effective variant is removed and a virtual
anchor becomes effective, the anchor inherits the departing variant's
last pose (a frontend-local copy) -- surviving children stay where they
were instead of teleporting to identity. Accepted caveat: a client child
can outlive the broadcast object it annotated, frozen in place, until its
author removes it.
- `client.scene.add_frame("/WorldAxes")` becomes the sanctioned per-client
world-axes override (shadowing the server's node), replacing the removed
`client.scene.world_axes` handle.

## Migration and sequencing

1. (Done, current branch) Name-only identity + `SceneNameIndex` rejection.
Errors are forward-compatible: relaxing them later breaks nobody.
2. Merged single producer per connection (planned): one ordered stream,
cross-scope `atomic()`. Independent of this design but shares the
filtered-window machinery.
3. This design: owner + virtual fields, variant slots + display rule on the
frontend, both `ValueError`s relaxed, cascade rewired to scope-local
semantics (the interim cross-scope cascade + handle invalidation from
step 1 is deleted; handles that used to be invalidated stay valid, so the
change is again a relaxation). The branch's rejection tests flip to
coexistence/shadowing assertions. Client/server version gating already
forces matched deploys; no wire compatibility shims needed.
4. Audience sets: `audience=` on add, per-client filtering in the window
generator, audience mutation on live elements. Owner ids from step 3 are
the identity substrate; the display rule generalizes by ranking owner
specificity (more-specific audience wins).
47 changes: 47 additions & 0 deletions docs/source/conventions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,59 @@ In ``viser``, all camera parameters use the **COLMAP/OpenCV convention**:

**Conversion**: A simple **180° rotation around the local X-axis** converts between the two conventions.

Server and Client Scopes
------------------------

Scene and GUI elements can be created through two kinds of handles:

- ``server.scene`` / ``server.gui``: **shared** elements, visible to every
connected client and replayed to clients that connect later.
- ``client.scene`` / ``client.gui`` (via :class:`~viser.ClientHandle`):
**per-client** elements, visible to one client only. Client state is
ephemeral -- it disappears when the connection closes, and a reconnecting
browser is a new client -- so per-client state should be (re)built in
:meth:`~viser.ViserServer.on_client_connect`.

Each scene-tree name can hold one node from each scope. When both exist,
the client-scoped node **shadows** the shared one for that client: it is
the one rendered and the one that receives clicks and drags, while other
clients keep seeing the shared node. Updates to a shadowed shared node keep
accumulating invisibly; removing the client-scoped node reveals the shared
node again with its latest state. This makes per-client overrides of shared
elements a one-liner::

# Everyone sees this...
server.scene.add_box("/box", color=(255, 0, 0))
# ...except this client, who now sees their own version instead:
client.scene.add_box("/box", color=(0, 255, 0))

Removal is **scope-local**: removing a node (or a whole subtree) through
one scope's handle never touches the other scope's nodes, even per-client
children named under a shared parent -- those stay, anchored at the
parent's last pose, until their own scope removes them. In the scene-tree
panel, per-client nodes are marked with a ``local`` badge.

GUI container nesting across scopes is **directional**: a ``client.gui``
element may be added inside a ``server.gui`` container context (its
audience is a subset of the container's), rendering inside the shared
folder for that client only::

with server.gui.add_folder("Shared folder"):
client.gui.add_button("Only I see this")

The reverse -- a ``server.gui`` element inside a ``client.gui`` container
-- raises, since no other client could see the container. Cross-nested
elements are the one exception to scope-local removal: removing the
server container also removes the client elements nested inside it (an
orphaned widget, unlike a scene node, has nowhere coherent to go).

----

.. seealso::

**Related Documentation**

- :class:`~viser.ViserServer` for scene management
- :class:`~viser.ClientHandle` for per-client state
- :func:`~viser.SceneApi.set_up_direction` for coordinate system configuration
- :mod:`~viser.transforms` for transformation utilities
77 changes: 77 additions & 0 deletions examples/03_interaction/08_per_client_scenes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Per-client scene state

Mix shared (broadcast) scene elements with per-client ones, including
per-client overrides of shared nodes.

**Features:**

* :attr:`viser.ClientHandle.scene` for elements only one client sees
* Shadowing: a client-scoped node with a shared node's name replaces it for
that client only, and the shared node (with its latest state) returns when
the client-scoped variant is removed
* Scope-local removal: per-client annotations survive shared-node removal
until their own scope removes them
"""

from __future__ import annotations

import time

import viser

server = viser.ViserServer()

# A shared box that every client sees, animated by the server.
shared_box = server.scene.add_box(
"/box", dimensions=(0.5, 0.5, 0.5), color=(200, 60, 60)
)


@server.on_client_connect
def _(client: viser.ClientHandle) -> None:
# Per-client GUI + scene state. Everything created through `client.` is
# visible to this client alone and is rebuilt here on reconnect (client
# state is ephemeral -- see the ClientHandle docs).
highlight = client.gui.add_checkbox("Highlight box (only me)", False)
annotate = client.gui.add_checkbox("Annotate box (only me)", False)

highlight_handle: viser.BoxHandle | None = None
annotation_handle = None

@highlight.on_update
def _(_) -> None:
nonlocal highlight_handle
if highlight.value:
# Same name as the shared box: this client-scoped variant
# SHADOWS the shared one for this client only. Other clients
# keep seeing the server's red box, and server updates keep
# accumulating in the hidden variant.
highlight_handle = client.scene.add_box(
"/box", dimensions=(0.55, 0.55, 0.55), color=(60, 200, 80)
)
elif highlight_handle is not None:
# Un-shadow: the shared box reappears with its LATEST state.
highlight_handle.remove()
highlight_handle = None

@annotate.on_update
def _(_) -> None:
nonlocal annotation_handle
if annotate.value:
# A per-client child under the shared node. If the server ever
# removes /box, this annotation survives (anchored where the box
# was) until this client removes it -- removal never reaches
# across scopes.
annotation_handle = client.scene.add_label(
"/box/note", text=f"client {client.client_id}'s note"
)
elif annotation_handle is not None:
annotation_handle.remove()
annotation_handle = None


while True:
# Server-side animation of the shared box; shadowing clients don't see
# these updates until they un-shadow.
shared_box.position = (0.0, 0.0, 0.4 + 0.2 * (time.time() % 1.0))
time.sleep(0.05)
13 changes: 13 additions & 0 deletions src/viser/_backwards_compat_shims.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ class DeprecatedAttributeShim:
`<=0.1.30`."""

def __getattr__(self, name: str) -> Any:
# During partial construction (or an attribute miss on a
# partially-torn-down object), `self.scene` / `self.gui` don't exist
# yet -- and looking them up would land back in THIS __getattr__,
# recursing until RecursionError. Bail out to a plain AttributeError
# instead, so init-ordering mistakes fail legibly. (Both attributes
# are plain instance attributes on ViserServer and ClientHandle, so
# __dict__ is the right place to check.)
if "scene" not in self.__dict__ or "gui" not in self.__dict__:
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}' "
"(object is partially constructed: scene/gui APIs not set up "
"yet)"
)
fixed_name = {
# Map from old method names (viser v0.1.*) to new methods names.
"reset_scene": "reset",
Expand Down
Loading
Loading