Skip to content

Commit 4fba50e

Browse files
feat: [Remote rendering 3.2d] report settled camera gestures to the server (#125)
Co-authored-by: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com>
1 parent 0428c6f commit 4fba50e

14 files changed

Lines changed: 973 additions & 23 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Remote rendering 3.2d] report settled camera gestures to the server

‎src/ansys/visor/viewer/app/trame/local_app.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from ansys.visor.viewer.config import settings
1212
from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType
1313
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
14+
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
15+
from ansys.visor.viewer.models.runtime.requests.sync_camera_payload import SyncCameraPayload
1416

1517
logger = VisorDefaultLogger(__name__)
1618

@@ -25,6 +27,10 @@ class ScenePartStateApi(Protocol):
2527
``isinstance`` check anywhere against it. Declaring it here rather than
2628
importing the scene keeps this module free of any scene type, so the
2729
injected object remains LocalApp's only route to the scene.
30+
31+
``sync_camera`` is not per-part, and it is declared here anyway: the one
32+
production injection site passes the whole scene coordinator, so a second
33+
protocol would be the same object under a second name.
2834
"""
2935

3036
def set_part_visibility(self, node_id: int, visible: bool) -> None: ...
@@ -48,6 +54,8 @@ def set_part_color_variable(
4854

4955
def clear_part_color_variable(self, node_id: int) -> None: ...
5056

57+
def sync_camera(self, camera_state: VisorCameraState) -> None: ...
58+
5159

5260
# ----------------------------------------------------------------------
5361
# Trigger payload models
@@ -175,6 +183,7 @@ class LocalApp:
175183
set_part_selected: selects or deselects one part
176184
set_part_color_variable: colours one part by a scalar variable
177185
clear_part_color_variable: stops colouring one part by a scalar variable
186+
sync_camera: records a settled camera reported by the frontend
178187
set_only_cookie: sets a cookie on the server (note: Trame server only allows a single cookie header)
179188
Protected Methods:
180189
_cleanup(): Cleans up the active actor in the visualization pipeline.
@@ -416,6 +425,48 @@ def clear_part_color_variable(self, payload) -> None:
416425
return
417426
api.clear_part_color_variable(payload.node_id)
418427

428+
# ------------------------------------------------------------------
429+
# Camera trigger
430+
#
431+
# Frontend -> Backend. One report per settled camera window, never one
432+
# per camera event: the debounce lives on the client, and the camera is
433+
# read once, at settle.
434+
#
435+
# ``origin`` is decided at the input, on the client, and travels
436+
# verbatim; the *server* decides what to do with it. A report that is
437+
# not a gesture is an echo of a camera the application itself applied --
438+
# a load, a reset, a scene-details push -- and applying it would
439+
# overwrite the record with a value the server had just sent. It is
440+
# dropped here, with a log line, before the lock is taken and before the
441+
# coordinator is even looked up: a dropped report is visible when
442+
# diagnosing an echo, and a report the client never sent is not.
443+
#
444+
# Exactly one debug line per arrival on every path, so that counting
445+
# arrivals in the log is a sound measurement.
446+
# ------------------------------------------------------------------
447+
448+
@trigger("sync_camera")
449+
@parse_payload(SyncCameraPayload)
450+
def sync_camera(self, payload) -> None:
451+
"""Frontend -> Backend: a settled camera window reports its camera.
452+
453+
A payload missing any of the seven camera fields, or carrying an
454+
``origin`` that is neither value, never reaches this body: it is a
455+
logged warning from the payload decorator and nothing is delegated.
456+
"""
457+
if payload.origin != "gesture":
458+
logger.debug("sync_camera: origin=%s; dropping.", payload.origin)
459+
return
460+
api = self._part_state_api("sync_camera")
461+
if api is None:
462+
return
463+
logger.debug(
464+
"sync_camera: origin=%s; applying position=%s.",
465+
payload.origin,
466+
payload.camera.position,
467+
)
468+
api.sync_camera(payload.camera)
469+
419470
def set_only_cookie(self, key: str, value: str):
420471
"""
421472
Sets a cookie on the server. NOTE: there is a limitation
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Model for the ``sync_camera`` trigger payload."""
2+
3+
from typing import Literal
4+
5+
from pydantic import BaseModel, ConfigDict
6+
7+
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
8+
9+
10+
class SyncCameraPayload(BaseModel):
11+
"""
12+
Payload of the ``sync_camera`` trigger.
13+
14+
``origin`` travels on the wire and the server decides what to do with it:
15+
the client sends both values and never suppresses a report it believes is
16+
an echo. A report the server drops is visible in the log when diagnosing
17+
an echo; one the client never sent is not.
18+
19+
``camera`` is a whole :class:`VisorCameraState` -- whole or absent, never
20+
partial. All seven fields are required with no default, so a payload
21+
missing any one of them fails validation and is a logged no-op at the
22+
trigger boundary rather than a half-applied camera.
23+
24+
Extra keys are ignored, which is deliberate rather than incidental: the
25+
client's own camera snapshot type carries five derived display fields
26+
(``distance``, ``orthographic``, ``orthographicScale``, ``unitsPerPixel``,
27+
``viewPortHeight``) beyond the seven applied ones, and a sender that
28+
spread that whole object would still validate here. What pins the wire
29+
shape is therefore a test on the payload the client builds, not this
30+
model.
31+
"""
32+
33+
model_config = ConfigDict(populate_by_name=True)
34+
35+
origin: Literal["gesture", "programmatic"]
36+
camera: VisorCameraState
37+

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType
1414
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
1515
from ansys.visor.viewer.core.visor_types import VisorDatasetType
16+
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
1617
from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1
1718
from ansys.visor.viewer.models.runtime.visor_scene_details import VisorSceneDetails
1819
from ansys.visor.viewer.renderer.base import IRenderer
@@ -411,6 +412,35 @@ def reset_camera(self):
411412
self._renderer.reset_camera(self._scene_graph.bounds)
412413
self._renderer.serialize_camera_state()
413414

415+
def sync_camera(self, camera_state: VisorCameraState) -> None:
416+
"""Record a camera the frontend reported, and project it.
417+
418+
The trigger path's coordinator method. It is the camera twin of the
419+
per-part coordinator surface below: the trigger handler arrives on
420+
trame's daemon thread and must route through a method that takes
421+
``_vtk_lock``, never call the renderer directly.
422+
423+
Both halves run in one critical section, and the re-serialisation is
424+
part of the write rather than an afterthought. The backend advertises
425+
a version number read from the live VTK object while serving content
426+
from a cache, so a write with no re-serialise publishes a new version
427+
against old content: the client then fetches the *pre*-gesture camera
428+
and applies it over the one the user just set, and a refresh shows the
429+
framing they moved away from. The load path proved this in
430+
Increment 2b; the trigger path has the same gap for the same reason.
431+
432+
What this method deliberately does **not** do is notify. No
433+
``render()``, no ``flush_wasm_state()``, no ``set_state``. A push here
434+
rebuilds the client, the rebuild re-delivers state, the reapply moves
435+
the camera and emits further settle reports, and each report pushes
436+
again. It would also race the rebuild against a half-written object
437+
graph -- the hazard ``_apply_runtime_state_to_render`` already refuses
438+
to reopen. Serialising without notifying is the whole point.
439+
"""
440+
with self._vtk_lock:
441+
self._renderer.sync_camera(camera_state)
442+
self._renderer.serialize_camera_state()
443+
414444
def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) -> dict:
415445
"""
416446
Frontend-trigger entry point for cell picking. Packs the world-space

