Skip to content

Commit 9cc6083

Browse files
feat: [Remote rendering 3.2b] save state reads from server camera (#111)
Co-authored-by: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com>
1 parent ebd27ff commit 9cc6083

8 files changed

Lines changed: 335 additions & 5 deletions

File tree

doc/changelog.d/111.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Remote rendering 3.2b] save state reads from server camera

src/ansys/visor/viewer/models/persist/scene/persisted_scene_state.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ class PersistedSceneState(BaseModel):
7575
cross_section_enabled: bool | None = None
7676
edges_enabled: bool | None = None
7777
bounding_box_enabled: bool | None = None
78-
camera: VisorCameraState | None = None
7978
dataset_states: Dict[str, "PersistedDatasetState"] = Field(default_factory=dict)
8079
variable_states: Dict[str, "VisorVariableState"] = Field(default_factory=dict)
8180
model_config = ConfigDict(arbitrary_types_allowed=True)

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,16 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1:
157157
The registry hands out live ``RuntimeDatasetState`` objects that the per-part
158158
setters mutate from the trame daemon thread, so each one is deep-copied under
159159
``_vtk_lock``. The lock is taken after the ``await`` and never held across one.
160+
161+
The camera is the second thing the browser's reply does not get to supply.
162+
It comes from the renderer's record, which is authoritative, rather than
163+
from the reply or from the pipeline ``vtkCamera``: the pipeline is the
164+
record's projection, and reading it back would re-import whatever drift
165+
VTK introduced -- ``ResetCamera`` rewrites ``clipping_range``. The
166+
assignment is unconditional. A ``None`` record means no camera was ever
167+
written, and writing that ``None`` through is what says so; the guard for
168+
"absent says nothing" belongs to the load path, in :meth:`apply_state`,
169+
not here.
160170
"""
161171
runtime_state = await self._get_runtime_state_async(timeout)
162172

@@ -165,6 +175,7 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1:
165175
dataset_id: dataset_state.model_copy(deep=True)
166176
for dataset_id, dataset_state in self._dataset_registry.runtime_state_dict.items()
167177
}
178+
runtime_state.scene.camera = self._renderer.get_camera_state()
168179
runtime_state.scene.dataset_states = registry_dataset_states
169180

170181
persisted = self._state_mapper.runtime_to_persisted(runtime_state)

src/ansys/visor/visor-client/src/renderer/IRenderer.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export type VisorCameraState = Readonly<{
2525
parallelScale: number;
2626
}>;
2727

28+
/** The seven camera fields that can be applied. No derived fields. */
29+
export type AppliedCameraState = Readonly<{
30+
position: readonly number[];
31+
focalPoint: readonly number[];
32+
viewUp: readonly number[];
33+
clippingRange: readonly number[];
34+
parallelProjection: boolean;
35+
viewAngle: number;
36+
parallelScale: number;
37+
}>;
38+
2839
/** Descriptor consumed by setColorVariableAsync. */
2940
export type ColorVariableDescriptor = Readonly<{
3041
spectrumId: string;
@@ -97,11 +108,11 @@ export interface IRenderer {
97108
setCameraViewAngleAsync(angle: number): Promise<void>;
98109
setCameraParallelScaleAsync(scale: number): Promise<void>;
99110
/**
100-
* Apply an entire camera snapshot in one RPC. WasmRenderer implements it
111+
* Apply the seven applied camera fields in one RPC. WasmRenderer implements it
101112
* by delegating to the seven per-field setters above. See §11 for the
102113
* Story 3.2 rationale (server-tracked camera + sync-back).
103114
*/
104-
setCameraStateAsync(state: VisorCameraState): Promise<void>;
115+
setCameraStateAsync(state: AppliedCameraState): Promise<void>;
105116
/** Frame the scene on the given bounds; used by scene-graph rebuilds. */
106117
resetCameraAsync(bounds?: readonly number[]): Promise<void>;
107118

src/ansys/visor/visor-client/src/renderer/NullRenderer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AppliedCameraState,
23
ColorVariableDescriptor,
34
GeometryPickMode,
45
IRenderer,
@@ -79,7 +80,7 @@ export class NullRenderer implements IRenderer {
7980
// no-op
8081
}
8182

82-
async setCameraStateAsync(_state: VisorCameraState): Promise<void> {
83+
async setCameraStateAsync(_state: AppliedCameraState): Promise<void> {
8384
// no-op
8485
}
8586

src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AppliedCameraState,
23
ColorVariableDescriptor,
34
GeometryPickMode,
45
IRenderer,
@@ -177,7 +178,7 @@ export class WasmRenderer implements IRenderer {
177178
await this.#vtkScene.camera.setParallelScale(scale);
178179
}
179180

180-
async setCameraStateAsync(state: VisorCameraState): Promise<void> {
181+
async setCameraStateAsync(state: AppliedCameraState): Promise<void> {
181182
// Sequential, not Promise.all, to preserve the observable ordering of
182183
// camera events any FPS/camera-changed listener sees (§11).
183184
await this.setCameraPositionAsync(state.position);

tests/integration/test_save_load_state.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from ansys.visor.viewer.app.visor_vtk import VisorVTK
4545
from ansys.visor.viewer.core.metadata import ExtendedMetadata
4646
from ansys.visor.viewer.models.common.part_properties import PartProperties
47+
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
4748
from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState
4849
from ansys.visor.viewer.models.persist.dataset.persisted_dataset_state import PersistedDatasetState
4950
from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1
@@ -100,6 +101,46 @@ def iface():
100101
pass
101102

102103

104+
# ------------------------------------------------------------------ #
105+
# Camera literals
106+
#
107+
# Hand-written, and different in every field between the two, so the saved
108+
# file names its source by value rather than by a recorded call. No value
109+
# here originates from VTK.
110+
# ------------------------------------------------------------------ #
111+
112+
RECORD_CAMERA_POSITION = [11.0, 12.0, 13.0]
113+
RECORD_CAMERA_CLIPPING_RANGE = [17.0, 18.0]
114+
REPLY_CAMERA_POSITION = [21.0, 22.0, 23.0]
115+
REPLY_CAMERA_CLIPPING_RANGE = [27.0, 28.0]
116+
117+
118+
def _record_camera() -> VisorCameraState:
119+
"""The camera the server's record holds at save time."""
120+
return VisorCameraState(
121+
position=RECORD_CAMERA_POSITION,
122+
focal_point=[14.0, 15.0, 16.0],
123+
view_up=[0.0, 1.0, 0.0],
124+
clipping_range=RECORD_CAMERA_CLIPPING_RANGE,
125+
parallel_projection=True,
126+
view_angle=31.0,
127+
parallel_scale=19.0,
128+
)
129+
130+
131+
def _reply_camera() -> VisorCameraState:
132+
"""The camera the browser answers getState with."""
133+
return VisorCameraState(
134+
position=REPLY_CAMERA_POSITION,
135+
focal_point=[24.0, 25.0, 26.0],
136+
view_up=[1.0, 0.0, 0.0],
137+
clipping_range=REPLY_CAMERA_CLIPPING_RANGE,
138+
parallel_projection=False,
139+
view_angle=32.0,
140+
parallel_scale=29.0,
141+
)
142+
143+
103144
# ================================================================== #
104145
# write_dataset / read_dataset round-trips
105146
# ================================================================== #
@@ -488,4 +529,47 @@ def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, t
488529
assert record.spectrum_id == "POINT::pressure::1"
489530
assert record.spectrum_component == 0
490531

