Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6f0e670
add camera record and pipeline projection to IRenderer and write it o…
LKasianAnsys Sep 4, 2026
2083d23
Re-serialize the camera after sync_camera
LKasianAnsys Sep 9, 2026
8247928
chore: adding changelog file 110.added.md [dependabot-skip]
pyansys-ci-bot Sep 14, 2026
0248dd4
serialize camera in reset_camera
LKasianAnsys Sep 11, 2026
d5b9247
serialize only camera in serialize_camera_state and tidy up docstrings
LKasianAnsys Sep 16, 2026
a4b1c6b
xfail test_load_state_into_empty_scene
LKasianAnsys Sep 16, 2026
9189f66
Merge branch 'main' into feat/3.2a-server-tracked-camera
LKasianAnsys Sep 16, 2026
c5627ba
Merge branch 'main' into feat/3.2a-server-tracked-camera
LKasianAnsys Sep 21, 2026
3d570f0
clean up apply_state method content
LKasianAnsys Sep 21, 2026
5778ff9
fix typo
LKasianAnsys Sep 21, 2026
f5d3b6a
read camera from server on save_state
LKasianAnsys Sep 9, 2026
c7cddb8
clean up duplicate camera field
LKasianAnsys Sep 10, 2026
ab8185e
create type for applied camera
LKasianAnsys Sep 10, 2026
ebb70a9
chore: adding changelog file 111.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
7cb6b8a
camera settle binding, input-attributed origin
LKasianAnsys Sep 11, 2026
17ce79c
chore: adding changelog file 124.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
2adf984
clean up comments and docstrings
LKasianAnsys Sep 16, 2026
f8fecf2
clean up comments and docstrings
LKasianAnsys Sep 16, 2026
28f4a84
clean up comments
LKasianAnsys Sep 16, 2026
001d001
chore: adding changelog file 124.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
b149c50
chore: adding changelog file 124.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
ac2b6b7
sync settled camera gestures to the server record
LKasianAnsys Sep 11, 2026
b84e76c
allow for keyboard shortcuts
LKasianAnsys Sep 11, 2026
37e882d
stop canvas from taking focus
LKasianAnsys Sep 11, 2026
2955c92
prettier fix
LKasianAnsys Sep 16, 2026
fbc3208
chore: adding changelog file 125.added.md [dependabot-skip]
pyansys-ci-bot Sep 16, 2026
07de410
update unit tests
LKasianAnsys Sep 16, 2026
77531dc
chore: adding changelog file 125.added.md [dependabot-skip]
pyansys-ci-bot Sep 21, 2026
5251ba9
Merge branch 'main' into feat/3.2d-report-camera-to-server
LKasianAnsys Sep 22, 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
1 change: 1 addition & 0 deletions doc/changelog.d/125.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[Remote rendering 3.2d] report settled camera gestures to the server
51 changes: 51 additions & 0 deletions src/ansys/visor/viewer/app/trame/local_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from ansys.visor.viewer.config import settings
from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.models.runtime.requests.sync_camera_payload import SyncCameraPayload

logger = VisorDefaultLogger(__name__)

Expand All @@ -25,6 +27,10 @@ class ScenePartStateApi(Protocol):
``isinstance`` check anywhere against it. Declaring it here rather than
importing the scene keeps this module free of any scene type, so the
injected object remains LocalApp's only route to the scene.

``sync_camera`` is not per-part, and it is declared here anyway: the one
production injection site passes the whole scene coordinator, so a second
protocol would be the same object under a second name.
"""

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

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

def sync_camera(self, camera_state: VisorCameraState) -> None: ...


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

# ------------------------------------------------------------------
# Camera trigger
#
# Frontend -> Backend. One report per settled camera window, never one
# per camera event: the debounce lives on the client, and the camera is
# read once, at settle.
#
# ``origin`` is decided at the input, on the client, and travels
# verbatim; the *server* decides what to do with it. A report that is
# not a gesture is an echo of a camera the application itself applied --
# a load, a reset, a scene-details push -- and applying it would
# overwrite the record with a value the server had just sent. It is
# dropped here, with a log line, before the lock is taken and before the
# coordinator is even looked up: a dropped report is visible when
# diagnosing an echo, and a report the client never sent is not.
#
# Exactly one debug line per arrival on every path, so that counting
# arrivals in the log is a sound measurement.
# ------------------------------------------------------------------

@trigger("sync_camera")
@parse_payload(SyncCameraPayload)
def sync_camera(self, payload) -> None:
"""Frontend -> Backend: a settled camera window reports its camera.