‎src/ansys/visor/visor-client/src/App.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,22 @@ function App() {
131131
requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation),
132132
wasmView.current.trameTriggerAsync
133133
);
134-
const newFrontend = new VisorFrontend(renderer, sceneDetails.vtkInfo.sceneGraph);
134+
const newFrontend = new VisorFrontend(
135+
renderer,
136+
sceneDetails.vtkInfo.sceneGraph,
137+
wasmView.current.trameTriggerAsync
138+
);
139+
// Release the frontend being replaced, here and not later: the
140+
// `VtkScene` behind both renderers is the same object across a
141+
// rebuild, so an unreleased subscription stays live and the next
142+
// gesture is reported once per surviving frontend. There is
143+
// deliberately no `await` between the new frontend subscribing (the
144+
// constructor above) and the old one releasing, so the window in
145+
// which two subscriptions coexist contains no suspension point.
146+
if (oldFrontend != null) {
147+
oldFrontend.releaseCameraSettledListener();
148+
}
149+
135150
if (visorArgs.current.darkMode != null) {
136151
// Explicit Dash prop takes precedence over the server's dark_mode value.
137152
sceneDetails.appState.ui.setDarkTheme(visorArgs.current.darkMode);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type {
2+
CameraOrigin,
3+
IRenderer,
4+
TrameTriggerSender,
5+
VisorCameraState,
6+
} from './renderer/IRenderer';
7+
8+
/**
9+
* The `sync_camera` trigger: the client's half of the server-tracked camera.
10+
*
11+
* This module exists apart from `VisorFrontend` so that it can be tested. A
12+
* `VisorFrontend` cannot be constructed under jest -- it needs a real scene
13+
* graph node, the module-global spectrum manager and a renderer that accepts
14+
* `attachSceneGraph`, and it ends in `Object.freeze` -- so a listener body
15+
* written inline there would be pinned by nothing, and the payload shape is
16+
* precisely the part no server-side gate can check. `CameraGestureTracker`
17+
* was split out for the same reason in the previous increment.
18+
*
19+
* What reaches here is already debounced: `addCameraSettledListener` fires
20+
* once per settle window, never once per camera event, and it carries that
21+
* window's origin. The camera is therefore read exactly once per settle --
22+
* the read is a multi-await round trip to the wasm camera, and paying it per
23+
* event is what the debounce exists to avoid.
24+
*/
25+
26+
/** The payload of the `sync_camera` trigger. Mirrors the server's model. */
27+
export type SyncCameraPayload = Readonly<{
28+
origin: CameraOrigin;
29+
camera: 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+
}>;
39+
40+
/**
41+
* The part of a renderer this module uses. Narrower than `IRenderer` so the
42+
* unit test can supply exactly these two members without a cast that would
43+
* defeat the type check it is here to get.
44+
*/
45+
export type CameraSyncSource = Pick<IRenderer, 'addCameraSettledListener' | 'getCameraStateAsync'>;
46+
47+
/**
48+
* Build the trigger payload from a settled camera snapshot.
49+
*
50+
* The seven applied fields are named one by one, deliberately, rather than
51+
* spread from the snapshot. `VisorCameraState` carries five further derived
52+
* display fields -- `distance`, `orthographic`, `orthographicScale`,
53+
* `unitsPerPixel`, `viewPortHeight` -- which are meaningless to the server's
54+
* record. Spreading would put all twelve on the wire, and the server would
55+
* accept it silently: pydantic ignores unknown keys, so every server-side
56+
* gate would stay green while the wire contract quietly became "whatever the
57+
* client's snapshot type happens to hold today". Naming the seven is the only
58+
* place that shape is decided, which is why the test asserts the key set.
59+
*
60+
* `parallelProjection` is carried verbatim. `getCameraStateAsync` has already
61+
* narrowed the wasm camera's raw value to a boolean; this module does not
62+
* re-derive it.
63+
*
64+
* `origin` is carried verbatim too, for both values. The client never
65+
* suppresses a report it believes is an echo -- the server decides, and logs
66+
* what it dropped.
67+
*/
68+
export function buildSyncCameraPayload(
69+
origin: CameraOrigin,
70+
camera: VisorCameraState
71+
): SyncCameraPayload {
72+
return {
73+
origin,
74+
camera: {
75+
position: camera.position,
76+
focalPoint: camera.focalPoint,
77+
viewUp: camera.viewUp,
78+
clippingRange: camera.clippingRange,
79+
parallelProjection: camera.parallelProjection,
80+
viewAngle: camera.viewAngle,
81+
parallelScale: camera.parallelScale,
82+
},
83+
};
84+
}
85+
86+
/**
87+
* Subscribe to settled camera windows and report each one to the server.
88+
*
89+
* Returns the remover from `addCameraSettledListener`, **verbatim**. The
90+
* caller owns it and must call it when the frontend holding it is replaced:
91+
* the `VtkScene` survives a client rebuild, so a frontend that is discarded
92+
* without releasing leaves its subscription live and every later gesture is
93+
* reported once per rebuild that has ever happened. Each of those reports is
94+
* individually valid, which is why no gate can see the fault.
95+
*
96+
* The sender is required. A frontend with no transport is not a state this
97+
* path supports: it would subscribe, read the camera on every settle, build a
98+
* payload and drop it, which is indistinguishable at runtime from a working
99+
* wire that the server is ignoring.
100+
*
101+
* A failed send is logged and swallowed, never rethrown. The settle callback
102+
* returns `void` and is invoked from a timer, so a rejection escaping it has
103+
* no caller to receive it and would surface as an unhandled rejection. The
104+
* log prefix is fixed and greppable because it is the only signal that a
105+
* report was lost -- the view looks identical either way.
106+
*/
107+
export function attachCameraSyncReporter(
108+
renderer: CameraSyncSource,
109+
send: TrameTriggerSender
110+
): () => void {
111+
return renderer.addCameraSettledListener((origin: CameraOrigin) => {
112+
void (async () => {
113+
try {
114+
const camera = await renderer.getCameraStateAsync();
115+
await send('sync_camera', buildSyncCameraPayload(origin, camera));
116+
} catch (err) {
117+
console.error(`[VISOR] sync_camera trigger send failed: origin='${origin}'`, err);
118+
}
119+
})();
120+
});
121+
}

‎src/ansys/visor/visor-client/src/VisorFrontend.tsx‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { TreeViewUtil } from './treeview/TreeView.tsx';
99
import { StateInput } from './state/appstate/VisorStateCommon.tsx';
1010
import VisorVtkSceneNode from './state/appstate/vtkInfo/VisorVtkSceneNode.tsx';
1111
import { IRenderer, VisorCameraState } from './renderer/IRenderer';
12+
import type { TrameTriggerSender } from './renderer/IRenderer';
13+
import { attachCameraSyncReporter } from './CameraSyncReporter';
1214
import { Panel_TopRight_Util } from './components/ui-panels/Panel_TopRight_Util.tsx';
1315
import { Panel_TopLeft_Util } from './components/ui-panels/Panel_TopLeft_Util.tsx';
1416
import { OrientationWidget } from './widgets/orientationWidget.ts';
@@ -17,7 +19,11 @@ import { UiScaffoldUtil } from './components/UiScaffold.tsx';
1719
export type { VisorCameraState } from './renderer/IRenderer';
1820

1921
export class VisorFrontend {
20-
constructor(renderer: IRenderer, sceneGraphNode: VisorVtkSceneNode) {
22+
constructor(
23+
renderer: IRenderer,
24+
sceneGraphNode: VisorVtkSceneNode,
25+
triggerSender: TrameTriggerSender
26+
) {
2127
const spectrumManager = getSpectrumManager();
2228
const sceneGraph = CreateVisorSceneGraph(sceneGraphNode, spectrumManager, renderer);
2329
spectrumManager.finishAddingDataArrayMetadata();
@@ -73,6 +79,26 @@ export class VisorFrontend {
7379
};
7480
this.domElement = renderer.domElement;
7581
this.getCameraStateAsync = () => renderer.getCameraStateAsync();
82+
// Report each settled camera window to the server. The remover is
83+
// held rather than discarded because the `VtkScene` behind the
84+
// renderer survives a client rebuild: a frontend replaced without
85+
// releasing leaves its subscription live, and every later gesture is
86+
// then reported once per rebuild that has ever happened. Every one of
87+
// those reports is individually valid, so nothing fails -- the record
88+
// is simply written several times and no gate can tell.
89+
//
90+
// Assigned to a `#private` field and exposed through a field-assigned
91+
// method: `Object.freeze(this)` below does not reach `#private` state,
92+
// but it would make a public field assigned after construction throw
93+
// in the browser and in no gate.
94+
this.#cameraSettledRemover = attachCameraSyncReporter(renderer, triggerSender);
95+
this.releaseCameraSettledListener = () => {
96+
const remover = this.#cameraSettledRemover;
97+
// Cleared first, so a second call is a no-op rather than a second
98+
// removal against a map the next frontend now owns.
99+
this.#cameraSettledRemover = null;
100+
remover?.();
101+
};
76102
this.defaultActorColor = [];
77103
this.setSpectrumRangeAsync = async (spectrumId, component, min, max) => {
78104
const spectrum = spectrumManager.globalSpectrumCollection.getSpectrum(spectrumId);
@@ -511,7 +537,14 @@ export class VisorFrontend {
511537
}
512538

513539
#unit: string;
540+
#cameraSettledRemover: (() => void) | null;
514541
darkMode: boolean;
542+
/**
543+
* Release this frontend's settled-camera subscription. Called by the
544+
* rebuild on the frontend it is replacing, before the replacement is
545+
* handed out. Idempotent.
546+
*/
547+
releaseCameraSettledListener: () => void;
515548
render: () => Promise<void>;
516549
resizeAsync: () => Promise<void>;
517550
sceneGraph: VisorSceneNodeExtended;

0 commit comments

Comments
 (0)