532+
def test_saved_visor_json_carries_the_camera_record_not_the_browsers(self, iface, tmp_path):
533+
"""save_state writes the server's camera record, not the browser's reply.
534+
535+
The record is seeded through ``sync_camera``, which also projects onto
536+
the pipeline camera, so record and pipeline hold the same values here.
537+
This case therefore discriminates the **record from the browser's
538+
reply** and nothing more; separating the record from its own pipeline
539+
projection is done in tests/unit/vtk/scene/test_base.py, against a
540+
renderer double whose pipeline read answers with different numbers.
541+
542+
No dataset is added, so ``finalize_scene``'s reset never runs and
543+
cannot overwrite the seeded record with a VTK-derived one.
544+
"""
545+
iface._scene._renderer.sync_camera(_record_camera())
546+
547+
# The browser answers getState with a different camera in every field.
548+
# A pass therefore proves the file came from the record.
549+
frontend_state = RuntimeAppState.from_components(
550+
dark_mode=False,
551+
unit="m",
552+
dataset_states={},
553+
camera=_reply_camera(),
554+
)
555+
556+
async def _frontend_round_trip(timeout: float = 5.0):
557+
return frontend_state
558+
559+
iface._scene._get_runtime_state_async = _frontend_round_trip
560+
iface._server_manager = MagicMock()
561+
iface._server_manager.running = True
562+
563+
asyncio.run(iface.save_state(str(tmp_path)))
564+
565+
with open(os.path.join(str(tmp_path), "visor.json"), "r") as fh:
566+
written = json.load(fh)
567+
568+
# write_state dumps by_alias, so the camera's own fields are aliased.
569+
camera = written["scene"]["camera"]
570+
assert camera["position"] == RECORD_CAMERA_POSITION
571+
assert camera["clippingRange"] == RECORD_CAMERA_CLIPPING_RANGE
572+
assert camera["position"] != REPLY_CAMERA_POSITION
573+
assert camera["clippingRange"] != REPLY_CAMERA_CLIPPING_RANGE
574+
491575

0 commit comments

Comments
 (0)