Skip to content

Commit ebd27ff

Browse files
feat: [Remote rendering 3.2a] server-tracked camera: record, load-path write, and re-serialization (#110)
Co-authored-by: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com>
1 parent f1d23b5 commit ebd27ff

15 files changed

Lines changed: 837 additions & 42 deletions

File tree

‎doc/changelog.d/110.added.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Remote rendering 3.2a] server-tracked camera: record, load-path write, and re-serialization

‎src/ansys/visor/viewer/renderer/base.py‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,18 +179,45 @@ def refresh_color_variable_range(
179179

180180
@abstractmethod
181181
def reset_camera(self, bounds: list[float]) -> None:
182-
"""Reset the camera to fit *bounds*."""
182+
"""Reset the camera to fit *bounds*, and write the result to the record.
183+
184+
An implementation with no pipeline camera leaves the record at its
185+
previous value rather than clearing it, so that a reset cannot destroy
186+
a camera the frontend reported.
187+
"""
183188

184189
@abstractmethod
185190
def get_camera_state(self) -> "VisorCameraState | None":
186191
"""
187-
Return the last camera state synced from the frontend, or ``None`` if
188-
none has been received.
192+
Return the camera record, or ``None`` if nothing has written one yet.
189193
"""
190194

191195
@abstractmethod
192196
def sync_camera(self, camera_state: "VisorCameraState") -> None:
193-
"""Store the camera state synced back from the frontend."""
197+
"""
198+
Write *camera_state* to the record and project it onto the pipeline
199+
camera.
200+
201+
The record stores the object as given, without copying: callers rely
202+
on object identity through :meth:`get_camera_state`.
203+
"""
204+
205+
@abstractmethod
206+
def serialize_camera_state(self) -> None:
207+
"""Make the state served to the client current for the camera.
208+
209+
The camera alone; the node pipelines are
210+
:meth:`serialize_pipeline_states`'s job. Writing the pipeline camera
211+
makes the server correct: it does not make the state the client is
212+
served correct, and the two are separate steps that can each silently
213+
do nothing.
214+
215+
**Serialize only; do not notify.** Pushing to the client is
216+
:meth:`flush_wasm_state`'s job and carries a rebuild race that the
217+
load path deliberately refuses.
218+
219+
No-op on a renderer that serves the client no VTK object state.
220+
"""
194221

195222
# ------------------------------------------------------------------------
196223
# Widget control (cross-section, bounding box)

‎src/ansys/visor/viewer/renderer/local_renderer.py‎

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ansys.visor.viewer.core.perf_timer import PerfTimer
2929
from ansys.visor.viewer.core.visor_colors import VisorColors
3030
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
31+
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
3132
from ansys.visor.viewer.models.runtime.vtk.renderer_annotation import (
3233
WasmNodeHandles,
3334
WasmRendererAnnotation,
@@ -40,7 +41,6 @@
4041
from ansys.visor.viewer.vtk.widgets.visor_orientation import VisorOrientationWidget
4142

4243
if TYPE_CHECKING:
43-
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
4444
from ansys.visor.viewer.vtk.scene_graph import VisorSceneGraphPartNode
4545

4646
logger = VisorDefaultLogger(__name__)
@@ -287,22 +287,82 @@ def refresh_color_variable_range(
287287
def reset_camera(self, bounds: list[float]) -> None:
288288
"""See :meth:`IRenderer.reset_camera`."""
289289
self._vtk_renderer.ResetCamera(bounds)
290+
self._last_camera_state = self._read_pipeline_camera()
290291

291292
def get_camera_state(self) -> Optional["VisorCameraState"]:
292-
"""See :meth:`IRenderer.get_camera_state`.
293-
294-
Returns ``None`` on this branch: no coordinator caller and no
295-
frontend round-trip populates the store. Phase 3 wires the sync.
296-
"""
293+
"""See :meth:`IRenderer.get_camera_state`."""
297294
return self._last_camera_state
298295

299296
def sync_camera(self, camera_state: "VisorCameraState") -> None:
300297
"""See :meth:`IRenderer.sync_camera`.
301298
302-
Stores the state for :meth:`get_camera_state` to return. No
303-
coordinator caller on this branch; Phase 3 wires the round-trip.
299+
Stores before projecting, so a raising VTK setter still leaves the
300+
record holding what the earlier caller asked for.
304301
"""
305302
self._last_camera_state = camera_state
303+
self._apply_to_pipeline_camera(camera_state)
304+
305+
def serialize_camera_state(self) -> None:
306+
"""See :meth:`IRenderer.serialize_camera_state`.
307+
308+
``vtklocal`` advertises each object's modification time off the live
309+
VTK object but serves state out of a serialization cache, so a write
310+
to the pipeline camera without this call publishes a new
311+
version number against the old content: the client fetches the
312+
pre-write camera and applies it over the one just installed.
313+
314+
``UpdateStateFromObject`` re-serializes the single already-registered
315+
id it is given and commits its dependency edges again; a mid-tree node
316+
re-serialized on its own stays reachable from its parent, so naming
317+
one id is safe. It is narrower than ``UpdateStatesFromObjects``,
318+
which serializes from the roots it is given and registers objects the
319+
store has not seen: an id the store has never held answers ``GetId``
320+
``0``, the ROOT sentinel, and the call degrades to an error-logged
321+
no-op.
322+
323+
No ``js_call``: that lives in ``LocalView.update``, so this
324+
serialises without re-opening the rebuild race
325+
``_push_runtime_state`` refuses.
326+
"""
327+
self._object_manager.UpdateStateFromObject(
328+
self._object_manager.GetId(self._vtk_renderer.GetActiveCamera())
329+
)
330+
331+
# ------------------------------------------------------------------
332+
# Pipeline camera helpers
333+
#
334+
# Neither takes a lock. The caller-holds convention applies exactly as
335+
# it does to every other IRenderer method: the scene coordinator holds
336+
# ``VisorSceneBase._vtk_lock`` across every path that reaches these.
337+
# ------------------------------------------------------------------
338+
339+
def _read_pipeline_camera(self) -> VisorCameraState:
340+
"""Read the active pipeline camera into a fresh camera state.
341+
342+
``GetParallelProjection`` returns an ``int``; the explicit ``bool()``
343+
keeps the field's type off pydantic's non-strict coercion.
344+
"""
345+
camera = self._vtk_renderer.GetActiveCamera()
346+
return VisorCameraState(
347+
position=list(camera.GetPosition()),
348+
focal_point=list(camera.GetFocalPoint()),
349+
view_up=list(camera.GetViewUp()),
350+
clipping_range=list(camera.GetClippingRange()),
351+
parallel_projection=bool(camera.GetParallelProjection()),
352+
view_angle=camera.GetViewAngle(),
353+
parallel_scale=camera.GetParallelScale(),
354+
)
355+
356+
def _apply_to_pipeline_camera(self, camera_state: "VisorCameraState") -> None:
357+
"""Write *camera_state*'s onto the active pipeline camera."""
358+
camera = self._vtk_renderer.GetActiveCamera()
359+
camera.SetPosition(camera_state.position)
360+
camera.SetFocalPoint(camera_state.focal_point)
361+
camera.SetViewUp(camera_state.view_up)
362+
camera.SetClippingRange(camera_state.clipping_range)
363+
camera.SetParallelProjection(camera_state.parallel_projection)
364+
camera.SetViewAngle(camera_state.view_angle)
365+
camera.SetParallelScale(camera_state.parallel_scale)
306366

307367
# ------------------------------------------------------------------
308368
# IRenderer: widget control (cross-section, bounding box)

‎src/ansys/visor/viewer/renderer/null_renderer.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
Implements every abstract method as a no-op so that the scene coordinator can
77
be unit-tested without a VTK environment. Methods whose return type is
88
annotated return the simplest valid empty value for that type; all others are
9-
``pass``. No VTK imports, no local view, no side effects, no state.
9+
``pass``. No VTK imports, no local view, no side effects.
10+
11+
One exception to "no state": the camera record, ``_last_camera_state``. The
12+
record half of the :class:`IRenderer` camera contract is not optional on any
13+
implementation -- only the projection half is, and here it is a no-op because
14+
there is no pipeline camera to project onto.
1015
"""
1116

1217
from __future__ import annotations
1318

14-
from typing import TYPE_CHECKING
19+
from typing import TYPE_CHECKING, Optional
1520

1621
from ansys.visor.viewer.renderer.base import IRenderer
1722

@@ -25,6 +30,12 @@
2530
class NullRenderer(IRenderer):
2631
"""Null-object implementation of :class:`IRenderer` for use in tests."""
2732

33+
_last_camera_state: Optional["VisorCameraState"]
34+
35+
def __init__(self) -> None:
36+
"""Initialize the camera record."""
37+
self._last_camera_state = None
38+
2839
# ------------------------------------------------------------------
2940
# Wire contract
3041
# ------------------------------------------------------------------
@@ -101,13 +112,27 @@ def refresh_color_variable_range(
101112
# ------------------------------------------------------------------
102113

103114
def reset_camera(self, bounds: list[float]) -> None:
104-
pass
115+
"""See :meth:`IRenderer.reset_camera`.
116+
117+
Deliberately does not write the record: with no pipeline camera there
118+
is nothing to derive a camera for *bounds* from, and clearing it would
119+
destroy a camera the frontend reported.
120+
"""
105121

106122
def get_camera_state(self) -> "VisorCameraState | None":
107-
return None
123+
"""See :meth:`IRenderer.get_camera_state`."""
124+
return self._last_camera_state
108125

109126
def sync_camera(self, camera_state: "VisorCameraState") -> None:
110-
pass
127+
"""See :meth:`IRenderer.sync_camera`."""
128+
self._last_camera_state = camera_state
129+
130+
def serialize_camera_state(self) -> None:
131+
"""See :meth:`IRenderer.serialize_camera_state`.
132+
133+
No-op: this renderer serves the client no VTK object state.
134+
"""
135+
111136

112137
# ------------------------------------------------------------------
113138
# Widget control (cross-section, bounding box)

‎src/ansys/visor/viewer/vtk/scene/base.py‎

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class VisorSceneBase(ABC):
4848
4949
* :meth:`_get_runtime_state_async` — wasm path does a frontend round-trip;
5050
RCA/headless paths build state server-side.
51-
* :meth:`_apply_runtime_state_to_render` — wasm path calls a JS
51+
* :meth:`_push_runtime_state` — wasm path calls a JS
5252
``set_state``; RCA path pushes camera onto ``vtkCamera``; headless
5353
is a no-op.
5454
@@ -117,7 +117,7 @@ async def _get_runtime_state_async(self, timeout: float) -> "RuntimeAppState":
117117
"""
118118

119119
@abstractmethod
120-
def _apply_runtime_state_to_render(self, runtime_app_state: "RuntimeAppState") -> None:
120+
def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None:
121121
"""
122122
Push a runtime app state onto the renderer / frontend after the
123123
shared per-part state has already been restored.
@@ -174,14 +174,14 @@ def apply_state(self, state: PersistedViewerStateV1):
174174
"""
175175
Apply a saved viewer state.
176176
177-
Shared work (per-part state restoration) is done here; the
178-
renderer-specific final step is delegated to
179-
:meth:`_apply_runtime_state_to_render`.
177+
One ``_restore_*`` step per state class, each making the server's own
178+
copy of that class match the loaded state: its stored state, and the
179+
VTK objects that the state drives.
180180
181-
Holds ``_vtk_lock`` for the whole body: the delegated step mutates
182-
VTK and pushes to the frontend. The critical section deliberately
183-
spans the outbound bridge call and the flush that follows it — the
184-
unit the lock protects is the compound sequence, not the VTK work.
181+
The renderer-speific delivery step is delegated to :meth:`_push_runtime_state`,
182+
and runs last, once every record above it has been written.
183+
184+
Holds ``_vtk_lock`` for the whole body, including the delegated render step.
185185
"""
186186
with self._vtk_lock:
187187
# Apply UI settings
@@ -190,9 +190,14 @@ def apply_state(self, state: PersistedViewerStateV1):
190190
# Transform the frontend PersistedViewerStateV1 -> RuntimeAppState
191191
runtime_app_state = self._state_mapper.persisted_to_runtime(state)
192192

193-
self._restore_part_states_from_runtime(runtime_app_state)
193+
# One call per state class: updates the server's stored state and its VTK objects.
194+
self._restore_part_states(runtime_app_state)
195+
self._restore_camera_state(runtime_app_state)
196+
# TODO: restore widget state, UI state, and variable states when they are synced back to the server.
194197

195-
self._apply_runtime_state_to_render(runtime_app_state)
198+
# The server's copy is now current; deliver it to the rendering backend.
199+
# wasm: set_state() to the browser; RCA: a rendered frame; headless: no-op.
200+
self._push_runtime_state(runtime_app_state)
196201

197202
# Note: There is intentionally no wasm flush here: the bridge call is fire-and-forget, so a flush
198203
# at this point races the client's rebuild against a half-written object graph.
@@ -393,6 +398,7 @@ def reset_camera(self):
393398
return
394399

395400
self._renderer.reset_camera(self._scene_graph.bounds)
401+
self._renderer.serialize_camera_state()
396402

397403
def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) -> dict:
398404
"""
@@ -518,7 +524,7 @@ def clear_part_color_variable(self, node_id: int) -> None:
518524
return
519525
self._renderer.clear_color_variable(node_id)
520526

521-
def _restore_part_states_from_runtime(self, runtime_app_state: "RuntimeAppState") -> None:
527+
def _restore_part_states(self, runtime_app_state: "RuntimeAppState") -> None:
522528
"""
523529
Restore per-part state from a runtime app state, on the load path.
524530
@@ -538,7 +544,7 @@ def _restore_part_states_from_runtime(self, runtime_app_state: "RuntimeAppState"
538544
dataset = self._dataset_registry.datasets.get(dataset_id)
539545
if dataset is None:
540546
logger.warning(
541-
"_restore_part_states_from_runtime: dataset %s is not registered; "
547+
"_restore_part_states: dataset %s is not registered; "
542548
"its part state was not applied to the pipeline.", dataset_id
543549
)
544550
continue
@@ -554,6 +560,22 @@ def _restore_part_states_from_runtime(self, runtime_app_state: "RuntimeAppState"
554560
part_id, part_state, variable_states, variables_by_part.get(part_id)
555561
)
556562

563+
def _restore_camera_state(self, runtime_app_state: "RuntimeAppState") -> None:
564+
"""
565+
Restore the camera state from a runtime app state, on the load path.
566+
567+
Write the loaded camera to the record and the pipeline camera, so a client rebuilt
568+
from server state (refresh) gets it. Must precede the render step. The re-serialize
569+
is required: the server advertises the camera's live MTime but serves its cached state,
570+
so without it a client fetches the pre-load camera. A state with no camera leaves both
571+
alone.
572+
573+
Callers must hold ``_vtk_lock``.
574+
"""
575+
if runtime_app_state.scene.camera is not None:
576+
self._renderer.sync_camera(runtime_app_state.scene.camera)
577+
self._renderer.serialize_camera_state()
578+
557579
def _restore_one_part_state(
558580
self,
559581
part_id: int,

‎src/ansys/visor/viewer/vtk/scene/local_scene.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async def _get_runtime_state_async(self, timeout: float) -> "RuntimeAppState":
5050
response = await self._frontend_bridge.request_state(timeout=timeout)
5151
return response.app_state
5252

53-
def _apply_runtime_state_to_render(self, runtime_app_state: "RuntimeAppState") -> None:
53+
def _push_runtime_state(self, runtime_app_state: "RuntimeAppState") -> None:
5454
"""
5555
Flush the VTK window then push the restored state to the React frontend.
5656

‎src/ansys/visor/viewer/vtk/scene/visor_state_mapper.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def persisted_to_runtime(self, state: PersistedViewerStateV1) -> RuntimeAppState
7676
7777
Returns the runtime dataset states on the ``RuntimeAppState``; it does not
7878
assign them to ``VisorDataset.state``. The registry is populated by
79-
:meth:`VisorSceneBase._restore_part_states_from_runtime`.
79+
:meth:`VisorSceneBase._restore_part_states`.
8080
8181
"""
8282
# UI settings

‎tests/e2e/regressions/test_save_load_state.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import json
14+
import sys
1415
from pathlib import Path
1516

1617
import pytest
@@ -142,6 +143,14 @@ def test_save_load_state_file_content_valid(self, visor_server, page, tmp_path):
142143
f"Dataset '{name}' has empty serialized_dataset_path"
143144
)
144145

146+
@pytest.mark.xfail(
147+
sys.platform != "win32",
148+
run=False,
149+
reason="#122: load_state into an empty scene does not render on "
150+
"Linux, and the shared server is not recoverable afterwards, "
151+
"so stopping this test from running there. The post-load checks "
152+
"pass on the failing state, which is why this was not caught earlier."
153+
)
145154
def test_load_state_into_empty_scene(self, visor_server, page, tmp_path):
146155
"""Loading state into an empty scene should restore datasets from snapshots.
147156

‎tests/integration/test_save_load_state.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, t
471471

472472
# No browser: the bridge push and the wasm flush are not exercised
473473
# in-process. The registry restore must not depend on either.
474-
iface._scene._apply_runtime_state_to_render = MagicMock()
474+
iface._scene._push_runtime_state = MagicMock()
475475
iface._scene._renderer.flush_wasm_state = MagicMock()
476476

477477
assert iface._scene.dataset_count == 0

0 commit comments

Comments
 (0)