A payload missing any of the seven camera fields, or carrying an
``origin`` that is neither value, never reaches this body: it is a
logged warning from the payload decorator and nothing is delegated.
"""
if payload.origin != "gesture":
logger.debug("sync_camera: origin=%s; dropping.", payload.origin)
return
api = self._part_state_api("sync_camera")
if api is None:
return
logger.debug(
"sync_camera: origin=%s; applying position=%s.",
payload.origin,
payload.camera.position,
)
api.sync_camera(payload.camera)

def set_only_cookie(self, key: str, value: str):
"""
Sets a cookie on the server. NOTE: there is a limitation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Model for the ``sync_camera`` trigger payload."""

from typing import Literal

from pydantic import BaseModel, ConfigDict

from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState


class SyncCameraPayload(BaseModel):
"""
Payload of the ``sync_camera`` trigger.

``origin`` travels on the wire and the server decides what to do with it:
the client sends both values and never suppresses a report it believes is
an echo. A report the server drops is visible in the log when diagnosing
an echo; one the client never sent is not.

``camera`` is a whole :class:`VisorCameraState` -- whole or absent, never
partial. All seven fields are required with no default, so a payload
missing any one of them fails validation and is a logged no-op at the
trigger boundary rather than a half-applied camera.

Extra keys are ignored, which is deliberate rather than incidental: the
client's own camera snapshot type carries five derived display fields
(``distance``, ``orthographic``, ``orthographicScale``, ``unitsPerPixel``,
``viewPortHeight``) beyond the seven applied ones, and a sender that
spread that whole object would still validate here. What pins the wire
shape is therefore a test on the payload the client builds, not this
model.
"""

model_config = ConfigDict(populate_by_name=True)

origin: Literal["gesture", "programmatic"]
camera: VisorCameraState

30 changes: 30 additions & 0 deletions src/ansys/visor/viewer/vtk/scene/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ansys.visor.viewer.core.visor_enums import VisorVtkVariableType
from ansys.visor.viewer.core.visor_logging import VisorDefaultLogger
from ansys.visor.viewer.core.visor_types import VisorDatasetType
from ansys.visor.viewer.models.common.visor_camera_state import VisorCameraState
from ansys.visor.viewer.models.persist.persisted_viewer_state import PersistedViewerStateV1
from ansys.visor.viewer.models.runtime.visor_scene_details import VisorSceneDetails
from ansys.visor.viewer.renderer.base import IRenderer
Expand Down Expand Up @@ -411,6 +412,35 @@ def reset_camera(self):
self._renderer.reset_camera(self._scene_graph.bounds)
self._renderer.serialize_camera_state()

def sync_camera(self, camera_state: VisorCameraState) -> None:
"""Record a camera the frontend reported, and project it.

The trigger path's coordinator method. It is the camera twin of the
per-part coordinator surface below: the trigger handler arrives on
trame's daemon thread and must route through a method that takes
``_vtk_lock``, never call the renderer directly.

Both halves run in one critical section, and the re-serialisation is
part of the write rather than an afterthought. The backend advertises
a version number read from the live VTK object while serving content
from a cache, so a write with no re-serialise publishes a new version
against old content: the client then fetches the *pre*-gesture camera
and applies it over the one the user just set, and a refresh shows the
framing they moved away from. The load path proved this in
Increment 2b; the trigger path has the same gap for the same reason.

What this method deliberately does **not** do is notify. No
``render()``, no ``flush_wasm_state()``, no ``set_state``. A push here
rebuilds the client, the rebuild re-delivers state, the reapply moves
the camera and emits further settle reports, and each report pushes
again. It would also race the rebuild against a half-written object
graph -- the hazard ``_apply_runtime_state_to_render`` already refuses
to reopen. Serialising without notifying is the whole point.
"""
with self._vtk_lock:
self._renderer.sync_camera(camera_state)
self._renderer.serialize_camera_state()

def pick_geometry(self, actor_wasm_id, cell_id, mode, world_x, world_y, world_z) -> dict:
"""
Frontend-trigger entry point for cell picking. Packs the world-space
Expand Down
17 changes: 16 additions & 1 deletion src/ansys/visor/visor-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,22 @@ function App() {
requireWasmAnnotation(sceneDetails.vtkInfo.rendererAnnotation),
wasmView.current.trameTriggerAsync
);
const newFrontend = new VisorFrontend(renderer, sceneDetails.vtkInfo.sceneGraph);
const newFrontend = new VisorFrontend(
renderer,
sceneDetails.vtkInfo.sceneGraph,
wasmView.current.trameTriggerAsync
);
// Release the frontend being replaced, here and not later: the
// `VtkScene` behind both renderers is the same object across a
// rebuild, so an unreleased subscription stays live and the next
// gesture is reported once per surviving frontend. There is
// deliberately no `await` between the new frontend subscribing (the
// constructor above) and the old one releasing, so the window in
// which two subscriptions coexist contains no suspension point.
if (oldFrontend != null) {
oldFrontend.releaseCameraSettledListener();
}

