Skip to content
Open
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/194.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Remote rendering 4.4a: Update server mode panels when scene changes
16 changes: 16 additions & 0 deletions src/ansys/visor/viewer/app/trame/server_state_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
SERVER_CAMERA_STATE_KEY: str = "server_camera_state"
"""State key carrying the server's camera record, as its camelCase fields, or None."""

SCENE_VERSION_KEY: str = "scene_version"
"""State key counting scene changes; absent until the first change, then 1, 2, ..."""


class ServerStatePublisher(CameraPublisher):
"""Writes server-originated state keys from any thread onto the trame event loop."""
Expand All @@ -41,6 +44,19 @@ def publish_camera(self, camera_state: VisorCameraState | None) -> None:
camera_record = None if camera_state is None else camera_state.model_dump(by_alias=True)
self._async_runner.call_soon(lambda: self._write(SERVER_CAMERA_STATE_KEY, camera_record))

def bump_scene_version(self) -> None:
"""Increment scene_version on the loop thread: read, add one, write and flush in one callback; callable from any thread.

The read happens inside the callback, so bumps queued together from
any threads are serialised on the loop and each one counts.
"""
self._async_runner.call_soon(self._write_next_scene_version)

def _write_next_scene_version(self) -> None:
"""Read scene_version (absent counts as 0), write it plus one, and flush; loop thread only."""
current = self._state[SCENE_VERSION_KEY]
self._write(SCENE_VERSION_KEY, (current or 0) + 1)

def _write(self, key: str, value: object) -> None:
"""Set one state key and flush it; loop thread only."""
self._state[key] = value
Expand Down
37 changes: 34 additions & 3 deletions src/ansys/visor/viewer/app/visor_vtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ def wrapper(*args, **kwargs):
return decorator


def notifies_scene_change(func):
"""Call self._post_scene_change_hook() once after func, whether func returns or raises.

A failed operation can still have changed the scene, so it notifies too.
Placed directly above the method, so the argument and server checks above
it reject a call before anything is notified.
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
finally:
self._post_scene_change_hook()
return wrapper


class VisorVTK(Visor):
"""
VisorVTK is a class that provides an interface for initializing and managing a
Expand Down Expand Up @@ -186,6 +202,11 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->
Called from ``__init__`` after ``_server_manager`` and ``_file_io`` are in place.
"""

@abstractmethod
def _post_scene_change_hook(self) -> None:
"""Signal one finished scene-changing operation to connected clients."""
raise NotImplementedError

""" Properties """
@property
def running(self) -> bool:
Expand Down Expand Up @@ -239,6 +260,7 @@ def stop(self):
@require_server_on
@require_input_is_not_none
@validate_input_metadata_types
@notifies_scene_change
def update(self,
input: str | VisorDatasetType,
metadata: Metadata | None = None
Expand Down Expand Up @@ -266,6 +288,7 @@ def update(self,
@handle_errors("add_dataset")
@require_input_is_not_none
@validate_input_metadata_types
@notifies_scene_change
def add_dataset(self,
input: str | VisorDatasetType,
metadata: Metadata | None = None
Expand All @@ -288,14 +311,15 @@ def add_dataset(self,
return dataset_id

@handle_errors("remove_dataset")
@notifies_scene_change
def remove_dataset(self, dataset_id: int) -> None:
"""
Remove a dataset from the current scene based on its dataset ID.
"""
self._scene.remove_dataset(dataset_id)
if self.dataset_count == 0:
# if we've removed the last dataset, reset widgets and camera so the blank scene is normalized
self.clear()
self._clear_scene()
else:
self._scene.update_widgets()
self._scene.render()
Expand All @@ -311,14 +335,14 @@ def list_datasets(self) -> dict:
return self._scene.list_all_dataset_info()

@handle_errors("clear")
@notifies_scene_change
def clear(self):
"""
Clear the current scene and reset the visualization pipeline.
This function removes all actors from the scene and resets the state.
"""
logger.info("Clearing the scene")
self._scene.clear()
self._scene.finalize_scene()
self._clear_scene()
logger.info("Scene cleared successfully")

@handle_errors("list_variables")
Expand All @@ -335,6 +359,7 @@ def list_variables(self, dataset_id: int) -> List[VisorPartVariables]:
return self._scene.list_variables_for_dataset(dataset_id)

@handle_errors("update_variables")
@notifies_scene_change
def update_variables(self, dataset_id: int, variables: List[Dict[str, Any]]) -> None:
"""
Update the variables of a specific dataset in the scene.
Expand Down Expand Up @@ -397,6 +422,7 @@ async def save_state(self, state_dir: str) -> None:
self._file_io.delete_stale_snapshots(state_dir, state)

