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
1 change: 1 addition & 0 deletions doc/changelog.d/111.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.2b] save state reads from server camera
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ class PersistedSceneState(BaseModel):
cross_section_enabled: bool | None = None
edges_enabled: bool | None = None
bounding_box_enabled: bool | None = None
camera: VisorCameraState | None = None
dataset_states: Dict[str, "PersistedDatasetState"] = Field(default_factory=dict)
variable_states: Dict[str, "VisorVariableState"] = Field(default_factory=dict)
model_config = ConfigDict(arbitrary_types_allowed=True)
Expand Down
11 changes: 11 additions & 0 deletions src/ansys/visor/viewer/vtk/scene/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ async def get_state(self, timeout: float) -> PersistedViewerStateV1:
The registry hands out live ``RuntimeDatasetState`` objects that the per-part
setters mutate from the trame daemon thread, so each one is deep-copied under
``_vtk_lock``. The lock is taken after the ``await`` and never held across one.

The camera is the second thing the browser's reply does not get to supply.
It comes from the renderer's record, which is authoritative, rather than
from the reply or from the pipeline ``vtkCamera``: the pipeline is the
record's projection, and reading it back would re-import whatever drift
VTK introduced -- ``ResetCamera`` rewrites ``clipping_range``. The
assignment is unconditional. A ``None`` record means no camera was ever
written, and writing that ``None`` through is what says so; the guard for
"absent says nothing" belongs to the load path, in :meth:`apply_state`,
not here.
"""
runtime_state = await self._get_runtime_state_async(timeout)

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

persisted = self._state_mapper.runtime_to_persisted(runtime_state)
Expand Down
15 changes: 13 additions & 2 deletions src/ansys/visor/visor-client/src/renderer/IRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export type VisorCameraState = Readonly<{
parallelScale: number;
}>;

/** The seven camera fields that can be applied. No derived fields. */
export type AppliedCameraState = Readonly<{
position: readonly number[];
focalPoint: readonly number[];
viewUp: readonly number[];
clippingRange: readonly number[];
parallelProjection: boolean;
viewAngle: number;
parallelScale: number;
}>;

/** Descriptor consumed by setColorVariableAsync. */
export type ColorVariableDescriptor = Readonly<{
spectrumId: string;
Expand Down Expand Up @@ -97,11 +108,11 @@ export interface IRenderer {
setCameraViewAngleAsync(angle: number): Promise<void>;
setCameraParallelScaleAsync(scale: number): Promise<void>;
/**
* Apply an entire camera snapshot in one RPC. WasmRenderer implements it
* Apply the seven applied camera fields in one RPC. WasmRenderer implements it
* by delegating to the seven per-field setters above. See §11 for the
* Story 3.2 rationale (server-tracked camera + sync-back).
*/
setCameraStateAsync(state: VisorCameraState): Promise<void>;
setCameraStateAsync(state: AppliedCameraState): Promise<void>;
/** Frame the scene on the given bounds; used by scene-graph rebuilds. */
resetCameraAsync(bounds?: readonly number[]): Promise<void>;

Expand Down
3 changes: 2 additions & 1 deletion src/ansys/visor/visor-client/src/renderer/NullRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AppliedCameraState,
ColorVariableDescriptor,
GeometryPickMode,
IRenderer,
Expand Down Expand Up @@ -79,7 +80,7 @@ export class NullRenderer implements IRenderer {
// no-op
}

async setCameraStateAsync(_state: VisorCameraState): Promise<void> {
async setCameraStateAsync(_state: AppliedCameraState): Promise<void> {
// no-op
}

Expand Down
3 changes: 2 additions & 1 deletion src/ansys/visor/visor-client/src/renderer/WasmRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AppliedCameraState,
ColorVariableDescriptor,
GeometryPickMode,
IRenderer,
Expand Down Expand Up @@ -177,7 +178,7 @@ export class WasmRenderer implements IRenderer {
await this.#vtkScene.camera.setParallelScale(scale);
}

async setCameraStateAsync(state: VisorCameraState): Promise<void> {
async setCameraStateAsync(state: AppliedCameraState): Promise<void> {
// Sequential, not Promise.all, to preserve the observable ordering of
// camera events any FPS/camera-changed listener sees (§11).
await this.setCameraPositionAsync(state.position);
Expand Down
84 changes: 84 additions & 0 deletions tests/integration/test_save_load_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from ansys.visor.viewer.app.visor_vtk import VisorVTK
from ansys.visor.viewer.core.metadata import ExtendedMetadata
from ansys.visor.viewer.models.common.part_properties import PartProperties
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.models.common.visor_ui_state import VisorUIState
from ansys.visor.viewer.models.persist.dataset.persisted_dataset_state import PersistedDatasetState
from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1
Expand Down Expand Up @@ -100,6 +101,46 @@ def iface():
pass


# ------------------------------------------------------------------ #
# Camera literals
#
# Hand-written, and different in every field between the two, so the saved
# file names its source by value rather than by a recorded call. No value
# here originates from VTK.
# ------------------------------------------------------------------ #

RECORD_CAMERA_POSITION = [11.0, 12.0, 13.0]
RECORD_CAMERA_CLIPPING_RANGE = [17.0, 18.0]
REPLY_CAMERA_POSITION = [21.0, 22.0, 23.0]
REPLY_CAMERA_CLIPPING_RANGE = [27.0, 28.0]


def _record_camera() -> VisorCameraState:
"""The camera the server's record holds at save time."""
return VisorCameraState(
position=RECORD_CAMERA_POSITION,
focal_point=[14.0, 15.0, 16.0],
view_up=[0.0, 1.0, 0.0],
clipping_range=RECORD_CAMERA_CLIPPING_RANGE,
parallel_projection=True,
view_angle=31.0,
parallel_scale=19.0,
)