if (visorArgs.current.darkMode != null) {
// Explicit Dash prop takes precedence over the server's dark_mode value.
sceneDetails.appState.ui.setDarkTheme(visorArgs.current.darkMode);
Expand Down
121 changes: 121 additions & 0 deletions src/ansys/visor/visor-client/src/CameraSyncReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type {
CameraOrigin,
IRenderer,
TrameTriggerSender,
VisorCameraState,
} from './renderer/IRenderer';

/**
* The `sync_camera` trigger: the client's half of the server-tracked camera.
*
* This module exists apart from `VisorFrontend` so that it can be tested. A
* `VisorFrontend` cannot be constructed under jest -- it needs a real scene
* graph node, the module-global spectrum manager and a renderer that accepts
* `attachSceneGraph`, and it ends in `Object.freeze` -- so a listener body
* written inline there would be pinned by nothing, and the payload shape is
* precisely the part no server-side gate can check. `CameraGestureTracker`
* was split out for the same reason in the previous increment.
*
* What reaches here is already debounced: `addCameraSettledListener` fires
* once per settle window, never once per camera event, and it carries that
* window's origin. The camera is therefore read exactly once per settle --
* the read is a multi-await round trip to the wasm camera, and paying it per
* event is what the debounce exists to avoid.
*/

/** The payload of the `sync_camera` trigger. Mirrors the server's model. */
export type SyncCameraPayload = Readonly<{
origin: CameraOrigin;
camera: Readonly<{
position: readonly number[];
focalPoint: readonly number[];
viewUp: readonly number[];
clippingRange: readonly number[];
parallelProjection: boolean;
viewAngle: number;
parallelScale: number;
}>;
}>;

/**
* The part of a renderer this module uses. Narrower than `IRenderer` so the
* unit test can supply exactly these two members without a cast that would
* defeat the type check it is here to get.
*/
export type CameraSyncSource = Pick<IRenderer, 'addCameraSettledListener' | 'getCameraStateAsync'>;

/**
* Build the trigger payload from a settled camera snapshot.
*
* The seven applied fields are named one by one, deliberately, rather than
* spread from the snapshot. `VisorCameraState` carries five further derived
* display fields -- `distance`, `orthographic`, `orthographicScale`,
* `unitsPerPixel`, `viewPortHeight` -- which are meaningless to the server's
* record. Spreading would put all twelve on the wire, and the server would
* accept it silently: pydantic ignores unknown keys, so every server-side
* gate would stay green while the wire contract quietly became "whatever the
* client's snapshot type happens to hold today". Naming the seven is the only
* place that shape is decided, which is why the test asserts the key set.
*
* `parallelProjection` is carried verbatim. `getCameraStateAsync` has already
* narrowed the wasm camera's raw value to a boolean; this module does not
* re-derive it.
*
* `origin` is carried verbatim too, for both values. The client never
* suppresses a report it believes is an echo -- the server decides, and logs
* what it dropped.
*/
export function buildSyncCameraPayload(
origin: CameraOrigin,
camera: VisorCameraState
): SyncCameraPayload {
return {
origin,
camera: {
position: camera.position,
focalPoint: camera.focalPoint,
viewUp: camera.viewUp,
clippingRange: camera.clippingRange,
parallelProjection: camera.parallelProjection,
viewAngle: camera.viewAngle,
parallelScale: camera.parallelScale,
},
};
}

/**
* Subscribe to settled camera windows and report each one to the server.
*
* Returns the remover from `addCameraSettledListener`, **verbatim**. The
* caller owns it and must call it when the frontend holding it is replaced:
* the `VtkScene` survives a client rebuild, so a frontend that is discarded
* without releasing leaves its subscription live and every later gesture is
* reported once per rebuild that has ever happened. Each of those reports is
* individually valid, which is why no gate can see the fault.
*
* The sender is required. A frontend with no transport is not a state this
* path supports: it would subscribe, read the camera on every settle, build a
* payload and drop it, which is indistinguishable at runtime from a working
* wire that the server is ignoring.
*
* A failed send is logged and swallowed, never rethrown. The settle callback
* returns `void` and is invoked from a timer, so a rejection escaping it has
* no caller to receive it and would surface as an unhandled rejection. The
* log prefix is fixed and greppable because it is the only signal that a
* report was lost -- the view looks identical either way.
*/
export function attachCameraSyncReporter(
renderer: CameraSyncSource,
send: TrameTriggerSender
): () => void {
return renderer.addCameraSettledListener((origin: CameraOrigin) => {
void (async () => {
try {
const camera = await renderer.getCameraStateAsync();
await send('sync_camera', buildSyncCameraPayload(origin, camera));
} catch (err) {
console.error(`[VISOR] sync_camera trigger send failed: origin='${origin}'`, err);
}
})();
});
}
35 changes: 34 additions & 1 deletion src/ansys/visor/visor-client/src/VisorFrontend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { TreeViewUtil } from './treeview/TreeView.tsx';
import { StateInput } from './state/appstate/VisorStateCommon.tsx';
import VisorVtkSceneNode from './state/appstate/vtkInfo/VisorVtkSceneNode.tsx';
import { IRenderer, VisorCameraState } from './renderer/IRenderer';
import type { TrameTriggerSender } from './renderer/IRenderer';
import { attachCameraSyncReporter } from './CameraSyncReporter';
import { Panel_TopRight_Util } from './components/ui-panels/Panel_TopRight_Util.tsx';
import { Panel_TopLeft_Util } from './components/ui-panels/Panel_TopLeft_Util.tsx';
import { OrientationWidget } from './widgets/orientationWidget.ts';
Expand All @@ -17,7 +19,11 @@ import { UiScaffoldUtil } from './components/UiScaffold.tsx';
export type { VisorCameraState } from './renderer/IRenderer';

export class VisorFrontend {
constructor(renderer: IRenderer, sceneGraphNode: VisorVtkSceneNode) {
constructor(
renderer: IRenderer,
sceneGraphNode: VisorVtkSceneNode,
triggerSender: TrameTriggerSender
) {
const spectrumManager = getSpectrumManager();
const sceneGraph = CreateVisorSceneGraph(sceneGraphNode, spectrumManager, renderer);
spectrumManager.finishAddingDataArrayMetadata();
Expand Down Expand Up @@ -73,6 +79,26 @@ export class VisorFrontend {
};
this.domElement = renderer.domElement;
this.getCameraStateAsync = () => renderer.getCameraStateAsync();
// Report each settled camera window to the server. The remover is
// held rather than discarded because the `VtkScene` behind the
// renderer survives a client rebuild: a frontend replaced without
// releasing leaves its subscription live, and every later gesture is
// then reported once per rebuild that has ever happened. Every one of
// those reports is individually valid, so nothing fails -- the record
// is simply written several times and no gate can tell.
//
// Assigned to a `#private` field and exposed through a field-assigned
// method: `Object.freeze(this)` below does not reach `#private` state,
// but it would make a public field assigned after construction throw
// in the browser and in no gate.
this.#cameraSettledRemover = attachCameraSyncReporter(renderer, triggerSender);
this.releaseCameraSettledListener = () => {
const remover = this.#cameraSettledRemover;
// Cleared first, so a second call is a no-op rather than a second
// removal against a map the next frontend now owns.
this.#cameraSettledRemover = null;
remover?.();
};
this.defaultActorColor = [];
this.setSpectrumRangeAsync = async (spectrumId, component, min, max) => {
const spectrum = spectrumManager.globalSpectrumCollection.getSpectrum(spectrumId);
Expand Down Expand Up @@ -511,7 +537,14 @@ export class VisorFrontend {
}

#unit: string;
#cameraSettledRemover: (() => void) | null;
darkMode: boolean;
/**
* Release this frontend's settled-camera subscription. Called by the
* rebuild on the frontend it is replacing, before the replacement is
* handed out. Idempotent.
*/
releaseCameraSettledListener: () => void;
render: () => Promise<void>;
resizeAsync: () => Promise<void>;
sceneGraph: VisorSceneNodeExtended;
Expand Down
Loading
Loading