@handle_errors("load state")
@notifies_scene_change
def load_state(self, state_dir: str) -> None:
"""
Load viewer state from a directory.
Expand Down Expand Up @@ -485,6 +511,11 @@ def _health(self) -> bool:
return self._server_manager.health

# Private Methods
def _clear_scene(self) -> None:
"""Clear the scene and finalise it; shared by clear and remove_dataset."""
self._scene.clear()
self._scene.finalize_scene()

def _initialize_server(self, trame_log_dir: str | None) -> TrameServerManager:
"""Initialize the Trame server manager."""
# Start Trame listening on the configured binding host (or the instance host if unset)
Expand Down
3 changes: 3 additions & 0 deletions src/ansys/visor/viewer/app/visor_vtk_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->
scene_mutation_api=self._scene,
)

def _post_scene_change_hook(self) -> None:
"""Client mode signals through LocalView.update and set_state; nothing to bump."""

12 changes: 9 additions & 3 deletions src/ansys/visor/viewer/app/visor_vtk_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,21 @@
class VisorVTKServer(VisorVTK):
"""Visor interface for VTK SERVER mode: server-side off-screen rendering streamed through trame-rca."""

_state_publisher: ServerStatePublisher

def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) -> None:
"""Wire the bridge, the state publisher, the server scene and ServerApp.