def _reply_camera() -> VisorCameraState:
"""The camera the browser answers getState with."""
return VisorCameraState(
position=REPLY_CAMERA_POSITION,
focal_point=[24.0, 25.0, 26.0],
view_up=[1.0, 0.0, 0.0],
clipping_range=REPLY_CAMERA_CLIPPING_RANGE,
parallel_projection=False,
view_angle=32.0,
parallel_scale=29.0,
)


# ================================================================== #
# write_dataset / read_dataset round-trips
# ================================================================== #
Expand Down Expand Up @@ -488,4 +529,47 @@ def test_reloading_the_saved_state_restores_the_registry(self, file_io, iface, t
assert record.spectrum_id == "POINT::pressure::1"
assert record.spectrum_component == 0

def test_saved_visor_json_carries_the_camera_record_not_the_browsers(self, iface, tmp_path):
"""save_state writes the server's camera record, not the browser's reply.

The record is seeded through ``sync_camera``, which also projects onto
the pipeline camera, so record and pipeline hold the same values here.
This case therefore discriminates the **record from the browser's
reply** and nothing more; separating the record from its own pipeline
projection is done in tests/unit/vtk/scene/test_base.py, against a
renderer double whose pipeline read answers with different numbers.

No dataset is added, so ``finalize_scene``'s reset never runs and
cannot overwrite the seeded record with a VTK-derived one.
"""
iface._scene._renderer.sync_camera(_record_camera())

# The browser answers getState with a different camera in every field.
# A pass therefore proves the file came from the record.
frontend_state = RuntimeAppState.from_components(
dark_mode=False,
unit="m",
dataset_states={},
camera=_reply_camera(),
)

async def _frontend_round_trip(timeout: float = 5.0):
return frontend_state

iface._scene._get_runtime_state_async = _frontend_round_trip
iface._server_manager = MagicMock()
iface._server_manager.running = True

asyncio.run(iface.save_state(str(tmp_path)))

with open(os.path.join(str(tmp_path), "visor.json"), "r") as fh:
written = json.load(fh)

# write_state dumps by_alias, so the camera's own fields are aliased.
camera = written["scene"]["camera"]
assert camera["position"] == RECORD_CAMERA_POSITION
assert camera["clippingRange"] == RECORD_CAMERA_CLIPPING_RANGE
assert camera["position"] != REPLY_CAMERA_POSITION
assert camera["clippingRange"] != REPLY_CAMERA_CLIPPING_RANGE


Loading
Loading