One bridge serves both: the scene's renderer requests frames through
it, and the app binds it to the view handler when the server is ready.
The publisher writes the renderer's camera record into this viewer's
server state.
server state, and the scene_version bump after each scene change.
"""
bridge = ServerLoopBridge(self._server_manager.async_runner)
publisher = ServerStatePublisher(self._server_manager.async_runner, self.server.state)
self._state_publisher = ServerStatePublisher(self._server_manager.async_runner, self.server.state)

self._scene = VisorServerScene(
self.server, dark_mode=self._dark_mode, bridge=bridge, camera_publisher=publisher
self.server, dark_mode=self._dark_mode, bridge=bridge, camera_publisher=self._state_publisher
)

self._trame_app = ServerApp(
Expand All @@ -52,3 +54,7 @@ def _initialize_rendering(self, standalone: bool, trame_log_dir: str | None) ->
bridge=bridge,
)

def _post_scene_change_hook(self) -> None:
"""Bump scene_version so connected clients rebuild; requests no frame, the operation already did."""
self._state_publisher.bump_scene_version()

39 changes: 39 additions & 0 deletions src/ansys/visor/visor-client/src/frontend/FrontendRebuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/** Rebuilds the frontend on request, one build at a time; requests during a build collapse into one more build. */
export class FrontendRebuilder {
/** Rebuild with build; a rejected build goes to onError. Both required. */
constructor(build: () => Promise<void>, onError: (err: unknown) => void) {
this.#build = build;
this.#onError = onError;
}

readonly #build: () => Promise<void>;
readonly #onError: (err: unknown) => void;
#building = false;
#requested = false;

/** Build now if idle, otherwise build once more after the current build. */
request(): void {
if (this.#building) {
this.#requested = true;
return;
}
void this.#buildAsync();
}

/** Build until no request arrived during the last build; the building flag is cleared in finally. */
async #buildAsync(): Promise<void> {
this.#building = true;
try {
do {
this.#requested = false;
try {
await this.#build();
} catch (err) {
this.#onError(err);
}
} while (this.#requested);
} finally {
this.#building = false;
}
}
}
63 changes: 49 additions & 14 deletions src/ansys/visor/visor-client/src/hooks/useServerVtk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,36 @@ import { RCA_AREA_NAME, ServerCanvas, type RcaSession } from '../rca/ServerCanva
import { ServerRenderer, type ServerStateSource } from '../renderer/ServerRenderer';
import type { TrameTriggerSender } from '../renderer/IRenderer';
import { buildFrontendAsync } from '../buildFrontendAsync';
import { FrontendRebuilder } from '../frontend/FrontendRebuilder';
import type { VisorArgs } from './useVisorArgs';

/** State key the server bumps after each scene change; must equal server_state_publisher.py's. */
export const SCENE_VERSION_KEY = 'scene_version';

/** What the server-mode hook gives App. */
export type ServerVtk = Readonly<{
visorFrontend: VisorFrontend | null;
schemaMismatchError: SchemaVersionMismatchError | null;
}>;

/** Over the probe's session: send the page theme, stream frames and build the frontend; idle when trame is null. */
/** A built server-mode frontend and the renderer it was built over, released together when replaced. */
class ServerFrontendBuild {
constructor(frontend: VisorFrontend, renderer: ServerRenderer) {
this.#frontend = frontend;
this.#renderer = renderer;
}

readonly #frontend: VisorFrontend;
readonly #renderer: ServerRenderer;

/** Remove the frontend's renderer listeners, then dispose the renderer. */
release(): void {
this.#frontend.release();
this.#renderer.dispose();
}
}

/** Over the probe's session: stream frames, and rebuild the frontend on every scene_version change; idle when trame is null. */
export function useServerVtk(trame: Trame | null, visorArgs: VisorArgs): ServerVtk {
const [visorFrontend, setVisorFrontend] = useState<VisorFrontend | null>(null);
const [schemaMismatchError, setSchemaMismatchError] =
Expand All @@ -25,56 +46,70 @@ export function useServerVtk(trame: Trame | null, visorArgs: VisorArgs): ServerV
return;
}
// Under StrictMode the effect runs twice in development: each run
// disposes only the canvas and renderer it created, and a cancelled
// run drops its frontend.
// disposes only the canvas and builds it created, and a build that
// finishes after cleanup disposes its own renderer.
let cancelled = false;
let renderer: ServerRenderer | null = null;
const disposeRenderer = () => {
renderer?.dispose();
renderer = null;
};
let current: ServerFrontendBuild | null = null;
const state = stateOf(trame);
// Subscribed before anything can make the server render, so the frame
// that render produces reaches the canvas.
const canvas = new ServerCanvas(sessionOf(trame), RCA_AREA_NAME);
const sender: TrameTriggerSender = (triggerName, payload) =>
trame.trigger(triggerName, [payload]);
(async () => {
const buildAsync = async (): Promise<void> => {
// A build requested before cleanup but started after it sends nothing.
if (cancelled) {
return;
}
if (visorArgs.darkMode != null) {
await sendDarkThemeAsync(trame, visorArgs.darkMode);
}
// No arguments: the trigger takes none, and a sender would pass one.
const sceneDetailsJson = await trame.trigger('get_visor_scene_details_json');
const built: { renderer: ServerRenderer | null } = { renderer: null };
let newFrontend: VisorFrontend;
try {
newFrontend = await buildFrontendAsync(
sceneDetailsJson,
async () => {
renderer = new ServerRenderer(canvas, stateOf(trame), sender);
return renderer;
built.renderer = new ServerRenderer(canvas, state, sender);
return built.renderer;
},
sender,
visorArgs.darkMode
);
} catch (err) {
built.renderer?.dispose();
if (err instanceof SchemaVersionMismatchError) {
// The current frontend is kept, as in client mode.
if (!cancelled) {
setSchemaMismatchError(err);
}
return;
}
throw err;
}
const renderer = built.renderer!;
if (cancelled) {
// Built after cleanup ran, so cleanup could not dispose it.
disposeRenderer();
renderer.dispose();
return;
}
current?.release();
current = new ServerFrontendBuild(newFrontend, renderer);
setSchemaMismatchError(null);
setVisorFrontend(newFrontend);
})();
};
const rebuilder = new FrontendRebuilder(buildAsync, (err) =>
console.error('[VISOR] server-mode rebuild failed', err)
);
// `watch` also calls back at once, which is the initial build.
const unwatch = state.watch([SCENE_VERSION_KEY], () => rebuilder.request());
return () => {
cancelled = true;
disposeRenderer();
unwatch();
current?.release();
current = null;
canvas.dispose();
setVisorFrontend(null);
setSchemaMismatchError(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ const mockTrame = {
getSession: () => ({ subscribe: mockSubscribe, call: async () => undefined }),
}),
},
// Calls back at once with no values, as trame's watch does before any state arrives.
state: {
watch: (_keys: string[], fn: (...values: unknown[]) => void) => {
fn();
return () => {};
},
},
// The scene-details fetch never answers, so the server frontend is never
// built: this test is about which scene loads, not what the server sends.
trigger: () => new Promise(() => {}),
Expand Down
Loading
Loading